Skip to main content

rustant_core/skills/
mod.rs

1//! # Skills System
2//!
3//! Declarative skill definitions parsed from SKILL.md files.
4//! Skills define tool registrations via YAML frontmatter and markdown-based
5//! tool definitions with parameter schemas and body templates.
6
7pub mod parser;
8pub mod types;
9pub mod validator;
10
11pub use parser::{ParseError, parse_skill_md};
12pub use types::{SkillConfig, SkillDefinition, SkillRequirement, SkillRiskLevel, SkillToolDef};
13pub use validator::{ValidationError, ValidationResult, validate_skill};
14
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17
18/// Registry of loaded skills.
19#[derive(Debug, Default)]
20pub struct SkillRegistry {
21    skills: HashMap<String, SkillDefinition>,
22}
23
24impl SkillRegistry {
25    pub fn new() -> Self {
26        Self::default()
27    }
28
29    /// Register a skill definition.
30    pub fn register(&mut self, skill: SkillDefinition) {
31        self.skills.insert(skill.name.clone(), skill);
32    }
33
34    /// Get a skill by name.
35    pub fn get(&self, name: &str) -> Option<&SkillDefinition> {
36        self.skills.get(name)
37    }
38
39    /// List all loaded skill names.
40    pub fn list_names(&self) -> Vec<&str> {
41        self.skills.keys().map(|k| k.as_str()).collect()
42    }
43
44    /// Number of loaded skills.
45    pub fn len(&self) -> usize {
46        self.skills.len()
47    }
48
49    /// Whether the registry is empty.
50    pub fn is_empty(&self) -> bool {
51        self.skills.is_empty()
52    }
53}
54
55/// Loads skills from a directory of SKILL.md files.
56pub struct SkillLoader {
57    skills_dir: PathBuf,
58}
59
60impl SkillLoader {
61    pub fn new(skills_dir: impl Into<PathBuf>) -> Self {
62        Self {
63            skills_dir: skills_dir.into(),
64        }
65    }
66
67    /// Scan the skills directory and load all .md files.
68    pub fn scan(&self) -> Vec<Result<SkillDefinition, (PathBuf, ParseError)>> {
69        let mut results = Vec::new();
70
71        let entries = match std::fs::read_dir(&self.skills_dir) {
72            Ok(entries) => entries,
73            Err(_) => return results,
74        };
75
76        for entry in entries.flatten() {
77            let path = entry.path();
78            if path.extension().map(|e| e == "md").unwrap_or(false) {
79                match self.load_file(&path) {
80                    Ok(mut skill) => {
81                        skill.source_path = Some(path.to_string_lossy().into_owned());
82                        results.push(Ok(skill));
83                    }
84                    Err(e) => {
85                        results.push(Err((path, e)));
86                    }
87                }
88            }
89        }
90
91        results
92    }
93
94    /// Load a single skill file.
95    pub fn load_file(&self, path: &Path) -> Result<SkillDefinition, ParseError> {
96        let content = std::fs::read_to_string(path)
97            .map_err(|e| ParseError::InvalidYaml(format!("Failed to read file: {}", e)))?;
98        parse_skill_md(&content)
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn test_skill_registry_register_and_get() {
108        let mut registry = SkillRegistry::new();
109        let skill = SkillDefinition {
110            name: "test".into(),
111            version: "1.0.0".into(),
112            description: "Test skill".into(),
113            author: None,
114            requires: vec![],
115            tools: vec![],
116            config: SkillConfig::default(),
117            risk_level: SkillRiskLevel::Low,
118            source_path: None,
119        };
120
121        registry.register(skill);
122        assert_eq!(registry.len(), 1);
123        assert!(!registry.is_empty());
124
125        let found = registry.get("test").unwrap();
126        assert_eq!(found.version, "1.0.0");
127    }
128
129    #[test]
130    fn test_skill_registry_list_names() {
131        let mut registry = SkillRegistry::new();
132        for name in &["alpha", "beta", "gamma"] {
133            registry.register(SkillDefinition {
134                name: name.to_string(),
135                version: "1.0.0".into(),
136                description: "".into(),
137                author: None,
138                requires: vec![],
139                tools: vec![],
140                config: Default::default(),
141                risk_level: SkillRiskLevel::Low,
142                source_path: None,
143            });
144        }
145        let names = registry.list_names();
146        assert_eq!(names.len(), 3);
147    }
148
149    #[test]
150    fn test_skill_loader_scan_empty_dir() {
151        let dir = tempfile::TempDir::new().unwrap();
152        let loader = SkillLoader::new(dir.path());
153        let results = loader.scan();
154        assert!(results.is_empty());
155    }
156
157    #[test]
158    fn test_skill_loader_scan_with_file() {
159        let dir = tempfile::TempDir::new().unwrap();
160        let skill_path = dir.path().join("test.skill.md");
161        std::fs::write(
162            &skill_path,
163            "---\nname: test-skill\nversion: 1.0.0\ndescription: A test\n---\n",
164        )
165        .unwrap();
166
167        let loader = SkillLoader::new(dir.path());
168        let results = loader.scan();
169        assert_eq!(results.len(), 1);
170        let skill = results[0].as_ref().unwrap();
171        assert_eq!(skill.name, "test-skill");
172    }
173
174    #[test]
175    fn test_skill_loader_scan_nonexistent_dir() {
176        let loader = SkillLoader::new("/nonexistent/path");
177        let results = loader.scan();
178        assert!(results.is_empty());
179    }
180}