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, serde::Deserialize)]
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> {
168 self.get("name").and_then(Value::as_str)
169 }
170
171 pub fn get(&self, key: &str) -> Option<&Value> {
172 self.yaml.get(yaml_key(key))
173 }
174
175 pub fn get_mut(&mut self, key: &str) -> Option<&mut Value> {
176 self.yaml.get_mut(yaml_key(key))
177 }
178
179 pub fn body(&self) -> &str {
181 &self.body
182 }
183
184 pub fn set_body(&mut self, body: String) {
186 self.body = body;
187 }
188
189 pub fn has_frontmatter(&self) -> bool {
190 self.has_frontmatter
191 }
192
193 pub fn keys(&self) -> Vec<String> {
195 self.yaml
196 .keys()
197 .filter_map(|k| k.as_str().map(str::to_owned))
198 .collect()
199 }
200
201 pub(crate) fn mapping(&self) -> &Mapping {
203 &self.yaml
204 }
205
206 pub fn insert(&mut self, key: &str, value: Value) {
208 self.has_frontmatter = true;
209 self.yaml.insert(yaml_key(key), value);
210 }
211
212 pub fn remove(&mut self, key: &str) -> Option<Value> {
213 self.yaml.shift_remove(yaml_key(key))
214 }
215
216 pub fn render(&self) -> String {
218 if !self.has_frontmatter && self.yaml.is_empty() {
219 return self.body.clone();
220 }
221 let mut out = self.render_frontmatter();
222 out.push_str(&self.body);
223 out
224 }
225
226 pub(crate) fn render_frontmatter(&self) -> String {
231 if !self.has_frontmatter && self.yaml.is_empty() {
232 return String::new();
233 }
234
235 let mut out = if self.has_bom {
236 String::from("\u{feff}---\n")
237 } else {
238 String::from("---\n")
239 };
240 if !self.yaml.is_empty() {
241 let mut yaml = serde_yaml::to_string(&self.yaml)
242 .expect("serializing frontmatter mapping should succeed");
243 if let Some(stripped) = yaml.strip_prefix("---\n") {
244 yaml = stripped.to_string();
245 }
246 out.push_str(&yaml);
247 if !yaml.ends_with('\n') {
248 out.push('\n');
249 }
250 }
251 out.push_str("---\n");
252 out
253 }
254}
255
256fn rewrite_skills(fm: &mut Frontmatter, renames: &IndexMap<String, String>) -> IndexSet<String> {
258 let mut renamed = IndexSet::new();
259 let key = yaml_key("skills");
260 if let Some(value) = fm.yaml.get_mut(&key) {
261 rewrite_skill_value(value, renames, &mut renamed);
262 }
263
264 renamed
265}
266
267fn rewrite_subagents(fm: &mut Frontmatter, renames: &IndexMap<String, String>) -> IndexSet<String> {
269 rewrite_string_list_field(fm, "subagents", renames)
270}
271
272pub fn rewrite_content_skills(
274 content: &str,
275 renames: &IndexMap<String, String>,
276) -> Result<Option<String>, FrontmatterError> {
277 let mut fm = Frontmatter::parse(content)?;
278 let renamed = rewrite_skills(&mut fm, renames);
279 if renamed.is_empty() {
280 Ok(None)
281 } else {
282 Ok(Some(fm.render()))
283 }
284}
285
286pub fn rewrite_content_subagents(
288 content: &str,
289 renames: &IndexMap<String, String>,
290) -> Result<Option<String>, FrontmatterError> {
291 let mut fm = Frontmatter::parse(content)?;
292 let renamed = rewrite_subagents(&mut fm, renames);
293 if renamed.is_empty() {
294 Ok(None)
295 } else {
296 Ok(Some(fm.render()))
297 }
298}
299
300fn rewrite_string_list_field(
301 fm: &mut Frontmatter,
302 field_name: &str,
303 renames: &IndexMap<String, String>,
304) -> IndexSet<String> {
305 let mut renamed = IndexSet::new();
306 let key = yaml_key(field_name);
307 if let Some(value) = fm.yaml.get_mut(&key) {
308 rewrite_string_list_value(value, renames, &mut renamed);
309 }
310
311 renamed
312}
313
314fn yaml_str_list(val: &Value) -> Vec<String> {
315 match val {
316 Value::Sequence(seq) => seq
317 .iter()
318 .filter_map(Value::as_str)
319 .map(str::to_owned)
320 .collect(),
321 Value::String(s) => vec![s.clone()],
322 _ => vec![],
323 }
324}
325
326fn rewrite_skill_value(
327 value: &mut Value,
328 renames: &IndexMap<String, String>,
329 renamed: &mut IndexSet<String>,
330) {
331 match value {
332 Value::Sequence(_) | Value::String(_) => rewrite_string_list_value(value, renames, renamed),
333 Value::Mapping(mapping) => {
334 for field in ["load", "available"] {
335 if let Some(child) = mapping.get_mut(yaml_key(field)) {
336 rewrite_skill_value(child, renames, renamed);
337 }
338 }
339 }
340 _ => {}
341 }
342}
343
344fn rewrite_string_list_value(
345 value: &mut Value,
346 renames: &IndexMap<String, String>,
347 renamed: &mut IndexSet<String>,
348) {
349 match value {
350 Value::Sequence(seq) => {
351 for item in seq {
352 let Some(name) = item.as_str() else {
353 continue;
354 };
355 if let Some(new_name) = renames.get(name) {
356 renamed.insert(name.to_string());
357 *item = Value::String(new_name.clone());
358 }
359 }
360 }
361 Value::String(name) => {
362 if let Some(new_name) = renames.get(name.as_str()) {
363 renamed.insert(name.clone());
364 *name = new_name.clone();
365 }
366 }
367 _ => {}
368 }
369}
370
371fn exceeds_flow_depth(yaml: &str) -> bool {
372 let mut depth = 0usize;
373 for byte in yaml.bytes() {
374 match byte {
375 b'[' | b'{' => {
376 depth += 1;
377 if depth > MAX_FLOW_DEPTH {
378 return true;
379 }
380 }
381 b']' | b'}' => depth = depth.saturating_sub(1),
382 _ => {}
383 }
384 }
385 false
386}
387
388struct Regions<'a> {
391 has_bom: bool,
392 yaml_start: usize,
394 yaml: &'a str,
395 body: &'a str,
396}
397
398fn split_regions(content: &str) -> Result<Option<Regions<'_>>, FrontmatterError> {
400 let (has_bom, document) = content
401 .strip_prefix('\u{feff}')
402 .map_or((false, content), |document| (true, document));
403 let (first_line, after_first_line) = split_first_line(document);
404 if !is_delimiter_line(first_line) {
405 return Ok(None);
406 }
407
408 let mut yaml_end = None;
409 let mut offset = 0usize;
410 for line in after_first_line.split_inclusive('\n') {
411 if is_delimiter_line(line) {
412 yaml_end = Some((offset, line.len()));
413 break;
414 }
415 offset += line.len();
416 }
417
418 let Some((yaml_len, closing_len)) = yaml_end else {
419 return Err(FrontmatterError::Unclosed);
420 };
421
422 if yaml_len > MAX_FRONTMATTER_BYTES {
423 return Err(FrontmatterError::TooLarge(yaml_len));
424 }
425 let yaml = &after_first_line[..yaml_len];
426 if exceeds_flow_depth(yaml) {
427 return Err(FrontmatterError::TooDeep);
428 }
429 Ok(Some(Regions {
430 has_bom,
431 yaml_start: content.len() - after_first_line.len(),
432 yaml,
433 body: &after_first_line[yaml_len + closing_len..],
434 }))
435}
436
437pub(crate) fn yaml_text(content: &str) -> Result<Option<(&str, usize)>, FrontmatterError> {
439 Ok(split_regions(content)?.map(|regions| (regions.yaml, regions.yaml_start)))
440}
441
442fn split_first_line(content: &str) -> (&str, &str) {
443 match content.split_once('\n') {
444 Some((first, rest)) => (first, rest),
445 None => (content, ""),
446 }
447}
448
449fn is_delimiter_line(line: &str) -> bool {
450 line.strip_suffix('\n')
451 .unwrap_or(line)
452 .strip_suffix('\r')
453 .unwrap_or(line.strip_suffix('\n').unwrap_or(line))
454 == "---"
455}
456
457fn validate_yaml_structure(text: &str) -> Vec<FrontmatterError> {
458 use yaml_rust2::parser::{Event, MarkedEventReceiver, Parser};
459 use yaml_rust2::scanner::Marker;
460
461 #[derive(Default)]
462 struct StructuralEvents {
463 anchor_or_alias: bool,
464 document_end: bool,
465 source: String,
466 char_to_byte: Vec<usize>,
467 }
468 impl MarkedEventReceiver for StructuralEvents {
469 fn on_event(&mut self, event: Event, marker: Marker) {
470 match event {
471 Event::Alias(_) | Event::Scalar(_, _, 1.., _) => self.anchor_or_alias = true,
472 Event::SequenceStart(1.., _) | Event::MappingStart(1.., _) => {
473 self.anchor_or_alias = true;
474 }
475 Event::DocumentEnd => {
476 let byte = self.char_to_byte.get(marker.index()).copied();
477 if byte.is_some_and(|byte| self.source[byte..].starts_with("...")) {
478 self.document_end = true;
479 }
480 }
481 _ => {}
482 }
483 }
484 }
485
486 let mut char_to_byte: Vec<usize> = text.char_indices().map(|(byte, _)| byte).collect();
487 char_to_byte.push(text.len());
488 let mut events = StructuralEvents {
489 source: text.to_owned(),
490 char_to_byte,
491 ..StructuralEvents::default()
492 };
493 let parse_error = Parser::new_from_str(text).load(&mut events, true).err();
496 let mut errors = Vec::new();
497 if events.anchor_or_alias {
498 errors.push(FrontmatterError::Anchor);
499 }
500 if events.document_end {
501 errors.push(FrontmatterError::DocumentEnd);
502 }
503 if let Some(error) = parse_error {
504 errors.push(FrontmatterError::UnsupportedSyntax(error.to_string()));
505 }
506 errors
507}
508
509fn yaml_key(key: &str) -> Value {
510 Value::String(key.to_string())
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516
517 #[test]
518 fn parse_and_render_roundtrip() {
519 let input = "---\nname: coder\nskills:\n- plan\n- review\n---\n# Body\ntext";
520 let fm = Frontmatter::parse(input).unwrap();
521 assert_eq!(fm.name(), Some("coder"));
522 assert_eq!(fm.skills(), vec!["plan", "review"]);
523 assert_eq!(fm.body(), "# Body\ntext");
524 assert!(fm.has_frontmatter());
525
526 let rendered = fm.render();
527 let reparsed = Frontmatter::parse(&rendered).unwrap();
528 assert_eq!(reparsed.name(), Some("coder"));
529 assert_eq!(reparsed.skills(), vec!["plan", "review"]);
530 assert_eq!(reparsed.body(), "# Body\ntext");
531 }
532
533 #[test]
534 fn parse_without_frontmatter_keeps_body() {
535 let input = "# Markdown only\ntext";
536 let fm = parse(input).unwrap();
537 assert!(!fm.has_frontmatter());
538 assert!(fm.skills().is_empty());
539 assert_eq!(fm.body(), input);
540 assert_eq!(fm.render(), input);
541 }
542
543 #[test]
544 fn parse_empty_frontmatter_roundtrips_delimiters() {
545 let input = "---\n---\nbody";
546 let fm = Frontmatter::parse(input).unwrap();
547 assert!(fm.has_frontmatter());
548 assert!(fm.skills().is_empty());
549 assert_eq!(fm.body(), "body");
550 assert_eq!(fm.render(), input);
551 }
552
553 #[test]
554 fn parse_malformed_yaml_errors() {
555 let input = "---\ninvalid: [:\n---\nbody";
556 assert!(matches!(
557 Frontmatter::parse(input),
558 Err(FrontmatterError::MalformedYaml(_))
559 ));
560 }
561
562 #[test]
563 fn parse_flow_style_skills() {
564 let input = "---\nskills: [plan, review]\n---\nbody";
565 let fm = Frontmatter::parse(input).unwrap();
566 assert_eq!(fm.skills(), vec!["plan", "review"]);
567 assert_eq!(fm.skills_structured().load, vec!["plan", "review"]);
568 assert!(fm.skills_structured().available.is_empty());
569 }
570
571 #[test]
572 fn parse_structured_skills() {
573 let input = "---\nskills:\n load: [principles]\n available:\n - planning\n - spawn\n---\nbody";
574 let fm = Frontmatter::parse(input).unwrap();
575 assert_eq!(fm.skills(), vec!["principles", "planning", "spawn"]);
576 assert_eq!(fm.skills_structured().load, vec!["principles"]);
577 assert_eq!(fm.skills_structured().available, vec!["planning", "spawn"]);
578 }
579
580 #[test]
581 fn rewrite_structured_skills_preserves_split() {
582 let input = "---\nskills:\n load: [plan]\n available: [review]\n---\nbody\n";
583 let renames = IndexMap::from([
584 ("plan".to_string(), "plan__org".to_string()),
585 ("review".to_string(), "review__org".to_string()),
586 ]);
587
588 let rewritten = rewrite_content_skills(input, &renames).unwrap().unwrap();
589 let fm = Frontmatter::parse(&rewritten).unwrap();
590 assert_eq!(fm.skills_structured().load, vec!["plan__org"]);
591 assert_eq!(fm.skills_structured().available, vec!["review__org"]);
592 }
593
594 #[test]
595 fn rewrite_does_not_corrupt_substrings() {
596 let input = "---\nskills:\n- plan\n- planner\n- planning-extended\n---\nbody\n";
597 let renames = IndexMap::from([(
598 "plan".to_string(),
599 "plan__meridian-flow_meridian-base".to_string(),
600 )]);
601
602 let rewritten = rewrite_content_skills(input, &renames).unwrap().unwrap();
603 let fm = Frontmatter::parse(&rewritten).unwrap();
604 assert_eq!(
605 fm.skills(),
606 vec![
607 "plan__meridian-flow_meridian-base",
608 "planner",
609 "planning-extended"
610 ]
611 );
612 }
613
614 #[test]
615 fn rewrite_subagents_uses_exact_matches() {
616 let input = "---\nsubagents:\n- web-researcher\n- web\n---\nbody\n";
617 let renames = IndexMap::from([(
618 "web-researcher".to_string(),
619 "web-researcher__source-a".to_string(),
620 )]);
621
622 let rewritten = rewrite_content_subagents(input, &renames).unwrap().unwrap();
623 let fm = Frontmatter::parse(&rewritten).unwrap();
624 assert_eq!(
625 fm.get("subagents").map(yaml_str_list).unwrap(),
626 vec!["web-researcher__source-a", "web"]
627 );
628 }
629}