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, 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
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    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    /// Markdown body after frontmatter.
180    pub fn body(&self) -> &str {
181        &self.body
182    }
183
184    /// Replace the Markdown body while preserving the parsed frontmatter.
185    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    /// All frontmatter keys as strings.
194    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    /// Parsed top-level YAML, for schema transforms that must verify an edited source.
202    pub(crate) fn mapping(&self) -> &Mapping {
203        &self.yaml
204    }
205
206    /// Insert or replace a top-level frontmatter field.
207    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    /// Serialize back to full markdown.
217    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    /// Serialize only the frontmatter block, delimiters included.
227    ///
228    /// A caller that changes the frontmatter and must keep the authored body byte for byte
229    /// (`mars migrate`'s full rewrite) renders this and appends the parsed body itself.
230    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
256/// Rename skills in frontmatter using exact-match replacement.
257fn 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
267/// Rename subagents in frontmatter using exact-match replacement.
268fn rewrite_subagents(fm: &mut Frontmatter, renames: &IndexMap<String, String>) -> IndexSet<String> {
269    rewrite_string_list_field(fm, "subagents", renames)
270}
271
272/// Parse content, rewrite skills, and render updated content if changed.
273pub 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
286/// Parse content, rewrite subagents, and render updated content if changed.
287pub 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
388/// A document's frontmatter region: the YAML between the fences, where it starts in the
389/// document, and the Markdown after the closing fence.
390struct Regions<'a> {
391    has_bom: bool,
392    /// Byte offset of `yaml`: past a byte-order mark and the opening fence line.
393    yaml_start: usize,
394    yaml: &'a str,
395    body: &'a str,
396}
397
398/// The frontmatter region of `content`, and `None` when it has no frontmatter.
399fn 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
437/// The YAML text between a document's frontmatter fences, and where it starts in the document.
438pub(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    // serde_yaml already accepted the document. The event parser is used only
494    // to distinguish YAML structure from identical text inside scalar values.
495    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}