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