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