Skip to main content

mars_agents/frontmatter/
mod.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}
11
12/// Structured agent skill references.
13///
14/// A flat `skills: [a, b]` list is represented as all `load` entries for
15/// backward compatibility. A structured `skills: { load: [...], available: [...] }`
16/// keeps the two launch-bundle channels separate.
17#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
18pub struct SkillsSpec {
19    pub load: Vec<String>,
20    pub available: Vec<String>,
21}
22
23impl SkillsSpec {
24    pub fn all(&self) -> Vec<String> {
25        self.load
26            .iter()
27            .chain(self.available.iter())
28            .cloned()
29            .collect()
30    }
31}
32
33/// Errors from frontmatter parsing.
34#[derive(Debug, thiserror::Error)]
35pub enum FrontmatterError {
36    #[error("malformed YAML frontmatter: {0}")]
37    MalformedYaml(#[from] serde_yaml::Error),
38
39    #[error("frontmatter is not a YAML mapping")]
40    NotAMapping,
41}
42
43/// Parse markdown content into frontmatter and body.
44pub fn parse(content: &str) -> Result<Frontmatter, FrontmatterError> {
45    Frontmatter::parse(content)
46}
47
48impl Frontmatter {
49    /// Parse a markdown document into frontmatter + body.
50    pub fn parse(content: &str) -> Result<Self, FrontmatterError> {
51        let (first_line, after_first_line) = split_first_line(content);
52        if !is_delimiter_line(first_line) {
53            return Ok(Self {
54                yaml: Mapping::new(),
55                body: content.to_string(),
56                has_frontmatter: false,
57            });
58        }
59
60        let mut yaml_end = None;
61        let mut offset = 0usize;
62        for line in after_first_line.split_inclusive('\n') {
63            if is_delimiter_line(line) {
64                yaml_end = Some((offset, line.len()));
65                break;
66            }
67            offset += line.len();
68        }
69
70        let Some((yaml_len, closing_len)) = yaml_end else {
71            return Ok(Self {
72                yaml: Mapping::new(),
73                body: content.to_string(),
74                has_frontmatter: false,
75            });
76        };
77
78        let yaml_text = &after_first_line[..yaml_len];
79        let body_start = yaml_len + closing_len;
80        let body = after_first_line[body_start..].to_string();
81
82        if yaml_text.trim().is_empty() {
83            return Ok(Self {
84                yaml: Mapping::new(),
85                body,
86                has_frontmatter: true,
87            });
88        }
89
90        let value: Value = serde_yaml::from_str(yaml_text)?;
91        let yaml = match value {
92            Value::Mapping(mapping) => mapping,
93            Value::Null => Mapping::new(),
94            _ => return Err(FrontmatterError::NotAMapping),
95        };
96
97        Ok(Self {
98            yaml,
99            body,
100            has_frontmatter: true,
101        })
102    }
103
104    /// Read all referenced skills as a flat list (`load` followed by `available`).
105    pub fn skills(&self) -> Vec<String> {
106        self.skills_structured().all()
107    }
108
109    /// Read `skills` in structured load/available form.
110    pub fn skills_structured(&self) -> SkillsSpec {
111        match self.get("skills") {
112            Some(Value::Mapping(mapping)) => SkillsSpec {
113                load: mapping
114                    .get(yaml_key("load"))
115                    .map(yaml_str_list)
116                    .unwrap_or_default(),
117                available: mapping
118                    .get(yaml_key("available"))
119                    .map(yaml_str_list)
120                    .unwrap_or_default(),
121            },
122            Some(value) => SkillsSpec {
123                load: yaml_str_list(value),
124                available: Vec::new(),
125            },
126            None => SkillsSpec::default(),
127        }
128    }
129
130    /// Read the `name` field if present.
131    pub fn name(&self) -> Option<&str> {
132        self.get("name").and_then(Value::as_str)
133    }
134
135    /// Read any YAML field by key.
136    pub fn get(&self, key: &str) -> Option<&Value> {
137        self.yaml.get(yaml_key(key))
138    }
139
140    /// Markdown body after frontmatter.
141    pub fn body(&self) -> &str {
142        &self.body
143    }
144
145    /// Whether this document contains frontmatter delimiters.
146    pub fn has_frontmatter(&self) -> bool {
147        self.has_frontmatter
148    }
149
150    /// All frontmatter keys as strings.
151    pub fn keys(&self) -> Vec<String> {
152        self.yaml
153            .keys()
154            .filter_map(|k| k.as_str().map(str::to_owned))
155            .collect()
156    }
157
158    /// Insert or replace a top-level frontmatter field.
159    pub fn insert(&mut self, key: &str, value: Value) {
160        self.has_frontmatter = true;
161        self.yaml.insert(yaml_key(key), value);
162    }
163
164    /// Remove a top-level frontmatter field.
165    pub fn remove(&mut self, key: &str) -> Option<Value> {
166        self.yaml.remove(yaml_key(key))
167    }
168
169    /// Serialize back to full markdown.
170    pub fn render(&self) -> String {
171        if !self.has_frontmatter && self.yaml.is_empty() {
172            return self.body.clone();
173        }
174
175        let mut out = String::from("---\n");
176        if !self.yaml.is_empty() {
177            let mut yaml = serde_yaml::to_string(&self.yaml)
178                .expect("serializing frontmatter mapping should succeed");
179            if let Some(stripped) = yaml.strip_prefix("---\n") {
180                yaml = stripped.to_string();
181            }
182            out.push_str(&yaml);
183            if !yaml.ends_with('\n') {
184                out.push('\n');
185            }
186        }
187        out.push_str("---\n");
188        out.push_str(&self.body);
189        out
190    }
191}
192
193/// Rename skills in frontmatter using exact-match replacement.
194pub fn rewrite_skills(
195    fm: &mut Frontmatter,
196    renames: &IndexMap<String, String>,
197) -> IndexSet<String> {
198    let mut renamed = IndexSet::new();
199    let key = yaml_key("skills");
200    if let Some(value) = fm.yaml.get_mut(&key) {
201        rewrite_skill_value(value, renames, &mut renamed);
202    }
203
204    renamed
205}
206
207/// Rename subagents in frontmatter using exact-match replacement.
208pub fn rewrite_subagents(
209    fm: &mut Frontmatter,
210    renames: &IndexMap<String, String>,
211) -> IndexSet<String> {
212    rewrite_string_list_field(fm, "subagents", renames)
213}
214
215/// Parse content, rewrite skills, and render updated content if changed.
216pub fn rewrite_content_skills(
217    content: &str,
218    renames: &IndexMap<String, String>,
219) -> Result<Option<String>, FrontmatterError> {
220    let mut fm = Frontmatter::parse(content)?;
221    let renamed = rewrite_skills(&mut fm, renames);
222    if renamed.is_empty() {
223        Ok(None)
224    } else {
225        Ok(Some(fm.render()))
226    }
227}
228
229/// Parse content, rewrite subagents, and render updated content if changed.
230pub fn rewrite_content_subagents(
231    content: &str,
232    renames: &IndexMap<String, String>,
233) -> Result<Option<String>, FrontmatterError> {
234    let mut fm = Frontmatter::parse(content)?;
235    let renamed = rewrite_subagents(&mut fm, renames);
236    if renamed.is_empty() {
237        Ok(None)
238    } else {
239        Ok(Some(fm.render()))
240    }
241}
242
243fn rewrite_string_list_field(
244    fm: &mut Frontmatter,
245    field_name: &str,
246    renames: &IndexMap<String, String>,
247) -> IndexSet<String> {
248    let mut renamed = IndexSet::new();
249    let key = yaml_key(field_name);
250    if let Some(value) = fm.yaml.get_mut(&key) {
251        rewrite_string_list_value(value, renames, &mut renamed);
252    }
253
254    renamed
255}
256
257fn yaml_str_list(val: &Value) -> Vec<String> {
258    match val {
259        Value::Sequence(seq) => seq
260            .iter()
261            .filter_map(Value::as_str)
262            .map(str::to_owned)
263            .collect(),
264        Value::String(s) => vec![s.clone()],
265        _ => vec![],
266    }
267}
268
269fn rewrite_skill_value(
270    value: &mut Value,
271    renames: &IndexMap<String, String>,
272    renamed: &mut IndexSet<String>,
273) {
274    match value {
275        Value::Sequence(_) | Value::String(_) => rewrite_string_list_value(value, renames, renamed),
276        Value::Mapping(mapping) => {
277            for field in ["load", "available"] {
278                if let Some(child) = mapping.get_mut(yaml_key(field)) {
279                    rewrite_skill_value(child, renames, renamed);
280                }
281            }
282        }
283        _ => {}
284    }
285}
286
287fn rewrite_string_list_value(
288    value: &mut Value,
289    renames: &IndexMap<String, String>,
290    renamed: &mut IndexSet<String>,
291) {
292    match value {
293        Value::Sequence(seq) => {
294            for item in seq {
295                let Some(name) = item.as_str() else {
296                    continue;
297                };
298                if let Some(new_name) = renames.get(name) {
299                    renamed.insert(name.to_string());
300                    *item = Value::String(new_name.clone());
301                }
302            }
303        }
304        Value::String(name) => {
305            if let Some(new_name) = renames.get(name.as_str()) {
306                renamed.insert(name.clone());
307                *name = new_name.clone();
308            }
309        }
310        _ => {}
311    }
312}
313
314fn split_first_line(content: &str) -> (&str, &str) {
315    match content.split_once('\n') {
316        Some((first, rest)) => (first, rest),
317        None => (content, ""),
318    }
319}
320
321fn is_delimiter_line(line: &str) -> bool {
322    line.trim_end() == "---"
323}
324
325fn yaml_key(key: &str) -> Value {
326    Value::String(key.to_string())
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn parse_and_render_roundtrip() {
335        let input = "---\nname: coder\nskills:\n- plan\n- review\n---\n# Body\ntext";
336        let fm = Frontmatter::parse(input).unwrap();
337        assert_eq!(fm.name(), Some("coder"));
338        assert_eq!(fm.skills(), vec!["plan", "review"]);
339        assert_eq!(fm.body(), "# Body\ntext");
340        assert!(fm.has_frontmatter());
341
342        let rendered = fm.render();
343        let reparsed = Frontmatter::parse(&rendered).unwrap();
344        assert_eq!(reparsed.name(), Some("coder"));
345        assert_eq!(reparsed.skills(), vec!["plan", "review"]);
346        assert_eq!(reparsed.body(), "# Body\ntext");
347    }
348
349    #[test]
350    fn parse_without_frontmatter_keeps_body() {
351        let input = "# Markdown only\ntext";
352        let fm = parse(input).unwrap();
353        assert!(!fm.has_frontmatter());
354        assert!(fm.skills().is_empty());
355        assert_eq!(fm.body(), input);
356        assert_eq!(fm.render(), input);
357    }
358
359    #[test]
360    fn parse_empty_frontmatter_roundtrips_delimiters() {
361        let input = "---\n---\nbody";
362        let fm = Frontmatter::parse(input).unwrap();
363        assert!(fm.has_frontmatter());
364        assert!(fm.skills().is_empty());
365        assert_eq!(fm.body(), "body");
366        assert_eq!(fm.render(), input);
367    }
368
369    #[test]
370    fn parse_malformed_yaml_errors() {
371        let input = "---\ninvalid: [:\n---\nbody";
372        assert!(matches!(
373            Frontmatter::parse(input),
374            Err(FrontmatterError::MalformedYaml(_))
375        ));
376    }
377
378    #[test]
379    fn parse_flow_style_skills() {
380        let input = "---\nskills: [plan, review]\n---\nbody";
381        let fm = Frontmatter::parse(input).unwrap();
382        assert_eq!(fm.skills(), vec!["plan", "review"]);
383        assert_eq!(fm.skills_structured().load, vec!["plan", "review"]);
384        assert!(fm.skills_structured().available.is_empty());
385    }
386
387    #[test]
388    fn parse_structured_skills() {
389        let input = "---\nskills:\n  load: [principles]\n  available:\n    - planning\n    - spawn\n---\nbody";
390        let fm = Frontmatter::parse(input).unwrap();
391        assert_eq!(fm.skills(), vec!["principles", "planning", "spawn"]);
392        assert_eq!(fm.skills_structured().load, vec!["principles"]);
393        assert_eq!(fm.skills_structured().available, vec!["planning", "spawn"]);
394    }
395
396    #[test]
397    fn rewrite_structured_skills_preserves_split() {
398        let input = "---\nskills:\n  load: [plan]\n  available: [review]\n---\nbody\n";
399        let renames = IndexMap::from([
400            ("plan".to_string(), "plan__org".to_string()),
401            ("review".to_string(), "review__org".to_string()),
402        ]);
403
404        let rewritten = rewrite_content_skills(input, &renames).unwrap().unwrap();
405        let fm = Frontmatter::parse(&rewritten).unwrap();
406        assert_eq!(fm.skills_structured().load, vec!["plan__org"]);
407        assert_eq!(fm.skills_structured().available, vec!["review__org"]);
408    }
409
410    #[test]
411    fn rewrite_does_not_corrupt_substrings() {
412        let input = "---\nskills:\n- plan\n- planner\n- planning-extended\n---\nbody\n";
413        let renames = IndexMap::from([(
414            "plan".to_string(),
415            "plan__meridian-flow_meridian-base".to_string(),
416        )]);
417
418        let rewritten = rewrite_content_skills(input, &renames).unwrap().unwrap();
419        let fm = Frontmatter::parse(&rewritten).unwrap();
420        assert_eq!(
421            fm.skills(),
422            vec![
423                "plan__meridian-flow_meridian-base",
424                "planner",
425                "planning-extended"
426            ]
427        );
428    }
429
430    #[test]
431    fn rewrite_subagents_uses_exact_matches() {
432        let input = "---\nsubagents:\n- web-researcher\n- web\n---\nbody\n";
433        let renames = IndexMap::from([(
434            "web-researcher".to_string(),
435            "web-researcher__source-a".to_string(),
436        )]);
437
438        let rewritten = rewrite_content_subagents(input, &renames).unwrap().unwrap();
439        let fm = Frontmatter::parse(&rewritten).unwrap();
440        assert_eq!(
441            fm.get("subagents").map(yaml_str_list).unwrap(),
442            vec!["web-researcher__source-a", "web"]
443        );
444    }
445}