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