1use 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 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
60pub 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
74fn 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}