Skip to main content

mars_schema/
frontmatter.rs

1use indexmap::{IndexMap, IndexSet};
2use serde_yaml::{Mapping, Value};
3
4/// Parsed markdown frontmatter and body.
5#[derive(Debug, Clone)]
6pub struct Frontmatter {
7    yaml: Mapping,
8    body: String,
9    has_frontmatter: bool,
10    has_bom: bool,
11}
12
13/// Structured agent skill references.
14///
15/// A flat `skills: [a, b]` list is represented as all `load` entries for
16/// backward compatibility. A structured `skills: { load: [...], available: [...] }`
17/// keeps the two launch-bundle channels separate.
18#[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
34/// The most frontmatter bytes a file may carry. Parsing time grows with the square of the
35/// nesting depth the text can hold, so an unbounded block is a way to stall a server that reads
36/// untrusted files; real frontmatter is a few hundred bytes.
37pub const MAX_FRONTMATTER_BYTES: usize = 64 * 1024;
38
39/// The deepest run of `[` and `{` the frontmatter may open, counted over the raw text. The
40/// YAML scanners take time quadratic in that depth, so a size cap alone still lets 64 KiB of `[`
41/// stall a parse for seconds; real frontmatter nests a few levels.
42pub const MAX_FLOW_DEPTH: usize = 64;
43
44/// Errors from frontmatter parsing.
45#[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
74/// Parse markdown content into frontmatter and body.
75pub fn parse(content: &str) -> Result<Frontmatter, FrontmatterError> {
76    Frontmatter::parse(content)
77}
78
79impl Frontmatter {
80    /// Parse a markdown document into frontmatter + body.
81    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    /// Parse semantic content while returning every structural refusal found.
91    ///
92    /// Schema readers use this to avoid losing an anchor diagnostic when the
93    /// structural parser later encounters syntax it cannot scan.
94    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    /// Read all referenced skills as a flat list (`load` followed by `available`).
142    pub fn skills(&self) -> Vec<String> {
143        self.skills_structured().all()
144    }
145
146    /// Read `skills` in structured load/available form.
147    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    /// Read the `name` field if present.
168    pub fn name(&self) -> Option<&str> {
169        self.get("name").and_then(Value::as_str)
170    }
171
172    /// Read any YAML field by key.
173    pub fn get(&self, key: &str) -> Option<&Value> {
174        self.yaml.get(yaml_key(key))
175    }
176
177    /// Mutably read a YAML field by key.
178    pub fn get_mut(&mut self, key: &str) -> Option<&mut Value> {
179        self.yaml.get_mut(yaml_key(key))
180    }
181
182    /// Markdown body after frontmatter.
183    pub fn body(&self) -> &str {
184        &self.body
185    }
186
187    /// Replace the Markdown body while preserving the parsed frontmatter.
188    pub fn set_body(&mut self, body: String) {
189        self.body = body;
190    }
191
192    /// Whether this document contains frontmatter delimiters.
193    pub fn has_frontmatter(&self) -> bool {
194        self.has_frontmatter
195    }
196
197    /// All frontmatter keys as strings.
198    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    /// Parsed top-level YAML, for schema transforms that must verify an edited source.
206    pub(crate) fn mapping(&self) -> &Mapping {
207        &self.yaml
208    }
209
210    /// Insert or replace a top-level frontmatter field.
211    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    /// Remove a top-level frontmatter field.
217    pub fn remove(&mut self, key: &str) -> Option<Value> {
218        self.yaml.shift_remove(yaml_key(key))
219    }
220
221    /// Serialize back to full markdown.
222    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    /// Serialize only the frontmatter block, delimiters included.
232    ///
233    /// A caller that changes the frontmatter and must keep the authored body byte for byte
234    /// (`mars migrate`'s full rewrite) renders this and appends the parsed body itself.
235    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
261/// Rename skills in frontmatter using exact-match replacement.
262fn 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
272/// Rename subagents in frontmatter using exact-match replacement.
273fn rewrite_subagents(fm: &mut Frontmatter, renames: &IndexMap<String, String>) -> IndexSet<String> {
274    rewrite_string_list_field(fm, "subagents", renames)
275}
276
277/// Parse content, rewrite skills, and render updated content if changed.
278pub 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
291/// Parse content, rewrite subagents, and render updated content if changed.
292pub 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
393/// A document's frontmatter region: the YAML between the fences, where it starts in the
394/// document, and the Markdown after the closing fence.
395struct Regions<'a> {
396    has_bom: bool,
397    /// Byte offset of `yaml`: past a byte-order mark and the opening fence line.
398    yaml_start: usize,
399    yaml: &'a str,
400    body: &'a str,
401}
402
403/// The frontmatter region of `content`, and `None` when it has no frontmatter.
404fn 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
442/// The YAML text between a document's frontmatter fences, and where it starts in the document.
443pub(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    // serde_yaml already accepted the document. The event parser is used only
499    // to distinguish YAML structure from identical text inside scalar values.
500    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}