use crate::error::{Result, SkillcError};
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Frontmatter {
pub name: String,
pub description: String,
#[serde(default)]
pub allowed_tools: Option<String>,
#[serde(flatten)]
pub extra: HashMap<String, serde_yaml::Value>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct RawFrontmatter {
pub name: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub allowed_tools: Option<String>,
#[serde(flatten)]
pub extra: HashMap<String, serde_yaml::Value>,
}
#[derive(Debug)]
pub struct ParseResult {
pub frontmatter: Option<RawFrontmatter>,
pub valid_delimiters: bool,
pub yaml_content: Option<String>,
}
fn extract_yaml_block(content: &str) -> Option<String> {
let after_open = content
.strip_prefix("---\r\n")
.or_else(|| content.strip_prefix("---\n"))?;
let close_pos = after_open.find("\n---")?;
Some(after_open[..close_pos].to_string())
}
pub fn parse_lenient(content: &str) -> ParseResult {
let yaml_content = extract_yaml_block(content);
let Some(yaml) = &yaml_content else {
return ParseResult {
frontmatter: None,
valid_delimiters: false,
yaml_content: None,
};
};
let frontmatter = serde_yaml::from_str(yaml).ok();
ParseResult {
frontmatter,
valid_delimiters: true,
yaml_content,
}
}
pub fn parse(content: &str) -> Result<Frontmatter> {
let yaml_content = extract_yaml_block(content).ok_or_else(|| {
if !content.starts_with("---") {
SkillcError::InvalidFrontmatter("File must start with ---".to_string())
} else {
SkillcError::InvalidFrontmatter("No closing --- found".to_string())
}
})?;
let frontmatter: Frontmatter = serde_yaml::from_str(&yaml_content)?;
if frontmatter.name.is_empty() {
return Err(SkillcError::MissingFrontmatterField("name".to_string()));
}
if frontmatter.description.is_empty() {
return Err(SkillcError::MissingFrontmatterField(
"description".to_string(),
));
}
Ok(frontmatter)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid() {
let content = r#"---
name: my-skill
description: A test skill
---
# Content
"#;
let fm = parse(content).expect("parse frontmatter");
assert_eq!(fm.name, "my-skill");
assert_eq!(fm.description, "A test skill");
}
#[test]
fn test_parse_with_allowed_tools() {
let content = r#"---
name: my-skill
description: A test skill
allowed-tools: Read, Write
---
"#;
let fm = parse(content).expect("parse frontmatter");
assert_eq!(fm.allowed_tools, Some("Read, Write".to_string()));
}
#[test]
fn test_parse_missing_open() {
let content = "# No frontmatter";
let result = parse(content);
assert!(result.is_err());
}
#[test]
fn test_parse_missing_close() {
let content = "---\nname: test\n# No close";
let result = parse(content);
assert!(result.is_err());
}
#[test]
fn test_parse_missing_name() {
let content = r#"---
description: A test skill
---
"#;
let result = parse(content);
assert!(result.is_err());
}
#[test]
fn test_parse_lenient_missing_name() {
let content = r#"---
description: A test skill
---
"#;
let result = parse_lenient(content);
assert!(result.valid_delimiters);
let fm = result.frontmatter.expect("should parse");
assert!(fm.name.is_none());
assert_eq!(fm.description, Some("A test skill".to_string()));
}
#[test]
fn test_parse_lenient_invalid_delimiters() {
let content = "# No frontmatter";
let result = parse_lenient(content);
assert!(!result.valid_delimiters);
assert!(result.frontmatter.is_none());
}
#[test]
fn test_parse_extra_fields() {
let content = r#"---
name: my-skill
description: A test skill
unknown-field: value
---
"#;
let fm = parse(content).expect("parse frontmatter");
assert!(fm.extra.contains_key("unknown-field"));
}
}