use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::error::{Error, Result};
#[derive(Debug, Clone, Deserialize)]
pub struct Frontmatter {
pub name: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub license: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SkillDefinition {
pub dir: PathBuf,
pub name: String,
pub description: String,
pub instructions: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub skill: PathBuf,
pub message: String,
}
impl Finding {
fn new(skill: impl Into<PathBuf>, message: impl Into<String>) -> Self {
Self {
skill: skill.into(),
message: message.into(),
}
}
}
fn split_frontmatter(text: &str) -> (Option<&str>, &str) {
let rest = match text
.strip_prefix("---\n")
.or_else(|| text.strip_prefix("---\r\n"))
{
Some(rest) => rest,
None => return (None, text),
};
for sep in ["\n---\n", "\n---\r\n", "\r\n---\r\n"] {
if let Some(idx) = rest.find(sep) {
let fm = &rest[..idx];
let body = &rest[idx + sep.len()..];
return (Some(fm), body);
}
}
(None, text)
}
pub fn load_skill(dir: &Path) -> Result<SkillDefinition> {
let skill_md = dir.join("SKILL.md");
let text = std::fs::read_to_string(&skill_md).map_err(|source| Error::Io {
path: skill_md.clone(),
source,
})?;
let (fm, body) = split_frontmatter(&text);
let frontmatter: Frontmatter = match fm {
Some(fm) => serde_yaml::from_str(fm).map_err(|source| Error::Yaml {
path: skill_md.clone(),
source,
})?,
None => Frontmatter {
name: None,
description: None,
license: None,
},
};
Ok(SkillDefinition {
dir: dir.to_path_buf(),
name: frontmatter.name.unwrap_or_default(),
description: frontmatter.description.unwrap_or_default(),
instructions: body.trim().to_string(),
})
}
pub fn validate_skill(dir: &Path) -> Result<Vec<Finding>> {
let skill_md = dir.join("SKILL.md");
if !skill_md.is_file() {
return Ok(vec![Finding::new(
dir,
"missing SKILL.md (a skill is a directory containing SKILL.md)",
)]);
}
let text = std::fs::read_to_string(&skill_md).map_err(|source| Error::Io {
path: skill_md.clone(),
source,
})?;
let mut findings = Vec::new();
let (fm, body) = split_frontmatter(&text);
let Some(fm) = fm else {
findings.push(Finding::new(
dir,
"SKILL.md has no YAML frontmatter (expected a leading `---` fenced block)",
));
return Ok(findings);
};
match serde_yaml::from_str::<Frontmatter>(fm) {
Ok(frontmatter) => {
match frontmatter.name.as_deref().map(str::trim) {
None | Some("") => findings.push(Finding::new(
dir,
"frontmatter is missing a non-empty `name`",
)),
Some(name) => {
if let Some(folder) = dir.file_name().and_then(|s| s.to_str()) {
if folder != name {
findings.push(Finding::new(
dir,
format!(
"frontmatter `name` ({name}) does not match the directory name ({folder})"
),
));
}
}
}
}
match frontmatter.description.as_deref().map(str::trim) {
None | Some("") => findings.push(Finding::new(
dir,
"frontmatter is missing a non-empty `description`",
)),
Some(desc) if desc.len() < 16 => findings.push(Finding::new(
dir,
"frontmatter `description` is too short to be useful (< 16 chars)",
)),
Some(_) => {}
}
}
Err(source) => {
findings.push(Finding::new(
dir,
format!("frontmatter is not valid YAML: {source}"),
));
}
}
if body.trim().is_empty() {
findings.push(Finding::new(
dir,
"SKILL.md has no instruction body after the frontmatter",
));
}
Ok(findings)
}
pub fn validate_path(path: &Path) -> Result<Vec<Finding>> {
if path.join("SKILL.md").is_file() {
return validate_skill(path);
}
let entries = std::fs::read_dir(path).map_err(|source| Error::Io {
path: path.to_path_buf(),
source,
})?;
let mut skill_dirs: Vec<PathBuf> = entries
.filter_map(std::result::Result::ok)
.map(|e| e.path())
.filter(|p| p.is_dir() && p.join("SKILL.md").is_file())
.collect();
skill_dirs.sort();
if skill_dirs.is_empty() {
return Ok(vec![Finding::new(
path,
"no skills found (expected a SKILL.md here or in an immediate subdirectory)",
)]);
}
let mut findings = Vec::new();
for dir in skill_dirs {
findings.extend(validate_skill(&dir)?);
}
Ok(findings)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_frontmatter_and_body() {
let text = "---\nname: greeter\ndescription: hi\n---\nBody here\n";
let (fm, body) = split_frontmatter(text);
assert_eq!(fm, Some("name: greeter\ndescription: hi"));
assert_eq!(body, "Body here\n");
}
#[test]
fn no_frontmatter_returns_none() {
let (fm, body) = split_frontmatter("# Just a heading\n");
assert!(fm.is_none());
assert_eq!(body, "# Just a heading\n");
}
}