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 (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    /// Read all referenced skills as a flat list (`load` followed by `available`).
169    pub fn skills(&self) -> Vec<String> {
170        self.skills_structured().all()
171    }
172
173    /// Read `skills` in structured load/available form.
174    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    /// Read the `name` field if present.
195    pub fn name(&self) -> Option<&str> {
196        self.get("name").and_then(Value::as_str)
197    }
198
199    /// Read any YAML field by key.
200    pub fn get(&self, key: &str) -> Option<&Value> {
201        self.yaml.get(yaml_key(key))
202    }
203
204    /// Mutably read a YAML field by key.
205    pub fn get_mut(&mut self, key: &str) -> Option<&mut Value> {
206        self.yaml.get_mut(yaml_key(key))
207    }
208
209    /// Markdown body after frontmatter.
210    pub fn body(&self) -> &str {
211        &self.body
212    }
213
214    /// Replace the Markdown body while preserving the parsed frontmatter.
215    pub fn set_body(&mut self, body: String) {
216        self.body = body;
217    }
218
219    /// Whether this document contains frontmatter delimiters.
220    pub fn has_frontmatter(&self) -> bool {
221        self.has_frontmatter
222    }
223
224    /// All frontmatter keys as strings.
225    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    /// Parsed top-level YAML, for schema transforms that must verify an edited source.
233    pub(crate) fn mapping(&self) -> &Mapping {
234        &self.yaml
235    }
236
237    /// Insert or replace a top-level frontmatter field.
238    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    /// Remove a top-level frontmatter field.
244    pub fn remove(&mut self, key: &str) -> Option<Value> {
245        self.yaml.shift_remove(yaml_key(key))
246    }
247
248    /// Serialize back to full markdown.
249    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    /// Serialize only the frontmatter block, delimiters included.
259    ///
260    /// A caller that changes the frontmatter and must keep the authored body byte for byte
261    /// (`mars migrate`'s full rewrite) renders this and appends the parsed body itself.
262    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
288/// Rename skills in frontmatter using exact-match replacement.
289fn 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
299/// Rename subagents in frontmatter using exact-match replacement.
300fn rewrite_subagents(fm: &mut Frontmatter, renames: &IndexMap<String, String>) -> IndexSet<String> {
301    rewrite_string_list_field(fm, "subagents", renames)
302}
303
304/// Parse content, rewrite skills, and render updated content if changed.
305pub 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
318/// Parse content, rewrite subagents, and render updated content if changed.
319pub 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    // serde_yaml already accepted the document. The event parser is used only
472    // to distinguish YAML structure from identical text inside scalar values.
473    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}