Skip to main content

a3s_code_core/skills/
registry.rs

1//! Skill Registry
2//!
3//! Manages skill registration, loading, and lookup.
4
5use super::Skill;
6use anyhow::Context;
7use std::collections::HashMap;
8use std::path::Path;
9use std::sync::{Arc, RwLock};
10
11/// Skill registry for managing available skills
12///
13/// Provides skill registration, loading from directories, and lookup by name.
14pub struct SkillRegistry {
15    skills: Arc<RwLock<HashMap<String, Arc<Skill>>>>,
16}
17
18impl SkillRegistry {
19    /// Create a new empty skill registry
20    pub fn new() -> Self {
21        Self {
22            skills: Arc::new(RwLock::new(HashMap::new())),
23        }
24    }
25
26    /// Create a registry with built-in skills
27    pub fn with_builtins() -> Self {
28        let registry = Self::new();
29        for skill in super::builtin::builtin_skills() {
30            registry.register(skill);
31        }
32        registry
33    }
34
35    /// Register a skill
36    pub fn register(&self, skill: Arc<Skill>) {
37        let mut skills = self.skills.write().unwrap();
38        skills.insert(skill.name.clone(), skill);
39    }
40
41    /// Get a skill by name
42    pub fn get(&self, name: &str) -> Option<Arc<Skill>> {
43        let skills = self.skills.read().unwrap();
44        skills.get(name).cloned()
45    }
46
47    /// List all registered skill names
48    pub fn list(&self) -> Vec<String> {
49        let skills = self.skills.read().unwrap();
50        skills.keys().cloned().collect()
51    }
52
53    /// Get all registered skills
54    pub fn all(&self) -> Vec<Arc<Skill>> {
55        let skills = self.skills.read().unwrap();
56        skills.values().cloned().collect()
57    }
58
59    /// Load skills from a directory
60    ///
61    /// Scans the directory for `.md` files and attempts to parse them as skills.
62    /// Silently skips files that fail to parse.
63    pub fn load_from_dir(&self, dir: impl AsRef<Path>) -> anyhow::Result<usize> {
64        let dir = dir.as_ref();
65
66        if !dir.exists() {
67            return Ok(0);
68        }
69
70        if !dir.is_dir() {
71            anyhow::bail!("Path is not a directory: {}", dir.display());
72        }
73
74        let mut loaded = 0;
75
76        for entry in std::fs::read_dir(dir)
77            .with_context(|| format!("Failed to read directory: {}", dir.display()))?
78        {
79            let entry = entry?;
80            let path = entry.path();
81
82            // Only process .md files
83            if path.extension().and_then(|s| s.to_str()) != Some("md") {
84                continue;
85            }
86
87            // Try to load the skill
88            match Skill::from_file(&path) {
89                Ok(skill) => {
90                    self.register(Arc::new(skill));
91                    loaded += 1;
92                }
93                Err(e) => {
94                    // Log but don't fail - some .md files might not be skills
95                    tracing::debug!("Skipped {}: {}", path.display(), e);
96                }
97            }
98        }
99
100        Ok(loaded)
101    }
102
103    /// Load a single skill from a file
104    pub fn load_from_file(&self, path: impl AsRef<Path>) -> anyhow::Result<Arc<Skill>> {
105        let skill = Skill::from_file(path)?;
106        let skill = Arc::new(skill);
107        self.register(skill.clone());
108        Ok(skill)
109    }
110
111    /// Remove a skill by name
112    pub fn remove(&self, name: &str) -> Option<Arc<Skill>> {
113        let mut skills = self.skills.write().unwrap();
114        skills.remove(name)
115    }
116
117    /// Clear all skills
118    pub fn clear(&self) {
119        let mut skills = self.skills.write().unwrap();
120        skills.clear();
121    }
122
123    /// Get the number of registered skills
124    pub fn len(&self) -> usize {
125        let skills = self.skills.read().unwrap();
126        skills.len()
127    }
128
129    /// Check if the registry is empty
130    pub fn is_empty(&self) -> bool {
131        self.len() == 0
132    }
133
134    /// Get all skills of a specific kind
135    pub fn by_kind(&self, kind: super::SkillKind) -> Vec<Arc<Skill>> {
136        let skills = self.skills.read().unwrap();
137        skills
138            .values()
139            .filter(|s| s.kind == kind)
140            .cloned()
141            .collect()
142    }
143
144    /// Get all skills with a specific tag
145    pub fn by_tag(&self, tag: &str) -> Vec<Arc<Skill>> {
146        let skills = self.skills.read().unwrap();
147        skills
148            .values()
149            .filter(|s| s.tags.iter().any(|t| t == tag))
150            .cloned()
151            .collect()
152    }
153
154    /// Generate system prompt content from all instruction skills
155    ///
156    /// Concatenates the content of all instruction-type skills for injection
157    /// into the system prompt.
158    pub fn to_system_prompt(&self) -> String {
159        let skills = self.skills.read().unwrap();
160
161        let instruction_skills: Vec<_> = skills
162            .values()
163            .filter(|s| s.kind == super::SkillKind::Instruction)
164            .collect();
165
166        if instruction_skills.is_empty() {
167            return String::new();
168        }
169
170        let mut prompt = String::from("# Available Skills\n\n");
171
172        for skill in instruction_skills {
173            prompt.push_str(&skill.to_system_prompt());
174            prompt.push_str("\n\n---\n\n");
175        }
176
177        prompt
178    }
179}
180
181impl Default for SkillRegistry {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::skills::SkillKind;
191    use std::io::Write;
192    use tempfile::TempDir;
193
194    #[test]
195    fn test_new_registry() {
196        let registry = SkillRegistry::new();
197        assert_eq!(registry.len(), 0);
198        assert!(registry.is_empty());
199    }
200
201    #[test]
202    fn test_with_builtins() {
203        let registry = SkillRegistry::with_builtins();
204        assert_eq!(registry.len(), 7, "Expected 7 built-in skills");
205        assert!(!registry.is_empty());
206
207        // Code assistance skills
208        assert!(registry.get("code-search").is_some());
209        assert!(registry.get("code-review").is_some());
210        assert!(registry.get("explain-code").is_some());
211        assert!(registry.get("find-bugs").is_some());
212
213        // Tool documentation skills
214        assert!(registry.get("builtin-tools").is_some());
215        assert!(registry.get("delegate-task").is_some());
216        assert!(registry.get("find-skills").is_some());
217    }
218
219    #[test]
220    fn test_register_and_get() {
221        let registry = SkillRegistry::new();
222
223        let skill = Arc::new(Skill {
224            name: "test-skill".to_string(),
225            description: "A test skill".to_string(),
226            allowed_tools: None,
227            disable_model_invocation: false,
228            kind: SkillKind::Instruction,
229            content: "Test content".to_string(),
230            tags: vec![],
231            version: None,
232        });
233
234        registry.register(skill.clone());
235
236        assert_eq!(registry.len(), 1);
237        let retrieved = registry.get("test-skill").unwrap();
238        assert_eq!(retrieved.name, "test-skill");
239    }
240
241    #[test]
242    fn test_list() {
243        let registry = SkillRegistry::with_builtins();
244        let names = registry.list();
245
246        assert_eq!(names.len(), 7, "Expected 7 built-in skills");
247        assert!(names.contains(&"code-search".to_string()));
248        assert!(names.contains(&"code-review".to_string()));
249        assert!(names.contains(&"builtin-tools".to_string()));
250        assert!(names.contains(&"delegate-task".to_string()));
251        assert!(names.contains(&"find-skills".to_string()));
252    }
253
254    #[test]
255    fn test_remove() {
256        let registry = SkillRegistry::with_builtins();
257        assert_eq!(registry.len(), 7);
258
259        let removed = registry.remove("code-search");
260        assert!(removed.is_some());
261        assert_eq!(registry.len(), 6);
262        assert!(registry.get("code-search").is_none());
263    }
264
265    #[test]
266    fn test_clear() {
267        let registry = SkillRegistry::with_builtins();
268        assert_eq!(registry.len(), 7);
269
270        registry.clear();
271        assert_eq!(registry.len(), 0);
272        assert!(registry.is_empty());
273    }
274
275    #[test]
276    fn test_by_kind() {
277        let registry = SkillRegistry::with_builtins();
278        let instruction_skills = registry.by_kind(SkillKind::Instruction);
279
280        assert_eq!(
281            instruction_skills.len(),
282            7,
283            "Expected 7 instruction skills (4 code assistance + 3 tool documentation)"
284        );
285
286        let tool_skills = registry.by_kind(SkillKind::Tool);
287        assert_eq!(tool_skills.len(), 0);
288    }
289
290    #[test]
291    fn test_by_tag() {
292        let registry = SkillRegistry::with_builtins();
293        let search_skills = registry.by_tag("search");
294
295        assert_eq!(search_skills.len(), 1);
296        assert_eq!(search_skills[0].name, "code-search");
297
298        let security_skills = registry.by_tag("security");
299        assert_eq!(security_skills.len(), 1);
300        assert_eq!(security_skills[0].name, "find-bugs");
301    }
302
303    #[test]
304    fn test_load_from_dir() -> anyhow::Result<()> {
305        let temp_dir = TempDir::new()?;
306
307        // Create a valid skill file
308        let skill_path = temp_dir.path().join("test-skill.md");
309        let mut file = std::fs::File::create(&skill_path)?;
310        writeln!(file, "---")?;
311        writeln!(file, "name: test-skill")?;
312        writeln!(file, "description: A test skill")?;
313        writeln!(file, "kind: instruction")?;
314        writeln!(file, "---")?;
315        writeln!(file, "# Test Skill")?;
316        writeln!(file, "This is a test skill.")?;
317        drop(file);
318
319        // Create a non-skill .md file (should be skipped)
320        let readme_path = temp_dir.path().join("README.md");
321        std::fs::write(&readme_path, "# README\nNot a skill")?;
322
323        // Create a non-.md file (should be skipped)
324        let txt_path = temp_dir.path().join("notes.txt");
325        std::fs::write(&txt_path, "Some notes")?;
326
327        let registry = SkillRegistry::new();
328        let loaded = registry.load_from_dir(temp_dir.path())?;
329
330        assert_eq!(loaded, 1);
331        assert_eq!(registry.len(), 1);
332        assert!(registry.get("test-skill").is_some());
333
334        Ok(())
335    }
336
337    #[test]
338    fn test_load_from_file() -> anyhow::Result<()> {
339        let temp_dir = TempDir::new()?;
340        let skill_path = temp_dir.path().join("my-skill.md");
341
342        let mut file = std::fs::File::create(&skill_path)?;
343        writeln!(file, "---")?;
344        writeln!(file, "name: my-skill")?;
345        writeln!(file, "description: My custom skill")?;
346        writeln!(file, "---")?;
347        writeln!(file, "# My Skill")?;
348        drop(file);
349
350        let registry = SkillRegistry::new();
351        let skill = registry.load_from_file(&skill_path)?;
352
353        assert_eq!(skill.name, "my-skill");
354        assert_eq!(registry.len(), 1);
355
356        Ok(())
357    }
358
359    #[test]
360    fn test_to_system_prompt() {
361        let registry = SkillRegistry::with_builtins();
362        let prompt = registry.to_system_prompt();
363
364        assert!(prompt.contains("# Available Skills"));
365        assert!(prompt.contains("code-search"));
366        assert!(prompt.contains("code-review"));
367        assert!(prompt.contains("explain-code"));
368        assert!(prompt.contains("find-bugs"));
369    }
370
371    #[test]
372    fn test_load_from_nonexistent_dir() {
373        let registry = SkillRegistry::new();
374        let result = registry.load_from_dir("/nonexistent/path");
375
376        assert!(result.is_ok());
377        assert_eq!(result.unwrap(), 0);
378    }
379}