use anyhow::{Context, Result};
use serde::Deserialize;
use std::path::Path;
#[derive(Debug, Deserialize)]
struct SkillFrontmatter {
name: String,
description: Option<String>,
triggers: Vec<String>,
}
pub fn parse_skill_file(path: &Path, content: &str) -> Result<super::Skill> {
let parts: Vec<&str> = content.splitn(3, "---").collect();
if parts.len() < 3 {
anyhow::bail!("Invalid SKILL.md format: missing YAML frontmatter delimiters");
}
let frontmatter: SkillFrontmatter =
serde_yaml::from_str(parts[1]).context("Failed to parse YAML frontmatter")?;
if frontmatter.name.is_empty() {
anyhow::bail!("Skill name cannot be empty");
}
if frontmatter.triggers.is_empty() {
anyhow::bail!("Skill must have at least one trigger");
}
Ok(super::Skill {
name: frontmatter.name,
description: frontmatter.description,
triggers: frontmatter.triggers,
content: parts[2].trim().to_string(),
source_path: path.to_path_buf(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::path::PathBuf;
proptest! {
#[test]
fn parse_never_panics(content in "\\PC{0,500}") {
let path = PathBuf::from("/test/SKILL.md");
let _ = parse_skill_file(&path, &content);
}
#[test]
fn valid_frontmatter_round_trips(
name in "[a-z][a-z0-9]{1,20}",
trigger in "[a-z]{1,30}",
body in "[a-zA-Z0-9 ]{0,100}",
) {
let trigger_yaml = serde_yaml::to_string(&trigger).unwrap();
let content = format!(
"---\nname: {name}\ntriggers:\n - {trigger_yaml}---\n{body}"
);
let path = PathBuf::from("/test/SKILL.md");
let skill = parse_skill_file(&path, &content)
.expect("valid frontmatter should parse");
prop_assert_eq!(&skill.name, &name);
prop_assert_eq!(&skill.triggers[0], &trigger);
prop_assert_eq!(&skill.source_path, &path);
}
#[test]
fn parsed_name_is_never_empty(
name in "[a-z][a-z0-9]{1,20}",
trigger in "[a-z]{1,10}",
) {
let trigger_yaml = serde_yaml::to_string(&trigger).unwrap();
let content = format!(
"---\nname: {name}\ntriggers:\n - {trigger_yaml}---\ncontent"
);
let path = PathBuf::from("/test/SKILL.md");
let skill = parse_skill_file(&path, &content).unwrap();
prop_assert!(!skill.name.is_empty());
prop_assert!(!skill.triggers.is_empty());
}
}
#[test]
fn test_parse_valid_skill_with_description() {
let content = r#"---
name: rust-testing
description: Guide for writing Rust tests
triggers:
- "rust test"
- "cargo test"
---
# Rust Testing
This is the skill content.
"#;
let path = PathBuf::from("/test/rust-testing/SKILL.md");
let skill = parse_skill_file(&path, content).unwrap();
assert_eq!(skill.name, "rust-testing");
assert_eq!(
skill.description,
Some("Guide for writing Rust tests".to_string())
);
assert_eq!(skill.triggers, vec!["rust test", "cargo test"]);
assert_eq!(
skill.content,
"# Rust Testing\n\nThis is the skill content."
);
assert_eq!(skill.source_path, path);
}
#[test]
fn test_parse_skill_without_description() {
let content = r#"---
name: minimal-skill
triggers:
- "test"
---
# Minimal Skill
Content only.
"#;
let path = PathBuf::from("/test/minimal/SKILL.md");
let skill = parse_skill_file(&path, content).unwrap();
assert_eq!(skill.name, "minimal-skill");
assert_eq!(skill.description, None);
assert_eq!(skill.triggers, vec!["test"]);
assert_eq!(skill.content, "# Minimal Skill\n\nContent only.");
}
#[test]
fn test_parse_missing_frontmatter() {
let content = "# Just Content\n\nNo frontmatter.";
let path = PathBuf::from("/test/SKILL.md");
let result = parse_skill_file(&path, content);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("missing YAML frontmatter")
);
}
#[test]
fn test_parse_empty_name() {
let content = r#"---
name: ""
triggers:
- "test"
---
Content
"#;
let path = PathBuf::from("/test/SKILL.md");
let result = parse_skill_file(&path, content);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("name cannot be empty")
);
}
#[test]
fn test_parse_no_triggers() {
let content = r#"---
name: test-skill
triggers: []
---
Content
"#;
let path = PathBuf::from("/test/SKILL.md");
let result = parse_skill_file(&path, content);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("at least one trigger")
);
}
}