1use indexmap::{IndexMap, IndexSet};
2use serde_yaml::{Mapping, Value};
3
4#[derive(Debug, Clone)]
6pub struct Frontmatter {
7 yaml: Mapping,
8 body: String,
9 has_frontmatter: bool,
10}
11
12#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
18pub struct SkillsSpec {
19 pub load: Vec<String>,
20 pub available: Vec<String>,
21}
22
23impl SkillsSpec {
24 pub fn all(&self) -> Vec<String> {
25 self.load
26 .iter()
27 .chain(self.available.iter())
28 .cloned()
29 .collect()
30 }
31}
32
33#[derive(Debug, thiserror::Error)]
35pub enum FrontmatterError {
36 #[error("malformed YAML frontmatter: {0}")]
37 MalformedYaml(#[from] serde_yaml::Error),
38
39 #[error("frontmatter is not a YAML mapping")]
40 NotAMapping,
41}
42
43pub fn parse(content: &str) -> Result<Frontmatter, FrontmatterError> {
45 Frontmatter::parse(content)
46}
47
48impl Frontmatter {
49 pub fn parse(content: &str) -> Result<Self, FrontmatterError> {
51 let (first_line, after_first_line) = split_first_line(content);
52 if !is_delimiter_line(first_line) {
53 return Ok(Self {
54 yaml: Mapping::new(),
55 body: content.to_string(),
56 has_frontmatter: false,
57 });
58 }
59
60 let mut yaml_end = None;
61 let mut offset = 0usize;
62 for line in after_first_line.split_inclusive('\n') {
63 if is_delimiter_line(line) {
64 yaml_end = Some((offset, line.len()));
65 break;
66 }
67 offset += line.len();
68 }
69
70 let Some((yaml_len, closing_len)) = yaml_end else {
71 return Ok(Self {
72 yaml: Mapping::new(),
73 body: content.to_string(),
74 has_frontmatter: false,
75 });
76 };
77
78 let yaml_text = &after_first_line[..yaml_len];
79 let body_start = yaml_len + closing_len;
80 let body = after_first_line[body_start..].to_string();
81
82 if yaml_text.trim().is_empty() {
83 return Ok(Self {
84 yaml: Mapping::new(),
85 body,
86 has_frontmatter: true,
87 });
88 }
89
90 let value: Value = serde_yaml::from_str(yaml_text)?;
91 let yaml = match value {
92 Value::Mapping(mapping) => mapping,
93 Value::Null => Mapping::new(),
94 _ => return Err(FrontmatterError::NotAMapping),
95 };
96
97 Ok(Self {
98 yaml,
99 body,
100 has_frontmatter: true,
101 })
102 }
103
104 pub fn skills(&self) -> Vec<String> {
106 self.skills_structured().all()
107 }
108
109 pub fn skills_structured(&self) -> SkillsSpec {
111 match self.get("skills") {
112 Some(Value::Mapping(mapping)) => SkillsSpec {
113 load: mapping
114 .get(yaml_key("load"))
115 .map(yaml_str_list)
116 .unwrap_or_default(),
117 available: mapping
118 .get(yaml_key("available"))
119 .map(yaml_str_list)
120 .unwrap_or_default(),
121 },
122 Some(value) => SkillsSpec {
123 load: yaml_str_list(value),
124 available: Vec::new(),
125 },
126 None => SkillsSpec::default(),
127 }
128 }
129
130 pub fn name(&self) -> Option<&str> {
132 self.get("name").and_then(Value::as_str)
133 }
134
135 pub fn get(&self, key: &str) -> Option<&Value> {
137 self.yaml.get(yaml_key(key))
138 }
139
140 pub fn body(&self) -> &str {
142 &self.body
143 }
144
145 pub fn has_frontmatter(&self) -> bool {
147 self.has_frontmatter
148 }
149
150 pub fn keys(&self) -> Vec<String> {
152 self.yaml
153 .keys()
154 .filter_map(|k| k.as_str().map(str::to_owned))
155 .collect()
156 }
157
158 pub fn insert(&mut self, key: &str, value: Value) {
160 self.has_frontmatter = true;
161 self.yaml.insert(yaml_key(key), value);
162 }
163
164 pub fn remove(&mut self, key: &str) -> Option<Value> {
166 self.yaml.remove(yaml_key(key))
167 }
168
169 pub fn render(&self) -> String {
171 if !self.has_frontmatter && self.yaml.is_empty() {
172 return self.body.clone();
173 }
174
175 let mut out = String::from("---\n");
176 if !self.yaml.is_empty() {
177 let mut yaml = serde_yaml::to_string(&self.yaml)
178 .expect("serializing frontmatter mapping should succeed");
179 if let Some(stripped) = yaml.strip_prefix("---\n") {
180 yaml = stripped.to_string();
181 }
182 out.push_str(&yaml);
183 if !yaml.ends_with('\n') {
184 out.push('\n');
185 }
186 }
187 out.push_str("---\n");
188 out.push_str(&self.body);
189 out
190 }
191}
192
193pub fn rewrite_skills(
195 fm: &mut Frontmatter,
196 renames: &IndexMap<String, String>,
197) -> IndexSet<String> {
198 let mut renamed = IndexSet::new();
199 let key = yaml_key("skills");
200 if let Some(value) = fm.yaml.get_mut(&key) {
201 rewrite_skill_value(value, renames, &mut renamed);
202 }
203
204 renamed
205}
206
207pub fn rewrite_subagents(
209 fm: &mut Frontmatter,
210 renames: &IndexMap<String, String>,
211) -> IndexSet<String> {
212 rewrite_string_list_field(fm, "subagents", renames)
213}
214
215pub fn rewrite_content_skills(
217 content: &str,
218 renames: &IndexMap<String, String>,
219) -> Result<Option<String>, FrontmatterError> {
220 let mut fm = Frontmatter::parse(content)?;
221 let renamed = rewrite_skills(&mut fm, renames);
222 if renamed.is_empty() {
223 Ok(None)
224 } else {
225 Ok(Some(fm.render()))
226 }
227}
228
229pub fn rewrite_content_subagents(
231 content: &str,
232 renames: &IndexMap<String, String>,
233) -> Result<Option<String>, FrontmatterError> {
234 let mut fm = Frontmatter::parse(content)?;
235 let renamed = rewrite_subagents(&mut fm, renames);
236 if renamed.is_empty() {
237 Ok(None)
238 } else {
239 Ok(Some(fm.render()))
240 }
241}
242
243fn rewrite_string_list_field(
244 fm: &mut Frontmatter,
245 field_name: &str,
246 renames: &IndexMap<String, String>,
247) -> IndexSet<String> {
248 let mut renamed = IndexSet::new();
249 let key = yaml_key(field_name);
250 if let Some(value) = fm.yaml.get_mut(&key) {
251 rewrite_string_list_value(value, renames, &mut renamed);
252 }
253
254 renamed
255}
256
257fn yaml_str_list(val: &Value) -> Vec<String> {
258 match val {
259 Value::Sequence(seq) => seq
260 .iter()
261 .filter_map(Value::as_str)
262 .map(str::to_owned)
263 .collect(),
264 Value::String(s) => vec![s.clone()],
265 _ => vec![],
266 }
267}
268
269fn rewrite_skill_value(
270 value: &mut Value,
271 renames: &IndexMap<String, String>,
272 renamed: &mut IndexSet<String>,
273) {
274 match value {
275 Value::Sequence(_) | Value::String(_) => rewrite_string_list_value(value, renames, renamed),
276 Value::Mapping(mapping) => {
277 for field in ["load", "available"] {
278 if let Some(child) = mapping.get_mut(yaml_key(field)) {
279 rewrite_skill_value(child, renames, renamed);
280 }
281 }
282 }
283 _ => {}
284 }
285}
286
287fn rewrite_string_list_value(
288 value: &mut Value,
289 renames: &IndexMap<String, String>,
290 renamed: &mut IndexSet<String>,
291) {
292 match value {
293 Value::Sequence(seq) => {
294 for item in seq {
295 let Some(name) = item.as_str() else {
296 continue;
297 };
298 if let Some(new_name) = renames.get(name) {
299 renamed.insert(name.to_string());
300 *item = Value::String(new_name.clone());
301 }
302 }
303 }
304 Value::String(name) => {
305 if let Some(new_name) = renames.get(name.as_str()) {
306 renamed.insert(name.clone());
307 *name = new_name.clone();
308 }
309 }
310 _ => {}
311 }
312}
313
314fn split_first_line(content: &str) -> (&str, &str) {
315 match content.split_once('\n') {
316 Some((first, rest)) => (first, rest),
317 None => (content, ""),
318 }
319}
320
321fn is_delimiter_line(line: &str) -> bool {
322 line.trim_end() == "---"
323}
324
325fn yaml_key(key: &str) -> Value {
326 Value::String(key.to_string())
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn parse_and_render_roundtrip() {
335 let input = "---\nname: coder\nskills:\n- plan\n- review\n---\n# Body\ntext";
336 let fm = Frontmatter::parse(input).unwrap();
337 assert_eq!(fm.name(), Some("coder"));
338 assert_eq!(fm.skills(), vec!["plan", "review"]);
339 assert_eq!(fm.body(), "# Body\ntext");
340 assert!(fm.has_frontmatter());
341
342 let rendered = fm.render();
343 let reparsed = Frontmatter::parse(&rendered).unwrap();
344 assert_eq!(reparsed.name(), Some("coder"));
345 assert_eq!(reparsed.skills(), vec!["plan", "review"]);
346 assert_eq!(reparsed.body(), "# Body\ntext");
347 }
348
349 #[test]
350 fn parse_without_frontmatter_keeps_body() {
351 let input = "# Markdown only\ntext";
352 let fm = parse(input).unwrap();
353 assert!(!fm.has_frontmatter());
354 assert!(fm.skills().is_empty());
355 assert_eq!(fm.body(), input);
356 assert_eq!(fm.render(), input);
357 }
358
359 #[test]
360 fn parse_empty_frontmatter_roundtrips_delimiters() {
361 let input = "---\n---\nbody";
362 let fm = Frontmatter::parse(input).unwrap();
363 assert!(fm.has_frontmatter());
364 assert!(fm.skills().is_empty());
365 assert_eq!(fm.body(), "body");
366 assert_eq!(fm.render(), input);
367 }
368
369 #[test]
370 fn parse_malformed_yaml_errors() {
371 let input = "---\ninvalid: [:\n---\nbody";
372 assert!(matches!(
373 Frontmatter::parse(input),
374 Err(FrontmatterError::MalformedYaml(_))
375 ));
376 }
377
378 #[test]
379 fn parse_flow_style_skills() {
380 let input = "---\nskills: [plan, review]\n---\nbody";
381 let fm = Frontmatter::parse(input).unwrap();
382 assert_eq!(fm.skills(), vec!["plan", "review"]);
383 assert_eq!(fm.skills_structured().load, vec!["plan", "review"]);
384 assert!(fm.skills_structured().available.is_empty());
385 }
386
387 #[test]
388 fn parse_structured_skills() {
389 let input = "---\nskills:\n load: [principles]\n available:\n - planning\n - spawn\n---\nbody";
390 let fm = Frontmatter::parse(input).unwrap();
391 assert_eq!(fm.skills(), vec!["principles", "planning", "spawn"]);
392 assert_eq!(fm.skills_structured().load, vec!["principles"]);
393 assert_eq!(fm.skills_structured().available, vec!["planning", "spawn"]);
394 }
395
396 #[test]
397 fn rewrite_structured_skills_preserves_split() {
398 let input = "---\nskills:\n load: [plan]\n available: [review]\n---\nbody\n";
399 let renames = IndexMap::from([
400 ("plan".to_string(), "plan__org".to_string()),
401 ("review".to_string(), "review__org".to_string()),
402 ]);
403
404 let rewritten = rewrite_content_skills(input, &renames).unwrap().unwrap();
405 let fm = Frontmatter::parse(&rewritten).unwrap();
406 assert_eq!(fm.skills_structured().load, vec!["plan__org"]);
407 assert_eq!(fm.skills_structured().available, vec!["review__org"]);
408 }
409
410 #[test]
411 fn rewrite_does_not_corrupt_substrings() {
412 let input = "---\nskills:\n- plan\n- planner\n- planning-extended\n---\nbody\n";
413 let renames = IndexMap::from([(
414 "plan".to_string(),
415 "plan__meridian-flow_meridian-base".to_string(),
416 )]);
417
418 let rewritten = rewrite_content_skills(input, &renames).unwrap().unwrap();
419 let fm = Frontmatter::parse(&rewritten).unwrap();
420 assert_eq!(
421 fm.skills(),
422 vec![
423 "plan__meridian-flow_meridian-base",
424 "planner",
425 "planning-extended"
426 ]
427 );
428 }
429
430 #[test]
431 fn rewrite_subagents_uses_exact_matches() {
432 let input = "---\nsubagents:\n- web-researcher\n- web\n---\nbody\n";
433 let renames = IndexMap::from([(
434 "web-researcher".to_string(),
435 "web-researcher__source-a".to_string(),
436 )]);
437
438 let rewritten = rewrite_content_subagents(input, &renames).unwrap().unwrap();
439 let fm = Frontmatter::parse(&rewritten).unwrap();
440 assert_eq!(
441 fm.get("subagents").map(yaml_str_list).unwrap(),
442 vec!["web-researcher__source-a", "web"]
443 );
444 }
445}