Skip to main content

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}