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}