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 preference 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 specified through an environment variable.
57    Env,
58    /// Loaded from the CLI's bundled plugin set.
59    Bundled,
60    /// Loaded from the persisted user configuration.
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 the plugin should be enabled by default.
116    pub default_enabled: bool,
117}
118
119/// Reduced plugin view for listing, doctor, and status surfaces.
120#[derive(Debug, Clone)]
121pub struct PluginSummary {
122    /// Stable provider identifier returned by the plugin.
123    pub plugin_id: String,
124    /// Optional plugin version reported during discovery.
125    pub plugin_version: Option<String>,
126    /// Absolute path to the plugin executable.
127    pub executable: PathBuf,
128    /// Discovery source used to locate the executable.
129    pub source: PluginSource,
130    /// Top-level commands exported by the plugin.
131    pub commands: Vec<String>,
132    /// Whether the plugin is enabled for dispatch.
133    pub enabled: bool,
134    /// Whether the plugin passed discovery-time validation.
135    pub healthy: bool,
136    /// Discovery or validation issue associated with this plugin.
137    pub issue: Option<String>,
138}
139
140/// One command-name conflict across multiple plugin providers.
141#[derive(Debug, Clone)]
142pub struct CommandConflict {
143    /// Conflicting command name.
144    pub command: String,
145    /// Plugin identifiers that provide `command`.
146    pub providers: Vec<String>,
147}
148
149/// Aggregated plugin health payload used by diagnostic surfaces.
150#[derive(Debug, Clone)]
151pub struct DoctorReport {
152    /// Summary entry for each discovered plugin.
153    pub plugins: Vec<PluginSummary>,
154    /// Commands that are provided by more than one plugin.
155    pub conflicts: Vec<CommandConflict>,
156}
157
158/// Normalized command-level catalog entry derived from the discovered plugin set.
159///
160/// Help, completion, and dispatch-selection code can share this view without
161/// understanding plugin discovery internals.
162#[derive(Debug, Clone)]
163pub struct CommandCatalogEntry {
164    /// Full command path, including parent commands when present.
165    pub name: String,
166    /// Short description shown in help and catalog output.
167    pub about: String,
168    /// Optional auth metadata returned by plugin discovery.
169    pub auth: Option<DescribeCommandAuthV1>,
170    /// Immediate subcommand names beneath `name`.
171    pub subcommands: Vec<String>,
172    /// Shell completion metadata for this command.
173    pub completion: CommandSpec,
174    /// Selected provider when dispatch has been resolved.
175    pub provider: Option<String>,
176    /// All providers that export this command.
177    pub providers: Vec<String>,
178    /// Whether more than one provider exports this command.
179    pub conflicted: bool,
180    /// Whether the caller must choose a provider before dispatch.
181    pub requires_selection: bool,
182    /// Whether the provider was selected explicitly by the caller.
183    pub selected_explicitly: bool,
184    /// Discovery source for the selected provider, if resolved.
185    pub source: Option<PluginSource>,
186}
187
188impl CommandCatalogEntry {
189    /// Returns the optional auth hint rendered in help and catalog views.
190    ///
191    /// # Examples
192    ///
193    /// ```
194    /// use osp_cli::completion::CommandSpec;
195    /// use osp_cli::plugin::CommandCatalogEntry;
196    /// use osp_cli::core::plugin::{DescribeCommandAuthV1, DescribeVisibilityModeV1};
197    ///
198    /// let entry = CommandCatalogEntry {
199    ///     name: "ldap user".to_string(),
200    ///     about: "lookup users".to_string(),
201    ///     auth: Some(DescribeCommandAuthV1 {
202    ///         visibility: Some(DescribeVisibilityModeV1::Authenticated),
203    ///         required_capabilities: Vec::new(),
204    ///         feature_flags: Vec::new(),
205    ///     }),
206    ///     subcommands: Vec::new(),
207    ///     completion: CommandSpec::new("ldap"),
208    ///     provider: Some("ldap".to_string()),
209    ///     providers: vec!["ldap".to_string()],
210    ///     conflicted: false,
211    ///     requires_selection: false,
212    ///     selected_explicitly: false,
213    ///     source: None,
214    /// };
215    ///
216    /// assert_eq!(entry.auth_hint().as_deref(), Some("auth"));
217    /// ```
218    pub fn auth_hint(&self) -> Option<String> {
219        self.auth.as_ref().and_then(|auth| auth.hint())
220    }
221}
222
223/// Raw stdout/stderr captured from a plugin subprocess invocation.
224#[derive(Debug, Clone)]
225pub struct RawPluginOutput {
226    /// Process exit status code.
227    pub status_code: i32,
228    /// Captured standard output.
229    pub stdout: String,
230    /// Captured standard error.
231    pub stderr: String,
232}
233
234/// Per-dispatch runtime hints and environment overrides for plugin execution.
235#[derive(Debug, Clone, Default)]
236#[non_exhaustive]
237pub struct PluginDispatchContext {
238    /// Runtime hints serialized into plugin requests.
239    pub runtime_hints: RuntimeHints,
240    /// Environment pairs injected into every plugin process.
241    pub shared_env: Vec<(String, String)>,
242    /// Additional environment pairs injected for specific plugins.
243    pub plugin_env: HashMap<String, Vec<(String, String)>>,
244    /// Provider identifier forced by the caller, if any.
245    pub provider_override: Option<String>,
246}
247
248impl PluginDispatchContext {
249    /// Creates dispatch context from the required runtime hint payload.
250    ///
251    /// # Examples
252    ///
253    /// ```
254    /// use osp_cli::core::output::{ColorMode, OutputFormat, UnicodeMode};
255    /// use osp_cli::core::runtime::{RuntimeHints, RuntimeTerminalKind, UiVerbosity};
256    /// use osp_cli::plugin::PluginDispatchContext;
257    ///
258    /// let context = PluginDispatchContext::new(RuntimeHints::new(
259    ///     UiVerbosity::Info,
260    ///     2,
261    ///     OutputFormat::Json,
262    ///     ColorMode::Always,
263    ///     UnicodeMode::Never,
264    /// ))
265    /// .with_provider_override(Some("ldap".to_string()))
266    /// .with_shared_env([("OSP_FORMAT", "json")]);
267    ///
268    /// assert_eq!(context.provider_override.as_deref(), Some("ldap"));
269    /// assert!(context.shared_env.iter().any(|(key, value)| key == "OSP_FORMAT" && value == "json"));
270    /// assert_eq!(context.runtime_hints.terminal_kind, RuntimeTerminalKind::Unknown);
271    /// ```
272    pub fn new(runtime_hints: RuntimeHints) -> Self {
273        Self {
274            runtime_hints,
275            shared_env: Vec::new(),
276            plugin_env: HashMap::new(),
277            provider_override: None,
278        }
279    }
280
281    /// Replaces the environment injected into every plugin process.
282    pub fn with_shared_env<I, K, V>(mut self, shared_env: I) -> Self
283    where
284        I: IntoIterator<Item = (K, V)>,
285        K: Into<String>,
286        V: Into<String>,
287    {
288        self.shared_env = shared_env
289            .into_iter()
290            .map(|(key, value)| (key.into(), value.into()))
291            .collect();
292        self
293    }
294
295    /// Replaces the environment injected for specific plugins.
296    pub fn with_plugin_env(mut self, plugin_env: HashMap<String, Vec<(String, String)>>) -> Self {
297        self.plugin_env = plugin_env;
298        self
299    }
300
301    /// Replaces the optional forced provider identifier.
302    pub fn with_provider_override(mut self, provider_override: Option<String>) -> Self {
303        self.provider_override = provider_override;
304        self
305    }
306
307    pub(crate) fn env_pairs_for<'a>(
308        &'a self,
309        plugin_id: &'a str,
310    ) -> impl Iterator<Item = (&'a str, &'a str)> {
311        self.shared_env
312            .iter()
313            .map(|(key, value)| (key.as_str(), value.as_str()))
314            .chain(
315                self.plugin_env
316                    .get(plugin_id)
317                    .into_iter()
318                    .flat_map(|entries| entries.iter())
319                    .map(|(key, value)| (key.as_str(), value.as_str())),
320            )
321    }
322}
323
324/// Errors returned when selecting or invoking a plugin command.
325#[derive(Debug)]
326pub enum PluginDispatchError {
327    /// No plugin provides the requested command.
328    CommandNotFound {
329        /// Command name requested by the caller.
330        command: String,
331    },
332    /// More than one plugin provides the requested command.
333    CommandAmbiguous {
334        /// Command name requested by the caller.
335        command: String,
336        /// Plugin identifiers that provide `command`.
337        providers: Vec<String>,
338    },
339    /// The requested provider exists, but not for the requested command.
340    ProviderNotFound {
341        /// Command name requested by the caller.
342        command: String,
343        /// Provider identifier requested by the caller.
344        requested_provider: String,
345        /// Plugin identifiers that provide `command`.
346        providers: Vec<String>,
347    },
348    /// Spawning or waiting for the plugin process failed.
349    ExecuteFailed {
350        /// Plugin identifier being invoked.
351        plugin_id: String,
352        /// Underlying process execution error.
353        source: std::io::Error,
354    },
355    /// The plugin process exceeded the configured timeout.
356    TimedOut {
357        /// Plugin identifier being invoked.
358        plugin_id: String,
359        /// Timeout applied to the subprocess call.
360        timeout: Duration,
361        /// Captured standard error emitted before timeout.
362        stderr: String,
363    },
364    /// The plugin process exited with a non-zero status code.
365    NonZeroExit {
366        /// Plugin identifier being invoked.
367        plugin_id: String,
368        /// Process exit status code.
369        status_code: i32,
370        /// Captured standard error emitted by the plugin.
371        stderr: String,
372    },
373    /// The plugin returned malformed JSON.
374    InvalidJsonResponse {
375        /// Plugin identifier being invoked.
376        plugin_id: String,
377        /// JSON decode error for the response payload.
378        source: serde_json::Error,
379    },
380    /// The plugin returned JSON that failed semantic validation.
381    InvalidResponsePayload {
382        /// Plugin identifier being invoked.
383        plugin_id: String,
384        /// Validation failure description.
385        reason: String,
386    },
387}
388
389impl Display for PluginDispatchError {
390    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
391        match self {
392            PluginDispatchError::CommandNotFound { command } => {
393                write!(f, "no plugin provides command: {command}")
394            }
395            PluginDispatchError::CommandAmbiguous { command, providers } => {
396                write!(
397                    f,
398                    "command `{command}` is provided by multiple plugins: {}",
399                    providers.join(", ")
400                )
401            }
402            PluginDispatchError::ProviderNotFound {
403                command,
404                requested_provider,
405                providers,
406            } => {
407                write!(
408                    f,
409                    "plugin `{requested_provider}` does not provide command `{command}`; available providers: {}",
410                    providers.join(", ")
411                )
412            }
413            PluginDispatchError::ExecuteFailed { plugin_id, source } => {
414                write!(f, "failed to execute plugin {plugin_id}: {source}")
415            }
416            PluginDispatchError::TimedOut {
417                plugin_id,
418                timeout,
419                stderr,
420            } => {
421                if stderr.trim().is_empty() {
422                    write!(
423                        f,
424                        "plugin {plugin_id} timed out after {} ms",
425                        timeout.as_millis()
426                    )
427                } else {
428                    write!(
429                        f,
430                        "plugin {plugin_id} timed out after {} ms: {}",
431                        timeout.as_millis(),
432                        stderr.trim()
433                    )
434                }
435            }
436            PluginDispatchError::NonZeroExit {
437                plugin_id,
438                status_code,
439                stderr,
440            } => {
441                if stderr.trim().is_empty() {
442                    write!(f, "plugin {plugin_id} exited with status {status_code}")
443                } else {
444                    write!(
445                        f,
446                        "plugin {plugin_id} exited with status {status_code}: {}",
447                        stderr.trim()
448                    )
449                }
450            }
451            PluginDispatchError::InvalidJsonResponse { plugin_id, source } => {
452                write!(f, "invalid JSON response from plugin {plugin_id}: {source}")
453            }
454            PluginDispatchError::InvalidResponsePayload { plugin_id, reason } => {
455                write!(f, "invalid plugin response from {plugin_id}: {reason}")
456            }
457        }
458    }
459}
460
461impl StdError for PluginDispatchError {
462    fn source(&self) -> Option<&(dyn StdError + 'static)> {
463        match self {
464            PluginDispatchError::ExecuteFailed { source, .. } => Some(source),
465            PluginDispatchError::InvalidJsonResponse { source, .. } => Some(source),
466            PluginDispatchError::CommandNotFound { .. }
467            | PluginDispatchError::CommandAmbiguous { .. }
468            | PluginDispatchError::ProviderNotFound { .. }
469            | PluginDispatchError::TimedOut { .. }
470            | PluginDispatchError::NonZeroExit { .. }
471            | PluginDispatchError::InvalidResponsePayload { .. } => None,
472        }
473    }
474}
475
476/// Coordinates plugin discovery, caching, and dispatch settings.
477pub struct PluginManager {
478    pub(crate) explicit_dirs: Vec<PathBuf>,
479    pub(crate) discovered_cache: RwLock<Option<Arc<[DiscoveredPlugin]>>>,
480    pub(crate) dispatch_discovered_cache: RwLock<Option<Arc<[DiscoveredPlugin]>>>,
481    pub(crate) command_preferences: RwLock<PluginCommandPreferences>,
482    pub(crate) config_root: Option<PathBuf>,
483    pub(crate) cache_root: Option<PathBuf>,
484    pub(crate) process_timeout: Duration,
485    pub(crate) allow_path_discovery: bool,
486}
487
488impl PluginManager {
489    /// Creates a plugin manager with the provided explicit search roots.
490    ///
491    /// # Examples
492    ///
493    /// ```
494    /// use osp_cli::plugin::PluginManager;
495    /// use std::path::PathBuf;
496    ///
497    /// let manager = PluginManager::new(vec![PathBuf::from("/plugins")]);
498    ///
499    /// assert_eq!(manager.explicit_dirs().len(), 1);
500    /// ```
501    pub fn new(explicit_dirs: Vec<PathBuf>) -> Self {
502        Self {
503            explicit_dirs,
504            discovered_cache: RwLock::new(None),
505            dispatch_discovered_cache: RwLock::new(None),
506            command_preferences: RwLock::new(PluginCommandPreferences::default()),
507            config_root: None,
508            cache_root: None,
509            process_timeout: Duration::from_millis(DEFAULT_PLUGIN_PROCESS_TIMEOUT_MS as u64),
510            allow_path_discovery: false,
511        }
512    }
513
514    /// Returns the explicit plugin search roots configured for this manager.
515    pub fn explicit_dirs(&self) -> &[PathBuf] {
516        &self.explicit_dirs
517    }
518
519    /// Sets config and cache roots used for persisted plugin metadata and
520    /// preferences.
521    ///
522    /// # Examples
523    ///
524    /// ```
525    /// use osp_cli::plugin::PluginManager;
526    /// use std::path::PathBuf;
527    ///
528    /// let manager = PluginManager::new(Vec::new()).with_roots(
529    ///     Some(PathBuf::from("/config")),
530    ///     Some(PathBuf::from("/cache")),
531    /// );
532    ///
533    /// assert_eq!(manager.config_root(), Some(PathBuf::from("/config").as_path()));
534    /// assert_eq!(manager.cache_root(), Some(PathBuf::from("/cache").as_path()));
535    /// ```
536    pub fn with_roots(mut self, config_root: Option<PathBuf>, cache_root: Option<PathBuf>) -> Self {
537        self.config_root = config_root;
538        self.cache_root = cache_root;
539        self
540    }
541
542    /// Returns the configured config root used for persisted plugin metadata.
543    pub fn config_root(&self) -> Option<&std::path::Path> {
544        self.config_root.as_deref()
545    }
546
547    /// Returns the configured cache root used for persisted plugin state.
548    pub fn cache_root(&self) -> Option<&std::path::Path> {
549        self.cache_root.as_deref()
550    }
551
552    /// Sets the subprocess timeout used for plugin describe and dispatch calls.
553    ///
554    /// Timeout values are clamped to at least one millisecond so the manager
555    /// never stores a zero-duration subprocess timeout.
556    ///
557    /// # Examples
558    ///
559    /// ```
560    /// use osp_cli::plugin::PluginManager;
561    /// use std::time::Duration;
562    ///
563    /// let manager = PluginManager::new(Vec::new())
564    ///     .with_process_timeout(Duration::from_millis(0));
565    ///
566    /// assert_eq!(manager.process_timeout(), Duration::from_millis(1));
567    /// ```
568    pub fn with_process_timeout(mut self, timeout: Duration) -> Self {
569        self.process_timeout = timeout.max(Duration::from_millis(1));
570        self
571    }
572
573    /// Returns the subprocess timeout used for describe and dispatch calls.
574    pub fn process_timeout(&self) -> Duration {
575        self.process_timeout
576    }
577
578    /// Enables or disables fallback discovery through the process `PATH`.
579    ///
580    /// # Examples
581    ///
582    /// ```
583    /// use osp_cli::plugin::PluginManager;
584    ///
585    /// let manager = PluginManager::new(Vec::new()).with_path_discovery(true);
586    ///
587    /// assert!(manager.path_discovery_enabled());
588    /// ```
589    pub fn with_path_discovery(mut self, allow_path_discovery: bool) -> Self {
590        self.allow_path_discovery = allow_path_discovery;
591        self
592    }
593
594    /// Returns whether fallback discovery through the process `PATH` is enabled.
595    pub fn path_discovery_enabled(&self) -> bool {
596        self.allow_path_discovery
597    }
598
599    pub(crate) fn with_command_preferences(
600        mut self,
601        preferences: PluginCommandPreferences,
602    ) -> Self {
603        self.command_preferences = RwLock::new(preferences);
604        self
605    }
606
607    /// Lists discovered plugins with health, command, and enablement status.
608    pub fn list_plugins(&self) -> Result<Vec<PluginSummary>> {
609        self.with_passive_view(|view| Ok(list_plugins(view)))
610    }
611
612    /// Builds the effective command catalog after provider resolution and health filtering.
613    pub fn command_catalog(&self) -> Result<Vec<CommandCatalogEntry>> {
614        self.with_passive_view(build_command_catalog)
615    }
616
617    /// Builds a command policy registry from active plugin describe metadata.
618    pub fn command_policy_registry(
619        &self,
620    ) -> Result<crate::core::command_policy::CommandPolicyRegistry> {
621        self.with_passive_view(build_command_policy_registry)
622    }
623
624    /// Returns completion words derived from the current plugin command catalog.
625    pub fn completion_words(&self) -> Result<Vec<String>> {
626        self.with_passive_view(|view| {
627            let catalog = build_command_catalog(view)?;
628            Ok(completion_words_from_catalog(&catalog))
629        })
630    }
631
632    /// Renders a plain-text help view for plugin commands in the REPL.
633    pub fn repl_help_text(&self) -> Result<String> {
634        self.with_passive_view(|view| {
635            let catalog = build_command_catalog(view)?;
636            Ok(render_repl_help(&catalog))
637        })
638    }
639
640    /// Returns the available provider labels for a command.
641    pub fn command_providers(&self, command: &str) -> Result<Vec<String>> {
642        self.with_passive_view(|view| Ok(command_provider_labels(command, view)))
643    }
644
645    /// Returns the selected provider label when command resolution is unambiguous.
646    pub fn selected_provider_label(&self, command: &str) -> Result<Option<String>> {
647        self.with_passive_view(|view| Ok(selected_provider_label(command, view)))
648    }
649
650    /// Produces a doctor report with plugin health summaries and command conflicts.
651    pub fn doctor(&self) -> Result<DoctorReport> {
652        self.with_passive_view(|view| Ok(build_doctor_report(view)))
653    }
654
655    pub(crate) fn validate_command(&self, command: &str) -> Result<()> {
656        let command = command.trim();
657        if command.is_empty() {
658            return Err(anyhow!("command must not be empty"));
659        }
660
661        self.with_dispatch_view(|view| {
662            if view.healthy_providers(command).is_empty() {
663                return Err(anyhow!("no healthy plugin provides command `{command}`"));
664            }
665            Ok(())
666        })
667    }
668
669    #[cfg(test)]
670    pub(crate) fn set_command_state(&self, command: &str, state: PluginCommandState) -> Result<()> {
671        self.validate_command(command)?;
672        self.update_command_preferences(|preferences| {
673            preferences.set_state(command, state);
674        });
675        Ok(())
676    }
677
678    /// Persists an explicit provider preference for a command.
679    pub fn set_preferred_provider(&self, command: &str, plugin_id: &str) -> Result<()> {
680        let command = command.trim();
681        let plugin_id = plugin_id.trim();
682        if command.is_empty() {
683            return Err(anyhow!("command must not be empty"));
684        }
685        if plugin_id.is_empty() {
686            return Err(anyhow!("plugin id must not be empty"));
687        }
688
689        self.validate_preferred_provider(command, plugin_id)?;
690        self.update_command_preferences(|preferences| preferences.set_provider(command, plugin_id));
691        Ok(())
692    }
693
694    /// Clears any stored provider preference for a command.
695    pub fn clear_preferred_provider(&self, command: &str) -> Result<bool> {
696        let command = command.trim();
697        if command.is_empty() {
698            return Err(anyhow!("command must not be empty"));
699        }
700
701        let mut removed = false;
702        self.update_command_preferences(|preferences| {
703            removed = preferences.clear_provider(command);
704        });
705        Ok(removed)
706    }
707
708    /// Verifies that a plugin is a healthy provider for a command before storing it.
709    pub fn validate_preferred_provider(&self, command: &str, plugin_id: &str) -> Result<()> {
710        self.with_dispatch_view(|view| {
711            let available = view.healthy_providers(command);
712            if available.is_empty() {
713                return Err(anyhow!("no healthy plugin provides command `{command}`"));
714            }
715            if !available.iter().any(|plugin| plugin.plugin_id == plugin_id) {
716                return Err(anyhow!(
717                    "plugin `{plugin_id}` does not provide healthy command `{command}`; available providers: {}",
718                    available
719                        .iter()
720                        .map(|plugin| plugin_label(plugin))
721                        .collect::<Vec<_>>()
722                        .join(", ")
723                ));
724            }
725            Ok(())
726        })
727    }
728
729    pub(super) fn resolve_provider(
730        &self,
731        command: &str,
732        provider_override: Option<&str>,
733    ) -> std::result::Result<DiscoveredPlugin, PluginDispatchError> {
734        self.with_dispatch_view(
735            |view| match view.resolve_provider(command, provider_override) {
736                Ok(ProviderResolution::Selected(selection)) => {
737                    tracing::debug!(
738                        command = %command,
739                        active_providers = view.healthy_providers(command).len(),
740                        selected_provider = %selection.plugin.plugin_id,
741                        selection_mode = ?selection.mode,
742                        "resolved plugin provider"
743                    );
744                    Ok(selection.plugin.clone())
745                }
746                Ok(ProviderResolution::Ambiguous(providers)) => {
747                    let provider_labels = providers
748                        .iter()
749                        .copied()
750                        .map(plugin_label)
751                        .collect::<Vec<_>>();
752                    tracing::warn!(
753                        command = %command,
754                        providers = provider_labels.join(", "),
755                        "plugin command requires explicit provider selection"
756                    );
757                    Err(PluginDispatchError::CommandAmbiguous {
758                        command: command.to_string(),
759                        providers: provider_labels,
760                    })
761                }
762                Err(ProviderResolutionError::RequestedProviderUnavailable {
763                    requested_provider,
764                    providers,
765                }) => {
766                    let provider_labels = providers
767                        .iter()
768                        .copied()
769                        .map(plugin_label)
770                        .collect::<Vec<_>>();
771                    tracing::warn!(
772                        command = %command,
773                        requested_provider = %requested_provider,
774                        providers = provider_labels.join(", "),
775                        "requested plugin provider is not available for command"
776                    );
777                    Err(PluginDispatchError::ProviderNotFound {
778                        command: command.to_string(),
779                        requested_provider,
780                        providers: provider_labels,
781                    })
782                }
783                Err(ProviderResolutionError::CommandNotFound) => {
784                    tracing::warn!(
785                        command = %command,
786                        active_plugins = view.healthy_plugins().len(),
787                        "no plugin provider found for command"
788                    );
789                    Err(PluginDispatchError::CommandNotFound {
790                        command: command.to_string(),
791                    })
792                }
793            },
794        )
795    }
796
797    // Build the shared passive plugin working set once per operation so read
798    // paths stop re-deriving health filtering and provider labels independently.
799    fn with_passive_view<R, F>(&self, apply: F) -> R
800    where
801        F: FnOnce(&ActivePluginView<'_>) -> R,
802    {
803        let discovered = self.discover();
804        let preferences = self.command_preferences();
805        let view = ActivePluginView::new(discovered.as_ref(), &preferences);
806        apply(&view)
807    }
808
809    // Dispatch paths use the execution-aware discovery snapshot, but the
810    // downstream provider-selection rules remain the same shared active view.
811    fn with_dispatch_view<R, F>(&self, apply: F) -> R
812    where
813        F: FnOnce(&ActivePluginView<'_>) -> R,
814    {
815        let discovered = self.discover_for_dispatch();
816        let preferences = self.command_preferences();
817        let view = ActivePluginView::new(discovered.as_ref(), &preferences);
818        apply(&view)
819    }
820
821    fn command_preferences(&self) -> PluginCommandPreferences {
822        self.command_preferences
823            .read()
824            .unwrap_or_else(|err| err.into_inner())
825            .clone()
826    }
827
828    pub(crate) fn command_preferences_snapshot(&self) -> PluginCommandPreferences {
829        self.command_preferences()
830    }
831
832    pub(crate) fn replace_command_preferences(&self, preferences: PluginCommandPreferences) {
833        let mut current = self
834            .command_preferences
835            .write()
836            .unwrap_or_else(|err| err.into_inner());
837        *current = preferences;
838    }
839
840    fn update_command_preferences<F>(&self, update: F)
841    where
842        F: FnOnce(&mut PluginCommandPreferences),
843    {
844        let mut preferences = self
845            .command_preferences
846            .write()
847            .unwrap_or_else(|err| err.into_inner());
848        update(&mut preferences);
849    }
850}