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}