1use std::path::{Path, PathBuf};
6
7use serde::Deserialize;
8
9use crate::error::{Error, Result};
10
11#[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#[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#[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
49fn 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 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 (None, text)
69}
70
71pub 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
102pub 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
184pub 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}