Skip to main content

agent_engine/skills/
registry.rs

1//! Slash command registry: built-ins + dynamically registered skills.
2
3use crate::help::HelpEntry;
4use crate::skills::{LoadedSkill, Plugin};
5use std::collections::HashMap;
6use std::sync::{Arc, RwLock};
7
8#[derive(Debug, Clone)]
9pub struct PluginSummary {
10    pub name: String,
11    pub skill_count: usize,
12}
13
14#[derive(Clone, Debug)]
15pub enum RegisteredPluginCommandBackend {
16    Shell {
17        command: String,
18        args: Vec<String>,
19    },
20    ExtensionTool {
21        tool: String,
22        input: serde_json::Value,
23    },
24    SkillPrompt {
25        skill: String,
26        prompt: String,
27    },
28    Interactive {
29        plugin_extension_id: String,
30    },
31}
32
33/// Editor kind for a plugin-declared settings field, normalised from the
34/// manifest into a form the settings UI can consume directly.
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub enum PluginSettingsEditor {
37    Text {
38        numeric: bool,
39    },
40    Cycler {
41        options: Vec<String>,
42    },
43    Picker,
44    /// Editor body rendered by the plugin via the
45    /// `settings.editor.*` JSON-RPC contract.
46    Custom,
47}
48
49#[derive(Clone, Debug)]
50pub struct PluginSettingsField {
51    pub key: String,
52    pub label: String,
53    pub editor: PluginSettingsEditor,
54    pub help: Option<String>,
55    pub default: Option<serde_json::Value>,
56}
57
58/// A plugin-declared category contributed to the `/settings` modal.
59///
60/// The owning plugin is recorded in `plugin` so that the settings UI can
61/// scope reads/writes to that plugin's config namespace and dispatch
62/// custom-editor JSON-RPC events to the right extension.
63#[derive(Clone, Debug)]
64pub struct PluginSettingsCategory {
65    pub plugin: String,
66    pub id: String,
67    pub label: String,
68    pub fields: Vec<PluginSettingsField>,
69}
70
71/// Resolution outcome for a typed slash command.
72#[derive(Clone, Debug)]
73pub struct RegisteredPluginCommand {
74    pub plugin: String,
75    pub name: String,
76    pub description: Option<String>,
77    pub backend: RegisteredPluginCommandBackend,
78    pub plugin_root: std::path::PathBuf,
79}
80
81/// A plugin's claim on a top-level lifecycle command namespace, derived
82/// from `provides.sidecar.lifecycle` in plugin.json. Phase 8 slice 8A.
83///
84/// When a plugin declares a lifecycle claim, the command registry
85/// auto-registers `/<command> toggle` and `/<command> status` (and any
86/// future lifecycle verbs) so the plugin's own UX namespace —
87/// e.g. a plugin-owned command — drives sidecar lifecycle instead
88/// of the modality-laden `/sidecar` builtin.
89///
90/// Multiple plugins may register lifecycle claims; in Phase 8 slice 8A
91/// the host still hosts at most one active sidecar so all claims share
92/// a single backing `App.sidecar` slot. Slice 8B widens this.
93#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct LifecycleClaim {
95    /// Plugin (manifest) name that owns this claim.
96    pub plugin: String,
97    /// Top-level command word the plugin claims.
98    pub command: String,
99    /// Optional settings-category id that the host should inject
100    /// per-plugin lifecycle keys (e.g. toggle keybind) into.
101    pub settings_category: Option<String>,
102    /// Human-readable label for pills, error messages, status lines.
103    pub display_name: String,
104    /// Sort key for pill ordering (higher = earlier). Range `-100..=100`,
105    /// already clamped during manifest deserialisation.
106    pub importance: i32,
107}
108
109pub enum Resolution {
110    /// A built-in command (dispatched via existing handle_command).
111    Builtin,
112    /// A single unambiguous skill.
113    Skill(Arc<LoadedSkill>),
114    /// A plugin command from plugin.json `commands`.
115    PluginCommand(Arc<RegisteredPluginCommand>),
116    /// Multiple skills share this unqualified name; user must qualify.
117    Ambiguous(Vec<String>), // list of plugin-qualified names
118    /// No such command.
119    Unknown,
120}
121
122struct Inner {
123    skills: HashMap<String, Vec<Arc<LoadedSkill>>>, // unqualified name -> all matches
124    qualified: HashMap<String, Arc<LoadedSkill>>,   // "plugin:skill" -> single
125    plugin_commands: HashMap<String, Arc<RegisteredPluginCommand>>, // "plugin:cmd" -> single
126    plugin_help_entries: Vec<HelpEntry>,
127    /// Plugin-declared settings categories, in plugin discovery order.
128    /// Path B Phase 4. The settings UI snapshots this on each open.
129    plugin_settings_categories: Vec<PluginSettingsCategory>,
130    /// Plugin-declared lifecycle claims (Phase 8 slice 8A). Indexed by
131    /// the claimed command word. At most one claim per
132    /// command word; first-loaded plugin wins on collision.
133    lifecycle_claims: HashMap<String, LifecycleClaim>,
134    /// Plugins that lost a lifecycle-claim collision, recorded so the
135    /// `/extensions` view can surface a warning. Each entry is
136    /// `(losing_plugin, command, winning_plugin)`.
137    lifecycle_claim_collisions: Vec<(String, String, String)>,
138}
139
140pub struct CommandRegistry {
141    builtins: Vec<&'static str>,
142    inner: RwLock<Inner>,
143}
144
145impl CommandRegistry {
146    pub fn new(builtins: &[&'static str], skills: Vec<LoadedSkill>) -> Self {
147        Self::new_with_plugins(builtins, skills, vec![])
148    }
149
150    pub fn new_with_plugins(
151        builtins: &[&'static str],
152        skills: Vec<LoadedSkill>,
153        plugins: Vec<Plugin>,
154    ) -> Self {
155        let r = CommandRegistry {
156            builtins: builtins.to_vec(),
157            inner: RwLock::new(Inner {
158                skills: HashMap::new(),
159                qualified: HashMap::new(),
160                plugin_commands: HashMap::new(),
161                plugin_help_entries: Vec::new(),
162                plugin_settings_categories: Vec::new(),
163                lifecycle_claims: HashMap::new(),
164                lifecycle_claim_collisions: Vec::new(),
165            }),
166        };
167        r.rebuild_with_plugins(skills, plugins);
168        r
169    }
170
171    /// Atomically replace the skill set. Built-ins are unchanged.
172    pub fn rebuild_with(&self, skills: Vec<LoadedSkill>) {
173        self.rebuild_with_plugins(skills, vec![]);
174    }
175
176    /// Atomically replace the skill and plugin-command set. Built-ins are unchanged.
177    pub fn rebuild_with_plugins(&self, skills: Vec<LoadedSkill>, plugins: Vec<Plugin>) {
178        let builtins_set: std::collections::HashSet<&str> = self.builtins.iter().copied().collect();
179        let mut new_skills: HashMap<String, Vec<Arc<LoadedSkill>>> = HashMap::new();
180        let mut new_qualified: HashMap<String, Arc<LoadedSkill>> = HashMap::new();
181        let mut new_plugin_commands: HashMap<String, Arc<RegisteredPluginCommand>> = HashMap::new();
182        let mut new_plugin_help_entries: Vec<HelpEntry> = Vec::new();
183        let mut new_plugin_settings_categories: Vec<PluginSettingsCategory> = Vec::new();
184        let mut new_lifecycle_claims: HashMap<String, LifecycleClaim> = HashMap::new();
185        let mut new_lifecycle_collisions: Vec<(String, String, String)> = Vec::new();
186        for plugin in plugins {
187            if let Some(manifest) = plugin.manifest {
188                new_plugin_help_entries.extend(manifest.help_entries.iter().cloned().map(
189                    |mut entry| {
190                        entry.source = Some(manifest.name.clone());
191                        entry
192                    },
193                ));
194                // Phase 8 slice 8A: harvest lifecycle claims from
195                // provides.sidecar.lifecycle. First-loaded wins on
196                // collision; the loser is recorded for surfacing in
197                // /extensions and the chat log.
198                if let Some(ref provides) = manifest.provides {
199                    if let Some(ref sidecar) = provides.sidecar {
200                        if let Some(ref lc) = sidecar.lifecycle {
201                            let claim = LifecycleClaim {
202                                plugin: manifest.name.clone(),
203                                command: lc.command.clone(),
204                                settings_category: lc.settings_category.clone(),
205                                display_name: lc.effective_display_name().to_string(),
206                                importance: lc.importance,
207                            };
208                            // Block plugins from hijacking built-in commands
209                            if builtins_set.contains(claim.command.as_str()) {
210                                tracing::warn!(
211                                    "plugin '{}' attempted to claim builtin command '{}'; rejected",
212                                    claim.plugin,
213                                    claim.command,
214                                );
215                            } else if let Some(existing) = new_lifecycle_claims.get(&claim.command)
216                            {
217                                new_lifecycle_collisions.push((
218                                    claim.plugin.clone(),
219                                    claim.command.clone(),
220                                    existing.plugin.clone(),
221                                ));
222                                tracing::warn!(
223                                    "lifecycle command '{}' claimed by both '{}' and '{}'; first-loaded wins",
224                                    claim.command, existing.plugin, claim.plugin,
225                                );
226                            } else {
227                                new_lifecycle_claims.insert(claim.command.clone(), claim);
228                            }
229                        }
230                    }
231                }
232                if let Some(ref settings) = manifest.settings {
233                    for cat in &settings.categories {
234                        let fields = cat
235                            .fields
236                            .iter()
237                            .map(|f| PluginSettingsField {
238                                key: f.key.clone(),
239                                label: f.label.clone(),
240                                editor: match f.editor {
241                                    crate::skills::manifest::ManifestEditorKind::Text => {
242                                        PluginSettingsEditor::Text { numeric: f.numeric }
243                                    }
244                                    crate::skills::manifest::ManifestEditorKind::Cycler => {
245                                        PluginSettingsEditor::Cycler {
246                                            options: f.options.clone(),
247                                        }
248                                    }
249                                    crate::skills::manifest::ManifestEditorKind::Picker => {
250                                        PluginSettingsEditor::Picker
251                                    }
252                                    crate::skills::manifest::ManifestEditorKind::Custom => {
253                                        PluginSettingsEditor::Custom
254                                    }
255                                },
256                                help: f.help.clone(),
257                                default: f.default.clone(),
258                            })
259                            .collect();
260                        new_plugin_settings_categories.push(PluginSettingsCategory {
261                            plugin: manifest.name.clone(),
262                            id: cat.id.clone(),
263                            label: cat.label.clone(),
264                            fields,
265                        });
266                    }
267                }
268                for cmd in manifest.commands {
269                    let (name, description, backend) = match cmd {
270                        crate::skills::manifest::ManifestCommand::Shell(cmd) => (
271                            cmd.name,
272                            cmd.description,
273                            RegisteredPluginCommandBackend::Shell {
274                                command: cmd.command,
275                                args: cmd.args,
276                            },
277                        ),
278                        crate::skills::manifest::ManifestCommand::ExtensionTool(cmd) => (
279                            cmd.name,
280                            cmd.description,
281                            RegisteredPluginCommandBackend::ExtensionTool {
282                                tool: cmd.tool,
283                                input: cmd.input,
284                            },
285                        ),
286                        crate::skills::manifest::ManifestCommand::SkillPrompt(cmd) => (
287                            cmd.name,
288                            cmd.description,
289                            RegisteredPluginCommandBackend::SkillPrompt {
290                                skill: cmd.skill,
291                                prompt: cmd.prompt,
292                            },
293                        ),
294                        crate::skills::manifest::ManifestCommand::Interactive(cmd) => {
295                            if !cmd.interactive {
296                                continue;
297                            }
298                            (
299                                cmd.name,
300                                cmd.description,
301                                RegisteredPluginCommandBackend::Interactive {
302                                    plugin_extension_id: manifest
303                                        .extension
304                                        .as_ref()
305                                        .map(|_| plugin.name.clone())
306                                        .unwrap_or_else(|| plugin.name.clone()),
307                                },
308                            )
309                        }
310                    };
311                    let q = format!("{}:{}", manifest.name, name);
312                    // Block plugins from registering commands that match builtin names
313                    if builtins_set.contains(name.as_str()) {
314                        tracing::warn!(
315                            "plugin '{}' command '{}' shadows builtin; skipping",
316                            manifest.name,
317                            name,
318                        );
319                        continue;
320                    }
321                    new_plugin_commands.insert(
322                        q,
323                        Arc::new(RegisteredPluginCommand {
324                            plugin: manifest.name.clone(),
325                            name,
326                            description,
327                            backend,
328                            plugin_root: plugin.root.clone(),
329                        }),
330                    );
331                }
332            }
333        }
334        for s in skills {
335            let arc = Arc::new(s);
336            // Unqualified entry
337            if builtins_set.contains(arc.name.as_str()) {
338                tracing::warn!(
339                    "skill '{}' shadowed by built-in; reachable only via qualified form '{}:{}'",
340                    arc.name,
341                    arc.plugin.as_deref().unwrap_or("?"),
342                    arc.name
343                );
344            } else {
345                new_skills
346                    .entry(arc.name.clone())
347                    .or_default()
348                    .push(arc.clone());
349            }
350            // Qualified entry
351            if let Some(ref p) = arc.plugin {
352                let q = format!("{}:{}", p, arc.name);
353                new_qualified.insert(q, arc.clone());
354            }
355        }
356        // Phase 8 slice 8A.3: for each lifecycle claim with a
357        // settings_category, inject a synthetic `_lifecycle_toggle_key`
358        // field at the front of the matching plugin-declared category.
359        // No-op (with a warn) if the named category doesn't exist.
360        for claim in new_lifecycle_claims.values() {
361            let Some(ref cat_id) = claim.settings_category else {
362                continue;
363            };
364            let pos = new_plugin_settings_categories
365                .iter()
366                .position(|c| c.plugin == claim.plugin && &c.id == cat_id);
367            match pos {
368                Some(idx) => {
369                    let injected = PluginSettingsField {
370                        key: "_lifecycle_toggle_key".to_string(),
371                        label: "Toggle key".to_string(),
372                        editor: PluginSettingsEditor::Cycler {
373                            options: ["F8", "F2", "F12", "C-V", "C-G"]
374                                .iter()
375                                .map(|s| s.to_string())
376                                .collect(),
377                        },
378                        help: Some("Keybind that toggles this sidecar.".to_string()),
379                        default: None,
380                    };
381                    new_plugin_settings_categories[idx]
382                        .fields
383                        .insert(0, injected);
384                }
385                None => {
386                    tracing::warn!(
387                        "lifecycle claim for plugin '{}' references settings_category '{}' but no such category was declared; skipping toggle-key injection",
388                        claim.plugin,
389                        cat_id,
390                    );
391                }
392            }
393        }
394
395        let mut w = self.inner.write().unwrap();
396        w.skills = new_skills;
397        w.qualified = new_qualified;
398        w.plugin_commands = new_plugin_commands;
399        w.plugin_help_entries = new_plugin_help_entries;
400        w.plugin_settings_categories = new_plugin_settings_categories;
401        w.lifecycle_claims = new_lifecycle_claims;
402        w.lifecycle_claim_collisions = new_lifecycle_collisions;
403    }
404
405    pub fn resolve(&self, cmd: &str) -> Resolution {
406        let r = self.inner.read().unwrap();
407        if cmd.contains(':') {
408            if let Some(c) = r.plugin_commands.get(cmd) {
409                return Resolution::PluginCommand(c.clone());
410            }
411            return match r.qualified.get(cmd) {
412                Some(s) => Resolution::Skill(s.clone()),
413                None => Resolution::Unknown,
414            };
415        }
416        if self.builtins.contains(&cmd) {
417            return Resolution::Builtin;
418        }
419        match r.skills.get(cmd) {
420            Some(v) if v.len() == 1 => Resolution::Skill(v[0].clone()),
421            Some(v) => Resolution::Ambiguous(
422                v.iter()
423                    .map(|s| format!("{}:{}", s.plugin.as_deref().unwrap_or("?"), s.name))
424                    .collect(),
425            ),
426            None => {
427                // Fall back to an unambiguous plugin command claiming
428                // this bare name. `find_plugin_command_unqualified`
429                // returns Some only when exactly one plugin claims it;
430                // ambiguous (>=2) and missing both fall through to
431                // Unknown so the user must qualify.
432                drop(r);
433                if let Some(c) = self.find_plugin_command_unqualified(cmd) {
434                    return Resolution::PluginCommand(c);
435                }
436                Resolution::Unknown
437            }
438        }
439    }
440
441    /// Look up a plugin command by its unqualified name. Returns the
442    /// only match, or None if zero or multiple plugins claim the name.
443    /// Used when a builtin command has the same identifier as a
444    /// plugin-owned command that also has extension subcommands
445    /// builtin and a plugin command name) and we need to bypass the
446    /// builtin check to reach the plugin.
447    pub fn find_plugin_command_unqualified(
448        &self,
449        name: &str,
450    ) -> Option<Arc<RegisteredPluginCommand>> {
451        let r = self.inner.read().unwrap();
452        let mut matches = r.plugin_commands.values().filter(|c| c.name == name);
453        let first = matches.next()?.clone();
454        if matches.next().is_some() {
455            return None; // ambiguous
456        }
457        Some(first)
458    }
459
460    /// All commands for autocomplete/help: builtins + unique unqualified skill names, sorted.
461    pub fn all_commands(&self) -> Vec<String> {
462        let r = self.inner.read().unwrap();
463        let mut v: Vec<String> = self.builtins.iter().map(|s| s.to_string()).collect();
464        v.extend(r.skills.keys().cloned());
465        // Plugin commands: group by unqualified name. If exactly one
466        // plugin claims a name, surface it bare so users can type
467        // `/crush` instead of `/crush:crush`. If two or more plugins
468        // share the name, surface every qualified key so the user can
469        // disambiguate. Mirrors `resolve()`'s bare-resolution logic.
470        let mut by_name: HashMap<&str, Vec<&str>> = HashMap::new();
471        for (qkey, c) in r.plugin_commands.iter() {
472            by_name
473                .entry(c.name.as_str())
474                .or_default()
475                .push(qkey.as_str());
476        }
477        for (name, qkeys) in by_name {
478            if qkeys.len() == 1 {
479                v.push(name.to_string());
480            } else {
481                v.extend(qkeys.iter().map(|s| s.to_string()));
482            }
483        }
484        // Phase 8 slice 8A: plugin-claimed lifecycle commands surface
485        // as top-level commands too.
486        v.extend(r.lifecycle_claims.keys().cloned());
487        v.sort();
488        v.dedup();
489        v
490    }
491
492    /// Look up the lifecycle claim for a top-level command word, if any.
493    /// Phase 8 slice 8A. Returns the claim regardless of arg/subcommand —
494    /// callers (the dispatcher) decide what to do with `toggle` / `status`.
495    pub fn lifecycle_for_command(&self, cmd: &str) -> Option<LifecycleClaim> {
496        let r = self.inner.read().unwrap();
497        r.lifecycle_claims.get(cmd).cloned()
498    }
499
500    /// Snapshot of every claimed lifecycle command, in arbitrary order.
501    /// Used by `/extensions` to render the active claims and by the
502    /// pill renderer to enumerate sidecars.
503    pub fn lifecycle_claims(&self) -> Vec<LifecycleClaim> {
504        self.inner
505            .read()
506            .unwrap()
507            .lifecycle_claims
508            .values()
509            .cloned()
510            .collect()
511    }
512
513    /// Plugins that lost a lifecycle-claim collision during the most
514    /// recent rebuild. Each entry is `(losing_plugin, command,
515    /// winning_plugin)`. Surfaced in `/extensions`.
516    pub fn lifecycle_claim_collisions(&self) -> Vec<(String, String, String)> {
517        self.inner
518            .read()
519            .unwrap()
520            .lifecycle_claim_collisions
521            .clone()
522    }
523
524    pub fn plugins(&self) -> Vec<PluginSummary> {
525        let r = self.inner.read().unwrap();
526        let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
527        let mut seen: std::collections::HashSet<(String, String)> =
528            std::collections::HashSet::new();
529        for c in r.plugin_commands.values() {
530            let key = (c.plugin.clone(), c.name.clone());
531            if seen.insert(key) {
532                *counts.entry(c.plugin.clone()).or_insert(0) += 0;
533            }
534        }
535        for s in r.qualified.values() {
536            if let Some(ref p) = s.plugin {
537                let key = (p.clone(), s.name.clone());
538                if seen.insert(key) {
539                    *counts.entry(p.clone()).or_insert(0) += 1;
540                }
541            }
542        }
543        counts
544            .into_iter()
545            .map(|(name, skill_count)| PluginSummary { name, skill_count })
546            .collect()
547    }
548
549    pub fn plugin_help_entries(&self) -> Vec<HelpEntry> {
550        self.inner.read().unwrap().plugin_help_entries.clone()
551    }
552
553    /// Snapshot of every plugin-declared settings category, preserving
554    /// plugin discovery order. The settings UI calls this on each open
555    /// to merge plugin categories with the built-in ones.
556    pub fn plugin_settings_categories(&self) -> Vec<PluginSettingsCategory> {
557        self.inner
558            .read()
559            .unwrap()
560            .plugin_settings_categories
561            .clone()
562    }
563
564    pub fn all_skills(&self) -> Vec<Arc<LoadedSkill>> {
565        let r = self.inner.read().unwrap();
566        let mut seen: std::collections::HashSet<(Option<String>, String)> =
567            std::collections::HashSet::new();
568        let mut out = Vec::new();
569        for list in r.skills.values() {
570            for s in list {
571                let key = (s.plugin.clone(), s.name.clone());
572                if seen.insert(key) {
573                    out.push(s.clone());
574                }
575            }
576        }
577        for s in r.qualified.values() {
578            let key = (s.plugin.clone(), s.name.clone());
579            if seen.insert(key) {
580                out.push(s.clone());
581            }
582        }
583        out
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use crate::skills::manifest::{ManifestCommand, ManifestShellCommand};
591    use std::path::PathBuf;
592
593    fn mk_cmd(plugin: &str, name: &str, root: PathBuf) -> Plugin {
594        Plugin {
595            name: plugin.to_string(),
596            root,
597            marketplace: None,
598            version: None,
599            description: None,
600            extension: None,
601            manifest: Some(crate::skills::manifest::PluginManifest {
602                name: plugin.to_string(),
603                version: None,
604                description: None,
605                keybinds: vec![],
606                compatibility: None,
607                commands: vec![ManifestCommand::Shell(ManifestShellCommand {
608                    name: name.to_string(),
609                    description: Some("desc".to_string()),
610                    command: "printf".to_string(),
611                    args: vec!["hi".to_string()],
612                })],
613                extension: None,
614                help_entries: vec![],
615                provides: None,
616                settings: None,
617            }),
618        }
619    }
620
621    fn mk_interactive_cmd(plugin: &str, name: &str, root: PathBuf) -> Plugin {
622        Plugin {
623            name: plugin.to_string(),
624            root,
625            marketplace: None,
626            version: None,
627            description: None,
628            extension: None,
629            manifest: Some(crate::skills::manifest::PluginManifest {
630                name: plugin.to_string(),
631                version: None,
632                description: None,
633                keybinds: vec![],
634                compatibility: None,
635                commands: vec![ManifestCommand::Interactive(
636                    crate::skills::manifest::ManifestInteractiveCommand {
637                        name: name.to_string(),
638                        description: Some("interactive desc".to_string()),
639                        interactive: true,
640                        subcommands: vec!["help".to_string()],
641                    },
642                )],
643                extension: None,
644                help_entries: vec![],
645                provides: None,
646                settings: None,
647            }),
648        }
649    }
650
651    #[test]
652    fn registers_interactive_plugin_command_backend() {
653        let reg = CommandRegistry::new_with_plugins(
654            &[],
655            vec![],
656            vec![mk_interactive_cmd(
657                "demo-plugin",
658                "demo",
659                PathBuf::from("/tmp/demo"),
660            )],
661        );
662
663        match reg.resolve("demo-plugin:demo") {
664            Resolution::PluginCommand(cmd) => match &cmd.backend {
665                RegisteredPluginCommandBackend::Interactive {
666                    plugin_extension_id,
667                } => {
668                    assert_eq!(plugin_extension_id, "demo-plugin");
669                    assert_eq!(cmd.name, "demo");
670                }
671                other => panic!("expected interactive backend, got {other:?}"),
672            },
673            _ => panic!("expected plugin command resolution"),
674        }
675    }
676
677    fn mk(name: &str, plugin: Option<&str>) -> LoadedSkill {
678        LoadedSkill::new_inline(
679            name,
680            "",
681            "",
682            plugin,
683            PathBuf::from("/"),
684            PathBuf::from("/SKILL.md"),
685        )
686    }
687
688    #[test]
689    fn plugin_help_entries_are_tagged_with_manifest_name() {
690        let root = PathBuf::from("/tmp/plugin-root");
691        let mut plugin = mk_cmd("acme-tools", "sync", root);
692        plugin
693            .manifest
694            .as_mut()
695            .unwrap()
696            .help_entries
697            .push(HelpEntry {
698                id: "acme-sync".to_string(),
699                command: "/acme:sync".to_string(),
700                title: "Acme Sync".to_string(),
701                summary: "Sync Acme workspace state.".to_string(),
702                category: "Plugin".to_string(),
703                topic: crate::help::HelpTopicKind::Command,
704                protected: false,
705                common: false,
706                aliases: vec![],
707                keywords: vec![],
708                lines: vec![],
709                usage: Some("/acme:sync [workspace]".to_string()),
710                examples: vec![crate::help::HelpExample {
711                    command: "/acme:sync docs".to_string(),
712                    description: "Sync docs.".to_string(),
713                }],
714                related: vec![],
715                source: None,
716            });
717
718        let registry = CommandRegistry::new_with_plugins(&[], vec![], vec![plugin]);
719        let entries = registry.plugin_help_entries();
720
721        assert_eq!(entries.len(), 1);
722        assert_eq!(entries[0].source.as_deref(), Some("acme-tools"));
723        assert_eq!(entries[0].usage.as_deref(), Some("/acme:sync [workspace]"));
724        assert_eq!(entries[0].examples[0].command, "/acme:sync docs");
725    }
726
727    #[test]
728    fn resolve_builtin() {
729        let r = CommandRegistry::new(&["clear"], vec![]);
730        assert!(matches!(r.resolve("clear"), Resolution::Builtin));
731    }
732
733    #[test]
734    fn resolve_unknown() {
735        let r = CommandRegistry::new(&["clear"], vec![]);
736        assert!(matches!(r.resolve("xyz"), Resolution::Unknown));
737    }
738
739    #[test]
740    fn resolve_unique_skill() {
741        let r = CommandRegistry::new(&[], vec![mk("search", Some("p"))]);
742        match r.resolve("search") {
743            Resolution::Skill(s) => assert_eq!(s.name, "search"),
744            _ => panic!(),
745        }
746    }
747
748    #[test]
749    fn resolve_ambiguous() {
750        let r = CommandRegistry::new(
751            &[],
752            vec![mk("search", Some("p1")), mk("search", Some("p2"))],
753        );
754        match r.resolve("search") {
755            Resolution::Ambiguous(v) => {
756                assert_eq!(v.len(), 2);
757                assert!(v.iter().any(|s| s == "p1:search"));
758                assert!(v.iter().any(|s| s == "p2:search"));
759            }
760            _ => panic!(),
761        }
762    }
763
764    #[test]
765    fn resolve_qualified() {
766        let r = CommandRegistry::new(
767            &[],
768            vec![mk("search", Some("p1")), mk("search", Some("p2"))],
769        );
770        match r.resolve("p1:search") {
771            Resolution::Skill(s) => assert_eq!(s.plugin.as_deref(), Some("p1")),
772            _ => panic!(),
773        }
774    }
775
776    #[test]
777    fn builtin_shadows_skill_unqualified() {
778        // Skill named "clear" should not win over the built-in.
779        let r = CommandRegistry::new(&["clear"], vec![mk("clear", Some("p"))]);
780        assert!(matches!(r.resolve("clear"), Resolution::Builtin));
781        // Qualified form still works.
782        match r.resolve("p:clear") {
783            Resolution::Skill(s) => assert_eq!(s.name, "clear"),
784            _ => panic!(),
785        }
786    }
787
788    #[test]
789    fn all_commands_sorted_and_deduped() {
790        let r = CommandRegistry::new(
791            &["clear", "model"],
792            vec![mk("search", Some("p")), mk("help-me", None)],
793        );
794        let cmds = r.all_commands();
795        assert_eq!(cmds, vec!["clear", "help-me", "model", "search"]);
796    }
797
798    #[test]
799    fn all_skills_dedups_plugin_skill() {
800        let r = CommandRegistry::new(&[], vec![mk("search", Some("p"))]);
801        let all = r.all_skills();
802        assert_eq!(all.len(), 1);
803        assert_eq!(all[0].name, "search");
804        assert_eq!(all[0].plugin.as_deref(), Some("p"));
805    }
806
807    #[test]
808    fn all_skills_includes_shadowed_skill() {
809        let r = CommandRegistry::new(&["clear"], vec![mk("clear", Some("p"))]);
810        let all = r.all_skills();
811        assert_eq!(all.len(), 1);
812        assert_eq!(all[0].name, "clear");
813        assert_eq!(all[0].plugin.as_deref(), Some("p"));
814    }
815
816    #[test]
817    fn resolve_qualified_unknown_returns_unknown() {
818        let r = CommandRegistry::new(&[], vec![mk("search", Some("p1"))]);
819        assert!(matches!(r.resolve("p1:nosuch"), Resolution::Unknown));
820        assert!(matches!(r.resolve("nosuch:search"), Resolution::Unknown));
821    }
822
823    #[test]
824    fn rebuild_replaces_skills() {
825        let r = CommandRegistry::new(&["clear"], vec![mk("old", None)]);
826        assert!(matches!(r.resolve("old"), Resolution::Skill(_)));
827        assert!(matches!(r.resolve("new"), Resolution::Unknown));
828        r.rebuild_with(vec![mk("new", None)]);
829        assert!(matches!(r.resolve("old"), Resolution::Unknown));
830        assert!(matches!(r.resolve("new"), Resolution::Skill(_)));
831    }
832
833    #[test]
834    fn rebuild_visible_through_shared_arc() {
835        let r = std::sync::Arc::new(CommandRegistry::new(&[], vec![mk("a", None)]));
836        let r2 = r.clone();
837        r.rebuild_with(vec![mk("b", None)]);
838        assert!(matches!(r2.resolve("b"), Resolution::Skill(_)));
839        assert!(matches!(r2.resolve("a"), Resolution::Unknown));
840    }
841
842    #[test]
843    fn resolve_qualified_plugin_command() {
844        let r = CommandRegistry::new_with_plugins(
845            &[],
846            vec![],
847            vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))],
848        );
849        match r.resolve("p:hello") {
850            Resolution::PluginCommand(cmd) => {
851                assert_eq!(cmd.plugin, "p");
852                assert_eq!(cmd.name, "hello");
853                assert!(matches!(
854                    &cmd.backend,
855                    RegisteredPluginCommandBackend::Shell { command, .. } if command == "printf"
856                ));
857                assert_eq!(cmd.plugin_root, PathBuf::from("/tmp/p"));
858            }
859            _ => panic!(),
860        }
861    }
862
863    #[test]
864    fn all_commands_includes_unambiguous_plugin_commands_bare() {
865        // Updated from `all_commands_includes_qualified_plugin_commands`:
866        // a sole plugin command is now surfaced bare ("hello"), not
867        // qualified ("p:hello"). Ambiguous names stay qualified — see
868        // `all_commands_ambiguous_plugin_commands_stay_qualified`.
869        let r = CommandRegistry::new_with_plugins(
870            &["help"],
871            vec![],
872            vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))],
873        );
874        let cmds = r.all_commands();
875        assert!(cmds.contains(&"help".to_string()));
876        assert!(cmds.contains(&"hello".to_string()));
877        assert!(!cmds.contains(&"p:hello".to_string()));
878    }
879
880    #[test]
881    fn resolve_bare_unique_plugin_command() {
882        let r = CommandRegistry::new_with_plugins(
883            &[],
884            vec![],
885            vec![mk_cmd("crush", "crush", PathBuf::from("/tmp/crush"))],
886        );
887        match r.resolve("crush") {
888            Resolution::PluginCommand(cmd) => {
889                assert_eq!(cmd.plugin, "crush");
890                assert_eq!(cmd.name, "crush");
891            }
892            _ => panic!("expected PluginCommand"),
893        }
894        // Qualified form still works (no regression).
895        match r.resolve("crush:crush") {
896            Resolution::PluginCommand(cmd) => assert_eq!(cmd.plugin, "crush"),
897            _ => panic!("expected PluginCommand"),
898        }
899    }
900
901    #[test]
902    fn resolve_bare_ambiguous_plugin_command_unknown() {
903        // Two plugins both expose "foo". Bare resolution must NOT pick
904        // one — `find_plugin_command_unqualified` returns None for >1
905        // match, so we fall through to Unknown. Qualified forms still
906        // resolve.
907        let r = CommandRegistry::new_with_plugins(
908            &[],
909            vec![],
910            vec![
911                mk_cmd("a", "foo", PathBuf::from("/tmp/a")),
912                mk_cmd("b", "foo", PathBuf::from("/tmp/b")),
913            ],
914        );
915        assert!(matches!(r.resolve("foo"), Resolution::Unknown));
916        assert!(matches!(r.resolve("a:foo"), Resolution::PluginCommand(_)));
917        assert!(matches!(r.resolve("b:foo"), Resolution::PluginCommand(_)));
918    }
919
920    #[test]
921    fn all_commands_ambiguous_plugin_commands_stay_qualified() {
922        let r = CommandRegistry::new_with_plugins(
923            &[],
924            vec![],
925            vec![
926                mk_cmd("a", "foo", PathBuf::from("/tmp/a")),
927                mk_cmd("b", "foo", PathBuf::from("/tmp/b")),
928            ],
929        );
930        let cmds = r.all_commands();
931        assert!(cmds.contains(&"a:foo".to_string()));
932        assert!(cmds.contains(&"b:foo".to_string()));
933        assert!(!cmds.contains(&"foo".to_string()));
934    }
935
936    #[test]
937    fn plugins_summary_groups_by_plugin_name() {
938        let r = CommandRegistry::new(
939            &[],
940            vec![
941                mk("a", Some("p1")),
942                mk("b", Some("p1")),
943                mk("c", Some("p2")),
944                mk("loose", None),
945            ],
946        );
947        let mut plugins = r.plugins();
948        plugins.sort_by(|a, b| a.name.cmp(&b.name));
949        assert_eq!(plugins.len(), 2);
950        assert_eq!(plugins[0].name, "p1");
951        assert_eq!(plugins[0].skill_count, 2);
952        assert_eq!(plugins[1].name, "p2");
953        assert_eq!(plugins[1].skill_count, 1);
954    }
955
956    fn mk_plugin_with_settings(plugin: &str, root: PathBuf) -> Plugin {
957        use crate::skills::manifest::{
958            ManifestEditorKind, ManifestSettings, ManifestSettingsCategory, ManifestSettingsField,
959        };
960        Plugin {
961            name: plugin.to_string(),
962            root,
963            marketplace: None,
964            version: None,
965            description: None,
966            extension: None,
967            manifest: Some(crate::skills::manifest::PluginManifest {
968                name: plugin.to_string(),
969                version: None,
970                description: None,
971                keybinds: vec![],
972                compatibility: None,
973                commands: vec![],
974                extension: None,
975                help_entries: vec![],
976                provides: None,
977                settings: Some(ManifestSettings {
978                    categories: vec![ManifestSettingsCategory {
979                        id: "demo".to_string(),
980                        label: "Demo".to_string(),
981                        fields: vec![
982                            ManifestSettingsField {
983                                key: "backend".to_string(),
984                                label: "Backend".to_string(),
985                                editor: ManifestEditorKind::Cycler,
986                                options: vec!["a".to_string(), "b".to_string()],
987                                help: None,
988                                default: None,
989                                numeric: false,
990                            },
991                            ManifestSettingsField {
992                                key: "endpoint".to_string(),
993                                label: "Endpoint".to_string(),
994                                editor: ManifestEditorKind::Text,
995                                options: vec![],
996                                help: Some("URL".to_string()),
997                                default: None,
998                                numeric: false,
999                            },
1000                        ],
1001                    }],
1002                }),
1003            }),
1004        }
1005    }
1006
1007    #[test]
1008    fn plugin_settings_categories_exposed_after_rebuild() {
1009        let r = CommandRegistry::new_with_plugins(
1010            &[],
1011            vec![],
1012            vec![mk_plugin_with_settings(
1013                "demo-plugin",
1014                PathBuf::from("/tmp/demo"),
1015            )],
1016        );
1017        let cats = r.plugin_settings_categories();
1018        assert_eq!(cats.len(), 1, "expected one plugin settings category");
1019        let cat = &cats[0];
1020        assert_eq!(cat.plugin, "demo-plugin");
1021        assert_eq!(cat.id, "demo");
1022        assert_eq!(cat.label, "Demo");
1023        assert_eq!(cat.fields.len(), 2);
1024
1025        match &cat.fields[0].editor {
1026            PluginSettingsEditor::Cycler { options } => {
1027                assert_eq!(options, &vec!["a".to_string(), "b".to_string()]);
1028            }
1029            other => panic!("expected cycler, got {other:?}"),
1030        }
1031        assert!(matches!(
1032            cat.fields[1].editor,
1033            PluginSettingsEditor::Text { numeric: false }
1034        ));
1035        assert_eq!(cat.fields[1].help.as_deref(), Some("URL"));
1036    }
1037
1038    #[test]
1039    fn plugin_settings_categories_empty_without_settings_block() {
1040        let r = CommandRegistry::new_with_plugins(
1041            &[],
1042            vec![],
1043            vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))],
1044        );
1045        assert!(r.plugin_settings_categories().is_empty());
1046    }
1047
1048    #[test]
1049    fn plugin_settings_categories_replaced_on_rebuild() {
1050        let r = CommandRegistry::new_with_plugins(
1051            &[],
1052            vec![],
1053            vec![mk_plugin_with_settings(
1054                "demo-plugin",
1055                PathBuf::from("/tmp/demo"),
1056            )],
1057        );
1058        assert_eq!(r.plugin_settings_categories().len(), 1);
1059        r.rebuild_with_plugins(vec![], vec![]);
1060        assert!(r.plugin_settings_categories().is_empty());
1061    }
1062
1063    #[test]
1064    fn plugin_settings_categories_does_not_hardcode_capture() {
1065        // Acceptance: declarative cycler/text fields are represented in
1066        // core data without plugin-specific knowledge.
1067        let r = CommandRegistry::new_with_plugins(
1068            &[],
1069            vec![],
1070            vec![mk_plugin_with_settings(
1071                "totally-unrelated",
1072                PathBuf::from("/tmp/x"),
1073            )],
1074        );
1075        let cats = r.plugin_settings_categories();
1076        assert_eq!(cats[0].plugin, "totally-unrelated");
1077        assert_eq!(cats[0].id, "demo");
1078        assert!(cats[0]
1079            .fields
1080            .iter()
1081            .any(|f| matches!(f.editor, PluginSettingsEditor::Cycler { .. })));
1082        assert!(cats[0]
1083            .fields
1084            .iter()
1085            .any(|f| matches!(f.editor, PluginSettingsEditor::Text { .. })));
1086    }
1087
1088    // ---- Phase 8 slice 8A: lifecycle claims ----
1089
1090    fn mk_plugin_with_lifecycle(
1091        plugin: &str,
1092        command: &str,
1093        display: Option<&str>,
1094        importance: i32,
1095        settings_category: Option<&str>,
1096    ) -> Plugin {
1097        use crate::skills::manifest::{
1098            PluginManifest, PluginProvides, SidecarLifecycle, SidecarManifest,
1099        };
1100        Plugin {
1101            name: plugin.to_string(),
1102            root: PathBuf::from(format!("/tmp/{plugin}")),
1103            marketplace: None,
1104            version: None,
1105            description: None,
1106            extension: None,
1107            manifest: Some(PluginManifest {
1108                name: plugin.to_string(),
1109                version: None,
1110                description: None,
1111                keybinds: vec![],
1112                compatibility: None,
1113                commands: vec![],
1114                extension: None,
1115                help_entries: vec![],
1116                provides: Some(PluginProvides {
1117                    sidecar: Some(SidecarManifest {
1118                        command: "bin/run".to_string(),
1119                        setup: None,
1120                        protocol_version: 1,
1121                        model: None,
1122                        lifecycle: Some(SidecarLifecycle {
1123                            command: command.to_string(),
1124                            settings_category: settings_category.map(str::to_string),
1125                            display_name: display.map(str::to_string),
1126                            importance,
1127                        }),
1128                    }),
1129                }),
1130                settings: None,
1131            }),
1132        }
1133    }
1134
1135    #[test]
1136    fn lifecycle_claim_registers_under_command_word() {
1137        let reg = CommandRegistry::new_with_plugins(
1138            &[],
1139            vec![],
1140            vec![mk_plugin_with_lifecycle(
1141                "sample-sidecar",
1142                "capture",
1143                Some("Sample"),
1144                50,
1145                Some("capture"),
1146            )],
1147        );
1148        let claim = reg
1149            .lifecycle_for_command("capture")
1150            .expect("sample lifecycle claim should be registered");
1151        assert_eq!(claim.plugin, "sample-sidecar");
1152        assert_eq!(claim.command, "capture");
1153        assert_eq!(claim.display_name, "Sample");
1154        assert_eq!(claim.importance, 50);
1155        assert_eq!(claim.settings_category.as_deref(), Some("capture"));
1156    }
1157
1158    #[test]
1159    fn lifecycle_claim_display_name_falls_back_to_command() {
1160        let reg = CommandRegistry::new_with_plugins(
1161            &[],
1162            vec![],
1163            vec![mk_plugin_with_lifecycle("p", "ocr", None, 0, None)],
1164        );
1165        let claim = reg.lifecycle_for_command("ocr").unwrap();
1166        assert_eq!(claim.display_name, "ocr");
1167    }
1168
1169    #[test]
1170    fn lifecycle_claim_surfaces_in_all_commands() {
1171        let reg = CommandRegistry::new_with_plugins(
1172            &[],
1173            vec![],
1174            vec![mk_plugin_with_lifecycle(
1175                "sample-sidecar",
1176                "capture",
1177                None,
1178                0,
1179                None,
1180            )],
1181        );
1182        assert!(reg.all_commands().contains(&"capture".to_string()));
1183    }
1184
1185    #[test]
1186    fn lifecycle_claim_collision_first_loaded_wins() {
1187        // Two plugins both claim "capture"; first in the discovery
1188        // order (the vec we pass) should win.
1189        let reg = CommandRegistry::new_with_plugins(
1190            &[],
1191            vec![],
1192            vec![
1193                mk_plugin_with_lifecycle("alpha-sidecar", "capture", Some("Alpha"), 10, None),
1194                mk_plugin_with_lifecycle("beta-sidecar", "capture", Some("Beta"), 90, None),
1195            ],
1196        );
1197        let claim = reg.lifecycle_for_command("capture").unwrap();
1198        assert_eq!(claim.plugin, "alpha-sidecar");
1199        let collisions = reg.lifecycle_claim_collisions();
1200        assert_eq!(collisions.len(), 1);
1201        assert_eq!(
1202            collisions[0],
1203            (
1204                "beta-sidecar".to_string(),
1205                "capture".to_string(),
1206                "alpha-sidecar".to_string(),
1207            )
1208        );
1209    }
1210
1211    #[test]
1212    fn lifecycle_claims_returns_all_unique_command_words() {
1213        let reg = CommandRegistry::new_with_plugins(
1214            &[],
1215            vec![],
1216            vec![
1217                mk_plugin_with_lifecycle("sample-sidecar", "capture", None, 50, None),
1218                mk_plugin_with_lifecycle("ocr-plugin", "ocr", None, 30, None),
1219            ],
1220        );
1221        let claims = reg.lifecycle_claims();
1222        let mut names: Vec<_> = claims.iter().map(|c| c.command.as_str()).collect();
1223        names.sort();
1224        assert_eq!(names, vec!["capture", "ocr"]);
1225    }
1226
1227    #[test]
1228    fn lifecycle_for_command_returns_none_when_no_claim() {
1229        let reg = CommandRegistry::new_with_plugins(&[], vec![], vec![]);
1230        assert!(reg.lifecycle_for_command("capture").is_none());
1231    }
1232
1233    #[test]
1234    fn rebuild_replaces_lifecycle_claims_atomically() {
1235        let reg = CommandRegistry::new_with_plugins(
1236            &[],
1237            vec![],
1238            vec![mk_plugin_with_lifecycle(
1239                "sample-sidecar",
1240                "capture",
1241                None,
1242                0,
1243                None,
1244            )],
1245        );
1246        assert!(reg.lifecycle_for_command("capture").is_some());
1247        // Rebuild without the plugin: the claim must vanish.
1248        reg.rebuild_with_plugins(vec![], vec![]);
1249        assert!(reg.lifecycle_for_command("capture").is_none());
1250        assert!(reg.lifecycle_claim_collisions().is_empty());
1251    }
1252
1253    // ---- Phase 8 slice 8A.3: virtual toggle-key field injection ----
1254
1255    fn mk_plugin_lifecycle_plus_settings(
1256        plugin: &str,
1257        command: &str,
1258        lifecycle_settings_category: Option<&str>,
1259        category_ids: &[&str],
1260    ) -> Plugin {
1261        use crate::skills::manifest::{
1262            ManifestEditorKind, ManifestSettings, ManifestSettingsCategory, ManifestSettingsField,
1263            PluginManifest, PluginProvides, SidecarLifecycle, SidecarManifest,
1264        };
1265        Plugin {
1266            name: plugin.to_string(),
1267            root: PathBuf::from(format!("/tmp/{plugin}")),
1268            marketplace: None,
1269            version: None,
1270            description: None,
1271            extension: None,
1272            manifest: Some(PluginManifest {
1273                name: plugin.to_string(),
1274                version: None,
1275                description: None,
1276                keybinds: vec![],
1277                compatibility: None,
1278                commands: vec![],
1279                extension: None,
1280                help_entries: vec![],
1281                provides: Some(PluginProvides {
1282                    sidecar: Some(SidecarManifest {
1283                        command: "bin/run".to_string(),
1284                        setup: None,
1285                        protocol_version: 1,
1286                        model: None,
1287                        lifecycle: Some(SidecarLifecycle {
1288                            command: command.to_string(),
1289                            settings_category: lifecycle_settings_category.map(str::to_string),
1290                            display_name: None,
1291                            importance: 0,
1292                        }),
1293                    }),
1294                }),
1295                settings: Some(ManifestSettings {
1296                    categories: category_ids
1297                        .iter()
1298                        .map(|id| ManifestSettingsCategory {
1299                            id: id.to_string(),
1300                            label: id.to_string(),
1301                            fields: vec![ManifestSettingsField {
1302                                key: "existing".to_string(),
1303                                label: "Existing".to_string(),
1304                                editor: ManifestEditorKind::Text,
1305                                options: vec![],
1306                                help: None,
1307                                default: None,
1308                                numeric: false,
1309                            }],
1310                        })
1311                        .collect(),
1312                }),
1313            }),
1314        }
1315    }
1316
1317    #[test]
1318    fn lifecycle_injects_virtual_toggle_key_into_matching_category() {
1319        let reg = CommandRegistry::new_with_plugins(
1320            &[],
1321            vec![],
1322            vec![mk_plugin_lifecycle_plus_settings(
1323                "sample-sidecar",
1324                "capture",
1325                Some("capture"),
1326                &["capture"],
1327            )],
1328        );
1329        let cats = reg.plugin_settings_categories();
1330        let capture = cats
1331            .iter()
1332            .find(|c| c.id == "capture" && c.plugin == "sample-sidecar")
1333            .expect("sample category present");
1334        assert!(!capture.fields.is_empty());
1335        let first = &capture.fields[0];
1336        assert_eq!(first.key, "_lifecycle_toggle_key");
1337        assert_eq!(first.label, "Toggle key");
1338        match &first.editor {
1339            PluginSettingsEditor::Cycler { options } => {
1340                assert_eq!(
1341                    options,
1342                    &vec![
1343                        "F8".to_string(),
1344                        "F2".to_string(),
1345                        "F12".to_string(),
1346                        "C-V".to_string(),
1347                        "C-G".to_string(),
1348                    ]
1349                );
1350            }
1351            other => panic!("expected cycler, got {other:?}"),
1352        }
1353        assert_eq!(capture.fields[1].key, "existing");
1354    }
1355
1356    #[test]
1357    fn lifecycle_no_injection_when_settings_category_is_none() {
1358        let reg = CommandRegistry::new_with_plugins(
1359            &[],
1360            vec![],
1361            vec![mk_plugin_lifecycle_plus_settings(
1362                "p",
1363                "ocr",
1364                None,
1365                &["capture"],
1366            )],
1367        );
1368        let cats = reg.plugin_settings_categories();
1369        let capture = cats.iter().find(|c| c.id == "capture").expect("category");
1370        assert!(capture
1371            .fields
1372            .iter()
1373            .all(|f| f.key != "_lifecycle_toggle_key"));
1374    }
1375
1376    #[test]
1377    fn lifecycle_no_injection_when_category_does_not_exist() {
1378        let reg = CommandRegistry::new_with_plugins(
1379            &[],
1380            vec![],
1381            vec![mk_plugin_lifecycle_plus_settings(
1382                "p",
1383                "capture",
1384                Some("nonexistent"),
1385                &["capture"],
1386            )],
1387        );
1388        let cats = reg.plugin_settings_categories();
1389        for c in &cats {
1390            assert!(c.fields.iter().all(|f| f.key != "_lifecycle_toggle_key"));
1391        }
1392    }
1393
1394    #[test]
1395    fn lifecycle_two_plugins_each_get_injection_in_their_own_category() {
1396        let reg = CommandRegistry::new_with_plugins(
1397            &[],
1398            vec![],
1399            vec![
1400                mk_plugin_lifecycle_plus_settings(
1401                    "sidecar-plugin",
1402                    "capture",
1403                    Some("capture"),
1404                    &["capture"],
1405                ),
1406                mk_plugin_lifecycle_plus_settings("ocr-plugin", "ocr", Some("ocr"), &["ocr"]),
1407            ],
1408        );
1409        let cats = reg.plugin_settings_categories();
1410        let capture = cats.iter().find(|c| c.plugin == "sidecar-plugin").unwrap();
1411        let ocr = cats.iter().find(|c| c.plugin == "ocr-plugin").unwrap();
1412        assert_eq!(capture.fields[0].key, "_lifecycle_toggle_key");
1413        assert_eq!(ocr.fields[0].key, "_lifecycle_toggle_key");
1414    }
1415}