Skip to main content

osp_cli/plugin/
state.rs

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}