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::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}