Skip to main content

zeroclaw_runtime/skills/
document.rs

1//! Parse and serialize canonical `SKILL.md` files.
2//!
3//! A [`SkillDocument`] is the on-disk pair of frontmatter and body. The
4//! splitter [`split_frontmatter`] is shared with the legacy `parse_skill_markdown`
5//! path in `super` so both readers see the same delimiter rules.
6
7use std::fmt::Write as _;
8
9use super::frontmatter::SkillFrontmatter;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct SkillDocument {
13    pub frontmatter: SkillFrontmatter,
14    pub body: String,
15}
16
17#[derive(Debug, thiserror::Error)]
18pub enum DocumentParseError {
19    #[error("SKILL.md is missing the leading `---` frontmatter delimiter")]
20    MissingFrontmatter,
21
22    #[error("SKILL.md frontmatter is missing required field `{0}`")]
23    MissingRequiredField(&'static str),
24}
25
26impl SkillDocument {
27    pub fn parse(content: &str) -> Result<Self, DocumentParseError> {
28        let (frontmatter_src, body) =
29            split_frontmatter(content).ok_or(DocumentParseError::MissingFrontmatter)?;
30        let frontmatter = parse_frontmatter(&frontmatter_src)?;
31        // Strip the conventional blank line that follows the closing `---`;
32        // callers see the body content directly.
33        let body = body.strip_prefix('\n').map(String::from).unwrap_or(body);
34        Ok(Self { frontmatter, body })
35    }
36
37    pub fn serialize(&self) -> String {
38        let mut out = String::with_capacity(self.body.len() + 256);
39        out.push_str("---\n");
40        write_field(&mut out, "name", &self.frontmatter.name);
41        write_block_scalar(&mut out, "description", &self.frontmatter.description);
42        write_optional(&mut out, "license", self.frontmatter.license.as_deref());
43        write_optional(&mut out, "author", self.frontmatter.author.as_deref());
44        write_optional(&mut out, "version", self.frontmatter.version.as_deref());
45        write_optional(&mut out, "category", self.frontmatter.category.as_deref());
46        out.push_str("---\n");
47        if !self.body.is_empty() {
48            if !self.body.starts_with('\n') {
49                out.push('\n');
50            }
51            out.push_str(&self.body);
52            if !self.body.ends_with('\n') {
53                out.push('\n');
54            }
55        }
56        out
57    }
58}
59
60/// Splits `---\n...\n---\n` from the body. Mirrors `super::split_skill_frontmatter`
61/// — extracted here so future readers don't drift on delimiter handling.
62pub fn split_frontmatter(content: &str) -> Option<(String, String)> {
63    let normalized = content.replace("\r\n", "\n");
64    let rest = normalized.strip_prefix("---\n")?;
65    if let Some(idx) = rest.find("\n---\n") {
66        return Some((rest[..idx].to_string(), rest[idx + 5..].to_string()));
67    }
68    if let Some(frontmatter) = rest.strip_suffix("\n---") {
69        return Some((frontmatter.to_string(), String::new()));
70    }
71    None
72}
73
74/// Flat `key: value` parser tightly typed to [`SkillFrontmatter`]. Handles
75/// inline strings and YAML block scalars (`>-`, `>`, `|`, `|-`) for
76/// `description`. Does not attempt nested mappings; the schema is flat by
77/// design.
78fn parse_frontmatter(src: &str) -> Result<SkillFrontmatter, DocumentParseError> {
79    let mut fm = SkillFrontmatter::default();
80    let mut multiline: Option<(String, Vec<String>)> = None;
81
82    let flush = |fm: &mut SkillFrontmatter, key: &str, parts: &[String]| {
83        let val = parts.join(" ");
84        let val = val.trim();
85        if val.is_empty() {
86            return;
87        }
88        assign(fm, key, val);
89    };
90
91    for line in src.lines() {
92        if let Some((ref key, ref mut parts)) = multiline {
93            if line.starts_with(' ') || line.starts_with('\t') {
94                parts.push(line.trim().to_string());
95                continue;
96            }
97            let (key_owned, parts_owned) = (key.clone(), std::mem::take(parts));
98            flush(&mut fm, &key_owned, &parts_owned);
99            multiline = None;
100        }
101        let Some((key, value)) = line.split_once(':') else {
102            continue;
103        };
104        let key = key.trim();
105        let value = value.trim().trim_matches('"').trim_matches('\'');
106        if matches!(value, ">-" | ">" | "|" | "|-") {
107            multiline = Some((key.to_string(), Vec::new()));
108            continue;
109        }
110        assign(&mut fm, key, value);
111    }
112    if let Some((key, parts)) = multiline {
113        flush(&mut fm, &key, &parts);
114    }
115
116    if fm.name.is_empty() {
117        return Err(DocumentParseError::MissingRequiredField("name"));
118    }
119    if fm.description.is_empty() {
120        return Err(DocumentParseError::MissingRequiredField("description"));
121    }
122    Ok(fm)
123}
124
125fn assign(fm: &mut SkillFrontmatter, key: &str, value: &str) {
126    match key {
127        "name" => fm.name = value.to_string(),
128        "description" => fm.description = value.to_string(),
129        "license" => fm.license = Some(value.to_string()),
130        "author" => fm.author = Some(value.to_string()),
131        "version" => fm.version = Some(value.to_string()),
132        "category" => fm.category = Some(value.to_string()),
133        _ => {}
134    }
135}
136
137fn write_field(out: &mut String, key: &str, value: &str) {
138    if value.contains('\n') {
139        write_block_scalar(out, key, value);
140    } else {
141        let _ = writeln!(out, "{key}: {value}");
142    }
143}
144
145fn write_block_scalar(out: &mut String, key: &str, value: &str) {
146    if value.contains('\n') || value.len() > 80 {
147        let _ = writeln!(out, "{key}: >-");
148        for line in value.split('\n') {
149            let _ = writeln!(out, "  {}", line.trim());
150        }
151    } else {
152        let _ = writeln!(out, "{key}: {value}");
153    }
154}
155
156fn write_optional(out: &mut String, key: &str, value: Option<&str>) {
157    if let Some(v) = value
158        && !v.is_empty()
159    {
160        write_field(out, key, v);
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn parses_minimal_canonical_frontmatter() {
170        let content = "---\nname: code-review\ndescription: Reviews PRs.\n---\n# Body\n";
171        let doc = SkillDocument::parse(content).unwrap();
172        assert_eq!(doc.frontmatter.name, "code-review");
173        assert_eq!(doc.frontmatter.description, "Reviews PRs.");
174        assert_eq!(doc.body, "# Body\n");
175    }
176
177    #[test]
178    fn parses_block_scalar_description() {
179        let content = "---\nname: x\ndescription: >-\n  multi-line\n  description text\n---\n";
180        let doc = SkillDocument::parse(content).unwrap();
181        assert_eq!(doc.frontmatter.description, "multi-line description text");
182    }
183
184    #[test]
185    fn parses_optional_flat_fields() {
186        let content = "---\nname: x\ndescription: y\nlicense: MIT\nauthor: alice\nversion: 0.1.0\ncategory: coding\n---\n";
187        let doc = SkillDocument::parse(content).unwrap();
188        assert_eq!(doc.frontmatter.license.as_deref(), Some("MIT"));
189        assert_eq!(doc.frontmatter.author.as_deref(), Some("alice"));
190        assert_eq!(doc.frontmatter.version.as_deref(), Some("0.1.0"));
191        assert_eq!(doc.frontmatter.category.as_deref(), Some("coding"));
192    }
193
194    #[test]
195    fn rejects_missing_required_name() {
196        let content = "---\ndescription: y\n---\n";
197        let err = SkillDocument::parse(content).unwrap_err();
198        assert!(matches!(
199            err,
200            DocumentParseError::MissingRequiredField("name")
201        ));
202    }
203
204    #[test]
205    fn rejects_missing_required_description() {
206        let content = "---\nname: x\n---\n";
207        let err = SkillDocument::parse(content).unwrap_err();
208        assert!(matches!(
209            err,
210            DocumentParseError::MissingRequiredField("description")
211        ));
212    }
213
214    #[test]
215    fn rejects_missing_frontmatter_delimiter() {
216        let content = "# No frontmatter\n";
217        let err = SkillDocument::parse(content).unwrap_err();
218        assert!(matches!(err, DocumentParseError::MissingFrontmatter));
219    }
220
221    #[test]
222    fn round_trips_minimal_document() {
223        let original = SkillDocument {
224            frontmatter: SkillFrontmatter {
225                name: "x".into(),
226                description: "y".into(),
227                ..Default::default()
228            },
229            body: "# X\n\nDoes X.\n".into(),
230        };
231        let serialized = original.serialize();
232        let parsed = SkillDocument::parse(&serialized).unwrap();
233        assert_eq!(parsed.frontmatter, original.frontmatter);
234        assert_eq!(parsed.body.trim_end(), original.body.trim_end());
235    }
236
237    #[test]
238    fn round_trips_with_optional_fields() {
239        let original = SkillDocument {
240            frontmatter: SkillFrontmatter {
241                name: "code-review".into(),
242                description: "Review pull requests for correctness, security, and style.".into(),
243                license: Some("MIT".into()),
244                author: Some("zeroclaw-labs".into()),
245                version: Some("0.2.0".into()),
246                category: Some("coding".into()),
247            },
248            body: "# Code Review\n\nReviews diffs.\n".into(),
249        };
250        let parsed = SkillDocument::parse(&original.serialize()).unwrap();
251        assert_eq!(parsed.frontmatter, original.frontmatter);
252    }
253}