Skip to main content

robit_agent/skill/
loader.rs

1//! Skill loading — each skill is a directory containing a `SKILL.md` file
2//! with YAML frontmatter + Markdown body.
3
4use std::collections::HashMap;
5use std::io;
6use std::path::{Path, PathBuf};
7
8use crate::skill::{Skill, SkillFrontmatter};
9
10/// The expected main file name inside each skill directory.
11const SKILL_FILE_NAME: &str = "SKILL.md";
12
13// ============================================================================
14// SkillLoadError
15// ============================================================================
16
17/// Error types that can occur during skill loading.
18#[derive(Debug)]
19pub enum SkillLoadError {
20    IoError { path: PathBuf, source: io::Error },
21    NoFrontmatter { path: PathBuf },
22    NoClosingDelimiter { path: PathBuf },
23    YamlParseError { path: PathBuf, source: serde_yaml::Error },
24    MissingSkillFile { dir: PathBuf },
25}
26
27impl std::fmt::Display for SkillLoadError {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            SkillLoadError::IoError { path, source } => {
31                write!(f, "Failed to read file {}: {}", path.display(), source)
32            }
33            SkillLoadError::NoFrontmatter { path } => {
34                write!(f, "File {} is missing YAML frontmatter (must start with ---)", path.display())
35            }
36            SkillLoadError::NoClosingDelimiter { path } => {
37                write!(f, "File {} is missing closing --- delimiter", path.display())
38            }
39            SkillLoadError::YamlParseError { path, source } => {
40                write!(f, "Failed to parse YAML in file {}: {}", path.display(), source)
41            }
42            SkillLoadError::MissingSkillFile { dir } => {
43                write!(
44                    f,
45                    "Skill directory {} does not contain a {} file",
46                    dir.display(),
47                    SKILL_FILE_NAME
48                )
49            }
50        }
51    }
52}
53
54// ============================================================================
55// Parse a single file
56// ============================================================================
57
58/// Parse a single skill `SKILL.md` file into a `Skill`.
59///
60/// Expected format:
61/// ```text
62/// ---
63/// name: my-skill
64/// description: ...
65/// ---
66///
67/// # Markdown body...
68/// ```
69pub fn parse_skill_file(path: &Path) -> Result<Skill, SkillLoadError> {
70    let content = std::fs::read_to_string(path)
71        .map_err(|e| SkillLoadError::IoError { path: path.to_path_buf(), source: e })?;
72
73    // Opening delimiter must be at the start of the file
74    let rest = content
75        .strip_prefix("---")
76        .ok_or_else(|| SkillLoadError::NoFrontmatter { path: path.to_path_buf() })?;
77
78    // Allow optional newline after opening ---
79    let rest = rest.strip_prefix('\r').unwrap_or(rest);
80    let rest = rest.strip_prefix('\n').unwrap_or(rest);
81
82    // Find closing delimiter --- on its own line
83    let closing_pos = find_closing_delimiter(rest)
84        .ok_or_else(|| SkillLoadError::NoClosingDelimiter { path: path.to_path_buf() })?;
85
86    let yaml_text = &rest[..closing_pos];
87    // Skip past "---" and any trailing whitespace
88    let markdown_body = &rest[closing_pos + 3..];
89    let markdown_body = markdown_body.strip_prefix('\r').unwrap_or(markdown_body);
90    let markdown_body = markdown_body.strip_prefix('\n').unwrap_or(markdown_body);
91
92    let frontmatter: SkillFrontmatter = serde_yaml::from_str(yaml_text.trim())
93        .map_err(|e| SkillLoadError::YamlParseError { path: path.to_path_buf(), source: e })?;
94
95    Ok(Skill {
96        frontmatter,
97        content: markdown_body.to_string(),
98        source_path: path.to_path_buf(),
99    })
100}
101
102/// Find the position of a closing `---` delimiter that is on its own line.
103fn find_closing_delimiter(text: &str) -> Option<usize> {
104    let mut search_start = 0;
105    while let Some(pos) = text[search_start..].find('\n') {
106        let abs_pos = search_start + pos;
107        if let Some(line_start) = text.get(abs_pos + 1..) {
108            let line = line_start.lines().next().unwrap_or("");
109            if line.trim_end() == "---" {
110                return Some(abs_pos + 1);
111            }
112        }
113        search_start = abs_pos + 1;
114        if search_start >= text.len() {
115            break;
116        }
117    }
118    if text.starts_with("---") {
119        return Some(0);
120    }
121    None
122}
123
124// ============================================================================
125// Directory loading
126// ============================================================================
127
128/// Load all skills from both global and project skill directories.
129///
130/// Each skill is a subdirectory containing a `SKILL.md` file.
131/// Project skills override global skills by name (same `name` field replaces).
132/// Only skills with `enabled: true` (or default) are included.
133///
134/// Returns a tuple of (loaded skills, load errors). Errors are non-fatal —
135/// the caller can log them and proceed with successfully loaded skills.
136pub fn load_skills(
137    global_dir: Option<PathBuf>,
138    project_dir: Option<PathBuf>,
139) -> (Vec<Skill>, Vec<SkillLoadError>) {
140    let mut skills_by_name: HashMap<String, Skill> = HashMap::new();
141    let mut errors: Vec<SkillLoadError> = Vec::new();
142
143    // Load global skills first (lower priority)
144    if let Some(dir) = global_dir {
145        load_from_directory(&dir, &mut skills_by_name, &mut errors);
146    }
147
148    // Load project skills second (higher priority, overrides by name)
149    if let Some(dir) = project_dir {
150        load_from_directory(&dir, &mut skills_by_name, &mut errors);
151    }
152
153    // Filter out disabled skills
154    let skills: Vec<Skill> = skills_by_name
155        .into_values()
156        .filter(|s| s.frontmatter.enabled)
157        .collect();
158
159    (skills, errors)
160}
161
162/// Load all skill subdirectories from a skills root directory.
163///
164/// Each subdirectory that contains a `SKILL.md` file is loaded as one skill.
165/// Subdirectories are processed in sorted order for deterministic behavior.
166fn load_from_directory(
167    dir: &Path,
168    skills_by_name: &mut HashMap<String, Skill>,
169    errors: &mut Vec<SkillLoadError>,
170) {
171    if !dir.is_dir() {
172        return;
173    }
174
175    let entries = match std::fs::read_dir(dir) {
176        Ok(e) => e,
177        Err(e) => {
178            errors.push(SkillLoadError::IoError {
179                path: dir.to_path_buf(),
180                source: e,
181            });
182            return;
183        }
184    };
185
186    // Collect entries, then sort directories by name for deterministic loading
187    let mut dirs: Vec<PathBuf> = entries
188        .filter_map(|e| e.ok())
189        .map(|e| e.path())
190        .filter(|p| p.is_dir())
191        .collect();
192    dirs.sort();
193
194    for skill_dir in &dirs {
195        let skill_file = skill_dir.join(SKILL_FILE_NAME);
196        if !skill_file.exists() {
197            errors.push(SkillLoadError::MissingSkillFile {
198                dir: skill_dir.clone(),
199            });
200            continue;
201        }
202
203        match parse_skill_file(&skill_file) {
204            Ok(skill) => {
205                if skills_by_name.contains_key(&skill.frontmatter.name) {
206                    tracing::info!(
207                        "Skill '{}' ({}) overridden by {}",
208                        skill.frontmatter.name,
209                        skills_by_name[&skill.frontmatter.name]
210                            .source_path
211                            .display(),
212                        skill_file.display()
213                    );
214                }
215                skills_by_name.insert(skill.frontmatter.name.clone(), skill);
216            }
217            Err(e) => {
218                errors.push(e);
219            }
220        }
221    }
222}
223
224// ============================================================================
225// Tests
226// ============================================================================
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use std::io::Write;
232
233    /// Create a skill directory with a SKILL.md file.
234    fn write_skill_dir(parent: &Path, dir_name: &str, content: &str) -> PathBuf {
235        let skill_dir = parent.join(dir_name);
236        std::fs::create_dir_all(&skill_dir).unwrap();
237        let mut f = std::fs::File::create(skill_dir.join(SKILL_FILE_NAME)).unwrap();
238        f.write_all(content.as_bytes()).unwrap();
239        skill_dir
240    }
241
242    #[test]
243    fn test_parse_valid_skill_file() {
244        let content = r#"---
245name: test-skill
246description: A test skill
247version: 2.0.0
248triggers:
249  - /test
250enabled: true
251---
252
253# Test Skill
254
255This is the body content."#;
256
257        let temp_dir = std::env::temp_dir().join("robit_test_skills");
258        std::fs::create_dir_all(&temp_dir).unwrap();
259        let skill_dir = write_skill_dir(&temp_dir, "test", content);
260        let skill = parse_skill_file(&skill_dir.join(SKILL_FILE_NAME)).unwrap();
261
262        assert_eq!(skill.frontmatter.name, "test-skill");
263        assert_eq!(skill.frontmatter.description, "A test skill");
264        assert_eq!(skill.frontmatter.version, "2.0.0");
265        assert_eq!(skill.frontmatter.triggers, vec!["/test"]);
266        assert!(skill.frontmatter.enabled);
267        assert!(skill.content.contains("# Test Skill"));
268        assert!(skill.content.contains("body content"));
269
270        std::fs::remove_dir_all(&temp_dir).ok();
271    }
272
273    #[test]
274    fn test_parse_minimal_frontmatter() {
275        let content = "---\nname: min\ndescription: minimal\n---\n\nBody";
276
277        let temp_dir = std::env::temp_dir().join("robit_test_min");
278        std::fs::create_dir_all(&temp_dir).unwrap();
279        let skill_dir = write_skill_dir(&temp_dir, "min", content);
280        let skill = parse_skill_file(&skill_dir.join(SKILL_FILE_NAME)).unwrap();
281
282        assert_eq!(skill.frontmatter.name, "min");
283        assert_eq!(skill.frontmatter.description, "minimal");
284        assert_eq!(skill.frontmatter.version, "1.0.0"); // default
285        assert!(skill.frontmatter.triggers.is_empty()); // default
286        assert!(skill.frontmatter.enabled); // default
287        assert_eq!(skill.content, "\nBody");
288
289        std::fs::remove_dir_all(&temp_dir).ok();
290    }
291
292    #[test]
293    fn test_parse_no_frontmatter() {
294        let content = "# Just markdown, no frontmatter";
295
296        let temp_dir = std::env::temp_dir().join("robit_test_nofm");
297        std::fs::create_dir_all(&temp_dir).unwrap();
298        let skill_dir = write_skill_dir(&temp_dir, "nofm", content);
299        let result = parse_skill_file(&skill_dir.join(SKILL_FILE_NAME));
300        assert!(matches!(result, Err(SkillLoadError::NoFrontmatter { .. })));
301
302        std::fs::remove_dir_all(&temp_dir).ok();
303    }
304
305    #[test]
306    fn test_parse_no_closing_delimiter() {
307        let content = "---\nname: broken\n";
308
309        let temp_dir = std::env::temp_dir().join("robit_test_nocl");
310        std::fs::create_dir_all(&temp_dir).unwrap();
311        let skill_dir = write_skill_dir(&temp_dir, "nocl", content);
312        let result = parse_skill_file(&skill_dir.join(SKILL_FILE_NAME));
313        assert!(matches!(result, Err(SkillLoadError::NoClosingDelimiter { .. })));
314
315        std::fs::remove_dir_all(&temp_dir).ok();
316    }
317
318    #[test]
319    fn test_parse_invalid_yaml() {
320        let content = "---\nname: [broken yaml\n---\n\nBody";
321
322        let temp_dir = std::env::temp_dir().join("robit_test_badym");
323        std::fs::create_dir_all(&temp_dir).unwrap();
324        let skill_dir = write_skill_dir(&temp_dir, "badym", content);
325        let result = parse_skill_file(&skill_dir.join(SKILL_FILE_NAME));
326        assert!(matches!(result, Err(SkillLoadError::YamlParseError { .. })));
327
328        std::fs::remove_dir_all(&temp_dir).ok();
329    }
330
331    #[test]
332    fn test_load_skills_with_priority() {
333        let temp = std::env::temp_dir().join("robit_test_priority");
334        let global_dir = temp.join("global");
335        let project_dir = temp.join("project");
336        std::fs::create_dir_all(&global_dir).unwrap();
337        std::fs::create_dir_all(&project_dir).unwrap();
338
339        // Global skill "shared"
340        write_skill_dir(
341            &global_dir,
342            "shared",
343            "---\nname: shared\ndescription: global version\n---\n\nglobal body",
344        );
345        // Another global skill
346        write_skill_dir(
347            &global_dir,
348            "only-global",
349            "---\nname: only-global\ndescription: only in global\n---\n\nonly body",
350        );
351        // Project skill overrides global "shared"
352        write_skill_dir(
353            &project_dir,
354            "shared",
355            "---\nname: shared\ndescription: project version\n---\n\nproject body",
356        );
357
358        let (skills, errors) = load_skills(Some(global_dir), Some(project_dir));
359        assert!(errors.is_empty());
360        assert_eq!(skills.len(), 2);
361
362        let shared = skills.iter().find(|s| s.frontmatter.name == "shared").unwrap();
363        assert_eq!(shared.frontmatter.description, "project version");
364        assert!(shared.content.contains("project body"));
365
366        let only_global = skills
367            .iter()
368            .find(|s| s.frontmatter.name == "only-global")
369            .unwrap();
370        assert_eq!(only_global.frontmatter.description, "only in global");
371
372        std::fs::remove_dir_all(temp).ok();
373    }
374
375    #[test]
376    fn test_load_skills_filters_disabled() {
377        let temp = std::env::temp_dir().join("robit_test_disabled");
378        std::fs::create_dir_all(&temp).unwrap();
379
380        write_skill_dir(
381            &temp,
382            "disabled",
383            "---\nname: disabled\ndescription: should be filtered\nenabled: false\n---\n\nbody",
384        );
385        write_skill_dir(
386            &temp,
387            "enabled",
388            "---\nname: enabled\ndescription: should be kept\n---\n\nbody",
389        );
390
391        let (skills, errors) = load_skills(Some(temp.clone()), None);
392        assert!(errors.is_empty());
393        assert_eq!(skills.len(), 1);
394        assert_eq!(skills[0].frontmatter.name, "enabled");
395
396        std::fs::remove_dir_all(temp).ok();
397    }
398
399    #[test]
400    fn test_load_skills_missing_file_warned() {
401        let temp = std::env::temp_dir().join("robit_test_miss");
402        std::fs::create_dir_all(&temp).unwrap();
403
404        // Directory without SKILL.md
405        std::fs::create_dir_all(temp.join("no-file")).unwrap();
406        // Valid skill
407        write_skill_dir(
408            &temp,
409            "valid",
410            "---\nname: valid\ndescription: ok\n---\n\nbody",
411        );
412
413        let (skills, errors) = load_skills(Some(temp.clone()), None);
414        assert_eq!(skills.len(), 1);
415        assert_eq!(skills[0].frontmatter.name, "valid");
416        // The missing file should produce an error
417        assert!(errors.iter().any(|e| matches!(e, SkillLoadError::MissingSkillFile { .. })));
418
419        std::fs::remove_dir_all(temp).ok();
420    }
421}