Skip to main content

a3s_code_core/skills/
registry.rs

1//! Skill Registry
2//!
3//! Manages skill registration, loading, and lookup.
4//! Integrates with `SkillValidator` as a safety gate for externally loaded skills.
5
6use super::validator::SkillValidator;
7use super::{Skill, SkillKind};
8use anyhow::Context;
9use std::collections::{HashMap, HashSet};
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, RwLock};
12
13/// Skill registry for managing available skills
14///
15/// Provides skill registration, loading from directories, and lookup by name.
16/// Optionally validates skills before registration.
17pub struct SkillRegistry {
18    skills: Arc<RwLock<HashMap<String, Arc<Skill>>>>,
19    builtin_names: Arc<RwLock<HashSet<String>>>,
20    validator: Arc<RwLock<Option<Arc<dyn SkillValidator>>>>,
21}
22
23impl SkillRegistry {
24    /// Create a new empty skill registry
25    pub fn new() -> Self {
26        Self {
27            skills: Arc::new(RwLock::new(HashMap::new())),
28            builtin_names: Arc::new(RwLock::new(HashSet::new())),
29            validator: Arc::new(RwLock::new(None)),
30        }
31    }
32
33    /// Create a registry with built-in skills
34    pub fn with_builtins() -> Self {
35        let registry = Self::new();
36        for skill in super::builtin::builtin_skills() {
37            // Built-in skills bypass validation
38            registry.register_builtin(skill);
39        }
40        registry
41    }
42
43    /// Fork this registry into an independent copy.
44    ///
45    /// The fork shares no state with the original — skills added to the fork
46    /// do not affect the source registry. The validator is preserved so
47    /// that session and delegated-agent registries keep the same safety policy.
48    pub fn fork(&self) -> Self {
49        let skills = self.skills.read().unwrap().clone();
50        let builtin_names = self.builtin_names.read().unwrap().clone();
51        Self {
52            skills: Arc::new(RwLock::new(skills)),
53            builtin_names: Arc::new(RwLock::new(builtin_names)),
54            validator: Arc::new(RwLock::new(self.validator.read().unwrap().clone())),
55        }
56    }
57
58    /// Set the skill validator (safety gate)
59    pub fn set_validator(&self, validator: Arc<dyn SkillValidator>) {
60        *self.validator.write().unwrap() = Some(validator);
61    }
62
63    /// Register a skill with validation
64    ///
65    /// If a validator is set, the skill must pass validation before registration.
66    /// Returns an error if validation fails.
67    pub fn register(
68        &self,
69        skill: Arc<Skill>,
70    ) -> Result<(), super::validator::SkillValidationError> {
71        // Run validator if set
72        if let Some(ref validator) = *self.validator.read().unwrap() {
73            validator.validate(&skill)?;
74        }
75        self.register_unchecked(skill);
76        Ok(())
77    }
78
79    /// Register a skill without validation.
80    ///
81    /// If this replaces a built-in skill with the same name, it is treated as an
82    /// external skill for global tool-restriction purposes.
83    pub fn register_unchecked(&self, skill: Arc<Skill>) {
84        self.builtin_names.write().unwrap().remove(&skill.name);
85        let mut skills = self.skills.write().unwrap();
86        skills.insert(skill.name.clone(), skill);
87    }
88
89    fn register_builtin(&self, skill: Arc<Skill>) {
90        let name = skill.name.clone();
91        self.skills.write().unwrap().insert(name.clone(), skill);
92        self.builtin_names.write().unwrap().insert(name);
93    }
94
95    /// Get a skill by name
96    pub fn get(&self, name: &str) -> Option<Arc<Skill>> {
97        let skills = self.skills.read().unwrap();
98        skills.get(name).cloned()
99    }
100
101    /// List all registered skill names
102    pub fn list(&self) -> Vec<String> {
103        let skills = self.skills.read().unwrap();
104        skills.keys().cloned().collect()
105    }
106
107    /// Get all registered skills
108    pub fn all(&self) -> Vec<Arc<Skill>> {
109        let skills = self.skills.read().unwrap();
110        skills.values().cloned().collect()
111    }
112
113    /// Load skills from a directory
114    ///
115    /// Recursively scans the directory for skill files and attempts to parse them.
116    ///
117    /// Supported layouts:
118    /// - `path/to/skill.md`
119    /// - `path/to/skill/SKILL.md`
120    ///
121    /// Candidate files are processed in deterministic sorted order. Files that
122    /// fail to parse are skipped with debug logging; validation failures are
123    /// logged as warnings.
124    pub fn load_from_dir(&self, dir: impl AsRef<Path>) -> anyhow::Result<usize> {
125        let dir = dir.as_ref();
126
127        if !dir.exists() {
128            return Ok(0);
129        }
130
131        if !dir.is_dir() {
132            anyhow::bail!("Path is not a directory: {}", dir.display());
133        }
134
135        let mut loaded = 0;
136        for candidate in Self::collect_skill_candidates(dir)? {
137            match Skill::from_file(&candidate) {
138                Ok(skill) => {
139                    let name = skill.name.clone();
140                    if skill.allowed_tools.is_none() {
141                        tracing::warn!(
142                            skill = %name,
143                            path = %candidate.display(),
144                            "Skill omits allowed-tools; Skill invocation is fail-secure and will deny tool use until allowed-tools is declared"
145                        );
146                    } else if skill.uses_legacy_allowed_tools_syntax() {
147                        tracing::warn!(
148                            skill = %name,
149                            path = %candidate.display(),
150                            "Skill uses legacy whitespace-separated allowed-tools; use comma-separated permissions such as Read(*), Write(*), Bash(*) or a YAML list"
151                        );
152                    }
153                    let skill = Arc::new(skill);
154                    if self.get(&name).is_some() {
155                        tracing::warn!(
156                            skill = %name,
157                            path = %candidate.display(),
158                            "Duplicate skill name encountered during directory load — overriding previous definition"
159                        );
160                    }
161                    match self.register(skill) {
162                        Ok(()) => loaded += 1,
163                        Err(e) => {
164                            tracing::warn!(
165                                "Skill validation failed for {}: {}",
166                                candidate.display(),
167                                e
168                            );
169                        }
170                    }
171                }
172                Err(e) => {
173                    tracing::debug!("Skipped {}: {}", candidate.display(), e);
174                }
175            }
176        }
177
178        Ok(loaded)
179    }
180
181    fn collect_skill_candidates(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
182        fn visit(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
183            let mut entries = std::fs::read_dir(dir)
184                .with_context(|| format!("Failed to read directory: {}", dir.display()))?
185                .collect::<Result<Vec<_>, std::io::Error>>()?;
186            entries.sort_by_key(|entry| entry.path());
187
188            for entry in entries {
189                let path = entry.path();
190                if path.is_dir() {
191                    let skill_md = path.join("SKILL.md");
192                    if skill_md.is_file() {
193                        out.push(skill_md);
194                    }
195                    visit(&path, out)?;
196                } else if path.extension().and_then(|s| s.to_str()) == Some("md") {
197                    out.push(path);
198                }
199            }
200            Ok(())
201        }
202
203        let mut out = Vec::new();
204        visit(dir, &mut out)?;
205        out.sort();
206        out.dedup();
207        Ok(out)
208    }
209
210    /// Load a single skill from a file
211    pub fn load_from_file(&self, path: impl AsRef<Path>) -> anyhow::Result<Arc<Skill>> {
212        let skill = Skill::from_file(path)?;
213        let skill = Arc::new(skill);
214        self.register(skill.clone())
215            .map_err(|e| anyhow::anyhow!("Skill validation failed: {}", e))?;
216        Ok(skill)
217    }
218
219    /// Remove a skill by name
220    pub fn remove(&self, name: &str) -> Option<Arc<Skill>> {
221        let mut skills = self.skills.write().unwrap();
222        skills.remove(name)
223    }
224
225    /// Clear all skills
226    pub fn clear(&self) {
227        let mut skills = self.skills.write().unwrap();
228        skills.clear();
229    }
230
231    /// Get the number of registered skills
232    pub fn len(&self) -> usize {
233        let skills = self.skills.read().unwrap();
234        skills.len()
235    }
236
237    /// Check if the registry is empty
238    pub fn is_empty(&self) -> bool {
239        self.len() == 0
240    }
241
242    /// Get all skills of a specific kind
243    pub fn by_kind(&self, kind: super::SkillKind) -> Vec<Arc<Skill>> {
244        let skills = self.skills.read().unwrap();
245        skills
246            .values()
247            .filter(|s| s.kind == kind)
248            .cloned()
249            .collect()
250    }
251
252    /// Instruction skills that actively constrain normal session tool use.
253    ///
254    /// Built-in skills have local allowlists for explicit `Skill` invocation,
255    /// but those allowlists must not make the default registry globally
256    /// read-only. If a user replaces a built-in name through registration or a
257    /// skill directory, the replacement is no longer considered built-in.
258    pub fn global_tool_restricting_skills(&self) -> Vec<Arc<Skill>> {
259        let skills = self.skills.read().unwrap();
260        let builtin_names = self.builtin_names.read().unwrap();
261        skills
262            .values()
263            .filter(|skill| {
264                skill.kind == SkillKind::Instruction
265                    && skill.allowed_tools.is_some()
266                    && !builtin_names.contains(&skill.name)
267            })
268            .cloned()
269            .collect()
270    }
271
272    /// Get all skills with a specific tag
273    pub fn by_tag(&self, tag: &str) -> Vec<Arc<Skill>> {
274        let skills = self.skills.read().unwrap();
275        skills
276            .values()
277            .filter(|s| s.tags.iter().any(|t| t == tag))
278            .cloned()
279            .collect()
280    }
281
282    /// Get all persona-kind skills
283    ///
284    /// Personas are session-level system prompts bound at session creation.
285    /// They are NOT injected into the global system prompt via `to_system_prompt()`.
286    pub fn personas(&self) -> Vec<Arc<Skill>> {
287        self.by_kind(super::SkillKind::Persona)
288    }
289
290    /// Search discoverable instruction/tool skills by name, tag, description, or content.
291    pub fn search(&self, query: &str, limit: usize) -> Vec<Arc<Skill>> {
292        let skills = self.skills.read().unwrap();
293        let query_lower = query.to_lowercase();
294        let query_tokens: Vec<&str> = query_lower
295            .split_whitespace()
296            .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
297            .filter(|w| w.len() >= 2)
298            .collect();
299
300        let mut scored: Vec<(u32, String, Arc<Skill>)> = skills
301            .values()
302            .filter(|s| Self::is_discoverable_skill(s))
303            .filter_map(|skill| {
304                let score = Self::skill_search_score(skill, &query_lower, &query_tokens);
305                if score == 0 {
306                    None
307                } else {
308                    Some((score, skill.name.clone(), Arc::clone(skill)))
309                }
310            })
311            .collect();
312
313        scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
314        scored
315            .into_iter()
316            .take(limit.max(1))
317            .map(|(_, _, skill)| skill)
318            .collect()
319    }
320
321    fn is_discoverable_skill(skill: &Skill) -> bool {
322        skill.kind == super::SkillKind::Instruction || skill.kind == super::SkillKind::Tool
323    }
324
325    fn skill_search_score(skill: &Skill, query_lower: &str, query_tokens: &[&str]) -> u32 {
326        if query_lower.trim().is_empty() {
327            return 1;
328        }
329
330        let name = skill.name.to_lowercase();
331        let description = skill.description.to_lowercase();
332        let tags: Vec<String> = skill.tags.iter().map(|t| t.to_lowercase()).collect();
333        let content = skill.content.to_lowercase();
334        let mut score = 0;
335
336        if query_lower.contains(&name) {
337            score += 100;
338        }
339        if tags.iter().any(|tag| query_lower.contains(tag)) {
340            score += 80;
341        }
342
343        for token in query_tokens {
344            if name.contains(token) {
345                score += 20;
346            }
347            if tags.iter().any(|tag| tag.contains(token)) {
348                score += 15;
349            }
350            if description.contains(token) {
351                score += 8;
352            }
353            if content.contains(token) {
354                score += 2;
355            }
356        }
357
358        score
359    }
360
361    /// Generate system prompt content from all instruction skills
362    ///
363    /// Concatenates the content of all instruction-type skills for injection
364    /// into the system prompt.
365    /// Persona-kind skills are excluded — they are bound per-session, not globally.
366    /// Generate the system prompt fragment for this registry.
367    ///
368    /// Only emits a skill directory (name + description) — NOT the full skill content.
369    /// Full content is injected on-demand via `match_skills` when a user request matches.
370    pub fn to_system_prompt(&self) -> String {
371        let skills = self.skills.read().unwrap();
372
373        let has_discoverable_skill = skills.values().any(|s| Self::is_discoverable_skill(s));
374
375        if !has_discoverable_skill {
376            return String::new();
377        }
378
379        String::from(crate::prompts::SKILLS_CATALOG_HEADER)
380    }
381
382    /// Return the full content of skills relevant to the given user input.
383    ///
384    /// Matches by checking if any skill name or tag appears in the input (case-insensitive).
385    /// Returns an empty string if no skills match — caller should not inject anything.
386    pub fn match_skills(&self, user_input: &str) -> String {
387        let matched = self.search(user_input, 3);
388
389        if matched.is_empty() {
390            return String::new();
391        }
392
393        let mut out = String::from("# Skill Instructions\n\n");
394        for skill in matched {
395            out.push_str(&skill.to_system_prompt());
396            out.push_str("\n\n---\n\n");
397        }
398        out
399    }
400}
401
402impl Default for SkillRegistry {
403    fn default() -> Self {
404        Self::new()
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::skills::SkillKind;
412    use std::io::Write;
413    use tempfile::TempDir;
414
415    #[test]
416    fn test_new_registry() {
417        let registry = SkillRegistry::new();
418        assert_eq!(registry.len(), 0);
419        assert!(registry.is_empty());
420    }
421
422    #[test]
423    fn test_with_builtins() {
424        let registry = SkillRegistry::with_builtins();
425        assert_eq!(registry.len(), 4, "Expected 4 built-in skills");
426        assert!(!registry.is_empty());
427
428        // Code assistance skills
429        assert!(registry.get("code-search").is_some());
430        assert!(registry.get("code-review").is_some());
431        assert!(registry.get("explain-code").is_some());
432        assert!(registry.get("find-bugs").is_some());
433    }
434
435    #[test]
436    fn test_register_and_get() {
437        let registry = SkillRegistry::new();
438
439        let skill = Arc::new(Skill {
440            name: "test-skill".to_string(),
441            description: "A test skill".to_string(),
442            allowed_tools: None,
443            disable_model_invocation: false,
444            kind: SkillKind::Instruction,
445            content: "Test content".to_string(),
446            tags: vec![],
447            version: None,
448        });
449
450        registry.register(skill.clone()).unwrap();
451
452        assert_eq!(registry.len(), 1);
453        let retrieved = registry.get("test-skill").unwrap();
454        assert_eq!(retrieved.name, "test-skill");
455    }
456
457    #[test]
458    fn test_list() {
459        let registry = SkillRegistry::with_builtins();
460        let names = registry.list();
461
462        assert_eq!(names.len(), 4, "Expected 4 built-in skills");
463        assert!(names.contains(&"code-search".to_string()));
464        assert!(names.contains(&"code-review".to_string()));
465        assert!(names.contains(&"explain-code".to_string()));
466        assert!(names.contains(&"find-bugs".to_string()));
467    }
468
469    #[test]
470    fn test_remove() {
471        let registry = SkillRegistry::with_builtins();
472        assert_eq!(registry.len(), 4);
473
474        let removed = registry.remove("code-search");
475        assert!(removed.is_some());
476        assert_eq!(registry.len(), 3);
477        assert!(registry.get("code-search").is_none());
478    }
479
480    #[test]
481    fn test_clear() {
482        let registry = SkillRegistry::with_builtins();
483        assert_eq!(registry.len(), 4);
484
485        registry.clear();
486        assert_eq!(registry.len(), 0);
487        assert!(registry.is_empty());
488    }
489
490    #[test]
491    fn test_by_kind() {
492        let registry = SkillRegistry::with_builtins();
493        let instruction_skills = registry.by_kind(SkillKind::Instruction);
494
495        assert_eq!(instruction_skills.len(), 4, "Expected 4 instruction skills");
496
497        let persona_skills = registry.by_kind(SkillKind::Persona);
498        assert_eq!(persona_skills.len(), 0);
499    }
500
501    #[test]
502    fn test_builtin_skills_do_not_restrict_global_tools() {
503        let registry = SkillRegistry::with_builtins();
504        assert!(registry.global_tool_restricting_skills().is_empty());
505    }
506
507    #[test]
508    fn test_replacing_builtin_name_reenables_global_tool_restriction() {
509        let registry = SkillRegistry::with_builtins();
510        registry.register_unchecked(Arc::new(Skill {
511            name: "code-review".to_string(),
512            description: "External replacement".to_string(),
513            allowed_tools: Some("read(*)".to_string()),
514            disable_model_invocation: false,
515            kind: SkillKind::Instruction,
516            content: String::new(),
517            tags: Vec::new(),
518            version: None,
519        }));
520
521        let restricting = registry.global_tool_restricting_skills();
522        assert_eq!(restricting.len(), 1);
523        assert_eq!(restricting[0].name, "code-review");
524    }
525
526    #[test]
527    fn test_by_tag() {
528        let registry = SkillRegistry::with_builtins();
529        let search_skills = registry.by_tag("search");
530
531        assert_eq!(search_skills.len(), 1);
532        let names: Vec<&str> = search_skills.iter().map(|s| s.name.as_str()).collect();
533        assert!(names.contains(&"code-search"));
534
535        let security_skills = registry.by_tag("security");
536        assert_eq!(security_skills.len(), 1);
537        assert_eq!(security_skills[0].name, "find-bugs");
538    }
539
540    #[test]
541    fn test_load_from_dir() -> anyhow::Result<()> {
542        let temp_dir = TempDir::new()?;
543
544        // Create a valid skill file
545        let skill_path = temp_dir.path().join("test-skill.md");
546        let mut file = std::fs::File::create(&skill_path)?;
547        writeln!(file, "---")?;
548        writeln!(file, "name: test-skill")?;
549        writeln!(file, "description: A test skill")?;
550        writeln!(file, "kind: instruction")?;
551        writeln!(file, "---")?;
552        writeln!(file, "# Test Skill")?;
553        writeln!(file, "This is a test skill.")?;
554        drop(file);
555
556        // Create a non-skill .md file (should be skipped)
557        let readme_path = temp_dir.path().join("README.md");
558        std::fs::write(&readme_path, "# README\nNot a skill")?;
559
560        // Create a non-.md file (should be skipped)
561        let txt_path = temp_dir.path().join("notes.txt");
562        std::fs::write(&txt_path, "Some notes")?;
563
564        let registry = SkillRegistry::new();
565        let loaded = registry.load_from_dir(temp_dir.path())?;
566
567        assert_eq!(loaded, 1);
568        assert_eq!(registry.len(), 1);
569        assert!(registry.get("test-skill").is_some());
570
571        Ok(())
572    }
573
574    #[test]
575    fn test_load_from_dir_recurses_into_nested_skill_dirs() -> anyhow::Result<()> {
576        let temp_dir = TempDir::new()?;
577        let nested = temp_dir.path().join("nested").join("code-review-helper");
578        std::fs::create_dir_all(&nested)?;
579
580        let skill_path = nested.join("SKILL.md");
581        let mut file = std::fs::File::create(&skill_path)?;
582        writeln!(file, "---")?;
583        writeln!(file, "name: nested-skill")?;
584        writeln!(file, "description: A nested skill")?;
585        writeln!(file, "kind: instruction")?;
586        writeln!(file, "---")?;
587        writeln!(file, "# Nested Skill")?;
588        writeln!(file, "This skill lives in a nested SKILL.md.")?;
589        drop(file);
590
591        let registry = SkillRegistry::new();
592        let loaded = registry.load_from_dir(temp_dir.path())?;
593
594        assert_eq!(loaded, 1);
595        assert!(registry.get("nested-skill").is_some());
596        Ok(())
597    }
598
599    #[test]
600    fn test_load_from_dir_accepts_yaml_list_allowed_tools() -> anyhow::Result<()> {
601        let temp_dir = TempDir::new()?;
602        let skill_path = temp_dir.path().join("ci-review.md");
603        std::fs::write(
604            &skill_path,
605            r#"---
606name: ci-review
607description: Review CI failures
608allowed-tools:
609  - Read
610  - Grep
611  - Bash(cargo test -p a3s-code-core:*)
612kind: instruction
613---
614# CI Review
615"#,
616        )?;
617
618        let registry = SkillRegistry::new();
619        let loaded = registry.load_from_dir(temp_dir.path())?;
620
621        assert_eq!(loaded, 1);
622        let skill = registry.get("ci-review").unwrap();
623        assert_eq!(
624            skill.allowed_tools.as_deref(),
625            Some("Read, Grep, Bash(cargo test -p a3s-code-core:*)")
626        );
627        let permissions = skill.parse_allowed_tools();
628        assert!(permissions
629            .iter()
630            .any(|perm| perm.tool == "Read" && perm.pattern == "*"));
631        assert!(permissions.iter().any(|perm| {
632            perm.tool == "Bash" && perm.pattern == "cargo test -p a3s-code-core:*"
633        }));
634        Ok(())
635    }
636
637    #[test]
638    fn test_load_from_file() -> anyhow::Result<()> {
639        let temp_dir = TempDir::new()?;
640        let skill_path = temp_dir.path().join("my-skill.md");
641
642        let mut file = std::fs::File::create(&skill_path)?;
643        writeln!(file, "---")?;
644        writeln!(file, "name: my-skill")?;
645        writeln!(file, "description: My custom skill")?;
646        writeln!(file, "---")?;
647        writeln!(file, "# My Skill")?;
648        drop(file);
649
650        let registry = SkillRegistry::new();
651        let skill = registry.load_from_file(&skill_path)?;
652
653        assert_eq!(skill.name, "my-skill");
654        assert_eq!(registry.len(), 1);
655
656        Ok(())
657    }
658
659    #[test]
660    fn test_to_system_prompt() {
661        let registry = SkillRegistry::with_builtins();
662        let prompt = registry.to_system_prompt();
663
664        assert!(prompt.contains("# Skills"));
665        assert!(prompt.contains("search_skills"));
666        assert!(prompt.contains("Skill"));
667        assert!(!prompt.contains("code-search"));
668        assert!(!prompt.contains("code-review"));
669    }
670
671    #[test]
672    fn test_load_from_nonexistent_dir() {
673        let registry = SkillRegistry::new();
674        let result = registry.load_from_dir("/nonexistent/path");
675
676        assert!(result.is_ok());
677        assert_eq!(result.unwrap(), 0);
678    }
679
680    #[test]
681    fn test_load_from_dir_rejects_file_path() -> anyhow::Result<()> {
682        let temp_dir = TempDir::new()?;
683        let path = temp_dir.path().join("not-a-directory.md");
684        std::fs::write(&path, "# not a directory")?;
685
686        let registry = SkillRegistry::new();
687        let err = registry.load_from_dir(&path).unwrap_err();
688        assert!(err.to_string().contains("Path is not a directory"));
689        Ok(())
690    }
691
692    #[test]
693    fn test_load_from_dir_duplicate_name_overrides_previous_definition() -> anyhow::Result<()> {
694        let temp_dir = TempDir::new()?;
695
696        let first = temp_dir.path().join("first.md");
697        std::fs::write(
698            &first,
699            "---\nname: duplicate-skill\ndescription: First copy\n---\n# First\nalpha\n",
700        )?;
701
702        let nested = temp_dir.path().join("nested");
703        std::fs::create_dir_all(&nested)?;
704        let second = nested.join("SKILL.md");
705        std::fs::write(
706            &second,
707            "---\nname: duplicate-skill\ndescription: Second copy\n---\n# Second\nbeta\n",
708        )?;
709
710        let registry = SkillRegistry::new();
711        let loaded = registry.load_from_dir(temp_dir.path())?;
712
713        assert_eq!(loaded, 2);
714        assert_eq!(registry.len(), 1);
715        assert_eq!(
716            registry.get("duplicate-skill").unwrap().description,
717            "Second copy"
718        );
719        Ok(())
720    }
721
722    // --- Validator integration ---
723
724    #[test]
725    fn test_register_with_validator_rejects_reserved() {
726        use crate::skills::validator::DefaultSkillValidator;
727
728        let registry = SkillRegistry::new();
729        registry.set_validator(Arc::new(DefaultSkillValidator::default()));
730
731        let skill = Arc::new(Skill {
732            name: "code-search".to_string(), // reserved
733            description: "Override builtin".to_string(),
734            allowed_tools: None,
735            disable_model_invocation: false,
736            kind: SkillKind::Instruction,
737            content: "Malicious override".to_string(),
738            tags: vec![],
739            version: None,
740        });
741
742        let result = registry.register(skill);
743        assert!(result.is_err());
744        assert_eq!(registry.len(), 0);
745    }
746
747    #[test]
748    fn test_register_with_validator_accepts_valid() {
749        use crate::skills::validator::DefaultSkillValidator;
750
751        let registry = SkillRegistry::new();
752        registry.set_validator(Arc::new(DefaultSkillValidator::default()));
753
754        let skill = Arc::new(Skill {
755            name: "my-custom-skill".to_string(),
756            description: "A valid skill".to_string(),
757            allowed_tools: Some("read(*), grep(*)".to_string()),
758            disable_model_invocation: false,
759            kind: SkillKind::Instruction,
760            content: "Help with code review.".to_string(),
761            tags: vec![],
762            version: None,
763        });
764
765        assert!(registry.register(skill).is_ok());
766        assert_eq!(registry.len(), 1);
767    }
768
769    #[test]
770    fn test_register_without_validator_accepts_anything() {
771        let registry = SkillRegistry::new();
772        // No validator set
773
774        let skill = Arc::new(Skill {
775            name: "code-search".to_string(), // reserved name, but no validator
776            description: "test".to_string(),
777            allowed_tools: None,
778            disable_model_invocation: false,
779            kind: SkillKind::Instruction,
780            content: "test".to_string(),
781            tags: vec![],
782            version: None,
783        });
784
785        assert!(registry.register(skill).is_ok());
786    }
787
788    #[test]
789    fn test_all_and_personas() {
790        let registry = SkillRegistry::new();
791
792        registry.register_unchecked(Arc::new(Skill {
793            name: "persona-skill".to_string(),
794            description: "Persona".to_string(),
795            allowed_tools: None,
796            disable_model_invocation: false,
797            kind: SkillKind::Persona,
798            content: "Persona content".to_string(),
799            tags: vec!["voice".to_string()],
800            version: None,
801        }));
802        registry.register_unchecked(Arc::new(Skill {
803            name: "instruction-skill".to_string(),
804            description: "Instruction".to_string(),
805            allowed_tools: None,
806            disable_model_invocation: false,
807            kind: SkillKind::Instruction,
808            content: "Instruction content".to_string(),
809            tags: vec!["workflow".to_string()],
810            version: None,
811        }));
812
813        assert_eq!(registry.all().len(), 2);
814        assert_eq!(registry.personas().len(), 1);
815        assert_eq!(registry.personas()[0].name, "persona-skill");
816    }
817
818    #[test]
819    fn test_load_from_file_with_validator_rejects() {
820        use crate::skills::validator::DefaultSkillValidator;
821
822        let temp_dir = TempDir::new().unwrap();
823        let skill_path = temp_dir.path().join("code-search.md");
824
825        let mut file = std::fs::File::create(&skill_path).unwrap();
826        writeln!(file, "---").unwrap();
827        writeln!(file, "name: code-search").unwrap(); // reserved
828        writeln!(file, "description: Override").unwrap();
829        writeln!(file, "---").unwrap();
830        writeln!(file, "# Override").unwrap();
831        drop(file);
832
833        let registry = SkillRegistry::new();
834        registry.set_validator(Arc::new(DefaultSkillValidator::default()));
835
836        let result = registry.load_from_file(&skill_path);
837        assert!(result.is_err());
838        assert_eq!(registry.len(), 0);
839    }
840
841    #[test]
842    fn test_fork_is_independent() {
843        let original = SkillRegistry::with_builtins();
844        let fork = original.fork();
845
846        // Fork has same skills as original
847        assert_eq!(fork.len(), original.len());
848
849        // Adding to fork does not affect original
850        fork.register_unchecked(Arc::new(Skill {
851            name: "session-only".to_string(),
852            description: "Only in fork".to_string(),
853            allowed_tools: None,
854            disable_model_invocation: false,
855            kind: SkillKind::Instruction,
856            content: "content".to_string(),
857            tags: vec![],
858            version: None,
859        }));
860
861        assert_eq!(fork.len(), original.len() + 1);
862        assert!(fork.get("session-only").is_some());
863        assert!(original.get("session-only").is_none());
864    }
865
866    #[test]
867    fn test_fork_inherits_builtins() {
868        let fork = SkillRegistry::with_builtins().fork();
869        assert!(fork.get("code-search").is_some());
870        assert!(fork.get("code-review").is_some());
871        assert!(fork.get("find-bugs").is_some());
872    }
873
874    #[test]
875    fn test_fork_preserves_validator() {
876        use crate::skills::validator::DefaultSkillValidator;
877
878        let original = SkillRegistry::new();
879        original.set_validator(Arc::new(DefaultSkillValidator::default()));
880
881        let fork = original.fork();
882        let invalid = Arc::new(Skill {
883            name: "BadName".to_string(),
884            description: "invalid".to_string(),
885            allowed_tools: None,
886            disable_model_invocation: false,
887            kind: SkillKind::Instruction,
888            content: "content".to_string(),
889            tags: vec![],
890            version: None,
891        });
892
893        assert!(fork.register(invalid).is_err());
894    }
895
896    #[test]
897    fn test_search_skills_ranks_matches() {
898        let registry = SkillRegistry::new();
899
900        registry.register_unchecked(Arc::new(Skill {
901            name: "build-planner".to_string(),
902            description: "Plan complex builds".to_string(),
903            allowed_tools: None,
904            disable_model_invocation: false,
905            kind: SkillKind::Instruction,
906            content: "Planner instructions".to_string(),
907            tags: vec!["architecture".to_string()],
908            version: None,
909        }));
910        let matches = registry.search("architecture plan", 5);
911        assert_eq!(matches.len(), 1);
912        assert_eq!(matches[0].name, "build-planner");
913    }
914
915    #[test]
916    fn test_match_skills_matches_name_tag_and_description() {
917        let registry = SkillRegistry::new();
918
919        registry.register_unchecked(Arc::new(Skill {
920            name: "build-planner".to_string(),
921            description: "Plan complex builds".to_string(),
922            allowed_tools: None,
923            disable_model_invocation: false,
924            kind: SkillKind::Instruction,
925            content: "Planner instructions".to_string(),
926            tags: vec!["architecture".to_string()],
927            version: None,
928        }));
929        let by_name = registry.match_skills("please use build-planner for this task");
930        assert!(by_name.contains("Planner instructions"));
931
932        let by_tag = registry.match_skills("need architecture guidance");
933        assert!(by_tag.contains("Planner instructions"));
934
935        let by_description = registry.match_skills("help me plan the release");
936        assert!(by_description.contains("Planner instructions"));
937
938        assert!(registry
939            .match_skills("totally unrelated request")
940            .is_empty());
941    }
942}