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
240 #[test]
241 fn unclosed_fence_yields_no_frontmatter() {
242 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 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 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 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}