use anyhow::{Context, Result};
use serde::de::DeserializeOwned;
pub fn split(text: &str) -> Option<(&str, &str)> {
let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
let (first, rest) = split_first_line(text)?;
if first.trim_end_matches('\r') != "---" {
return None;
}
let mut consumed = 0usize;
for line in rest.split_inclusive('\n') {
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed == "---" {
let yaml = &rest[..consumed];
let body = &rest[consumed + line.len()..];
return Some((yaml, body));
}
consumed += line.len();
}
None
}
fn split_first_line(s: &str) -> Option<(&str, &str)> {
s.find('\n').map(|i| (&s[..i], &s[i + 1..]))
}
pub fn parse<T: DeserializeOwned>(text: &str) -> Result<(T, &str)> {
let (yaml, body) =
split(text).context("missing YAML frontmatter (file must begin with `---` fence)")?;
let value: T = serde_yaml_ng::from_str(yaml).context("parsing YAML frontmatter")?;
Ok((value, body))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Agent, License, Mode, Rule, Skill, ToolCap};
#[test]
fn split_basic() {
let input = "---\nname: foo\n---\nbody text\n";
let (yaml, body) = split(input).unwrap();
assert_eq!(yaml, "name: foo\n");
assert_eq!(body, "body text\n");
}
#[test]
fn split_strips_bom() {
let input = "\u{FEFF}---\nname: foo\n---\nbody\n";
let (yaml, body) = split(input).unwrap();
assert_eq!(yaml, "name: foo\n");
assert_eq!(body, "body\n");
}
#[test]
fn split_empty_frontmatter() {
let input = "---\n---\nbody\n";
let (yaml, body) = split(input).unwrap();
assert_eq!(yaml, "");
assert_eq!(body, "body\n");
}
#[test]
fn split_no_fence_returns_none() {
assert!(split("# just markdown\n").is_none());
assert!(split("not a fence\n---\n").is_none());
assert!(split("----\nname: foo\n----\n").is_none());
}
#[test]
fn parse_rejects_missing_frontmatter() {
let result = parse::<Skill>("# just markdown\n");
assert!(result.is_err(), "expected error for missing frontmatter");
let msg = format!("{:#}", result.unwrap_err());
assert!(
msg.contains("missing YAML frontmatter"),
"unexpected message: {msg}"
);
}
#[test]
fn parse_rejects_schema_2() {
let input = concat!(
"---\n",
"schema: 2\n",
"name: too-new\n",
"description: from a future version\n",
"---\nbody\n"
);
let result = parse::<Skill>(input);
assert!(result.is_err(), "expected error for schema 2");
let msg = format!("{:#}", result.unwrap_err());
assert!(
msg.contains("schema version 2") && msg.contains("not supported"),
"unexpected message: {msg}"
);
}
#[test]
fn skill_appendix_a3_round_trip() {
let input = concat!(
"---\n",
"schema: 1\n",
"name: create-api-endpoint\n",
"description: Use when scaffolding new REST API endpoints with Zod validation\n",
"license: proprietary\n",
"metadata:\n",
" version: \"1.0.0\"\n",
" author: platform-api\n",
"---\n",
"body\n",
);
let (skill, body) = parse::<Skill>(input).unwrap();
assert_eq!(skill.schema.get(), 1);
assert_eq!(skill.name, "create-api-endpoint");
assert_eq!(skill.license, Some(License("proprietary".into())));
assert_eq!(body, "body\n");
let metadata = skill.metadata.as_ref().unwrap();
assert_eq!(metadata.version.as_deref(), Some("1.0.0"));
round_trip(&skill);
}
#[test]
fn rule_appendix_a2_round_trip() {
let input = concat!(
"---\n",
"schema: 1\n",
"name: api-conventions\n",
"description: Use when writing or modifying API handler files\n",
"license: proprietary\n",
"scope:\n",
" paths:\n",
" - \"src/api/**/*.ts\"\n",
" - \"src/handlers/**/*.ts\"\n",
"copilot:\n",
" excludeAgent: code-review\n",
"metadata:\n",
" version: \"2.1.0\"\n",
" author: platform-api\n",
"---\n",
"body\n",
);
let (rule, _body) = parse::<Rule>(input).unwrap();
assert_eq!(rule.name, "api-conventions");
let scope = rule.scope.as_ref().unwrap();
assert_eq!(scope.paths, vec!["src/api/**/*.ts", "src/handlers/**/*.ts"]);
assert!(rule.copilot.is_some(), "copilot passthrough must be parsed");
round_trip(&rule);
}
#[test]
fn agent_appendix_a4_round_trip() {
let input = concat!(
"---\n",
"schema: 1\n",
"name: security-reviewer\n",
"description: Use when reviewing code for injection flaws\n",
"license: proprietary\n",
"mode: subagent\n",
"model: sonnet\n",
"tools:\n",
" - read\n",
" - grep\n",
" - glob\n",
" - bash\n",
"preload-skills:\n",
" - security-baseline\n",
"opencode:\n",
" temperature: 0.2\n",
"metadata:\n",
" version: \"1.0.0\"\n",
" author: platform-security\n",
" audience:\n",
" - claude\n",
" - copilot\n",
" - opencode\n",
"---\n",
"body\n",
);
let (agent, _body) = parse::<Agent>(input).unwrap();
assert_eq!(agent.name, "security-reviewer");
assert_eq!(agent.mode, Some(Mode::Subagent));
assert_eq!(agent.model.as_deref(), Some("sonnet"));
assert_eq!(
agent.tools,
vec![ToolCap::Read, ToolCap::Grep, ToolCap::Glob, ToolCap::Bash]
);
assert_eq!(agent.preload_skills, vec!["security-baseline".to_string()]);
assert!(
agent.opencode.is_some(),
"opencode passthrough must be parsed"
);
round_trip(&agent);
}
#[test]
fn metadata_unknown_keys_preserved() {
let input = concat!(
"---\n",
"schema: 1\n",
"name: foo\n",
"description: test unknown keys\n",
"metadata:\n",
" version: \"1.0.0\"\n",
" author: bar\n",
" custom-key: custom-value\n",
" team:\n",
" name: alpha\n",
" lead: jdoe\n",
"---\n",
);
let (skill, _) = parse::<Skill>(input).unwrap();
let metadata = skill.metadata.as_ref().unwrap();
assert_eq!(metadata.version.as_deref(), Some("1.0.0"));
assert!(metadata.extra.contains_key("custom-key"));
assert!(metadata.extra.contains_key("team"));
round_trip(&skill);
}
fn round_trip<T>(value: &T)
where
T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
{
let yaml = serde_yaml_ng::to_string(value).expect("serialize");
let back: T = serde_yaml_ng::from_str(&yaml).expect("deserialize");
assert_eq!(value, &back, "round-trip mismatch");
}
}