Skip to main content

skilltest_core/
skill.rs

1//! Skill definitions: a directory containing a `SKILL.md` with YAML frontmatter
2//! and a Markdown body. This module loads them and validates them, powering the
3//! `skilltest validate` subcommand.
4
5use std::path::{Path, PathBuf};
6
7use serde::Deserialize;
8
9use crate::error::{Error, Result};
10
11/// The YAML frontmatter at the top of a `SKILL.md`. Only `name` and
12/// `description` are required; unknown keys are allowed so authors can carry
13/// extra metadata.
14#[derive(Debug, Clone, Deserialize)]
15pub struct Frontmatter {
16    pub name: Option<String>,
17    pub description: Option<String>,
18    #[serde(default)]
19    pub license: Option<String>,
20}
21
22/// A loaded skill: where it lives, its parsed frontmatter, and its instruction
23/// body (everything after the frontmatter), which is what we hand to a provider
24/// when running the skill.
25#[derive(Debug, Clone)]
26pub struct SkillDefinition {
27    pub dir: PathBuf,
28    pub name: String,
29    pub description: String,
30    pub instructions: String,
31}
32
33/// A single validation problem found in a skill definition.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Finding {
36    pub skill: PathBuf,
37    pub message: String,
38}
39
40impl Finding {
41    fn new(skill: impl Into<PathBuf>, message: impl Into<String>) -> Self {
42        Self {
43            skill: skill.into(),
44            message: message.into(),
45        }
46    }
47}
48
49/// Split a `SKILL.md` into `(frontmatter_yaml, body)`. Returns `None` for the
50/// frontmatter when the document does not open with a `---` fence.
51fn split_frontmatter(text: &str) -> (Option<&str>, &str) {
52    let rest = match text
53        .strip_prefix("---\n")
54        .or_else(|| text.strip_prefix("---\r\n"))
55    {
56        Some(rest) => rest,
57        None => return (None, text),
58    };
59    // Find the closing fence at the start of a line.
60    for sep in ["\n---\n", "\n---\r\n", "\r\n---\r\n"] {
61        if let Some(idx) = rest.find(sep) {
62            let fm = &rest[..idx];
63            let body = &rest[idx + sep.len()..];
64            return (Some(fm), body);
65        }
66    }
67    // Opened a fence but never closed it.
68    (None, text)
69}
70
71/// Load a skill definition from a directory containing `SKILL.md`.
72///
73/// # Errors
74/// Returns [`Error::Io`] if `SKILL.md` cannot be read and [`Error::Yaml`] if the
75/// frontmatter is not valid YAML.
76pub fn load_skill(dir: &Path) -> Result<SkillDefinition> {
77    let skill_md = dir.join("SKILL.md");
78    let text = std::fs::read_to_string(&skill_md).map_err(|source| Error::Io {
79        path: skill_md.clone(),
80        source,
81    })?;
82    let (fm, body) = split_frontmatter(&text);
83    let frontmatter: Frontmatter = match fm {
84        Some(fm) => serde_yaml::from_str(fm).map_err(|source| Error::Yaml {
85            path: skill_md.clone(),
86            source,
87        })?,
88        None => Frontmatter {
89            name: None,
90            description: None,
91            license: None,
92        },
93    };
94    Ok(SkillDefinition {
95        dir: dir.to_path_buf(),
96        name: frontmatter.name.unwrap_or_default(),
97        description: frontmatter.description.unwrap_or_default(),
98        instructions: body.trim().to_string(),
99    })
100}
101
102/// Validate a single skill directory, returning any findings. An empty vec
103/// means the skill is valid. This never errors on a *bad* skill — invalidity is
104/// reported as findings; it only errors if the directory is unreadable.
105///
106/// # Errors
107/// Returns [`Error::Io`] only when the directory exists but cannot be inspected
108/// in a way that prevents validation from proceeding.
109pub fn validate_skill(dir: &Path) -> Result<Vec<Finding>> {
110    let skill_md = dir.join("SKILL.md");
111    if !skill_md.is_file() {
112        return Ok(vec![Finding::new(
113            dir,
114            "missing SKILL.md (a skill is a directory containing SKILL.md)",
115        )]);
116    }
117
118    let text = std::fs::read_to_string(&skill_md).map_err(|source| Error::Io {
119        path: skill_md.clone(),
120        source,
121    })?;
122
123    let mut findings = Vec::new();
124    let (fm, body) = split_frontmatter(&text);
125
126    let Some(fm) = fm else {
127        findings.push(Finding::new(
128            dir,
129            "SKILL.md has no YAML frontmatter (expected a leading `---` fenced block)",
130        ));
131        return Ok(findings);
132    };
133
134    match serde_yaml::from_str::<Frontmatter>(fm) {
135        Ok(frontmatter) => {
136            match frontmatter.name.as_deref().map(str::trim) {
137                None | Some("") => findings.push(Finding::new(
138                    dir,
139                    "frontmatter is missing a non-empty `name`",
140                )),
141                Some(name) => {
142                    if let Some(folder) = dir.file_name().and_then(|s| s.to_str()) {
143                        if folder != name {
144                            findings.push(Finding::new(
145                                dir,
146                                format!(
147                                    "frontmatter `name` ({name}) does not match the directory name ({folder})"
148                                ),
149                            ));
150                        }
151                    }
152                }
153            }
154            match frontmatter.description.as_deref().map(str::trim) {
155                None | Some("") => findings.push(Finding::new(
156                    dir,
157                    "frontmatter is missing a non-empty `description`",
158                )),
159                Some(desc) if desc.len() < 16 => findings.push(Finding::new(
160                    dir,
161                    "frontmatter `description` is too short to be useful (< 16 chars)",
162                )),
163                Some(_) => {}
164            }
165        }
166        Err(source) => {
167            findings.push(Finding::new(
168                dir,
169                format!("frontmatter is not valid YAML: {source}"),
170            ));
171        }
172    }
173
174    if body.trim().is_empty() {
175        findings.push(Finding::new(
176            dir,
177            "SKILL.md has no instruction body after the frontmatter",
178        ));
179    }
180
181    Ok(findings)
182}
183
184/// Validate either a single skill directory or a folder *containing* skill
185/// directories. A directory is treated as a skill if it directly contains a
186/// `SKILL.md`; otherwise its immediate subdirectories are validated.
187///
188/// # Errors
189/// Propagates [`Error::Io`] from reading the directory tree.
190pub fn validate_path(path: &Path) -> Result<Vec<Finding>> {
191    if path.join("SKILL.md").is_file() {
192        return validate_skill(path);
193    }
194
195    let entries = std::fs::read_dir(path).map_err(|source| Error::Io {
196        path: path.to_path_buf(),
197        source,
198    })?;
199
200    let mut skill_dirs: Vec<PathBuf> = entries
201        .filter_map(std::result::Result::ok)
202        .map(|e| e.path())
203        .filter(|p| p.is_dir() && p.join("SKILL.md").is_file())
204        .collect();
205    skill_dirs.sort();
206
207    if skill_dirs.is_empty() {
208        return Ok(vec![Finding::new(
209            path,
210            "no skills found (expected a SKILL.md here or in an immediate subdirectory)",
211        )]);
212    }
213
214    let mut findings = Vec::new();
215    for dir in skill_dirs {
216        findings.extend(validate_skill(&dir)?);
217    }
218    Ok(findings)
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn splits_frontmatter_and_body() {
227        let text = "---\nname: greeter\ndescription: hi\n---\nBody here\n";
228        let (fm, body) = split_frontmatter(text);
229        assert_eq!(fm, Some("name: greeter\ndescription: hi"));
230        assert_eq!(body, "Body here\n");
231    }
232
233    #[test]
234    fn no_frontmatter_returns_none() {
235        let (fm, body) = split_frontmatter("# Just a heading\n");
236        assert!(fm.is_none());
237        assert_eq!(body, "# Just a heading\n");
238    }
239
240    #[test]
241    fn unclosed_fence_yields_no_frontmatter() {
242        // Opening `---` but no closing fence falls back to "no frontmatter".
243        let (fm, body) = split_frontmatter("---\nname: x\nstill going\n");
244        assert!(fm.is_none());
245        assert_eq!(body, "---\nname: x\nstill going\n");
246    }
247
248    /// Make a unique temp skill directory with a `SKILL.md` of `contents`.
249    fn skill_dir(tag: &str, name: &str, contents: &str) -> PathBuf {
250        use std::sync::atomic::{AtomicU64, Ordering};
251        static N: AtomicU64 = AtomicU64::new(0);
252        let root = std::env::temp_dir().join(format!(
253            "skilltest-skill-{}-{tag}-{}",
254            std::process::id(),
255            N.fetch_add(1, Ordering::Relaxed)
256        ));
257        let dir = root.join(name);
258        std::fs::create_dir_all(&dir).unwrap();
259        std::fs::write(dir.join("SKILL.md"), contents).unwrap();
260        dir
261    }
262
263    #[test]
264    fn load_skill_reads_frontmatter_and_body() {
265        let dir = skill_dir(
266            "load",
267            "greeter",
268            "---\nname: greeter\ndescription: a friendly greeter\n---\nGreet warmly.\n",
269        );
270        let skill = load_skill(&dir).unwrap();
271        assert_eq!(skill.name, "greeter");
272        assert_eq!(skill.description, "a friendly greeter");
273        assert_eq!(skill.instructions, "Greet warmly.");
274    }
275
276    #[test]
277    fn load_skill_without_frontmatter_uses_defaults() {
278        let dir = skill_dir("nofm", "bare", "# Just a body\nNo frontmatter here.\n");
279        let skill = load_skill(&dir).unwrap();
280        assert_eq!(skill.name, "");
281        assert_eq!(skill.description, "");
282        assert!(skill.instructions.contains("No frontmatter here."));
283    }
284
285    #[test]
286    fn load_skill_missing_file_is_io_error() {
287        let dir = std::env::temp_dir().join(format!("skilltest-missing-{}", std::process::id()));
288        assert!(matches!(load_skill(&dir), Err(Error::Io { .. })));
289    }
290
291    #[test]
292    fn load_skill_bad_frontmatter_is_yaml_error() {
293        let dir = skill_dir("badyaml", "x", "---\nname: [unterminated\n---\nbody\n");
294        assert!(matches!(load_skill(&dir), Err(Error::Yaml { .. })));
295    }
296
297    #[test]
298    fn validate_skill_accepts_a_good_skill() {
299        let dir = skill_dir(
300            "good",
301            "greeter",
302            "---\nname: greeter\ndescription: a sufficiently long description\n---\nDo the thing.\n",
303        );
304        assert!(validate_skill(&dir).unwrap().is_empty());
305    }
306
307    #[test]
308    fn validate_skill_flags_missing_skill_md() {
309        let root = std::env::temp_dir().join(format!("skilltest-empty-{}", std::process::id()));
310        std::fs::create_dir_all(&root).unwrap();
311        let findings = validate_skill(&root).unwrap();
312        assert_eq!(findings.len(), 1);
313        assert!(findings[0].message.contains("missing SKILL.md"));
314    }
315
316    #[test]
317    fn validate_skill_flags_missing_frontmatter() {
318        let dir = skill_dir("nofm2", "x", "Just a body, no fence.\n");
319        let findings = validate_skill(&dir).unwrap();
320        assert!(findings
321            .iter()
322            .any(|f| f.message.contains("no YAML frontmatter")));
323    }
324
325    #[test]
326    fn validate_skill_flags_name_mismatch_and_short_description() {
327        let dir = skill_dir(
328            "mismatch",
329            "actual-folder",
330            "---\nname: declared-name\ndescription: short\n---\nbody\n",
331        );
332        let findings = validate_skill(&dir).unwrap();
333        assert!(findings
334            .iter()
335            .any(|f| f.message.contains("does not match the directory name")));
336        assert!(findings.iter().any(|f| f.message.contains("too short")));
337    }
338
339    #[test]
340    fn validate_skill_flags_missing_name_description_and_body() {
341        let dir = skill_dir("blank", "blank", "---\nlicense: MIT\n---\n\n");
342        let findings = validate_skill(&dir).unwrap();
343        assert!(findings
344            .iter()
345            .any(|f| f.message.contains("non-empty `name`")));
346        assert!(findings
347            .iter()
348            .any(|f| f.message.contains("non-empty `description`")));
349        assert!(findings
350            .iter()
351            .any(|f| f.message.contains("no instruction body")));
352    }
353
354    #[test]
355    fn validate_skill_flags_invalid_frontmatter_yaml() {
356        let dir = skill_dir("invalidyaml", "x", "---\nname: [unterminated\n---\nbody\n");
357        let findings = validate_skill(&dir).unwrap();
358        assert!(findings
359            .iter()
360            .any(|f| f.message.contains("not valid YAML")));
361    }
362
363    #[test]
364    fn validate_path_on_a_single_skill_dir() {
365        let dir = skill_dir(
366            "single",
367            "greeter",
368            "---\nname: greeter\ndescription: a long enough description\n---\nbody\n",
369        );
370        assert!(validate_path(&dir).unwrap().is_empty());
371    }
372
373    #[test]
374    fn validate_path_over_a_folder_of_skills() {
375        // Build a parent dir holding two skill subdirs (one good, one bad).
376        let good = skill_dir(
377            "folder-good",
378            "greeter",
379            "---\nname: greeter\ndescription: a long enough description\n---\nbody\n",
380        );
381        let parent = good.parent().unwrap().to_path_buf();
382        let bad = parent.join("broken");
383        std::fs::create_dir_all(&bad).unwrap();
384        std::fs::write(bad.join("SKILL.md"), "no frontmatter at all\n").unwrap();
385        let findings = validate_path(&parent).unwrap();
386        // The bad skill contributes a finding; the good one doesn't.
387        assert!(findings
388            .iter()
389            .any(|f| f.message.contains("no YAML frontmatter")));
390    }
391
392    #[test]
393    fn validate_path_reports_empty_folder() {
394        let root = std::env::temp_dir().join(format!("skilltest-noskills-{}", std::process::id()));
395        std::fs::create_dir_all(&root).unwrap();
396        let findings = validate_path(&root).unwrap();
397        assert_eq!(findings.len(), 1);
398        assert!(findings[0].message.contains("no skills found"));
399    }
400
401    #[test]
402    fn validate_path_missing_dir_is_io_error() {
403        let dir = std::env::temp_dir().join(format!("skilltest-nodir-{}", std::process::id()));
404        assert!(matches!(validate_path(&dir), Err(Error::Io { .. })));
405    }
406}