Skip to main content

osp_cli/plugin/
manager.rs

1use crate::completion::CommandSpec;
2use crate::core::runtime::RuntimeHints;
3use std::collections::HashMap;
4use std::error::Error as StdError;
5use std::fmt::{Display, Formatter};
6use std::path::PathBuf;
7use std::sync::{Arc, RwLock};
8use std::time::Duration;
9
10pub const DEFAULT_PLUGIN_PROCESS_TIMEOUT_MS: usize = 10_000;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum PluginSource {
14    Explicit,
15    Env,
16    Bundled,
17    UserConfig,
18    Path,
19}
20
21impl Display for PluginSource {
22    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
23        let value = match self {
24            PluginSource::Explicit => "explicit",
25            PluginSource::Env => "env",
26            PluginSource::Bundled => "bundled",
27            PluginSource::UserConfig => "user",
28            PluginSource::Path => "path",
29        };
30        write!(f, "{value}")
31    }
32}
33
34#[derive(Debug, Clone)]
35pub struct DiscoveredPlugin {
36    pub plugin_id: String,
37    pub plugin_version: Option<String>,
38    pub executable: PathBuf,
39    pub source: PluginSource,
40    pub commands: Vec<String>,
41    pub command_specs: Vec<CommandSpec>,
42    pub issue: Option<String>,
43    pub default_enabled: bool,
44}
45
46#[derive(Debug, Clone)]
47pub struct PluginSummary {
48    pub plugin_id: String,
49    pub plugin_version: Option<String>,
50    pub executable: PathBuf,
51    pub source: PluginSource,
52    pub commands: Vec<String>,
53    pub enabled: bool,
54    pub healthy: bool,
55    pub issue: Option<String>,
56}
57
58#[derive(Debug, Clone)]
59pub struct CommandConflict {
60    pub command: String,
61    pub providers: Vec<String>,
62}
63
64#[derive(Debug, Clone)]
65pub struct DoctorReport {
66    pub plugins: Vec<PluginSummary>,
67    pub conflicts: Vec<CommandConflict>,
68}
69
70#[derive(Debug, Clone)]
71pub struct CommandCatalogEntry {
72    pub name: String,
73    pub about: String,
74    pub subcommands: Vec<String>,
75    pub completion: CommandSpec,
76    pub provider: Option<String>,
77    pub providers: Vec<String>,
78    pub conflicted: bool,
79    pub requires_selection: bool,
80    pub selected_explicitly: bool,
81    pub source: Option<PluginSource>,
82}
83
84#[derive(Debug, Clone)]
85pub struct RawPluginOutput {
86    pub status_code: i32,
87    pub stdout: String,
88    pub stderr: String,
89}
90
91#[derive(Debug, Clone, Default)]
92pub struct PluginDispatchContext {
93    pub runtime_hints: RuntimeHints,
94    pub shared_env: Vec<(String, String)>,
95    pub plugin_env: HashMap<String, Vec<(String, String)>>,
96    pub provider_override: Option<String>,
97}
98
99impl PluginDispatchContext {
100    pub(crate) fn env_pairs_for<'a>(
101        &'a self,
102        plugin_id: &'a str,
103    ) -> impl Iterator<Item = (&'a str, &'a str)> {
104        self.shared_env
105            .iter()
106            .map(|(key, value)| (key.as_str(), value.as_str()))
107            .chain(
108                self.plugin_env
109                    .get(plugin_id)
110                    .into_iter()
111                    .flat_map(|entries| entries.iter())
112                    .map(|(key, value)| (key.as_str(), value.as_str())),
113            )
114    }
115}
116
117#[derive(Debug)]
118pub enum PluginDispatchError {
119    CommandNotFound {
120        command: String,
121    },
122    CommandAmbiguous {
123        command: String,
124        providers: Vec<String>,
125    },
126    ProviderNotFound {
127        command: String,
128        requested_provider: String,
129        providers: Vec<String>,
130    },
131    ExecuteFailed {
132        plugin_id: String,
133        source: std::io::Error,
134    },
135    TimedOut {
136        plugin_id: String,
137        timeout: Duration,
138        stderr: String,
139    },
140    NonZeroExit {
141        plugin_id: String,
142        status_code: i32,
143        stderr: String,
144    },
145    InvalidJsonResponse {
146        plugin_id: String,
147        source: serde_json::Error,
148    },
149    InvalidResponsePayload {
150        plugin_id: String,
151        reason: String,
152    },
153}
154
155impl Display for PluginDispatchError {
156    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
157        match self {
158            PluginDispatchError::CommandNotFound { command } => {
159                write!(f, "no plugin provides command: {command}")
160            }
161            PluginDispatchError::CommandAmbiguous { command, providers } => {
162                write!(
163                    f,
164                    "command `{command}` is provided by multiple plugins: {}",
165                    providers.join(", ")
166                )
167            }
168            PluginDispatchError::ProviderNotFound {
169                command,
170                requested_provider,
171                providers,
172            } => {
173                write!(
174                    f,
175                    "plugin `{requested_provider}` does not provide command `{command}`; available providers: {}",
176                    providers.join(", ")
177                )
178            }
179            PluginDispatchError::ExecuteFailed { plugin_id, source } => {
180                write!(f, "failed to execute plugin {plugin_id}: {source}")
181            }
182            PluginDispatchError::TimedOut {
183                plugin_id,
184                timeout,
185                stderr,
186            } => {
187                if stderr.trim().is_empty() {
188                    write!(
189                        f,
190                        "plugin {plugin_id} timed out after {} ms",
191                        timeout.as_millis()
192                    )
193                } else {
194                    write!(
195                        f,
196                        "plugin {plugin_id} timed out after {} ms: {}",
197                        timeout.as_millis(),
198                        stderr.trim()
199                    )
200                }
201            }
202            PluginDispatchError::NonZeroExit {
203                plugin_id,
204                status_code,
205                stderr,
206            } => {
207                if stderr.trim().is_empty() {
208                    write!(f, "plugin {plugin_id} exited with status {status_code}")
209                } else {
210                    write!(
211                        f,
212                        "plugin {plugin_id} exited with status {status_code}: {}",
213                        stderr.trim()
214                    )
215                }
216            }
217            PluginDispatchError::InvalidJsonResponse { plugin_id, source } => {
218                write!(f, "invalid JSON response from plugin {plugin_id}: {source}")
219            }
220            PluginDispatchError::InvalidResponsePayload { plugin_id, reason } => {
221                write!(f, "invalid plugin response from {plugin_id}: {reason}")
222            }
223        }
224    }
225}
226
227impl StdError for PluginDispatchError {
228    fn source(&self) -> Option<&(dyn StdError + 'static)> {
229        match self {
230            PluginDispatchError::ExecuteFailed { source, .. } => Some(source),
231            PluginDispatchError::InvalidJsonResponse { source, .. } => Some(source),
232            PluginDispatchError::CommandNotFound { .. }
233            | PluginDispatchError::CommandAmbiguous { .. }
234            | PluginDispatchError::ProviderNotFound { .. }
235            | PluginDispatchError::TimedOut { .. }
236            | PluginDispatchError::NonZeroExit { .. }
237            | PluginDispatchError::InvalidResponsePayload { .. } => None,
238        }
239    }
240}
241
242pub struct PluginManager {
243    pub(crate) explicit_dirs: Vec<PathBuf>,
244    pub(crate) discovered_cache: RwLock<Option<Arc<[DiscoveredPlugin]>>>,
245    pub(crate) config_root: Option<PathBuf>,
246    pub(crate) cache_root: Option<PathBuf>,
247    pub(crate) process_timeout: Duration,
248    pub(crate) allow_path_discovery: bool,
249}
250
251impl PluginManager {
252    pub fn new(explicit_dirs: Vec<PathBuf>) -> Self {
253        Self {
254            explicit_dirs,
255            discovered_cache: RwLock::new(None),
256            config_root: None,
257            cache_root: None,
258            process_timeout: Duration::from_millis(DEFAULT_PLUGIN_PROCESS_TIMEOUT_MS as u64),
259            allow_path_discovery: false,
260        }
261    }
262
263    pub fn with_roots(mut self, config_root: Option<PathBuf>, cache_root: Option<PathBuf>) -> Self {
264        self.config_root = config_root;
265        self.cache_root = cache_root;
266        self
267    }
268
269    pub fn with_process_timeout(mut self, timeout: Duration) -> Self {
270        self.process_timeout = timeout.max(Duration::from_millis(1));
271        self
272    }
273
274    pub fn with_path_discovery(mut self, allow_path_discovery: bool) -> Self {
275        self.allow_path_discovery = allow_path_discovery;
276        self
277    }
278}