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