Skip to main content

ai_agents_skills/
loader.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use ai_agents_core::{AgentError, Result};
5
6use crate::definition::{SkillDefinition, SkillRef};
7
8pub struct SkillLoader {
9    search_paths: Vec<PathBuf>,
10    base_dir: Option<PathBuf>,
11    cache: HashMap<String, SkillDefinition>,
12}
13
14impl SkillLoader {
15    pub fn new() -> Self {
16        Self {
17            search_paths: vec![PathBuf::from("templates/skills")],
18            base_dir: None,
19            cache: HashMap::new(),
20        }
21    }
22
23    pub fn with_base_dir(mut self, dir: impl Into<PathBuf>) -> Self {
24        self.base_dir = Some(dir.into());
25        self
26    }
27
28    pub fn set_base_dir(&mut self, dir: impl Into<PathBuf>) {
29        self.base_dir = Some(dir.into());
30    }
31
32    pub fn add_search_path(&mut self, path: impl Into<PathBuf>) {
33        self.search_paths.push(path.into());
34    }
35
36    pub fn load_refs(&mut self, refs: &[SkillRef]) -> Result<Vec<SkillDefinition>> {
37        let mut skills = Vec::new();
38        for skill_ref in refs {
39            let skill = self.load_ref(skill_ref)?;
40            skills.push(skill);
41        }
42        Ok(skills)
43    }
44
45    pub fn load_ref(&mut self, skill_ref: &SkillRef) -> Result<SkillDefinition> {
46        match skill_ref {
47            SkillRef::Name(name) => self.load_by_name(name),
48            SkillRef::File { file } => self.load_from_path(file),
49            SkillRef::Inline(def) => Ok(def.clone()),
50        }
51    }
52
53    pub fn load_by_name(&mut self, name: &str) -> Result<SkillDefinition> {
54        if let Some(cached) = self.cache.get(name) {
55            return Ok(cached.clone());
56        }
57
58        let file_name = format!("{}.skill.yaml", name);
59
60        for search_path in &self.search_paths {
61            let path = search_path.join(&file_name);
62            if path.exists() {
63                let skill = self.load_from_path(&path)?;
64                self.cache.insert(name.to_string(), skill.clone());
65                return Ok(skill);
66            }
67        }
68
69        Err(AgentError::Skill(format!(
70            "Skill '{}' not found in search paths: {:?}",
71            name, self.search_paths
72        )))
73    }
74
75    pub fn load_from_path(&mut self, path: &Path) -> Result<SkillDefinition> {
76        let resolved = if path.is_relative() {
77            if let Some(ref base) = self.base_dir {
78                let candidate = base.join(path);
79                if candidate.exists() {
80                    candidate
81                } else {
82                    path.to_path_buf()
83                }
84            } else {
85                path.to_path_buf()
86            }
87        } else {
88            path.to_path_buf()
89        };
90
91        let content = std::fs::read_to_string(&resolved).map_err(|e| {
92            AgentError::Skill(format!("Failed to read skill file {:?}: {}", resolved, e))
93        })?;
94
95        let skill: SkillDefinition = serde_yaml::from_str(&content).map_err(|e| {
96            AgentError::Skill(format!("Failed to parse skill file {:?}: {}", resolved, e))
97        })?;
98
99        self.cache.insert(skill.id.clone(), skill.clone());
100        Ok(skill)
101    }
102
103    pub fn get_cached(&self, id: &str) -> Option<&SkillDefinition> {
104        self.cache.get(id)
105    }
106
107    pub fn clear_cache(&mut self) {
108        self.cache.clear();
109    }
110}
111
112impl Default for SkillLoader {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::definition::SkillStep;
122    use std::fs;
123    use tempfile::TempDir;
124
125    fn write_skill_yaml(dir: &Path, relative_path: &str) -> PathBuf {
126        let full = dir.join(relative_path);
127        if let Some(parent) = full.parent() {
128            fs::create_dir_all(parent).unwrap();
129        }
130        fs::write(
131            &full,
132            r#"
133id: test_file_skill
134description: "loaded from file"
135trigger: "test"
136steps:
137  - prompt: "hello"
138"#,
139        )
140        .unwrap();
141        full
142    }
143
144    #[test]
145    fn test_loader_inline() {
146        let mut loader = SkillLoader::new();
147
148        let inline_skill = SkillDefinition {
149            id: "test_skill".to_string(),
150            description: "Test".to_string(),
151            trigger: "When testing".to_string(),
152            steps: vec![SkillStep::Prompt {
153                prompt: "Hello".to_string(),
154                llm: None,
155            }],
156            reasoning: None,
157            reflection: None,
158            disambiguation: None,
159        };
160
161        let skill_ref = SkillRef::Inline(inline_skill.clone());
162        let loaded = loader.load_ref(&skill_ref).unwrap();
163
164        assert_eq!(loaded.id, "test_skill");
165        assert_eq!(loaded.steps.len(), 1);
166    }
167
168    #[test]
169    fn test_loader_missing_skill() {
170        let mut loader = SkillLoader::new();
171        let result = loader.load_by_name("nonexistent_skill");
172        assert!(result.is_err());
173    }
174
175    #[test]
176    fn test_loader_cache() {
177        let mut loader = SkillLoader::new();
178
179        let inline_skill = SkillDefinition {
180            id: "cached_skill".to_string(),
181            description: "Cached".to_string(),
182            trigger: "When cached".to_string(),
183            steps: vec![],
184            reasoning: None,
185            reflection: None,
186            disambiguation: None,
187        };
188
189        loader
190            .cache
191            .insert("cached_skill".to_string(), inline_skill);
192        assert!(loader.get_cached("cached_skill").is_some());
193        assert!(loader.get_cached("unknown").is_none());
194
195        loader.clear_cache();
196        assert!(loader.get_cached("cached_skill").is_none());
197    }
198
199    #[test]
200    fn test_load_from_path_with_base_dir() {
201        let tmp = TempDir::new().unwrap();
202        write_skill_yaml(tmp.path(), "skills/helper.skill.yaml");
203
204        let mut loader = SkillLoader::new().with_base_dir(tmp.path());
205        let skill = loader
206            .load_from_path(Path::new("skills/helper.skill.yaml"))
207            .unwrap();
208        assert_eq!(skill.id, "test_file_skill");
209    }
210
211    #[test]
212    fn test_load_from_path_absolute_ignores_base_dir() {
213        let tmp = TempDir::new().unwrap();
214        let abs = write_skill_yaml(tmp.path(), "skills/helper.skill.yaml");
215
216        // base_dir points somewhere else — should not matter for absolute paths
217        let other = TempDir::new().unwrap();
218        let mut loader = SkillLoader::new().with_base_dir(other.path());
219        let skill = loader.load_from_path(&abs).unwrap();
220        assert_eq!(skill.id, "test_file_skill");
221    }
222
223    #[test]
224    fn test_load_from_path_no_base_dir_uses_cwd() {
225        // Without base_dir, a relative path that doesn't exist under CWD should fail
226        let mut loader = SkillLoader::new();
227        let result = loader.load_from_path(Path::new("nonexistent/skill.yaml"));
228        assert!(result.is_err());
229    }
230
231    #[test]
232    fn test_set_base_dir() {
233        let tmp = TempDir::new().unwrap();
234        write_skill_yaml(tmp.path(), "my_skill.skill.yaml");
235
236        let mut loader = SkillLoader::new();
237        loader.set_base_dir(tmp.path());
238        let skill = loader
239            .load_from_path(Path::new("my_skill.skill.yaml"))
240            .unwrap();
241        assert_eq!(skill.id, "test_file_skill");
242    }
243
244    #[test]
245    fn test_load_ref_file_with_base_dir() {
246        let tmp = TempDir::new().unwrap();
247        write_skill_yaml(tmp.path(), "skills/math.skill.yaml");
248
249        let mut loader = SkillLoader::new().with_base_dir(tmp.path());
250        let skill_ref = SkillRef::File {
251            file: PathBuf::from("skills/math.skill.yaml"),
252        };
253        let skill = loader.load_ref(&skill_ref).unwrap();
254        assert_eq!(skill.id, "test_file_skill");
255    }
256}