1use 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#[derive(Clone, Debug, PartialEq, Eq)]
36pub enum PluginSettingsEditor {
37 Text {
38 numeric: bool,
39 },
40 Cycler {
41 options: Vec<String>,
42 },
43 Picker,
44 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#[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#[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
82pub enum Resolution {
83 Builtin,
85 Skill(Arc<LoadedSkill>),
87 PluginCommand(Arc<RegisteredPluginCommand>),
89 Ambiguous(Vec<String>), Unknown,
93}
94
95struct Inner {
96 skills: HashMap<String, Vec<Arc<LoadedSkill>>>, qualified: HashMap<String, Arc<LoadedSkill>>, plugin_commands: HashMap<String, Arc<RegisteredPluginCommand>>, plugin_help_entries: Vec<HelpEntry>,
100 plugin_settings_categories: Vec<PluginSettingsCategory>,
103}
104
105pub const ATTACHMENT_COMMANDS: &[&str] = &["attach", "attachments", "detach"];
107
108pub fn attachment_path_argument(arg: &str) -> Result<&std::path::Path, String> {
112 let arg = arg.trim();
113 let path = if let Some(quote) = arg.chars().next().filter(|c| matches!(c, '\'' | '"')) {
114 if arg.len() < 2 || !arg.ends_with(quote) {
115 return Err("/attach: unmatched surrounding quote".into());
116 }
117 &arg[1..arg.len() - 1]
118 } else {
119 arg
120 };
121 if path.is_empty() {
122 return Err("usage: /attach PATH (the entire argument is one path)".into());
123 }
124 Ok(std::path::Path::new(path))
125}
126
127pub const ATTACHMENT_DISCLOSURE: &str =
128 "Attachment bytes are included in provider requests and stored in private session storage after submission.";
129
130pub struct CommandRegistry {
131 builtins: Vec<&'static str>,
132 inner: RwLock<Inner>,
133}
134
135impl CommandRegistry {
136 pub fn new(builtins: &[&'static str], skills: Vec<LoadedSkill>) -> Self {
137 Self::new_with_plugins(builtins, skills, vec![])
138 }
139
140 pub fn new_with_plugins(
141 builtins: &[&'static str],
142 skills: Vec<LoadedSkill>,
143 plugins: Vec<Plugin>,
144 ) -> Self {
145 let mut builtins = builtins.to_vec();
146 for &command in ATTACHMENT_COMMANDS {
147 if !builtins.contains(&command) {
148 builtins.push(command);
149 }
150 }
151 let r = CommandRegistry {
152 builtins,
153 inner: RwLock::new(Inner {
154 skills: HashMap::new(),
155 qualified: HashMap::new(),
156 plugin_commands: HashMap::new(),
157 plugin_help_entries: Vec::new(),
158 plugin_settings_categories: Vec::new(),
159 }),
160 };
161 r.rebuild_with_plugins(skills, plugins);
162 r
163 }
164
165 pub fn rebuild_with(&self, skills: Vec<LoadedSkill>) {
167 self.rebuild_with_plugins(skills, vec![]);
168 }
169
170 pub fn rebuild_with_plugins(&self, skills: Vec<LoadedSkill>, plugins: Vec<Plugin>) {
172 let builtins_set: std::collections::HashSet<&str> = self.builtins.iter().copied().collect();
173 let mut new_skills: HashMap<String, Vec<Arc<LoadedSkill>>> = HashMap::new();
174 let mut new_qualified: HashMap<String, Arc<LoadedSkill>> = HashMap::new();
175 let mut new_plugin_commands: HashMap<String, Arc<RegisteredPluginCommand>> = HashMap::new();
176 let mut new_plugin_help_entries: Vec<HelpEntry> = Vec::new();
177 let mut new_plugin_settings_categories: Vec<PluginSettingsCategory> = Vec::new();
178 for plugin in plugins {
179 if let Some(manifest) = plugin.manifest {
180 new_plugin_help_entries.extend(manifest.help_entries.iter().cloned().map(
181 |mut entry| {
182 entry.source = Some(manifest.name.clone());
183 entry
184 },
185 ));
186 if let Some(ref settings) = manifest.settings {
187 for cat in &settings.categories {
188 let fields = cat
189 .fields
190 .iter()
191 .map(|f| PluginSettingsField {
192 key: f.key.clone(),
193 label: f.label.clone(),
194 editor: match f.editor {
195 crate::skills::manifest::ManifestEditorKind::Text => {
196 PluginSettingsEditor::Text { numeric: f.numeric }
197 }
198 crate::skills::manifest::ManifestEditorKind::Cycler => {
199 PluginSettingsEditor::Cycler {
200 options: f.options.clone(),
201 }
202 }
203 crate::skills::manifest::ManifestEditorKind::Picker => {
204 PluginSettingsEditor::Picker
205 }
206 crate::skills::manifest::ManifestEditorKind::Custom => {
207 PluginSettingsEditor::Custom
208 }
209 },
210 help: f.help.clone(),
211 default: f.default.clone(),
212 })
213 .collect();
214 new_plugin_settings_categories.push(PluginSettingsCategory {
215 plugin: manifest.name.clone(),
216 id: cat.id.clone(),
217 label: cat.label.clone(),
218 fields,
219 });
220 }
221 }
222 for cmd in manifest.commands {
223 let (name, description, backend) = match cmd {
224 crate::skills::manifest::ManifestCommand::Shell(cmd) => (
225 cmd.name,
226 cmd.description,
227 RegisteredPluginCommandBackend::Shell {
228 command: cmd.command,
229 args: cmd.args,
230 },
231 ),
232 crate::skills::manifest::ManifestCommand::ExtensionTool(cmd) => (
233 cmd.name,
234 cmd.description,
235 RegisteredPluginCommandBackend::ExtensionTool {
236 tool: cmd.tool,
237 input: cmd.input,
238 },
239 ),
240 crate::skills::manifest::ManifestCommand::SkillPrompt(cmd) => (
241 cmd.name,
242 cmd.description,
243 RegisteredPluginCommandBackend::SkillPrompt {
244 skill: cmd.skill,
245 prompt: cmd.prompt,
246 },
247 ),
248 crate::skills::manifest::ManifestCommand::Interactive(cmd) => {
249 if !cmd.interactive {
250 continue;
251 }
252 (
253 cmd.name,
254 cmd.description,
255 RegisteredPluginCommandBackend::Interactive {
256 plugin_extension_id: manifest
257 .extension
258 .as_ref()
259 .map(|_| plugin.name.clone())
260 .unwrap_or_else(|| plugin.name.clone()),
261 },
262 )
263 }
264 };
265 let q = format!("{}:{}", manifest.name, name);
266 if builtins_set.contains(name.as_str()) {
268 tracing::warn!(
269 "plugin '{}' command '{}' shadows builtin; skipping",
270 manifest.name,
271 name,
272 );
273 continue;
274 }
275 new_plugin_commands.insert(
276 q,
277 Arc::new(RegisteredPluginCommand {
278 plugin: manifest.name.clone(),
279 name,
280 description,
281 backend,
282 plugin_root: plugin.root.clone(),
283 }),
284 );
285 }
286 }
287 }
288 for s in skills {
289 let arc = Arc::new(s);
290 if builtins_set.contains(arc.name.as_str()) {
292 tracing::warn!(
293 "skill '{}' shadowed by built-in; reachable only via qualified form '{}:{}'",
294 arc.name,
295 arc.plugin.as_deref().unwrap_or("?"),
296 arc.name
297 );
298 } else {
299 new_skills
300 .entry(arc.name.clone())
301 .or_default()
302 .push(arc.clone());
303 }
304 if let Some(ref p) = arc.plugin {
306 let q = format!("{}:{}", p, arc.name);
307 new_qualified.insert(q, arc.clone());
308 }
309 }
310
311 let mut w = self.inner.write().unwrap();
312 w.skills = new_skills;
313 w.qualified = new_qualified;
314 w.plugin_commands = new_plugin_commands;
315 w.plugin_help_entries = new_plugin_help_entries;
316 w.plugin_settings_categories = new_plugin_settings_categories;
317 }
318
319 pub fn resolve(&self, cmd: &str) -> Resolution {
320 let r = self.inner.read().unwrap();
321 if cmd.contains(':') {
322 if let Some(c) = r.plugin_commands.get(cmd) {
323 return Resolution::PluginCommand(c.clone());
324 }
325 return match r.qualified.get(cmd) {
326 Some(s) => Resolution::Skill(s.clone()),
327 None => Resolution::Unknown,
328 };
329 }
330 if self.builtins.contains(&cmd) {
331 return Resolution::Builtin;
332 }
333 match r.skills.get(cmd) {
334 Some(v) if v.len() == 1 => Resolution::Skill(v[0].clone()),
335 Some(v) => Resolution::Ambiguous(
336 v.iter()
337 .map(|s| format!("{}:{}", s.plugin.as_deref().unwrap_or("?"), s.name))
338 .collect(),
339 ),
340 None => {
341 drop(r);
347 if let Some(c) = self.find_plugin_command_unqualified(cmd) {
348 return Resolution::PluginCommand(c);
349 }
350 Resolution::Unknown
351 }
352 }
353 }
354
355 pub fn find_plugin_command_unqualified(
362 &self,
363 name: &str,
364 ) -> Option<Arc<RegisteredPluginCommand>> {
365 let r = self.inner.read().unwrap();
366 let mut matches = r.plugin_commands.values().filter(|c| c.name == name);
367 let first = matches.next()?.clone();
368 if matches.next().is_some() {
369 return None; }
371 Some(first)
372 }
373
374 pub fn all_commands(&self) -> Vec<String> {
376 let r = self.inner.read().unwrap();
377 let mut v: Vec<String> = self.builtins.iter().map(|s| s.to_string()).collect();
378 v.extend(r.skills.keys().cloned());
379 let mut by_name: HashMap<&str, Vec<&str>> = HashMap::new();
385 for (qkey, c) in r.plugin_commands.iter() {
386 by_name
387 .entry(c.name.as_str())
388 .or_default()
389 .push(qkey.as_str());
390 }
391 for (name, qkeys) in by_name {
392 if qkeys.len() == 1 {
393 v.push(name.to_string());
394 } else {
395 v.extend(qkeys.iter().map(|s| s.to_string()));
396 }
397 }
398 v.sort();
399 v.dedup();
400 v
401 }
402
403
404 pub fn plugins(&self) -> Vec<PluginSummary> {
405 let r = self.inner.read().unwrap();
406 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
407 let mut seen: std::collections::HashSet<(String, String)> =
408 std::collections::HashSet::new();
409 for c in r.plugin_commands.values() {
410 let key = (c.plugin.clone(), c.name.clone());
411 if seen.insert(key) {
412 *counts.entry(c.plugin.clone()).or_insert(0) += 0;
413 }
414 }
415 for s in r.qualified.values() {
416 if let Some(ref p) = s.plugin {
417 let key = (p.clone(), s.name.clone());
418 if seen.insert(key) {
419 *counts.entry(p.clone()).or_insert(0) += 1;
420 }
421 }
422 }
423 counts
424 .into_iter()
425 .map(|(name, skill_count)| PluginSummary { name, skill_count })
426 .collect()
427 }
428
429 pub fn plugin_help_entries(&self) -> Vec<HelpEntry> {
430 self.inner.read().unwrap().plugin_help_entries.clone()
431 }
432
433 pub fn plugin_settings_categories(&self) -> Vec<PluginSettingsCategory> {
437 self.inner
438 .read()
439 .unwrap()
440 .plugin_settings_categories
441 .clone()
442 }
443
444 pub fn all_skills(&self) -> Vec<Arc<LoadedSkill>> {
445 let r = self.inner.read().unwrap();
446 let mut seen: std::collections::HashSet<(Option<String>, String)> =
447 std::collections::HashSet::new();
448 let mut out = Vec::new();
449 for list in r.skills.values() {
450 for s in list {
451 let key = (s.plugin.clone(), s.name.clone());
452 if seen.insert(key) {
453 out.push(s.clone());
454 }
455 }
456 }
457 for s in r.qualified.values() {
458 let key = (s.plugin.clone(), s.name.clone());
459 if seen.insert(key) {
460 out.push(s.clone());
461 }
462 }
463 out
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use crate::skills::manifest::{ManifestCommand, ManifestShellCommand};
471 use std::path::PathBuf;
472
473 #[test]
474 fn attachment_commands_are_reserved_and_deduplicated() {
475 let r = CommandRegistry::new(&["attach"], vec![mk("attach", Some("p"))]);
476 for &command in ATTACHMENT_COMMANDS {
477 assert!(matches!(r.resolve(command), Resolution::Builtin));
478 assert_eq!(
479 r.all_commands()
480 .iter()
481 .filter(|c| c.as_str() == command)
482 .count(),
483 1
484 );
485 }
486 assert!(matches!(r.resolve("p:attach"), Resolution::Skill(_)));
487 }
488
489 #[test]
490 fn attachment_argument_is_one_literal_path() {
491 for arg in ["some file.txt", " \"some file.txt\" ", "'some file.txt'"] {
492 assert_eq!(
493 attachment_path_argument(arg).unwrap(),
494 std::path::Path::new("some file.txt")
495 );
496 }
497 assert_eq!(
498 attachment_path_argument("~/file* $HOME.txt").unwrap(),
499 std::path::Path::new("~/file* $HOME.txt")
500 );
501 for arg in [
502 "",
503 " ",
504 "\"\"",
505 "''",
506 "\"",
507 "'",
508 "\"unclosed",
509 "'unclosed\"",
510 ] {
511 assert!(attachment_path_argument(arg).is_err(), "{arg:?}");
512 }
513 }
514
515 fn mk_cmd(plugin: &str, name: &str, root: PathBuf) -> Plugin {
516 Plugin {
517 name: plugin.to_string(),
518 root,
519 marketplace: None,
520 version: None,
521 description: None,
522 extension: None,
523 manifest: Some(crate::skills::manifest::PluginManifest {
524 name: plugin.to_string(),
525 version: None,
526 description: None,
527 keybinds: vec![],
528 compatibility: None,
529 commands: vec![ManifestCommand::Shell(ManifestShellCommand {
530 name: name.to_string(),
531 description: Some("desc".to_string()),
532 command: "printf".to_string(),
533 args: vec!["hi".to_string()],
534 })],
535 extension: None,
536 help_entries: vec![],
537 settings: None,
538 }),
539 }
540 }
541
542 fn mk_interactive_cmd(plugin: &str, name: &str, root: PathBuf) -> Plugin {
543 Plugin {
544 name: plugin.to_string(),
545 root,
546 marketplace: None,
547 version: None,
548 description: None,
549 extension: None,
550 manifest: Some(crate::skills::manifest::PluginManifest {
551 name: plugin.to_string(),
552 version: None,
553 description: None,
554 keybinds: vec![],
555 compatibility: None,
556 commands: vec![ManifestCommand::Interactive(
557 crate::skills::manifest::ManifestInteractiveCommand {
558 name: name.to_string(),
559 description: Some("interactive desc".to_string()),
560 interactive: true,
561 subcommands: vec!["help".to_string()],
562 },
563 )],
564 extension: None,
565 help_entries: vec![],
566 settings: None,
567 }),
568 }
569 }
570
571 #[test]
572 fn registers_interactive_plugin_command_backend() {
573 let reg = CommandRegistry::new_with_plugins(
574 &[],
575 vec![],
576 vec![mk_interactive_cmd(
577 "demo-plugin",
578 "demo",
579 PathBuf::from("/tmp/demo"),
580 )],
581 );
582
583 match reg.resolve("demo-plugin:demo") {
584 Resolution::PluginCommand(cmd) => match &cmd.backend {
585 RegisteredPluginCommandBackend::Interactive {
586 plugin_extension_id,
587 } => {
588 assert_eq!(plugin_extension_id, "demo-plugin");
589 assert_eq!(cmd.name, "demo");
590 }
591 other => panic!("expected interactive backend, got {other:?}"),
592 },
593 _ => panic!("expected plugin command resolution"),
594 }
595 }
596
597 fn mk(name: &str, plugin: Option<&str>) -> LoadedSkill {
598 LoadedSkill::new_inline(
599 name,
600 "",
601 "",
602 plugin,
603 PathBuf::from("/"),
604 PathBuf::from("/SKILL.md"),
605 )
606 }
607
608 #[test]
609 fn plugin_help_entries_are_tagged_with_manifest_name() {
610 let root = PathBuf::from("/tmp/plugin-root");
611 let mut plugin = mk_cmd("acme-tools", "sync", root);
612 plugin
613 .manifest
614 .as_mut()
615 .unwrap()
616 .help_entries
617 .push(HelpEntry {
618 id: "acme-sync".to_string(),
619 command: "/acme:sync".to_string(),
620 title: "Acme Sync".to_string(),
621 summary: "Sync Acme workspace state.".to_string(),
622 category: "Plugin".to_string(),
623 topic: crate::help::HelpTopicKind::Command,
624 protected: false,
625 common: false,
626 aliases: vec![],
627 keywords: vec![],
628 lines: vec![],
629 usage: Some("/acme:sync [workspace]".to_string()),
630 examples: vec![crate::help::HelpExample {
631 command: "/acme:sync docs".to_string(),
632 description: "Sync docs.".to_string(),
633 }],
634 related: vec![],
635 source: None,
636 });
637
638 let registry = CommandRegistry::new_with_plugins(&[], vec![], vec![plugin]);
639 let entries = registry.plugin_help_entries();
640
641 assert_eq!(entries.len(), 1);
642 assert_eq!(entries[0].source.as_deref(), Some("acme-tools"));
643 assert_eq!(entries[0].usage.as_deref(), Some("/acme:sync [workspace]"));
644 assert_eq!(entries[0].examples[0].command, "/acme:sync docs");
645 }
646
647 #[test]
648 fn resolve_builtin() {
649 let r = CommandRegistry::new(&["clear"], vec![]);
650 assert!(matches!(r.resolve("clear"), Resolution::Builtin));
651 }
652
653 #[test]
654 fn resolve_unknown() {
655 let r = CommandRegistry::new(&["clear"], vec![]);
656 assert!(matches!(r.resolve("xyz"), Resolution::Unknown));
657 }
658
659 #[test]
660 fn resolve_unique_skill() {
661 let r = CommandRegistry::new(&[], vec![mk("search", Some("p"))]);
662 match r.resolve("search") {
663 Resolution::Skill(s) => assert_eq!(s.name, "search"),
664 _ => panic!(),
665 }
666 }
667
668 #[test]
669 fn resolve_ambiguous() {
670 let r = CommandRegistry::new(
671 &[],
672 vec![mk("search", Some("p1")), mk("search", Some("p2"))],
673 );
674 match r.resolve("search") {
675 Resolution::Ambiguous(v) => {
676 assert_eq!(v.len(), 2);
677 assert!(v.iter().any(|s| s == "p1:search"));
678 assert!(v.iter().any(|s| s == "p2:search"));
679 }
680 _ => panic!(),
681 }
682 }
683
684 #[test]
685 fn resolve_qualified() {
686 let r = CommandRegistry::new(
687 &[],
688 vec![mk("search", Some("p1")), mk("search", Some("p2"))],
689 );
690 match r.resolve("p1:search") {
691 Resolution::Skill(s) => assert_eq!(s.plugin.as_deref(), Some("p1")),
692 _ => panic!(),
693 }
694 }
695
696 #[test]
697 fn builtin_shadows_skill_unqualified() {
698 let r = CommandRegistry::new(&["clear"], vec![mk("clear", Some("p"))]);
700 assert!(matches!(r.resolve("clear"), Resolution::Builtin));
701 match r.resolve("p:clear") {
703 Resolution::Skill(s) => assert_eq!(s.name, "clear"),
704 _ => panic!(),
705 }
706 }
707
708 #[test]
709 fn all_commands_sorted_and_deduped() {
710 let r = CommandRegistry::new(
711 &["clear", "model"],
712 vec![mk("search", Some("p")), mk("help-me", None)],
713 );
714 let cmds = r.all_commands();
715 assert_eq!(
716 cmds,
717 vec![
718 "attach",
719 "attachments",
720 "clear",
721 "detach",
722 "help-me",
723 "model",
724 "search"
725 ]
726 );
727 }
728
729 #[test]
730 fn all_skills_dedups_plugin_skill() {
731 let r = CommandRegistry::new(&[], vec![mk("search", Some("p"))]);
732 let all = r.all_skills();
733 assert_eq!(all.len(), 1);
734 assert_eq!(all[0].name, "search");
735 assert_eq!(all[0].plugin.as_deref(), Some("p"));
736 }
737
738 #[test]
739 fn all_skills_includes_shadowed_skill() {
740 let r = CommandRegistry::new(&["clear"], vec![mk("clear", Some("p"))]);
741 let all = r.all_skills();
742 assert_eq!(all.len(), 1);
743 assert_eq!(all[0].name, "clear");
744 assert_eq!(all[0].plugin.as_deref(), Some("p"));
745 }
746
747 #[test]
748 fn resolve_qualified_unknown_returns_unknown() {
749 let r = CommandRegistry::new(&[], vec![mk("search", Some("p1"))]);
750 assert!(matches!(r.resolve("p1:nosuch"), Resolution::Unknown));
751 assert!(matches!(r.resolve("nosuch:search"), Resolution::Unknown));
752 }
753
754 #[test]
755 fn rebuild_replaces_skills() {
756 let r = CommandRegistry::new(&["clear"], vec![mk("old", None)]);
757 assert!(matches!(r.resolve("old"), Resolution::Skill(_)));
758 assert!(matches!(r.resolve("new"), Resolution::Unknown));
759 r.rebuild_with(vec![mk("new", None)]);
760 assert!(matches!(r.resolve("old"), Resolution::Unknown));
761 assert!(matches!(r.resolve("new"), Resolution::Skill(_)));
762 }
763
764 #[test]
765 fn rebuild_visible_through_shared_arc() {
766 let r = std::sync::Arc::new(CommandRegistry::new(&[], vec![mk("a", None)]));
767 let r2 = r.clone();
768 r.rebuild_with(vec![mk("b", None)]);
769 assert!(matches!(r2.resolve("b"), Resolution::Skill(_)));
770 assert!(matches!(r2.resolve("a"), Resolution::Unknown));
771 }
772
773 #[test]
774 fn resolve_qualified_plugin_command() {
775 let r = CommandRegistry::new_with_plugins(
776 &[],
777 vec![],
778 vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))],
779 );
780 match r.resolve("p:hello") {
781 Resolution::PluginCommand(cmd) => {
782 assert_eq!(cmd.plugin, "p");
783 assert_eq!(cmd.name, "hello");
784 assert!(matches!(
785 &cmd.backend,
786 RegisteredPluginCommandBackend::Shell { command, .. } if command == "printf"
787 ));
788 assert_eq!(cmd.plugin_root, PathBuf::from("/tmp/p"));
789 }
790 _ => panic!(),
791 }
792 }
793
794 #[test]
795 fn all_commands_includes_unambiguous_plugin_commands_bare() {
796 let r = CommandRegistry::new_with_plugins(
801 &["help"],
802 vec![],
803 vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))],
804 );
805 let cmds = r.all_commands();
806 assert!(cmds.contains(&"help".to_string()));
807 assert!(cmds.contains(&"hello".to_string()));
808 assert!(!cmds.contains(&"p:hello".to_string()));
809 }
810
811 #[test]
812 fn resolve_bare_unique_plugin_command() {
813 let r = CommandRegistry::new_with_plugins(
814 &[],
815 vec![],
816 vec![mk_cmd("crush", "crush", PathBuf::from("/tmp/crush"))],
817 );
818 match r.resolve("crush") {
819 Resolution::PluginCommand(cmd) => {
820 assert_eq!(cmd.plugin, "crush");
821 assert_eq!(cmd.name, "crush");
822 }
823 _ => panic!("expected PluginCommand"),
824 }
825 match r.resolve("crush:crush") {
827 Resolution::PluginCommand(cmd) => assert_eq!(cmd.plugin, "crush"),
828 _ => panic!("expected PluginCommand"),
829 }
830 }
831
832 #[test]
833 fn resolve_bare_ambiguous_plugin_command_unknown() {
834 let r = CommandRegistry::new_with_plugins(
839 &[],
840 vec![],
841 vec![
842 mk_cmd("a", "foo", PathBuf::from("/tmp/a")),
843 mk_cmd("b", "foo", PathBuf::from("/tmp/b")),
844 ],
845 );
846 assert!(matches!(r.resolve("foo"), Resolution::Unknown));
847 assert!(matches!(r.resolve("a:foo"), Resolution::PluginCommand(_)));
848 assert!(matches!(r.resolve("b:foo"), Resolution::PluginCommand(_)));
849 }
850
851 #[test]
852 fn all_commands_ambiguous_plugin_commands_stay_qualified() {
853 let r = CommandRegistry::new_with_plugins(
854 &[],
855 vec![],
856 vec![
857 mk_cmd("a", "foo", PathBuf::from("/tmp/a")),
858 mk_cmd("b", "foo", PathBuf::from("/tmp/b")),
859 ],
860 );
861 let cmds = r.all_commands();
862 assert!(cmds.contains(&"a:foo".to_string()));
863 assert!(cmds.contains(&"b:foo".to_string()));
864 assert!(!cmds.contains(&"foo".to_string()));
865 }
866
867 #[test]
868 fn plugins_summary_groups_by_plugin_name() {
869 let r = CommandRegistry::new(
870 &[],
871 vec![
872 mk("a", Some("p1")),
873 mk("b", Some("p1")),
874 mk("c", Some("p2")),
875 mk("loose", None),
876 ],
877 );
878 let mut plugins = r.plugins();
879 plugins.sort_by(|a, b| a.name.cmp(&b.name));
880 assert_eq!(plugins.len(), 2);
881 assert_eq!(plugins[0].name, "p1");
882 assert_eq!(plugins[0].skill_count, 2);
883 assert_eq!(plugins[1].name, "p2");
884 assert_eq!(plugins[1].skill_count, 1);
885 }
886
887 fn mk_plugin_with_settings(plugin: &str, root: PathBuf) -> Plugin {
888 use crate::skills::manifest::{
889 ManifestEditorKind, ManifestSettings, ManifestSettingsCategory, ManifestSettingsField,
890 };
891 Plugin {
892 name: plugin.to_string(),
893 root,
894 marketplace: None,
895 version: None,
896 description: None,
897 extension: None,
898 manifest: Some(crate::skills::manifest::PluginManifest {
899 name: plugin.to_string(),
900 version: None,
901 description: None,
902 keybinds: vec![],
903 compatibility: None,
904 commands: vec![],
905 extension: None,
906 help_entries: vec![],
907 settings: Some(ManifestSettings {
908 categories: vec![ManifestSettingsCategory {
909 id: "demo".to_string(),
910 label: "Demo".to_string(),
911 fields: vec![
912 ManifestSettingsField {
913 key: "backend".to_string(),
914 label: "Backend".to_string(),
915 editor: ManifestEditorKind::Cycler,
916 options: vec!["a".to_string(), "b".to_string()],
917 help: None,
918 default: None,
919 numeric: false,
920 },
921 ManifestSettingsField {
922 key: "endpoint".to_string(),
923 label: "Endpoint".to_string(),
924 editor: ManifestEditorKind::Text,
925 options: vec![],
926 help: Some("URL".to_string()),
927 default: None,
928 numeric: false,
929 },
930 ],
931 }],
932 }),
933 }),
934 }
935 }
936
937 #[test]
938 fn plugin_settings_categories_exposed_after_rebuild() {
939 let r = CommandRegistry::new_with_plugins(
940 &[],
941 vec![],
942 vec![mk_plugin_with_settings(
943 "demo-plugin",
944 PathBuf::from("/tmp/demo"),
945 )],
946 );
947 let cats = r.plugin_settings_categories();
948 assert_eq!(cats.len(), 1, "expected one plugin settings category");
949 let cat = &cats[0];
950 assert_eq!(cat.plugin, "demo-plugin");
951 assert_eq!(cat.id, "demo");
952 assert_eq!(cat.label, "Demo");
953 assert_eq!(cat.fields.len(), 2);
954
955 match &cat.fields[0].editor {
956 PluginSettingsEditor::Cycler { options } => {
957 assert_eq!(options, &vec!["a".to_string(), "b".to_string()]);
958 }
959 other => panic!("expected cycler, got {other:?}"),
960 }
961 assert!(matches!(
962 cat.fields[1].editor,
963 PluginSettingsEditor::Text { numeric: false }
964 ));
965 assert_eq!(cat.fields[1].help.as_deref(), Some("URL"));
966 }
967
968 #[test]
969 fn plugin_settings_categories_empty_without_settings_block() {
970 let r = CommandRegistry::new_with_plugins(
971 &[],
972 vec![],
973 vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))],
974 );
975 assert!(r.plugin_settings_categories().is_empty());
976 }
977
978 #[test]
979 fn plugin_settings_categories_replaced_on_rebuild() {
980 let r = CommandRegistry::new_with_plugins(
981 &[],
982 vec![],
983 vec![mk_plugin_with_settings(
984 "demo-plugin",
985 PathBuf::from("/tmp/demo"),
986 )],
987 );
988 assert_eq!(r.plugin_settings_categories().len(), 1);
989 r.rebuild_with_plugins(vec![], vec![]);
990 assert!(r.plugin_settings_categories().is_empty());
991 }
992
993 #[test]
994 fn plugin_settings_categories_does_not_hardcode_capture() {
995 let r = CommandRegistry::new_with_plugins(
998 &[],
999 vec![],
1000 vec![mk_plugin_with_settings(
1001 "totally-unrelated",
1002 PathBuf::from("/tmp/x"),
1003 )],
1004 );
1005 let cats = r.plugin_settings_categories();
1006 assert_eq!(cats[0].plugin, "totally-unrelated");
1007 assert_eq!(cats[0].id, "demo");
1008 assert!(cats[0]
1009 .fields
1010 .iter()
1011 .any(|f| matches!(f.editor, PluginSettingsEditor::Cycler { .. })));
1012 assert!(cats[0]
1013 .fields
1014 .iter()
1015 .any(|f| matches!(f.editor, PluginSettingsEditor::Text { .. })));
1016 }
1017}