agent_skills/
loader.rs

1//! Directory-based skill loading.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::error::LoadError;
7use crate::skill::Skill;
8
9/// A loaded skill directory containing the skill and its files.
10///
11/// Provides access to the parsed SKILL.md as well as optional directories
12/// (scripts/, references/, assets/).
13///
14/// # Examples
15///
16/// ```no_run
17/// use agent_skills::SkillDirectory;
18/// use std::path::Path;
19///
20/// let dir = SkillDirectory::load(Path::new("./my-skill")).unwrap();
21/// println!("Loaded skill: {}", dir.skill().name());
22///
23/// // Check for optional directories
24/// if dir.has_scripts() {
25///     for script in dir.scripts().unwrap() {
26///         println!("Found script: {}", script.display());
27///     }
28/// }
29/// ```
30#[derive(Debug, Clone)]
31pub struct SkillDirectory {
32    path: PathBuf,
33    skill: Skill,
34}
35
36impl SkillDirectory {
37    /// Loads a skill from a directory path.
38    ///
39    /// The directory must contain a SKILL.md file. The skill name in the
40    /// frontmatter must match the directory name (per the specification).
41    ///
42    /// # Errors
43    ///
44    /// Returns `LoadError` if:
45    /// - The directory doesn't exist
46    /// - SKILL.md is missing
47    /// - SKILL.md cannot be read
48    /// - SKILL.md cannot be parsed
49    /// - The skill name doesn't match the directory name
50    ///
51    /// # Examples
52    ///
53    /// ```no_run
54    /// use agent_skills::SkillDirectory;
55    /// use std::path::Path;
56    ///
57    /// let dir = SkillDirectory::load(Path::new("./my-skill")).unwrap();
58    /// assert_eq!(dir.skill().name().as_str(), "my-skill");
59    /// ```
60    pub fn load(path: impl AsRef<Path>) -> Result<Self, LoadError> {
61        let path = path.as_ref();
62        let path_str = path.display().to_string();
63
64        // Check directory exists
65        if !path.exists() {
66            return Err(LoadError::DirectoryNotFound { path: path_str });
67        }
68
69        if !path.is_dir() {
70            return Err(LoadError::DirectoryNotFound { path: path_str });
71        }
72
73        // Read SKILL.md
74        let skill_file = path.join("SKILL.md");
75        if !skill_file.exists() {
76            return Err(LoadError::SkillFileNotFound { path: path_str });
77        }
78
79        let content = fs::read_to_string(&skill_file).map_err(|e| LoadError::IoError {
80            path: skill_file.display().to_string(),
81            kind: e.kind(),
82            message: e.to_string(),
83        })?;
84
85        // Parse skill
86        let skill = Skill::parse(&content)?;
87
88        // Validate name matches directory name
89        let dir_name = path
90            .file_name()
91            .and_then(|n| n.to_str())
92            .unwrap_or_default();
93
94        if skill.name().as_str() != dir_name {
95            return Err(LoadError::NameMismatch {
96                directory_name: dir_name.to_string(),
97                skill_name: skill.name().as_str().to_string(),
98            });
99        }
100
101        Ok(Self {
102            path: path.to_path_buf(),
103            skill,
104        })
105    }
106
107    /// Returns the directory path.
108    #[must_use]
109    pub fn path(&self) -> &Path {
110        &self.path
111    }
112
113    /// Returns the parsed skill.
114    #[must_use]
115    pub const fn skill(&self) -> &Skill {
116        &self.skill
117    }
118
119    /// Returns `true` if a scripts/ directory exists.
120    #[must_use]
121    pub fn has_scripts(&self) -> bool {
122        self.path.join("scripts").is_dir()
123    }
124
125    /// Returns `true` if a references/ directory exists.
126    #[must_use]
127    pub fn has_references(&self) -> bool {
128        self.path.join("references").is_dir()
129    }
130
131    /// Returns `true` if an assets/ directory exists.
132    #[must_use]
133    pub fn has_assets(&self) -> bool {
134        self.path.join("assets").is_dir()
135    }
136
137    /// Lists files in the scripts/ directory.
138    ///
139    /// Returns an empty vector if the scripts/ directory doesn't exist.
140    ///
141    /// # Errors
142    ///
143    /// Returns `LoadError::IoError` if the directory cannot be read.
144    pub fn scripts(&self) -> Result<Vec<PathBuf>, LoadError> {
145        list_files_in_subdir(&self.path, "scripts")
146    }
147
148    /// Lists files in the references/ directory.
149    ///
150    /// Returns an empty vector if the references/ directory doesn't exist.
151    ///
152    /// # Errors
153    ///
154    /// Returns `LoadError::IoError` if the directory cannot be read.
155    pub fn references(&self) -> Result<Vec<PathBuf>, LoadError> {
156        list_files_in_subdir(&self.path, "references")
157    }
158
159    /// Lists files in the assets/ directory.
160    ///
161    /// Returns an empty vector if the assets/ directory doesn't exist.
162    ///
163    /// # Errors
164    ///
165    /// Returns `LoadError::IoError` if the directory cannot be read.
166    pub fn assets(&self) -> Result<Vec<PathBuf>, LoadError> {
167        list_files_in_subdir(&self.path, "assets")
168    }
169
170    /// Reads a reference file by name.
171    ///
172    /// The name is relative to the references/ directory.
173    ///
174    /// # Errors
175    ///
176    /// Returns `LoadError::FileNotFound` if the file doesn't exist.
177    /// Returns `LoadError::IoError` if the file cannot be read.
178    ///
179    /// # Examples
180    ///
181    /// ```no_run
182    /// use agent_skills::SkillDirectory;
183    /// use std::path::Path;
184    ///
185    /// let dir = SkillDirectory::load(Path::new("./my-skill")).unwrap();
186    /// let content = dir.read_reference("REFERENCE.md").unwrap();
187    /// ```
188    pub fn read_reference(&self, name: &str) -> Result<String, LoadError> {
189        let file_path = self.path.join("references").join(name);
190        read_file_as_string(&file_path)
191    }
192
193    /// Reads a script file by name.
194    ///
195    /// The name is relative to the scripts/ directory.
196    ///
197    /// # Errors
198    ///
199    /// Returns `LoadError::FileNotFound` if the file doesn't exist.
200    /// Returns `LoadError::IoError` if the file cannot be read.
201    pub fn read_script(&self, name: &str) -> Result<String, LoadError> {
202        let file_path = self.path.join("scripts").join(name);
203        read_file_as_string(&file_path)
204    }
205
206    /// Reads an asset file by name as bytes.
207    ///
208    /// The name is relative to the assets/ directory.
209    ///
210    /// # Errors
211    ///
212    /// Returns `LoadError::FileNotFound` if the file doesn't exist.
213    /// Returns `LoadError::IoError` if the file cannot be read.
214    ///
215    /// # Examples
216    ///
217    /// ```no_run
218    /// use agent_skills::SkillDirectory;
219    /// use std::path::Path;
220    ///
221    /// let dir = SkillDirectory::load(Path::new("./my-skill")).unwrap();
222    /// let bytes = dir.read_asset("template.txt").unwrap();
223    /// ```
224    pub fn read_asset(&self, name: &str) -> Result<Vec<u8>, LoadError> {
225        let file_path = self.path.join("assets").join(name);
226        read_file_as_bytes(&file_path)
227    }
228
229    /// Reads an asset file by name as a string (UTF-8).
230    ///
231    /// # Errors
232    ///
233    /// Returns `LoadError::FileNotFound` if the file doesn't exist.
234    /// Returns `LoadError::IoError` if the file cannot be read or is not valid UTF-8.
235    pub fn read_asset_string(&self, name: &str) -> Result<String, LoadError> {
236        let file_path = self.path.join("assets").join(name);
237        read_file_as_string(&file_path)
238    }
239}
240
241/// Lists files in a subdirectory of a skill directory.
242fn list_files_in_subdir(base_path: &Path, subdir: &str) -> Result<Vec<PathBuf>, LoadError> {
243    let dir_path = base_path.join(subdir);
244
245    if !dir_path.exists() {
246        return Ok(Vec::new());
247    }
248
249    let entries = fs::read_dir(&dir_path).map_err(|e| LoadError::IoError {
250        path: dir_path.display().to_string(),
251        kind: e.kind(),
252        message: e.to_string(),
253    })?;
254
255    let mut files = Vec::new();
256    for entry in entries {
257        let entry = entry.map_err(|e| LoadError::IoError {
258            path: dir_path.display().to_string(),
259            kind: e.kind(),
260            message: e.to_string(),
261        })?;
262        let path = entry.path();
263        if path.is_file() {
264            files.push(path);
265        }
266    }
267
268    // Sort for consistent ordering
269    files.sort();
270    Ok(files)
271}
272
273/// Reads a file as a string.
274fn read_file_as_string(path: &Path) -> Result<String, LoadError> {
275    let path_str = path.display().to_string();
276
277    if !path.exists() {
278        return Err(LoadError::FileNotFound { path: path_str });
279    }
280
281    fs::read_to_string(path).map_err(|e| LoadError::IoError {
282        path: path_str,
283        kind: e.kind(),
284        message: e.to_string(),
285    })
286}
287
288/// Reads a file as bytes.
289fn read_file_as_bytes(path: &Path) -> Result<Vec<u8>, LoadError> {
290    let path_str = path.display().to_string();
291
292    if !path.exists() {
293        return Err(LoadError::FileNotFound { path: path_str });
294    }
295
296    fs::read(path).map_err(|e| LoadError::IoError {
297        path: path_str,
298        kind: e.kind(),
299        message: e.to_string(),
300    })
301}
302
303#[cfg(test)]
304#[allow(clippy::unwrap_used, clippy::expect_used)]
305mod tests {
306    use super::*;
307    use tempfile::TempDir;
308
309    fn create_skill_dir(temp: &TempDir, name: &str, content: &str) -> PathBuf {
310        let skill_dir = temp.path().join(name);
311        fs::create_dir(&skill_dir).unwrap();
312        fs::write(skill_dir.join("SKILL.md"), content).unwrap();
313        skill_dir
314    }
315
316    fn minimal_skill_content(name: &str) -> String {
317        format!(
318            r#"---
319name: {name}
320description: Test skill.
321---
322# Instructions
323"#
324        )
325    }
326
327    #[test]
328    fn loads_valid_skill_directory() {
329        let temp = TempDir::new().unwrap();
330        let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
331
332        let result = SkillDirectory::load(&skill_dir);
333        assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
334        let dir = result.unwrap();
335        assert_eq!(dir.skill().name().as_str(), "my-skill");
336    }
337
338    #[test]
339    fn rejects_nonexistent_directory() {
340        let result = SkillDirectory::load("/nonexistent/path");
341        assert!(matches!(result, Err(LoadError::DirectoryNotFound { .. })));
342    }
343
344    #[test]
345    fn rejects_missing_skill_file() {
346        let temp = TempDir::new().unwrap();
347        let skill_dir = temp.path().join("empty-skill");
348        fs::create_dir(&skill_dir).unwrap();
349
350        let result = SkillDirectory::load(&skill_dir);
351        assert!(matches!(result, Err(LoadError::SkillFileNotFound { .. })));
352    }
353
354    #[test]
355    fn detects_name_mismatch() {
356        let temp = TempDir::new().unwrap();
357        let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("other-name"));
358
359        let result = SkillDirectory::load(&skill_dir);
360        assert!(matches!(
361            result,
362            Err(LoadError::NameMismatch {
363                directory_name,
364                skill_name,
365            }) if directory_name == "my-skill" && skill_name == "other-name"
366        ));
367    }
368
369    #[test]
370    fn has_scripts_returns_false_without_scripts_dir() {
371        let temp = TempDir::new().unwrap();
372        let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
373
374        let dir = SkillDirectory::load(&skill_dir).unwrap();
375        assert!(!dir.has_scripts());
376    }
377
378    #[test]
379    fn has_scripts_returns_true_with_scripts_dir() {
380        let temp = TempDir::new().unwrap();
381        let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
382        fs::create_dir(skill_dir.join("scripts")).unwrap();
383
384        let dir = SkillDirectory::load(&skill_dir).unwrap();
385        assert!(dir.has_scripts());
386    }
387
388    #[test]
389    fn lists_scripts() {
390        let temp = TempDir::new().unwrap();
391        let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
392        let scripts_dir = skill_dir.join("scripts");
393        fs::create_dir(&scripts_dir).unwrap();
394        fs::write(scripts_dir.join("run.sh"), "#!/bin/bash").unwrap();
395        fs::write(scripts_dir.join("build.py"), "# Python").unwrap();
396
397        let dir = SkillDirectory::load(&skill_dir).unwrap();
398        let scripts = dir.scripts().unwrap();
399        assert_eq!(scripts.len(), 2);
400    }
401
402    #[test]
403    fn scripts_returns_empty_without_scripts_dir() {
404        let temp = TempDir::new().unwrap();
405        let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
406
407        let dir = SkillDirectory::load(&skill_dir).unwrap();
408        let scripts = dir.scripts().unwrap();
409        assert!(scripts.is_empty());
410    }
411
412    #[test]
413    fn has_references_works() {
414        let temp = TempDir::new().unwrap();
415        let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
416
417        let dir = SkillDirectory::load(&skill_dir).unwrap();
418        assert!(!dir.has_references());
419
420        fs::create_dir(skill_dir.join("references")).unwrap();
421        let dir = SkillDirectory::load(&skill_dir).unwrap();
422        assert!(dir.has_references());
423    }
424
425    #[test]
426    fn has_assets_works() {
427        let temp = TempDir::new().unwrap();
428        let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
429
430        let dir = SkillDirectory::load(&skill_dir).unwrap();
431        assert!(!dir.has_assets());
432
433        fs::create_dir(skill_dir.join("assets")).unwrap();
434        let dir = SkillDirectory::load(&skill_dir).unwrap();
435        assert!(dir.has_assets());
436    }
437
438    #[test]
439    fn reads_reference_file() {
440        let temp = TempDir::new().unwrap();
441        let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
442        let refs_dir = skill_dir.join("references");
443        fs::create_dir(&refs_dir).unwrap();
444        fs::write(refs_dir.join("REFERENCE.md"), "# Reference\n\nContent").unwrap();
445
446        let dir = SkillDirectory::load(&skill_dir).unwrap();
447        let content = dir.read_reference("REFERENCE.md").unwrap();
448        assert!(content.contains("# Reference"));
449    }
450
451    #[test]
452    fn read_reference_returns_error_for_missing_file() {
453        let temp = TempDir::new().unwrap();
454        let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
455
456        let dir = SkillDirectory::load(&skill_dir).unwrap();
457        let result = dir.read_reference("nonexistent.md");
458        assert!(matches!(result, Err(LoadError::FileNotFound { .. })));
459    }
460
461    #[test]
462    fn reads_asset_as_bytes() {
463        let temp = TempDir::new().unwrap();
464        let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
465        let assets_dir = skill_dir.join("assets");
466        fs::create_dir(&assets_dir).unwrap();
467        fs::write(assets_dir.join("data.bin"), [0x00, 0x01, 0x02]).unwrap();
468
469        let dir = SkillDirectory::load(&skill_dir).unwrap();
470        let bytes = dir.read_asset("data.bin").unwrap();
471        assert_eq!(bytes, vec![0x00, 0x01, 0x02]);
472    }
473
474    #[test]
475    fn reads_asset_as_string() {
476        let temp = TempDir::new().unwrap();
477        let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
478        let assets_dir = skill_dir.join("assets");
479        fs::create_dir(&assets_dir).unwrap();
480        fs::write(assets_dir.join("template.txt"), "Hello, World!").unwrap();
481
482        let dir = SkillDirectory::load(&skill_dir).unwrap();
483        let content = dir.read_asset_string("template.txt").unwrap();
484        assert_eq!(content, "Hello, World!");
485    }
486
487    #[test]
488    fn reads_script_file() {
489        let temp = TempDir::new().unwrap();
490        let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
491        let scripts_dir = skill_dir.join("scripts");
492        fs::create_dir(&scripts_dir).unwrap();
493        fs::write(scripts_dir.join("run.sh"), "#!/bin/bash\necho hello").unwrap();
494
495        let dir = SkillDirectory::load(&skill_dir).unwrap();
496        let content = dir.read_script("run.sh").unwrap();
497        assert!(content.contains("#!/bin/bash"));
498    }
499
500    #[test]
501    fn path_returns_directory_path() {
502        let temp = TempDir::new().unwrap();
503        let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
504
505        let dir = SkillDirectory::load(&skill_dir).unwrap();
506        assert_eq!(dir.path(), skill_dir);
507    }
508}