ai_agents_skills/
loader.rs1use 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 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 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}