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