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