osp_cli/plugin/manager.rs
1//! Public plugin facade and shared plugin data types.
2//!
3//! This module exists so the rest of the app can depend on one stable plugin
4//! entry point while discovery, selection, catalog building, and dispatch live
5//! in narrower neighboring modules.
6//!
7//! High-level flow:
8//!
9//! - store discovered plugin metadata and process/runtime settings
10//! - delegate catalog and selection work to neighboring modules
11//! - hand the chosen provider to the dispatch layer when execution is needed
12//!
13//! Contract:
14//!
15//! - this file owns the public facade and shared plugin DTOs
16//! - catalog building and provider selection logic live in neighboring
17//! modules
18//! - subprocess execution and timeout handling belong in `plugin::dispatch`
19//!
20//! Public API shape:
21//!
22//! - discovered plugins and catalog entries are semantic payloads
23//! - dispatch machinery uses concrete constructors such as
24//! [`PluginDispatchContext::new`] plus `with_*` refinements instead of raw
25//! ad hoc assembly
26
27use super::active::ActivePluginView;
28use super::catalog::{
29 build_command_catalog, build_command_policy_registry, build_doctor_report,
30 command_provider_labels, completion_words_from_catalog, list_plugins, render_repl_help,
31 selected_provider_label,
32};
33use super::selection::{ProviderResolution, ProviderResolutionError, plugin_label};
34use super::state::PluginCommandPreferences;
35#[cfg(test)]
36use super::state::PluginCommandState;
37use crate::completion::CommandSpec;
38use crate::core::plugin::{DescribeCommandAuthV1, DescribeCommandV1};
39use crate::core::runtime::RuntimeHints;
40use anyhow::{Result, anyhow};
41use std::collections::HashMap;
42use std::error::Error as StdError;
43use std::fmt::{Display, Formatter};
44use std::path::PathBuf;
45use std::sync::{Arc, RwLock};
46use std::time::Duration;
47
48/// Default timeout, in milliseconds, for plugin subprocess calls.
49pub const DEFAULT_PLUGIN_PROCESS_TIMEOUT_MS: usize = 10_000;
50
51/// Describes how a plugin executable was discovered.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum PluginSource {
54 /// Loaded from an explicit search directory supplied by the caller.
55 Explicit,
56 /// Loaded from a path listed in the `OSP_PLUGIN_PATH` environment variable.
57 Env,
58 /// Loaded from the CLI's bundled plugin set.
59 Bundled,
60 /// Loaded from the per-user plugin directory under the configured config root.
61 UserConfig,
62 /// Loaded by scanning the process `PATH`.
63 Path,
64}
65
66impl Display for PluginSource {
67 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
68 write!(f, "{}", self.as_str())
69 }
70}
71
72impl PluginSource {
73 /// Returns the stable label used in diagnostics and persisted metadata.
74 ///
75 /// # Examples
76 ///
77 /// ```
78 /// use osp_cli::plugin::PluginSource;
79 ///
80 /// assert_eq!(PluginSource::Bundled.to_string(), "bundled");
81 /// ```
82 pub fn as_str(self) -> &'static str {
83 match self {
84 PluginSource::Explicit => "explicit",
85 PluginSource::Env => "env",
86 PluginSource::Bundled => "bundled",
87 PluginSource::UserConfig => "user",
88 PluginSource::Path => "path",
89 }
90 }
91}
92
93/// Canonical in-memory record for one discovered plugin provider.
94///
95/// This is the rich internal form used for catalog building, completion, and
96/// dispatch decisions after discovery has finished.
97#[derive(Debug, Clone)]
98pub struct DiscoveredPlugin {
99 /// Stable provider identifier returned by the plugin.
100 pub plugin_id: String,
101 /// Optional plugin version reported during discovery.
102 pub plugin_version: Option<String>,
103 /// Absolute path to the plugin executable.
104 pub executable: PathBuf,
105 /// Discovery source used to locate the executable.
106 pub source: PluginSource,
107 /// Top-level commands exported by the plugin.
108 pub commands: Vec<String>,
109 /// Raw describe-command payloads returned by the plugin.
110 pub describe_commands: Vec<DescribeCommandV1>,
111 /// Normalized completion specs derived from `describe_commands`.
112 pub command_specs: Vec<CommandSpec>,
113 /// Discovery or validation issue associated with this plugin.
114 pub issue: Option<String>,
115 /// Whether commands from this plugin default to enabled when no explicit
116 /// command-state preference overrides them.
117 pub default_enabled: bool,
118}
119
120/// Reduced plugin view for listing, doctor, and status surfaces.
121///
122/// `enabled` reflects command-state filtering, while `healthy` reflects
123/// discovery-time validation and describe-cache status.
124#[derive(Debug, Clone)]
125pub struct PluginSummary {
126 /// Stable provider identifier returned by the plugin.
127 pub plugin_id: String,
128 /// Optional plugin version reported during discovery.
129 pub plugin_version: Option<String>,
130 /// Absolute path to the plugin executable.
131 pub executable: PathBuf,
132 /// Discovery source used to locate the executable.
133 pub source: PluginSource,
134 /// Top-level commands exported by the plugin.
135 pub commands: Vec<String>,
136 /// Whether at least one exported command remains enabled after
137 /// command-state filtering.
138 pub enabled: bool,
139 /// Whether the plugin passed discovery-time validation.
140 pub healthy: bool,
141 /// Discovery or validation issue associated with this plugin.
142 pub issue: Option<String>,
143}
144
145/// One command-name conflict across multiple plugin providers.
146#[derive(Debug, Clone)]
147pub struct CommandConflict {
148 /// Conflicting command name.
149 pub command: String,
150 /// Provider labels that provide `command`, such as `alpha (explicit)`.
151 pub providers: Vec<String>,
152}
153
154/// Aggregated plugin health payload used by diagnostic surfaces.
155#[derive(Debug, Clone)]
156pub struct DoctorReport {
157 /// Summary entry for each discovered plugin.
158 pub plugins: Vec<PluginSummary>,
159 /// Commands that are provided by more than one provider label.
160 pub conflicts: Vec<CommandConflict>,
161}
162
163/// Normalized command-level catalog entry derived from the discovered plugin set.
164///
165/// Help, completion, and dispatch-selection code can share this view without
166/// understanding plugin discovery internals.
167#[derive(Debug, Clone)]
168pub struct CommandCatalogEntry {
169 /// Full command path, including parent commands when present.
170 pub name: String,
171 /// Short description shown in help and catalog output.
172 pub about: String,
173 /// Optional auth metadata returned by plugin discovery.
174 pub auth: Option<DescribeCommandAuthV1>,
175 /// Immediate subcommand names beneath `name`.
176 pub subcommands: Vec<String>,
177 /// Shell completion metadata for this command.
178 pub completion: CommandSpec,
179 /// Selected provider identifier when dispatch has been resolved.
180 pub provider: Option<String>,
181 /// Provider labels for every provider that exports this command.
182 pub providers: Vec<String>,
183 /// Whether more than one provider exports this command.
184 pub conflicted: bool,
185 /// Whether the caller must choose a provider before dispatch.
186 pub requires_selection: bool,
187 /// Whether the provider was selected by explicit preference rather than by
188 /// unique-provider resolution.
189 pub selected_explicitly: bool,
190 /// Discovery source for the selected provider, if resolved.
191 pub source: Option<PluginSource>,
192}
193
194impl CommandCatalogEntry {
195 /// Returns the optional auth hint rendered in help and catalog views.
196 ///
197 /// # Examples
198 ///
199 /// ```
200 /// use osp_cli::completion::CommandSpec;
201 /// use osp_cli::plugin::CommandCatalogEntry;
202 /// use osp_cli::core::plugin::{DescribeCommandAuthV1, DescribeVisibilityModeV1};
203 ///
204 /// let entry = CommandCatalogEntry {
205 /// name: "ldap user".to_string(),
206 /// about: "lookup users".to_string(),
207 /// auth: Some(DescribeCommandAuthV1 {
208 /// visibility: Some(DescribeVisibilityModeV1::Authenticated),
209 /// required_capabilities: Vec::new(),
210 /// feature_flags: Vec::new(),
211 /// }),
212 /// subcommands: Vec::new(),
213 /// completion: CommandSpec::new("ldap"),
214 /// provider: Some("ldap".to_string()),
215 /// providers: vec!["ldap".to_string()],
216 /// conflicted: false,
217 /// requires_selection: false,
218 /// selected_explicitly: false,
219 /// source: None,
220 /// };
221 ///
222 /// assert_eq!(entry.auth_hint().as_deref(), Some("auth"));
223 /// ```
224 pub fn auth_hint(&self) -> Option<String> {
225 self.auth.as_ref().and_then(|auth| auth.hint())
226 }
227}
228
229/// Raw stdout/stderr captured from a plugin subprocess invocation.
230///
231/// This is the payload returned by passthrough dispatch APIs. A non-zero plugin
232/// exit code is preserved in `status_code` instead of being converted into a
233/// semantic response or validation error.
234#[derive(Debug, Clone)]
235pub struct RawPluginOutput {
236 /// Process exit status code, or `1` when the child ended without a
237 /// conventional exit code.
238 pub status_code: i32,
239 /// Captured standard output.
240 pub stdout: String,
241 /// Captured standard error.
242 pub stderr: String,
243}
244
245/// Per-dispatch runtime hints and environment overrides for plugin execution.
246#[derive(Debug, Clone, Default)]
247#[non_exhaustive]
248#[must_use]
249pub struct PluginDispatchContext {
250 /// Runtime hints serialized into plugin requests.
251 pub runtime_hints: RuntimeHints,
252 /// Environment pairs injected into every plugin process.
253 pub shared_env: Vec<(String, String)>,
254 /// Additional environment pairs injected for specific plugins.
255 pub plugin_env: HashMap<String, Vec<(String, String)>>,
256 /// Provider identifier forced by the caller, if any.
257 pub provider_override: Option<String>,
258}
259
260impl PluginDispatchContext {
261 /// Creates dispatch context from the required runtime hint payload.
262 ///
263 /// # Examples
264 ///
265 /// ```
266 /// use osp_cli::core::output::{ColorMode, OutputFormat, UnicodeMode};
267 /// use osp_cli::core::runtime::{RuntimeHints, RuntimeTerminalKind, UiVerbosity};
268 /// use osp_cli::plugin::PluginDispatchContext;
269 ///
270 /// let context = PluginDispatchContext::new(RuntimeHints::new(
271 /// UiVerbosity::Info,
272 /// 2,
273 /// OutputFormat::Json,
274 /// ColorMode::Always,
275 /// UnicodeMode::Never,
276 /// ))
277 /// .with_provider_override(Some("ldap".to_string()))
278 /// .with_shared_env([("OSP_FORMAT", "json")]);
279 ///
280 /// assert_eq!(context.provider_override.as_deref(), Some("ldap"));
281 /// assert!(context.shared_env.iter().any(|(key, value)| key == "OSP_FORMAT" && value == "json"));
282 /// assert_eq!(context.runtime_hints.terminal_kind, RuntimeTerminalKind::Unknown);
283 /// ```
284 pub fn new(runtime_hints: RuntimeHints) -> Self {
285 Self {
286 runtime_hints,
287 shared_env: Vec::new(),
288 plugin_env: HashMap::new(),
289 provider_override: None,
290 }
291 }
292
293 /// Replaces the environment injected into every plugin process.
294 ///
295 /// Defaults to no shared environment overrides when omitted.
296 pub fn with_shared_env<I, K, V>(mut self, shared_env: I) -> Self
297 where
298 I: IntoIterator<Item = (K, V)>,
299 K: Into<String>,
300 V: Into<String>,
301 {
302 self.shared_env = shared_env
303 .into_iter()
304 .map(|(key, value)| (key.into(), value.into()))
305 .collect();
306 self
307 }
308
309 /// Replaces the environment injected for specific plugins.
310 ///
311 /// Defaults to no plugin-specific environment overrides when omitted.
312 /// Matching entries are appended after `shared_env` for the selected
313 /// plugin.
314 pub fn with_plugin_env(mut self, plugin_env: HashMap<String, Vec<(String, String)>>) -> Self {
315 self.plugin_env = plugin_env;
316 self
317 }
318
319 /// Replaces the optional forced provider identifier.
320 ///
321 /// Defaults to the manager's normal provider-resolution rules when omitted.
322 /// Use this for one-shot dispatch overrides without mutating manager-local
323 /// provider selections.
324 pub fn with_provider_override(mut self, provider_override: Option<String>) -> Self {
325 self.provider_override = provider_override;
326 self
327 }
328
329 pub(crate) fn env_pairs_for<'a>(
330 &'a self,
331 plugin_id: &'a str,
332 ) -> impl Iterator<Item = (&'a str, &'a str)> {
333 self.shared_env
334 .iter()
335 .map(|(key, value)| (key.as_str(), value.as_str()))
336 .chain(
337 self.plugin_env
338 .get(plugin_id)
339 .into_iter()
340 .flat_map(|entries| entries.iter())
341 .map(|(key, value)| (key.as_str(), value.as_str())),
342 )
343 }
344}
345
346/// Errors returned when selecting or invoking a plugin command.
347///
348/// Variants that list `providers` use provider labels as rendered in help and
349/// diagnostics, not bare plugin ids.
350#[derive(Debug)]
351pub enum PluginDispatchError {
352 /// No plugin provides the requested command.
353 CommandNotFound {
354 /// Command name requested by the caller.
355 command: String,
356 },
357 /// More than one plugin provides the requested command.
358 CommandAmbiguous {
359 /// Command name requested by the caller.
360 command: String,
361 /// Provider labels that provide `command`.
362 providers: Vec<String>,
363 },
364 /// The requested provider exists, but not for the requested command.
365 ProviderNotFound {
366 /// Command name requested by the caller.
367 command: String,
368 /// Provider identifier requested by the caller.
369 requested_provider: String,
370 /// Provider labels that provide `command`.
371 providers: Vec<String>,
372 },
373 /// Spawning or waiting for the plugin process failed.
374 ExecuteFailed {
375 /// Plugin identifier being invoked.
376 plugin_id: String,
377 /// Underlying process execution error.
378 source: std::io::Error,
379 },
380 /// The plugin process exceeded the configured timeout.
381 TimedOut {
382 /// Plugin identifier being invoked.
383 plugin_id: String,
384 /// Timeout applied to the subprocess call.
385 timeout: Duration,
386 /// Captured standard error emitted before timeout.
387 stderr: String,
388 },
389 /// The plugin process exited with a non-zero status code.
390 NonZeroExit {
391 /// Plugin identifier being invoked.
392 plugin_id: String,
393 /// Process exit status code.
394 status_code: i32,
395 /// Captured standard error emitted by the plugin.
396 stderr: String,
397 },
398 /// The plugin returned malformed JSON.
399 InvalidJsonResponse {
400 /// Plugin identifier being invoked.
401 plugin_id: String,
402 /// JSON decode error for the response payload.
403 source: serde_json::Error,
404 },
405 /// The plugin returned JSON that failed semantic validation.
406 InvalidResponsePayload {
407 /// Plugin identifier being invoked.
408 plugin_id: String,
409 /// Validation failure description.
410 reason: String,
411 },
412}
413
414impl Display for PluginDispatchError {
415 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
416 match self {
417 PluginDispatchError::CommandNotFound { command } => {
418 write!(f, "no plugin provides command: {command}")
419 }
420 PluginDispatchError::CommandAmbiguous { command, providers } => {
421 write!(
422 f,
423 "command `{command}` is provided by multiple plugins: {}",
424 providers.join(", ")
425 )
426 }
427 PluginDispatchError::ProviderNotFound {
428 command,
429 requested_provider,
430 providers,
431 } => {
432 write!(
433 f,
434 "plugin `{requested_provider}` does not provide command `{command}`; available providers: {}",
435 providers.join(", ")
436 )
437 }
438 PluginDispatchError::ExecuteFailed { plugin_id, source } => {
439 write!(f, "failed to execute plugin {plugin_id}: {source}")
440 }
441 PluginDispatchError::TimedOut {
442 plugin_id,
443 timeout,
444 stderr,
445 } => {
446 if stderr.trim().is_empty() {
447 write!(
448 f,
449 "plugin {plugin_id} timed out after {} ms",
450 timeout.as_millis()
451 )
452 } else {
453 write!(
454 f,
455 "plugin {plugin_id} timed out after {} ms: {}",
456 timeout.as_millis(),
457 stderr.trim()
458 )
459 }
460 }
461 PluginDispatchError::NonZeroExit {
462 plugin_id,
463 status_code,
464 stderr,
465 } => {
466 if stderr.trim().is_empty() {
467 write!(f, "plugin {plugin_id} exited with status {status_code}")
468 } else {
469 write!(
470 f,
471 "plugin {plugin_id} exited with status {status_code}: {}",
472 stderr.trim()
473 )
474 }
475 }
476 PluginDispatchError::InvalidJsonResponse { plugin_id, source } => {
477 write!(f, "invalid JSON response from plugin {plugin_id}: {source}")
478 }
479 PluginDispatchError::InvalidResponsePayload { plugin_id, reason } => {
480 write!(f, "invalid plugin response from {plugin_id}: {reason}")
481 }
482 }
483 }
484}
485
486impl StdError for PluginDispatchError {
487 fn source(&self) -> Option<&(dyn StdError + 'static)> {
488 match self {
489 PluginDispatchError::ExecuteFailed { source, .. } => Some(source),
490 PluginDispatchError::InvalidJsonResponse { source, .. } => Some(source),
491 PluginDispatchError::CommandNotFound { .. }
492 | PluginDispatchError::CommandAmbiguous { .. }
493 | PluginDispatchError::ProviderNotFound { .. }
494 | PluginDispatchError::TimedOut { .. }
495 | PluginDispatchError::NonZeroExit { .. }
496 | PluginDispatchError::InvalidResponsePayload { .. } => None,
497 }
498 }
499}
500
501/// Coordinates plugin discovery, cached metadata, and dispatch settings.
502///
503/// This is the main host-side facade for plugin integration. A typical caller
504/// constructs one manager, points it at explicit roots plus optional config and
505/// cache roots, then asks it for one of three things:
506///
507/// - plugin inventory via [`PluginManager::list_plugins`]
508/// - merged command metadata via [`PluginManager::command_catalog`] or
509/// [`PluginManager::command_policy_registry`]
510/// - dispatch-time configuration such as manager-local provider selections
511///
512/// If you are implementing the plugin executable itself rather than the host,
513/// start in [`crate::core::plugin`] instead of here.
514#[must_use]
515pub struct PluginManager {
516 pub(crate) explicit_dirs: Vec<PathBuf>,
517 pub(crate) discovered_cache: RwLock<Option<Arc<[DiscoveredPlugin]>>>,
518 pub(crate) dispatch_discovered_cache: RwLock<Option<Arc<[DiscoveredPlugin]>>>,
519 pub(crate) command_preferences: RwLock<PluginCommandPreferences>,
520 pub(crate) config_root: Option<PathBuf>,
521 pub(crate) cache_root: Option<PathBuf>,
522 pub(crate) process_timeout: Duration,
523 pub(crate) allow_path_discovery: bool,
524 pub(crate) allow_default_roots: bool,
525}
526
527impl PluginManager {
528 /// Creates a plugin manager with the provided explicit search roots.
529 ///
530 /// # Examples
531 ///
532 /// ```
533 /// use osp_cli::plugin::PluginManager;
534 /// use std::path::PathBuf;
535 ///
536 /// let manager = PluginManager::new(vec![PathBuf::from("/plugins")]);
537 ///
538 /// assert_eq!(manager.explicit_dirs().len(), 1);
539 /// ```
540 pub fn new(explicit_dirs: Vec<PathBuf>) -> Self {
541 Self {
542 explicit_dirs,
543 discovered_cache: RwLock::new(None),
544 dispatch_discovered_cache: RwLock::new(None),
545 command_preferences: RwLock::new(PluginCommandPreferences::default()),
546 config_root: None,
547 cache_root: None,
548 process_timeout: Duration::from_millis(DEFAULT_PLUGIN_PROCESS_TIMEOUT_MS as u64),
549 allow_path_discovery: false,
550 allow_default_roots: true,
551 }
552 }
553
554 /// Returns the explicit plugin search roots configured for this manager.
555 pub fn explicit_dirs(&self) -> &[PathBuf] {
556 &self.explicit_dirs
557 }
558
559 /// Sets config and cache roots used for user plugin discovery and describe
560 /// cache files.
561 ///
562 /// The config root feeds the per-user plugin directory lookup. The cache
563 /// root feeds the on-disk describe cache. This does not make command
564 /// provider selections persistent by itself; those remain manager-local
565 /// in-memory state.
566 ///
567 /// # Examples
568 ///
569 /// ```
570 /// use osp_cli::plugin::PluginManager;
571 /// use std::path::PathBuf;
572 ///
573 /// let manager = PluginManager::new(Vec::new()).with_roots(
574 /// Some(PathBuf::from("/config")),
575 /// Some(PathBuf::from("/cache")),
576 /// );
577 ///
578 /// assert_eq!(manager.config_root(), Some(PathBuf::from("/config").as_path()));
579 /// assert_eq!(manager.cache_root(), Some(PathBuf::from("/cache").as_path()));
580 /// ```
581 pub fn with_roots(mut self, config_root: Option<PathBuf>, cache_root: Option<PathBuf>) -> Self {
582 self.config_root = config_root;
583 self.cache_root = cache_root;
584 self
585 }
586
587 /// Returns the configured config root used to resolve the user plugin
588 /// directory.
589 pub fn config_root(&self) -> Option<&std::path::Path> {
590 self.config_root.as_deref()
591 }
592
593 /// Returns the configured cache root used for the describe metadata cache.
594 pub fn cache_root(&self) -> Option<&std::path::Path> {
595 self.cache_root.as_deref()
596 }
597
598 /// Enables or disables fallback to platform config/cache roots when
599 /// explicit roots are not configured.
600 ///
601 /// The default is `true`. Disable this when the caller wants plugin
602 /// discovery and describe-cache state to stay fully in-memory unless
603 /// explicit roots are provided.
604 pub fn with_default_roots(mut self, allow_default_roots: bool) -> Self {
605 self.allow_default_roots = allow_default_roots;
606 self
607 }
608
609 /// Returns whether platform config/cache root fallback is enabled.
610 pub fn default_roots_enabled(&self) -> bool {
611 self.allow_default_roots
612 }
613
614 /// Sets the subprocess timeout used for plugin describe and dispatch calls.
615 ///
616 /// Timeout values are clamped to at least one millisecond so the manager
617 /// never stores a zero-duration subprocess timeout.
618 ///
619 /// # Examples
620 ///
621 /// ```
622 /// use osp_cli::plugin::PluginManager;
623 /// use std::time::Duration;
624 ///
625 /// let manager = PluginManager::new(Vec::new())
626 /// .with_process_timeout(Duration::from_millis(0));
627 ///
628 /// assert_eq!(manager.process_timeout(), Duration::from_millis(1));
629 /// ```
630 pub fn with_process_timeout(mut self, timeout: Duration) -> Self {
631 self.process_timeout = timeout.max(Duration::from_millis(1));
632 self
633 }
634
635 /// Returns the subprocess timeout used for describe and dispatch calls.
636 pub fn process_timeout(&self) -> Duration {
637 self.process_timeout
638 }
639
640 /// Enables or disables fallback discovery through the process `PATH`.
641 ///
642 /// PATH discovery is passive on browse/read surfaces. A PATH-discovered
643 /// plugin will not be executed for `--describe` during passive listing or
644 /// catalog building, so command metadata is unavailable there until the
645 /// first command dispatch to that plugin. Dispatching a command triggers
646 /// `--describe` as a cache miss and writes the result to the on-disk
647 /// describe cache; subsequent browse and catalog calls will then see the
648 /// full command metadata.
649 ///
650 /// # Examples
651 ///
652 /// ```
653 /// use osp_cli::plugin::PluginManager;
654 ///
655 /// let manager = PluginManager::new(Vec::new()).with_path_discovery(true);
656 ///
657 /// assert!(manager.path_discovery_enabled());
658 /// ```
659 pub fn with_path_discovery(mut self, allow_path_discovery: bool) -> Self {
660 self.allow_path_discovery = allow_path_discovery;
661 self
662 }
663
664 /// Returns whether fallback discovery through the process `PATH` is enabled.
665 pub fn path_discovery_enabled(&self) -> bool {
666 self.allow_path_discovery
667 }
668
669 pub(crate) fn with_command_preferences(
670 mut self,
671 preferences: PluginCommandPreferences,
672 ) -> Self {
673 self.command_preferences = RwLock::new(preferences);
674 self
675 }
676
677 /// Lists discovered plugins with health, command, and enablement status.
678 ///
679 /// When PATH discovery is enabled, PATH-discovered plugins can appear here
680 /// before their command metadata is available because passive discovery
681 /// does not execute them for `--describe`.
682 ///
683 /// # Examples
684 ///
685 /// ```
686 /// use osp_cli::plugin::PluginManager;
687 ///
688 /// let plugins = PluginManager::new(Vec::new()).list_plugins();
689 ///
690 /// assert!(plugins.is_empty());
691 /// ```
692 pub fn list_plugins(&self) -> Vec<PluginSummary> {
693 self.with_passive_view(list_plugins)
694 }
695
696 /// Builds the effective command catalog after provider resolution and
697 /// health filtering.
698 ///
699 /// This is the host-facing "what commands exist?" view used by help,
700 /// completion, and similar browse/read surfaces. PATH-discovered plugins
701 /// only contribute commands here after describe metadata has been cached;
702 /// passive discovery alone is not enough.
703 ///
704 /// # Examples
705 ///
706 /// ```
707 /// use osp_cli::plugin::PluginManager;
708 ///
709 /// let catalog = PluginManager::new(Vec::new()).command_catalog();
710 ///
711 /// assert!(catalog.is_empty());
712 /// ```
713 pub fn command_catalog(&self) -> Vec<CommandCatalogEntry> {
714 self.with_passive_view(build_command_catalog)
715 }
716
717 /// Builds a command policy registry from active plugin describe metadata.
718 ///
719 /// Use this when plugin auth hints need to participate in the same runtime
720 /// visibility and access evaluation as native commands. Commands that
721 /// still require provider selection are omitted until one provider is
722 /// selected explicitly.
723 ///
724 /// # Examples
725 ///
726 /// ```
727 /// use osp_cli::plugin::PluginManager;
728 ///
729 /// let registry = PluginManager::new(Vec::new()).command_policy_registry();
730 ///
731 /// assert!(registry.is_empty());
732 /// ```
733 pub fn command_policy_registry(&self) -> crate::core::command_policy::CommandPolicyRegistry {
734 self.with_passive_view(build_command_policy_registry)
735 }
736
737 /// Returns completion words derived from the current plugin command catalog.
738 ///
739 /// The returned list always includes the REPL backbone words used by the
740 /// plugin/completion surface, even when no plugins are currently available.
741 ///
742 /// # Examples
743 ///
744 /// ```
745 /// use osp_cli::plugin::PluginManager;
746 ///
747 /// let words = PluginManager::new(Vec::new()).completion_words();
748 ///
749 /// assert!(words.contains(&"help".to_string()));
750 /// assert!(words.contains(&"|".to_string()));
751 /// ```
752 pub fn completion_words(&self) -> Vec<String> {
753 self.with_passive_view(|view| {
754 let catalog = build_command_catalog(view);
755 completion_words_from_catalog(&catalog)
756 })
757 }
758
759 /// Renders a plain-text help view for plugin commands in the REPL.
760 ///
761 /// # Examples
762 ///
763 /// ```
764 /// use osp_cli::plugin::PluginManager;
765 ///
766 /// let help = PluginManager::new(Vec::new()).repl_help_text();
767 ///
768 /// assert!(help.contains("Backbone commands: help, exit, quit"));
769 /// assert!(help.contains("No plugin commands available."));
770 /// ```
771 pub fn repl_help_text(&self) -> String {
772 self.with_passive_view(|view| {
773 let catalog = build_command_catalog(view);
774 render_repl_help(&catalog)
775 })
776 }
777
778 /// Returns the available provider labels for a command after health and
779 /// enablement filtering.
780 ///
781 /// Unknown commands and commands with no currently available providers
782 /// return an empty list.
783 ///
784 /// # Examples
785 ///
786 /// ```
787 /// use osp_cli::plugin::PluginManager;
788 ///
789 /// let providers = PluginManager::new(Vec::new()).command_providers("shared");
790 ///
791 /// assert!(providers.is_empty());
792 /// ```
793 pub fn command_providers(&self, command: &str) -> Vec<String> {
794 self.with_passive_view(|view| command_provider_labels(command, view))
795 }
796
797 /// Returns the selected provider label when command resolution is
798 /// unambiguous.
799 ///
800 /// Returns `None` when the command is unknown, ambiguous, or currently
801 /// unavailable after health and enablement filtering.
802 ///
803 /// # Examples
804 ///
805 /// ```
806 /// use osp_cli::plugin::PluginManager;
807 ///
808 /// let provider = PluginManager::new(Vec::new()).selected_provider_label("shared");
809 ///
810 /// assert_eq!(provider, None);
811 /// ```
812 pub fn selected_provider_label(&self, command: &str) -> Option<String> {
813 self.with_passive_view(|view| selected_provider_label(command, view))
814 }
815
816 /// Produces a doctor report with plugin health summaries and command conflicts.
817 ///
818 /// # Examples
819 ///
820 /// ```
821 /// use osp_cli::plugin::PluginManager;
822 ///
823 /// let report = PluginManager::new(Vec::new()).doctor();
824 ///
825 /// assert!(report.plugins.is_empty());
826 /// assert!(report.conflicts.is_empty());
827 /// ```
828 pub fn doctor(&self) -> DoctorReport {
829 self.with_passive_view(build_doctor_report)
830 }
831
832 pub(crate) fn validate_command(&self, command: &str) -> Result<()> {
833 let command = command.trim();
834 if command.is_empty() {
835 return Err(anyhow!("command must not be empty"));
836 }
837
838 self.with_dispatch_view(|view| {
839 if view.healthy_providers(command).is_empty() {
840 return Err(anyhow!("no healthy plugin provides command `{command}`"));
841 }
842 Ok(())
843 })
844 }
845
846 #[cfg(test)]
847 pub(crate) fn set_command_state(&self, command: &str, state: PluginCommandState) -> Result<()> {
848 self.validate_command(command)?;
849 self.update_command_preferences(|preferences| {
850 preferences.set_state(command, state);
851 });
852 Ok(())
853 }
854
855 /// Applies an explicit provider selection for a command on this manager.
856 ///
857 /// The selection is kept in the manager's in-memory command-preference
858 /// state and affects subsequent command resolution through this
859 /// `PluginManager` value. It is not written to disk.
860 ///
861 /// # Examples
862 ///
863 /// ```
864 /// # #[cfg(unix)] {
865 /// use osp_cli::plugin::PluginManager;
866 /// # use std::fs;
867 /// # use std::os::unix::fs::PermissionsExt;
868 /// # use std::time::{SystemTime, UNIX_EPOCH};
869 /// #
870 /// # fn write_provider_plugin(dir: &std::path::Path, plugin_id: &str) -> std::io::Result<()> {
871 /// # let plugin_path = dir.join(format!("osp-{plugin_id}"));
872 /// # let script = format!(
873 /// # r#"#!/bin/sh
874 /// # PATH=/usr/bin:/bin
875 /// # if [ "$1" = "--describe" ]; then
876 /// # cat <<'JSON'
877 /// # {{"protocol_version":1,"plugin_id":"{plugin_id}","plugin_version":"0.1.0","min_osp_version":"0.1.0","commands":[{{"name":"shared","about":"{plugin_id} plugin","args":[],"flags":{{}},"subcommands":[]}}]}}
878 /// # JSON
879 /// # exit 0
880 /// # fi
881 /// #
882 /// # cat <<'JSON'
883 /// # {{"protocol_version":1,"ok":true,"data":{{"message":"ok"}},"error":null,"meta":{{"format_hint":"table","columns":["message"]}}}}
884 /// # JSON
885 /// # "#,
886 /// # plugin_id = plugin_id
887 /// # );
888 /// # fs::write(&plugin_path, script)?;
889 /// # let mut perms = fs::metadata(&plugin_path)?.permissions();
890 /// # perms.set_mode(0o755);
891 /// # fs::set_permissions(&plugin_path, perms)?;
892 /// # Ok(())
893 /// # }
894 /// #
895 /// # let root = std::env::temp_dir().join(format!(
896 /// # "osp-cli-doc-provider-selection-{}-{}",
897 /// # std::process::id(),
898 /// # SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
899 /// # ));
900 /// # let plugins_dir = root.join("plugins");
901 /// # fs::create_dir_all(&plugins_dir)?;
902 /// # write_provider_plugin(&plugins_dir, "alpha")?;
903 /// # write_provider_plugin(&plugins_dir, "beta")?;
904 /// #
905 /// let manager = PluginManager::new(vec![plugins_dir]);
906 ///
907 /// assert_eq!(manager.selected_provider_label("shared"), None);
908 ///
909 /// manager.select_provider("shared", "beta")?;
910 ///
911 /// assert_eq!(
912 /// manager.selected_provider_label("shared").as_deref(),
913 /// Some("beta (explicit)")
914 /// );
915 /// # fs::remove_dir_all(&root).ok();
916 /// # }
917 /// # Ok::<(), Box<dyn std::error::Error>>(())
918 /// ```
919 ///
920 /// # Errors
921 ///
922 /// Returns an error when `command` or `plugin_id` is blank, when no
923 /// healthy provider currently exports `command`, or when `plugin_id` is
924 /// not one of the healthy providers for `command`.
925 pub fn select_provider(&self, command: &str, plugin_id: &str) -> Result<()> {
926 let command = command.trim();
927 let plugin_id = plugin_id.trim();
928 if command.is_empty() {
929 return Err(anyhow!("command must not be empty"));
930 }
931 if plugin_id.is_empty() {
932 return Err(anyhow!("plugin id must not be empty"));
933 }
934
935 self.validate_provider_selection(command, plugin_id)?;
936 self.update_command_preferences(|preferences| preferences.set_provider(command, plugin_id));
937 Ok(())
938 }
939
940 /// Clears any explicit in-memory provider selection for a command.
941 ///
942 /// # Examples
943 ///
944 /// ```
945 /// use osp_cli::plugin::PluginManager;
946 ///
947 /// let removed = PluginManager::new(Vec::new())
948 /// .clear_provider_selection("shared")
949 /// .unwrap();
950 ///
951 /// assert!(!removed);
952 /// ```
953 ///
954 /// # Errors
955 ///
956 /// Returns an error when `command` is blank.
957 pub fn clear_provider_selection(&self, command: &str) -> Result<bool> {
958 let command = command.trim();
959 if command.is_empty() {
960 return Err(anyhow!("command must not be empty"));
961 }
962
963 let mut removed = false;
964 self.update_command_preferences(|preferences| {
965 removed = preferences.clear_provider(command);
966 });
967 Ok(removed)
968 }
969
970 /// Verifies that a plugin is a healthy provider candidate for a command.
971 ///
972 /// This validates the command/plugin pair against the manager's current
973 /// discovery view but does not change selection state or persist anything.
974 ///
975 /// # Examples
976 ///
977 /// ```
978 /// use osp_cli::plugin::PluginManager;
979 ///
980 /// let err = PluginManager::new(Vec::new())
981 /// .validate_provider_selection("shared", "alpha")
982 /// .unwrap_err();
983 ///
984 /// assert!(err.to_string().contains("no healthy plugin provides command"));
985 /// ```
986 ///
987 /// # Errors
988 ///
989 /// Returns an error when no healthy provider currently exports `command`,
990 /// or when `plugin_id` is not one of the healthy providers for `command`.
991 pub fn validate_provider_selection(&self, command: &str, plugin_id: &str) -> Result<()> {
992 self.with_dispatch_view(|view| {
993 let available = view.healthy_providers(command);
994 if available.is_empty() {
995 return Err(anyhow!("no healthy plugin provides command `{command}`"));
996 }
997 if !available.iter().any(|plugin| plugin.plugin_id == plugin_id) {
998 return Err(anyhow!(
999 "plugin `{plugin_id}` does not provide healthy command `{command}`; available providers: {}",
1000 available
1001 .iter()
1002 .map(|plugin| plugin_label(plugin))
1003 .collect::<Vec<_>>()
1004 .join(", ")
1005 ));
1006 }
1007 Ok(())
1008 })
1009 }
1010
1011 pub(super) fn resolve_provider(
1012 &self,
1013 command: &str,
1014 provider_override: Option<&str>,
1015 ) -> std::result::Result<DiscoveredPlugin, PluginDispatchError> {
1016 self.with_dispatch_view(
1017 |view| match view.resolve_provider(command, provider_override) {
1018 Ok(ProviderResolution::Selected(selection)) => {
1019 tracing::debug!(
1020 command = %command,
1021 active_providers = view.healthy_providers(command).len(),
1022 selected_provider = %selection.plugin.plugin_id,
1023 selection_mode = ?selection.mode,
1024 "resolved plugin provider"
1025 );
1026 Ok(selection.plugin.clone())
1027 }
1028 Ok(ProviderResolution::Ambiguous(providers)) => {
1029 let provider_labels = providers
1030 .iter()
1031 .copied()
1032 .map(plugin_label)
1033 .collect::<Vec<_>>();
1034 tracing::warn!(
1035 command = %command,
1036 providers = provider_labels.join(", "),
1037 "plugin command requires explicit provider selection"
1038 );
1039 Err(PluginDispatchError::CommandAmbiguous {
1040 command: command.to_string(),
1041 providers: provider_labels,
1042 })
1043 }
1044 Err(ProviderResolutionError::RequestedProviderUnavailable {
1045 requested_provider,
1046 providers,
1047 }) => {
1048 let provider_labels = providers
1049 .iter()
1050 .copied()
1051 .map(plugin_label)
1052 .collect::<Vec<_>>();
1053 tracing::warn!(
1054 command = %command,
1055 requested_provider = %requested_provider,
1056 providers = provider_labels.join(", "),
1057 "requested plugin provider is not available for command"
1058 );
1059 Err(PluginDispatchError::ProviderNotFound {
1060 command: command.to_string(),
1061 requested_provider,
1062 providers: provider_labels,
1063 })
1064 }
1065 Err(ProviderResolutionError::CommandNotFound) => {
1066 tracing::warn!(
1067 command = %command,
1068 active_plugins = view.healthy_plugins().len(),
1069 "no plugin provider found for command"
1070 );
1071 Err(PluginDispatchError::CommandNotFound {
1072 command: command.to_string(),
1073 })
1074 }
1075 },
1076 )
1077 }
1078
1079 // Build the shared passive plugin working set once per operation so read
1080 // paths stop re-deriving health filtering and provider labels independently.
1081 fn with_passive_view<R, F>(&self, apply: F) -> R
1082 where
1083 F: FnOnce(&ActivePluginView<'_>) -> R,
1084 {
1085 let discovered = self.discover();
1086 let preferences = self.command_preferences();
1087 let view = ActivePluginView::new(discovered.as_ref(), &preferences);
1088 apply(&view)
1089 }
1090
1091 // Dispatch paths use the execution-aware discovery snapshot, but the
1092 // downstream provider-selection rules remain the same shared active view.
1093 fn with_dispatch_view<R, F>(&self, apply: F) -> R
1094 where
1095 F: FnOnce(&ActivePluginView<'_>) -> R,
1096 {
1097 let discovered = self.discover_for_dispatch();
1098 let preferences = self.command_preferences();
1099 let view = ActivePluginView::new(discovered.as_ref(), &preferences);
1100 apply(&view)
1101 }
1102
1103 fn command_preferences(&self) -> PluginCommandPreferences {
1104 self.command_preferences
1105 .read()
1106 .unwrap_or_else(|err| err.into_inner())
1107 .clone()
1108 }
1109
1110 pub(crate) fn command_preferences_snapshot(&self) -> PluginCommandPreferences {
1111 self.command_preferences()
1112 }
1113
1114 pub(crate) fn replace_command_preferences(&self, preferences: PluginCommandPreferences) {
1115 let mut current = self
1116 .command_preferences
1117 .write()
1118 .unwrap_or_else(|err| err.into_inner());
1119 *current = preferences;
1120 }
1121
1122 fn update_command_preferences<F>(&self, update: F)
1123 where
1124 F: FnOnce(&mut PluginCommandPreferences),
1125 {
1126 let mut preferences = self
1127 .command_preferences
1128 .write()
1129 .unwrap_or_else(|err| err.into_inner());
1130 update(&mut preferences);
1131 }
1132}