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 has_bom: bool,
11}
12
13#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
19pub struct SkillsSpec {
20 pub load: Vec<String>,
21 pub available: Vec<String>,
22}
23
24impl SkillsSpec {
25 pub fn all(&self) -> Vec<String> {
26 self.load
27 .iter()
28 .chain(self.available.iter())
29 .cloned()
30 .collect()
31 }
32}
33
34pub const MAX_FRONTMATTER_BYTES: usize = 64 * 1024;
38
39pub const MAX_FLOW_DEPTH: usize = 64;
43
44#[derive(Debug, thiserror::Error)]
46pub enum FrontmatterError {
47 #[error("frontmatter is {0} bytes; the limit is {MAX_FRONTMATTER_BYTES}")]
48 TooLarge(usize),
49
50 #[error("frontmatter nests `[` or `{{` more than {MAX_FLOW_DEPTH} deep")]
51 TooDeep,
52
53 #[error("malformed YAML frontmatter: {0}")]
54 MalformedYaml(#[from] serde_yaml::Error),
55
56 #[error("frontmatter is not closed with an exact `---` line")]
57 Unclosed,
58
59 #[error("YAML anchors and aliases are not supported")]
60 Anchor,
61
62 #[error("YAML document-end markers are not supported")]
63 DocumentEnd,
64
65 #[error(
66 "YAML syntax accepted by the semantic parser is unsupported by the structural scanner: {0}"
67 )]
68 UnsupportedSyntax(String),
69
70 #[error("frontmatter is not a YAML mapping")]
71 NotAMapping,
72}
73
74pub fn parse(content: &str) -> Result<Frontmatter, FrontmatterError> {
76 Frontmatter::parse(content)
77}
78
79impl Frontmatter {
80 pub fn parse(content: &str) -> Result<Self, FrontmatterError> {
82 let (frontmatter, mut structural_errors) = Self::parse_preserving_structure(content)?;
83 if structural_errors.is_empty() {
84 Ok(frontmatter)
85 } else {
86 Err(structural_errors.remove(0))
87 }
88 }
89
90 pub(crate) fn parse_preserving_structure(
95 content: &str,
96 ) -> Result<(Self, Vec<FrontmatterError>), FrontmatterError> {
97 let Some(regions) = split_regions(content)? else {
98 return Ok((
99 Self {
100 yaml: Mapping::new(),
101 body: content.to_string(),
102 has_frontmatter: false,
103 has_bom: false,
104 },
105 Vec::new(),
106 ));
107 };
108
109 let yaml_text = regions.yaml;
110 if yaml_text.trim().is_empty() {
111 return Ok((
112 Self {
113 yaml: Mapping::new(),
114 body: regions.body.to_string(),
115 has_frontmatter: true,
116 has_bom: regions.has_bom,
117 },
118 Vec::new(),
119 ));
120 }
121
122 let value: Value = serde_yaml::from_str(yaml_text)?;
123 let structural_errors = validate_yaml_structure(yaml_text);
124 let yaml = match value {
125 Value::Mapping(mapping) => mapping,
126 Value::Null => Mapping::new(),
127 _ => return Err(FrontmatterError::NotAMapping),
128 };
129
130 Ok((
131 Self {
132 yaml,
133 body: regions.body.to_string(),
134 has_frontmatter: true,
135 has_bom: regions.has_bom,
136 },
137 structural_errors,
138 ))
139 }
140
141 pub fn skills(&self) -> Vec<String> {
143 self.skills_structured().all()
144 }
145
146 pub fn skills_structured(&self) -> SkillsSpec {
148 match self.get("skills") {
149 Some(Value::Mapping(mapping)) => SkillsSpec {
150 load: mapping
151 .get(yaml_key("load"))
152 .map(yaml_str_list)
153 .unwrap_or_default(),
154 available: mapping
155 .get(yaml_key("available"))
156 .map(yaml_str_list)
157 .unwrap_or_default(),
158 },
159 Some(value) => SkillsSpec {
160 load: yaml_str_list(value),
161 available: Vec::new(),
162 },
163 None => SkillsSpec::default(),
164 }
165 }
166
167 pub fn name(&self) -> Option<&str> {
169 self.get("name").and_then(Value::as_str)
170 }
171
172 pub fn get(&self, key: &str) -> Option<&Value> {
174 self.yaml.get(yaml_key(key))
175 }
176
177 pub fn get_mut(&mut self, key: &str) -> Option<&mut Value> {
179 self.yaml.get_mut(yaml_key(key))
180 }
181
182 pub fn body(&self) -> &str {
184 &self.body
185 }
186
187 pub fn set_body(&mut self, body: String) {
189 self.body = body;
190 }
191
192 pub fn has_frontmatter(&self) -> bool {
194 self.has_frontmatter
195 }
196
197 pub fn keys(&self) -> Vec<String> {
199 self.yaml
200 .keys()
201 .filter_map(|k| k.as_str().map(str::to_owned))
202 .collect()
203 }
204
205 pub(crate) fn mapping(&self) -> &Mapping {
207 &self.yaml
208 }
209
210 pub fn insert(&mut self, key: &str, value: Value) {
212 self.has_frontmatter = true;
213 self.yaml.insert(yaml_key(key), value);
214 }
215
216 pub fn remove(&mut self, key: &str) -> Option<Value> {
218 self.yaml.shift_remove(yaml_key(key))
219 }
220
221 pub fn render(&self) -> String {
223 if !self.has_frontmatter && self.yaml.is_empty() {
224 return self.body.clone();
225 }
226 let mut out = self.render_frontmatter();
227 out.push_str(&self.body);
228 out
229 }
230
231 pub(crate) fn render_frontmatter(&self) -> String {
236 if !self.has_frontmatter && self.yaml.is_empty() {
237 return String::new();
238 }
239
240 let mut out = if self.has_bom {
241 String::from("\u{feff}---\n")
242 } else {
243 String::from("---\n")
244 };
245 if !self.yaml.is_empty() {
246 let mut yaml = serde_yaml::to_string(&self.yaml)
247 .expect("serializing frontmatter mapping should succeed");
248 if let Some(stripped) = yaml.strip_prefix("---\n") {
249 yaml = stripped.to_string();
250 }
251 out.push_str(&yaml);
252 if !yaml.ends_with('\n') {
253 out.push('\n');
254 }
255 }
256 out.push_str("---\n");
257 out
258 }
259}
260
261fn rewrite_skills(fm: &mut Frontmatter, renames: &IndexMap<String, String>) -> IndexSet<String> {
263 let mut renamed = IndexSet::new();
264 let key = yaml_key("skills");
265 if let Some(value) = fm.yaml.get_mut(&key) {
266 rewrite_skill_value(value, renames, &mut renamed);
267 }
268
269 renamed
270}
271
272fn rewrite_subagents(fm: &mut Frontmatter, renames: &IndexMap<String, String>) -> IndexSet<String> {
274 rewrite_string_list_field(fm, "subagents", renames)
275}
276
277pub fn rewrite_content_skills(
279 content: &str,
280 renames: &IndexMap<String, String>,
281) -> Result<Option<String>, FrontmatterError> {
282 let mut fm = Frontmatter::parse(content)?;
283 let renamed = rewrite_skills(&mut fm, renames);
284 if renamed.is_empty() {
285 Ok(None)
286 } else {
287 Ok(Some(fm.render()))
288 }
289}
290
291pub fn rewrite_content_subagents(
293 content: &str,
294 renames: &IndexMap<String, String>,
295) -> Result<Option<String>, FrontmatterError> {
296 let mut fm = Frontmatter::parse(content)?;
297 let renamed = rewrite_subagents(&mut fm, renames);
298 if renamed.is_empty() {
299 Ok(None)
300 } else {
301 Ok(Some(fm.render()))
302 }
303}
304
305fn rewrite_string_list_field(
306 fm: &mut Frontmatter,
307 field_name: &str,
308 renames: &IndexMap<String, String>,
309) -> IndexSet<String> {
310 let mut renamed = IndexSet::new();
311 let key = yaml_key(field_name);
312 if let Some(value) = fm.yaml.get_mut(&key) {
313 rewrite_string_list_value(value, renames, &mut renamed);
314 }
315
316 renamed
317}
318
319fn yaml_str_list(val: &Value) -> Vec<String> {
320 match val {
321 Value::Sequence(seq) => seq
322 .iter()
323 .filter_map(Value::as_str)
324 .map(str::to_owned)
325 .collect(),
326 Value::String(s) => vec![s.clone()],
327 _ => vec![],
328 }
329}
330
331fn rewrite_skill_value(
332 value: &mut Value,
333 renames: &IndexMap<String, String>,
334 renamed: &mut IndexSet<String>,
335) {
336 match value {
337 Value::Sequence(_) | Value::String(_) => rewrite_string_list_value(value, renames, renamed),
338 Value::Mapping(mapping) => {
339 for field in ["load", "available"] {
340 if let Some(child) = mapping.get_mut(yaml_key(field)) {
341 rewrite_skill_value(child, renames, renamed);
342 }
343 }
344 }
345 _ => {}
346 }
347}
348
349fn rewrite_string_list_value(
350 value: &mut Value,
351 renames: &IndexMap<String, String>,
352 renamed: &mut IndexSet<String>,
353) {
354 match value {
355 Value::Sequence(seq) => {
356 for item in seq {
357 let Some(name) = item.as_str() else {
358 continue;
359 };
360 if let Some(new_name) = renames.get(name) {
361 renamed.insert(name.to_string());
362 *item = Value::String(new_name.clone());
363 }
364 }
365 }
366 Value::String(name) => {
367 if let Some(new_name) = renames.get(name.as_str()) {
368 renamed.insert(name.clone());
369 *name = new_name.clone();
370 }
371 }
372 _ => {}
373 }
374}
375
376fn exceeds_flow_depth(yaml: &str) -> bool {
377 let mut depth = 0usize;
378 for byte in yaml.bytes() {
379 match byte {
380 b'[' | b'{' => {
381 depth += 1;
382 if depth > MAX_FLOW_DEPTH {
383 return true;
384 }
385 }
386 b']' | b'}' => depth = depth.saturating_sub(1),
387 _ => {}
388 }
389 }
390 false
391}
392
393struct Regions<'a> {
396 has_bom: bool,
397 yaml_start: usize,
399 yaml: &'a str,
400 body: &'a str,
401}
402
403fn split_regions(content: &str) -> Result<Option<Regions<'_>>, FrontmatterError> {
405 let (has_bom, document) = content
406 .strip_prefix('\u{feff}')
407 .map_or((false, content), |document| (true, document));
408 let (first_line, after_first_line) = split_first_line(document);
409 if !is_delimiter_line(first_line) {
410 return Ok(None);
411 }
412
413 let mut yaml_end = None;
414 let mut offset = 0usize;
415 for line in after_first_line.split_inclusive('\n') {
416 if is_delimiter_line(line) {
417 yaml_end = Some((offset, line.len()));
418 break;
419 }
420 offset += line.len();
421 }
422
423 let Some((yaml_len, closing_len)) = yaml_end else {
424 return Err(FrontmatterError::Unclosed);
425 };
426
427 if yaml_len > MAX_FRONTMATTER_BYTES {
428 return Err(FrontmatterError::TooLarge(yaml_len));
429 }
430 let yaml = &after_first_line[..yaml_len];
431 if exceeds_flow_depth(yaml) {
432 return Err(FrontmatterError::TooDeep);
433 }
434 Ok(Some(Regions {
435 has_bom,
436 yaml_start: content.len() - after_first_line.len(),
437 yaml,
438 body: &after_first_line[yaml_len + closing_len..],
439 }))
440}
441
442pub(crate) fn yaml_text(content: &str) -> Result<Option<(&str, usize)>, FrontmatterError> {
444 Ok(split_regions(content)?.map(|regions| (regions.yaml, regions.yaml_start)))
445}
446
447fn split_first_line(content: &str) -> (&str, &str) {
448 match content.split_once('\n') {
449 Some((first, rest)) => (first, rest),
450 None => (content, ""),
451 }
452}
453
454fn is_delimiter_line(line: &str) -> bool {
455 line.strip_suffix('\n')
456 .unwrap_or(line)
457 .strip_suffix('\r')
458 .unwrap_or(line.strip_suffix('\n').unwrap_or(line))
459 == "---"
460}
461
462fn validate_yaml_structure(text: &str) -> Vec<FrontmatterError> {
463 use yaml_rust2::parser::{Event, MarkedEventReceiver, Parser};
464 use yaml_rust2::scanner::Marker;
465
466 #[derive(Default)]
467 struct StructuralEvents {
468 anchor_or_alias: bool,
469 document_end: bool,
470 source: String,
471 char_to_byte: Vec<usize>,
472 }
473 impl MarkedEventReceiver for StructuralEvents {
474 fn on_event(&mut self, event: Event, marker: Marker) {
475 match event {
476 Event::Alias(_) | Event::Scalar(_, _, 1.., _) => self.anchor_or_alias = true,
477 Event::SequenceStart(1.., _) | Event::MappingStart(1.., _) => {
478 self.anchor_or_alias = true;
479 }
480 Event::DocumentEnd => {
481 let byte = self.char_to_byte.get(marker.index()).copied();
482 if byte.is_some_and(|byte| self.source[byte..].starts_with("...")) {
483 self.document_end = true;
484 }
485 }
486 _ => {}
487 }
488 }
489 }
490
491 let mut char_to_byte: Vec<usize> = text.char_indices().map(|(byte, _)| byte).collect();
492 char_to_byte.push(text.len());
493 let mut events = StructuralEvents {
494 source: text.to_owned(),
495 char_to_byte,
496 ..StructuralEvents::default()
497 };
498 let parse_error = Parser::new_from_str(text).load(&mut events, true).err();
501 let mut errors = Vec::new();
502 if events.anchor_or_alias {
503 errors.push(FrontmatterError::Anchor);
504 }
505 if events.document_end {
506 errors.push(FrontmatterError::DocumentEnd);
507 }
508 if let Some(error) = parse_error {
509 errors.push(FrontmatterError::UnsupportedSyntax(error.to_string()));
510 }
511 errors
512}
513
514fn yaml_key(key: &str) -> Value {
515 Value::String(key.to_string())
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[test]
523 fn parse_and_render_roundtrip() {
524 let input = "---\nname: coder\nskills:\n- plan\n- review\n---\n# Body\ntext";
525 let fm = Frontmatter::parse(input).unwrap();
526 assert_eq!(fm.name(), Some("coder"));
527 assert_eq!(fm.skills(), vec!["plan", "review"]);
528 assert_eq!(fm.body(), "# Body\ntext");
529 assert!(fm.has_frontmatter());
530
531 let rendered = fm.render();
532 let reparsed = Frontmatter::parse(&rendered).unwrap();
533 assert_eq!(reparsed.name(), Some("coder"));
534 assert_eq!(reparsed.skills(), vec!["plan", "review"]);
535 assert_eq!(reparsed.body(), "# Body\ntext");
536 }
537
538 #[test]
539 fn parse_without_frontmatter_keeps_body() {
540 let input = "# Markdown only\ntext";
541 let fm = parse(input).unwrap();
542 assert!(!fm.has_frontmatter());
543 assert!(fm.skills().is_empty());
544 assert_eq!(fm.body(), input);
545 assert_eq!(fm.render(), input);
546 }
547
548 #[test]
549 fn parse_empty_frontmatter_roundtrips_delimiters() {
550 let input = "---\n---\nbody";
551 let fm = Frontmatter::parse(input).unwrap();
552 assert!(fm.has_frontmatter());
553 assert!(fm.skills().is_empty());
554 assert_eq!(fm.body(), "body");
555 assert_eq!(fm.render(), input);
556 }
557
558 #[test]
559 fn parse_malformed_yaml_errors() {
560 let input = "---\ninvalid: [:\n---\nbody";
561 assert!(matches!(
562 Frontmatter::parse(input),
563 Err(FrontmatterError::MalformedYaml(_))
564 ));
565 }
566
567 #[test]
568 fn parse_flow_style_skills() {
569 let input = "---\nskills: [plan, review]\n---\nbody";
570 let fm = Frontmatter::parse(input).unwrap();
571 assert_eq!(fm.skills(), vec!["plan", "review"]);
572 assert_eq!(fm.skills_structured().load, vec!["plan", "review"]);
573 assert!(fm.skills_structured().available.is_empty());
574 }
575
576 #[test]
577 fn parse_structured_skills() {
578 let input = "---\nskills:\n load: [principles]\n available:\n - planning\n - spawn\n---\nbody";
579 let fm = Frontmatter::parse(input).unwrap();
580 assert_eq!(fm.skills(), vec!["principles", "planning", "spawn"]);
581 assert_eq!(fm.skills_structured().load, vec!["principles"]);
582 assert_eq!(fm.skills_structured().available, vec!["planning", "spawn"]);
583 }
584
585 #[test]
586 fn rewrite_structured_skills_preserves_split() {
587 let input = "---\nskills:\n load: [plan]\n available: [review]\n---\nbody\n";
588 let renames = IndexMap::from([
589 ("plan".to_string(), "plan__org".to_string()),
590 ("review".to_string(), "review__org".to_string()),
591 ]);
592
593 let rewritten = rewrite_content_skills(input, &renames).unwrap().unwrap();
594 let fm = Frontmatter::parse(&rewritten).unwrap();
595 assert_eq!(fm.skills_structured().load, vec!["plan__org"]);
596 assert_eq!(fm.skills_structured().available, vec!["review__org"]);
597 }
598
599 #[test]
600 fn rewrite_does_not_corrupt_substrings() {
601 let input = "---\nskills:\n- plan\n- planner\n- planning-extended\n---\nbody\n";
602 let renames = IndexMap::from([(
603 "plan".to_string(),
604 "plan__meridian-flow_meridian-base".to_string(),
605 )]);
606
607 let rewritten = rewrite_content_skills(input, &renames).unwrap().unwrap();
608 let fm = Frontmatter::parse(&rewritten).unwrap();
609 assert_eq!(
610 fm.skills(),
611 vec![
612 "plan__meridian-flow_meridian-base",
613 "planner",
614 "planning-extended"
615 ]
616 );
617 }
618
619 #[test]
620 fn rewrite_subagents_uses_exact_matches() {
621 let input = "---\nsubagents:\n- web-researcher\n- web\n---\nbody\n";
622 let renames = IndexMap::from([(
623 "web-researcher".to_string(),
624 "web-researcher__source-a".to_string(),
625 )]);
626
627 let rewritten = rewrite_content_subagents(input, &renames).unwrap().unwrap();
628 let fm = Frontmatter::parse(&rewritten).unwrap();
629 assert_eq!(
630 fm.get("subagents").map(yaml_str_list).unwrap(),
631 vec!["web-researcher__source-a", "web"]
632 );
633 }
634}