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}