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}