1use super::conversion::{collect_completion_words, direct_subcommand_names};
2use super::manager::{
3 CommandCatalogEntry, CommandConflict, DiscoveredPlugin, DoctorReport, PluginManager,
4 PluginSummary,
5};
6use crate::completion::CommandSpec;
7use crate::config::default_config_root_dir;
8use crate::plugin::PluginDispatchError;
9use anyhow::{Result, anyhow};
10use std::collections::{BTreeMap, BTreeSet, HashMap};
11use std::path::PathBuf;
12
13#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
14pub(super) struct PluginState {
15 #[serde(default)]
16 pub(super) enabled: Vec<String>,
17 #[serde(default)]
18 pub(super) disabled: Vec<String>,
19 #[serde(default)]
20 pub(super) preferred_providers: BTreeMap<String, String>,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24enum ProviderSelectionMode {
25 Override,
26 Preference,
27 Unique,
28}
29
30#[derive(Debug, Clone, Copy)]
31struct ProviderSelection<'a> {
32 plugin: &'a DiscoveredPlugin,
33 mode: ProviderSelectionMode,
34}
35
36enum ProviderResolution<'a> {
37 Selected(ProviderSelection<'a>),
38 Ambiguous(Vec<&'a DiscoveredPlugin>),
39}
40
41#[derive(Debug)]
42enum ProviderResolutionError<'a> {
43 CommandNotFound,
44 RequestedProviderUnavailable {
45 requested_provider: String,
46 providers: Vec<&'a DiscoveredPlugin>,
47 },
48}
49
50impl PluginManager {
51 pub fn list_plugins(&self) -> Result<Vec<PluginSummary>> {
52 let discovered = self.discover();
53 let state = self.load_state().unwrap_or_default();
54
55 Ok(discovered
56 .iter()
57 .map(|plugin| PluginSummary {
58 enabled: is_enabled(&state, &plugin.plugin_id, plugin.default_enabled),
59 healthy: plugin.issue.is_none(),
60 issue: plugin.issue.clone(),
61 plugin_id: plugin.plugin_id.clone(),
62 plugin_version: plugin.plugin_version.clone(),
63 executable: plugin.executable.clone(),
64 source: plugin.source,
65 commands: plugin.commands.clone(),
66 })
67 .collect())
68 }
69
70 pub fn command_catalog(&self) -> Result<Vec<CommandCatalogEntry>> {
71 let state = self.load_state().unwrap_or_default();
72 let discovered = self.discover();
73 let active = active_plugins(discovered.as_ref(), &state).collect::<Vec<_>>();
74 let provider_index = provider_labels_by_command(&active);
75 let command_names = active
76 .iter()
77 .flat_map(|plugin| plugin.command_specs.iter().map(|spec| spec.name.clone()))
78 .collect::<BTreeSet<_>>();
79 let mut out = Vec::new();
80
81 for command_name in command_names {
82 let providers = provider_index
83 .get(&command_name)
84 .cloned()
85 .unwrap_or_default();
86 match resolve_provider_for_command(&command_name, &active, &state, None)
87 .expect("active command name should resolve to one or more providers")
88 {
89 ProviderResolution::Selected(selection) => {
90 let spec = selection
91 .plugin
92 .command_specs
93 .iter()
94 .find(|spec| spec.name == command_name)
95 .expect("selected provider should include command spec");
96 out.push(CommandCatalogEntry {
97 name: command_name,
98 about: spec.tooltip.clone().unwrap_or_default(),
99 subcommands: direct_subcommand_names(spec),
100 completion: spec.clone(),
101 provider: Some(selection.plugin.plugin_id.clone()),
102 providers: providers.clone(),
103 conflicted: providers.len() > 1,
104 requires_selection: false,
105 selected_explicitly: matches!(
106 selection.mode,
107 ProviderSelectionMode::Override | ProviderSelectionMode::Preference
108 ),
109 source: Some(selection.plugin.source),
110 });
111 }
112 ProviderResolution::Ambiguous(_) => {
113 let about = format!(
114 "provider selection required; use --plugin-provider <plugin-id> or `osp plugins select-provider {command_name} <plugin-id>`"
115 );
116 out.push(CommandCatalogEntry {
117 name: command_name.clone(),
118 about: about.clone(),
119 subcommands: Vec::new(),
120 completion: CommandSpec::new(command_name),
121 provider: None,
122 providers: providers.clone(),
123 conflicted: true,
124 requires_selection: true,
125 selected_explicitly: false,
126 source: None,
127 });
128 }
129 }
130 }
131
132 out.sort_by(|a, b| a.name.cmp(&b.name));
133 Ok(out)
134 }
135
136 pub fn completion_words(&self) -> Result<Vec<String>> {
137 let catalog = self.command_catalog()?;
138 let mut words = vec![
139 "help".to_string(),
140 "exit".to_string(),
141 "quit".to_string(),
142 "P".to_string(),
143 "F".to_string(),
144 "V".to_string(),
145 "|".to_string(),
146 ];
147
148 for command in catalog {
149 words.push(command.name);
150 words.extend(collect_completion_words(&command.completion));
151 }
152
153 words.sort();
154 words.dedup();
155 Ok(words)
156 }
157
158 pub fn repl_help_text(&self) -> Result<String> {
159 let catalog = self.command_catalog()?;
160 let mut out = String::new();
161
162 out.push_str("Backbone commands: help, exit, quit\n");
163 if catalog.is_empty() {
164 out.push_str("No plugin commands available.\n");
165 return Ok(out);
166 }
167
168 out.push_str("Plugin commands:\n");
169 for command in catalog {
170 let subs = if command.subcommands.is_empty() {
171 "".to_string()
172 } else {
173 format!(" [{}]", command.subcommands.join(", "))
174 };
175 let about = if command.about.trim().is_empty() {
176 "-".to_string()
177 } else {
178 command.about.clone()
179 };
180 if command.requires_selection {
181 out.push_str(&format!(
182 " {name}{subs} - {about} (providers: {providers})\n",
183 name = command.name,
184 providers = command.providers.join(", "),
185 ));
186 } else {
187 let conflict = if command.conflicted {
188 format!(" conflicts: {}", command.providers.join(", "))
189 } else {
190 String::new()
191 };
192 out.push_str(&format!(
193 " {name}{subs} - {about} ({provider}/{source}){conflict}\n",
194 name = command.name,
195 provider = command.provider.as_deref().unwrap_or("-"),
196 source = command
197 .source
198 .map(|value| value.to_string())
199 .unwrap_or_else(|| "-".to_string()),
200 conflict = conflict,
201 ));
202 }
203 }
204
205 Ok(out)
206 }
207
208 pub fn command_providers(&self, command: &str) -> Vec<String> {
209 let state = self.load_state().unwrap_or_default();
210 let discovered = self.discover();
211 let mut out = Vec::new();
212 for plugin in active_plugins(discovered.as_ref(), &state) {
213 if plugin.commands.iter().any(|name| name == command) {
214 out.push(format!("{} ({})", plugin.plugin_id, plugin.source));
215 }
216 }
217 out
218 }
219
220 pub fn selected_provider_label(&self, command: &str) -> Option<String> {
221 let state = self.load_state().unwrap_or_default();
222 let discovered = self.discover();
223 let active = active_plugins(discovered.as_ref(), &state).collect::<Vec<_>>();
224 match resolve_provider_for_command(command, &active, &state, None).ok()? {
225 ProviderResolution::Selected(selection) => Some(plugin_label(selection.plugin)),
226 ProviderResolution::Ambiguous(_) => None,
227 }
228 }
229
230 pub fn doctor(&self) -> Result<DoctorReport> {
231 let plugins = self.list_plugins()?;
232 let mut conflicts_index: HashMap<String, Vec<String>> = HashMap::new();
233
234 for plugin in &plugins {
235 if !plugin.enabled || !plugin.healthy {
236 continue;
237 }
238 for command in &plugin.commands {
239 conflicts_index
240 .entry(command.clone())
241 .or_default()
242 .push(format!("{} ({})", plugin.plugin_id, plugin.source));
243 }
244 }
245
246 let mut conflicts = conflicts_index
247 .into_iter()
248 .filter_map(|(command, providers)| {
249 if providers.len() > 1 {
250 Some(CommandConflict { command, providers })
251 } else {
252 None
253 }
254 })
255 .collect::<Vec<CommandConflict>>();
256 conflicts.sort_by(|a, b| a.command.cmp(&b.command));
257
258 Ok(DoctorReport { plugins, conflicts })
259 }
260
261 pub fn set_enabled(&self, plugin_id: &str, enabled: bool) -> Result<()> {
262 let mut state = self.load_state().unwrap_or_default();
263 state.enabled.retain(|id| id != plugin_id);
264 state.disabled.retain(|id| id != plugin_id);
265
266 if enabled {
267 state.enabled.push(plugin_id.to_string());
268 } else {
269 state.disabled.push(plugin_id.to_string());
270 }
271
272 state.enabled.sort();
273 state.enabled.dedup();
274 state.disabled.sort();
275 state.disabled.dedup();
276 self.save_state(&state)
277 }
278
279 pub fn set_preferred_provider(&self, command: &str, plugin_id: &str) -> Result<()> {
280 let command = command.trim();
281 let plugin_id = plugin_id.trim();
282 if command.is_empty() {
283 return Err(anyhow!("command must not be empty"));
284 }
285 if plugin_id.is_empty() {
286 return Err(anyhow!("plugin id must not be empty"));
287 }
288
289 let mut state = self.load_state().unwrap_or_default();
290 let discovered = self.discover();
291 let active = active_plugins(discovered.as_ref(), &state).collect::<Vec<_>>();
292 let available = providers_for_command(command, &active);
293 if available.is_empty() {
294 return Err(anyhow!("no active plugin provides command `{command}`"));
295 }
296 if !available.iter().any(|plugin| plugin.plugin_id == plugin_id) {
297 return Err(anyhow!(
298 "plugin `{plugin_id}` does not provide active command `{command}`; available providers: {}",
299 available
300 .iter()
301 .map(|plugin| plugin_label(plugin))
302 .collect::<Vec<_>>()
303 .join(", ")
304 ));
305 }
306
307 state
308 .preferred_providers
309 .insert(command.to_string(), plugin_id.to_string());
310 self.save_state(&state)
311 }
312
313 pub fn clear_preferred_provider(&self, command: &str) -> Result<bool> {
314 let command = command.trim();
315 if command.is_empty() {
316 return Err(anyhow!("command must not be empty"));
317 }
318
319 let mut state = self.load_state().unwrap_or_default();
320 let removed = state.preferred_providers.remove(command).is_some();
321 if removed {
322 self.save_state(&state)?;
323 }
324 Ok(removed)
325 }
326
327 pub(super) fn resolve_provider(
328 &self,
329 command: &str,
330 provider_override: Option<&str>,
331 ) -> std::result::Result<DiscoveredPlugin, PluginDispatchError> {
332 let state = self.load_state().unwrap_or_default();
333 let discovered = self.discover();
334 let active = active_plugins(discovered.as_ref(), &state).collect::<Vec<_>>();
335 match resolve_provider_for_command(command, &active, &state, provider_override) {
336 Ok(ProviderResolution::Selected(selection)) => {
337 tracing::debug!(
338 command = %command,
339 active_providers = providers_for_command(command, &active).len(),
340 selected_provider = %selection.plugin.plugin_id,
341 selection_mode = ?selection.mode,
342 "resolved plugin provider"
343 );
344 Ok(selection.plugin.clone())
345 }
346 Ok(ProviderResolution::Ambiguous(providers)) => {
347 let provider_labels = providers
348 .iter()
349 .copied()
350 .map(plugin_label)
351 .collect::<Vec<_>>();
352 tracing::warn!(
353 command = %command,
354 providers = provider_labels.join(", "),
355 "plugin command requires explicit provider selection"
356 );
357 Err(PluginDispatchError::CommandAmbiguous {
358 command: command.to_string(),
359 providers: provider_labels,
360 })
361 }
362 Err(ProviderResolutionError::RequestedProviderUnavailable {
363 requested_provider,
364 providers,
365 }) => {
366 let provider_labels = providers
367 .iter()
368 .copied()
369 .map(plugin_label)
370 .collect::<Vec<_>>();
371 tracing::warn!(
372 command = %command,
373 requested_provider = %requested_provider,
374 providers = provider_labels.join(", "),
375 "requested plugin provider is not available for command"
376 );
377 Err(PluginDispatchError::ProviderNotFound {
378 command: command.to_string(),
379 requested_provider,
380 providers: provider_labels,
381 })
382 }
383 Err(ProviderResolutionError::CommandNotFound) => {
384 tracing::warn!(
385 command = %command,
386 active_plugins = active.len(),
387 "no plugin provider found for command"
388 );
389 Err(PluginDispatchError::CommandNotFound {
390 command: command.to_string(),
391 })
392 }
393 }
394 }
395
396 pub(super) fn load_state(&self) -> Result<PluginState> {
397 let path = self
398 .plugin_state_path()
399 .ok_or_else(|| anyhow!("failed to resolve plugin state path"))?;
400 if !path.exists() {
401 tracing::debug!(path = %path.display(), "plugin state file missing; using defaults");
402 return Ok(PluginState::default());
403 }
404
405 let raw = std::fs::read_to_string(&path)
406 .map_err(anyhow::Error::from)
407 .and_then(|raw| serde_json::from_str::<PluginState>(&raw).map_err(anyhow::Error::from))
408 .map_err(|err| {
409 err.context(format!(
410 "failed to load plugin state from {}",
411 path.display()
412 ))
413 })?;
414 tracing::debug!(
415 path = %path.display(),
416 enabled = raw.enabled.len(),
417 disabled = raw.disabled.len(),
418 preferred = raw.preferred_providers.len(),
419 "loaded plugin state"
420 );
421 Ok(raw)
422 }
423
424 pub(super) fn save_state(&self, state: &PluginState) -> Result<()> {
425 let path = self
426 .plugin_state_path()
427 .ok_or_else(|| anyhow!("failed to resolve plugin state path"))?;
428 if let Some(parent) = path.parent() {
429 std::fs::create_dir_all(parent)?;
430 }
431
432 let payload = serde_json::to_string_pretty(state)?;
433 write_text_atomic(&path, &payload)
434 }
435
436 fn plugin_state_path(&self) -> Option<PathBuf> {
437 let mut path = self.config_root.clone().or_else(default_config_root_dir)?;
438 path.push("plugins.json");
439 Some(path)
440 }
441}
442
443pub(super) fn is_active_plugin(plugin: &DiscoveredPlugin, state: &PluginState) -> bool {
444 plugin.issue.is_none() && is_enabled(state, &plugin.plugin_id, plugin.default_enabled)
445}
446
447pub(super) fn active_plugins<'a>(
448 discovered: &'a [DiscoveredPlugin],
449 state: &'a PluginState,
450) -> impl Iterator<Item = &'a DiscoveredPlugin> + 'a {
451 discovered
452 .iter()
453 .filter(move |plugin| is_active_plugin(plugin, state))
454}
455
456fn plugin_label(plugin: &DiscoveredPlugin) -> String {
457 format!("{} ({})", plugin.plugin_id, plugin.source)
458}
459
460fn plugin_provides_command(plugin: &DiscoveredPlugin, command: &str) -> bool {
461 plugin.commands.iter().any(|name| name == command)
462}
463
464fn providers_for_command<'a>(
465 command: &str,
466 plugins: &[&'a DiscoveredPlugin],
467) -> Vec<&'a DiscoveredPlugin> {
468 plugins
469 .iter()
470 .copied()
471 .filter(|plugin| plugin_provides_command(plugin, command))
472 .collect()
473}
474
475fn resolve_provider_for_command<'a>(
476 command: &str,
477 plugins: &[&'a DiscoveredPlugin],
478 state: &PluginState,
479 provider_override: Option<&str>,
480) -> std::result::Result<ProviderResolution<'a>, ProviderResolutionError<'a>> {
481 let providers = providers_for_command(command, plugins);
482 if providers.is_empty() {
483 return Err(ProviderResolutionError::CommandNotFound);
484 }
485
486 if let Some(requested_provider) = provider_override
487 .map(str::trim)
488 .filter(|value| !value.is_empty())
489 {
490 if let Some(plugin) = providers
491 .iter()
492 .copied()
493 .find(|plugin| plugin.plugin_id == requested_provider)
494 {
495 return Ok(ProviderResolution::Selected(ProviderSelection {
496 plugin,
497 mode: ProviderSelectionMode::Override,
498 }));
499 }
500 return Err(ProviderResolutionError::RequestedProviderUnavailable {
501 requested_provider: requested_provider.to_string(),
502 providers,
503 });
504 }
505
506 if let Some(preferred) = state.preferred_providers.get(command) {
507 if let Some(plugin) = providers
508 .iter()
509 .copied()
510 .find(|plugin| plugin.plugin_id == *preferred)
511 {
512 return Ok(ProviderResolution::Selected(ProviderSelection {
513 plugin,
514 mode: ProviderSelectionMode::Preference,
515 }));
516 }
517
518 tracing::trace!(
519 command = %command,
520 preferred_provider = %preferred,
521 available_providers = providers.len(),
522 "preferred provider not available; reevaluating command provider"
523 );
524 }
525
526 if providers.len() == 1 {
527 return Ok(ProviderResolution::Selected(ProviderSelection {
528 plugin: providers[0],
529 mode: ProviderSelectionMode::Unique,
530 }));
531 }
532
533 Ok(ProviderResolution::Ambiguous(providers))
534}
535
536fn provider_labels_by_command(plugins: &[&DiscoveredPlugin]) -> HashMap<String, Vec<String>> {
537 let mut index = HashMap::new();
538 for plugin in plugins {
539 let label = plugin_label(plugin);
540 for command in &plugin.commands {
541 index
542 .entry(command.clone())
543 .or_insert_with(Vec::new)
544 .push(label.clone());
545 }
546 }
547 index
548}
549
550pub(super) fn is_enabled(state: &PluginState, plugin_id: &str, default_enabled: bool) -> bool {
551 if state.enabled.iter().any(|id| id == plugin_id) {
552 return true;
553 }
554 if state.disabled.iter().any(|id| id == plugin_id) {
555 return false;
556 }
557 default_enabled
558}
559
560pub(super) fn write_text_atomic(path: &std::path::Path, payload: &str) -> Result<()> {
561 let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
562 let file_name = path
563 .file_name()
564 .ok_or_else(|| anyhow!("path has no file name: {}", path.display()))?;
565 let suffix = std::time::SystemTime::now()
566 .duration_since(std::time::UNIX_EPOCH)
567 .unwrap_or_default()
568 .as_nanos();
569 let mut temp_name = std::ffi::OsString::from(".");
570 temp_name.push(file_name);
571 temp_name.push(format!(".tmp-{}-{suffix}", std::process::id()));
572 let temp_path = parent.join(temp_name);
573 std::fs::write(&temp_path, payload)?;
574 if let Err(err) = std::fs::rename(&temp_path, path) {
575 let _ = std::fs::remove_file(&temp_path);
576 return Err(err.into());
577 }
578 Ok(())
579}
580
581pub(super) fn merge_issue(target: &mut Option<String>, message: String) {
582 if message.trim().is_empty() {
583 return;
584 }
585
586 match target {
587 Some(existing) => {
588 existing.push_str("; ");
589 existing.push_str(&message);
590 }
591 None => *target = Some(message),
592 }
593}