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}