Skip to main content

osp_cli/plugin/
manager.rs

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