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    /// Serialize back to full markdown.
175    pub fn render(&self) -> String {
176        if !self.has_frontmatter && self.yaml.is_empty() {
177            return self.body.clone();
178        }
179
180        let mut out = String::from("---\n");
181        if !self.yaml.is_empty() {
182            let mut yaml = serde_yaml::to_string(&self.yaml)
183                .expect("serializing frontmatter mapping should succeed");
184            if let Some(stripped) = yaml.strip_prefix("---\n") {
185                yaml = stripped.to_string();
186            }
187            out.push_str(&yaml);
188            if !yaml.ends_with('\n') {
189                out.push('\n');
190            }
191        }
192        out.push_str("---\n");
193        out.push_str(&self.body);
194        out
195    }
196}
197
198/// Rename skills in frontmatter using exact-match replacement.
199pub fn rewrite_skills(
200    fm: &mut Frontmatter,
201    renames: &IndexMap<String, String>,
202) -> IndexSet<String> {
203    let mut renamed = IndexSet::new();
204    let key = yaml_key("skills");
205    if let Some(value) = fm.yaml.get_mut(&key) {
206        rewrite_skill_value(value, renames, &mut renamed);
207    }
208
209    renamed
210}
211
212/// Parse content, rewrite skills, and render updated content if changed.
213pub fn rewrite_content_skills(
214    content: &str,
215    renames: &IndexMap<String, String>,
216) -> Result<Option<String>, FrontmatterError> {
217    let mut fm = Frontmatter::parse(content)?;
218    let renamed = rewrite_skills(&mut fm, renames);
219    if renamed.is_empty() {
220        Ok(None)
221    } else {
222        Ok(Some(fm.render()))
223    }
224}
225
226fn yaml_str_list(val: &Value) -> Vec<String> {
227    match val {
228        Value::Sequence(seq) => seq
229            .iter()
230            .filter_map(Value::as_str)
231            .map(str::to_owned)
232            .collect(),
233        Value::String(s) => vec![s.clone()],
234        _ => vec![],
235    }
236}
237
238fn rewrite_skill_value(
239    value: &mut Value,
240    renames: &IndexMap<String, String>,
241    renamed: &mut IndexSet<String>,
242) {
243    match value {
244        Value::Sequence(seq) => {
245            for item in seq {
246                let Some(skill) = item.as_str() else {
247                    continue;
248                };
249                if let Some(new_name) = renames.get(skill) {
250                    renamed.insert(skill.to_string());
251                    *item = Value::String(new_name.clone());
252                }
253            }
254        }
255        Value::String(skill) => {
256            if let Some(new_name) = renames.get(skill.as_str()) {
257                renamed.insert(skill.clone());
258                *skill = new_name.clone();
259            }
260        }
261        Value::Mapping(mapping) => {
262            for field in ["load", "available"] {
263                if let Some(child) = mapping.get_mut(yaml_key(field)) {
264                    rewrite_skill_value(child, renames, renamed);
265                }
266            }
267        }
268        _ => {}
269    }
270}
271
272fn split_first_line(content: &str) -> (&str, &str) {
273    match content.split_once('\n') {
274        Some((first, rest)) => (first, rest),
275        None => (content, ""),
276    }
277}
278
279fn is_delimiter_line(line: &str) -> bool {
280    line.trim_end() == "---"
281}
282
283fn yaml_key(key: &str) -> Value {
284    Value::String(key.to_string())
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn parse_and_render_roundtrip() {
293        let input = "---\nname: coder\nskills:\n- plan\n- review\n---\n# Body\ntext";
294        let fm = Frontmatter::parse(input).unwrap();
295        assert_eq!(fm.name(), Some("coder"));
296        assert_eq!(fm.skills(), vec!["plan", "review"]);
297        assert_eq!(fm.body(), "# Body\ntext");
298        assert!(fm.has_frontmatter());
299
300        let rendered = fm.render();
301        let reparsed = Frontmatter::parse(&rendered).unwrap();
302        assert_eq!(reparsed.name(), Some("coder"));
303        assert_eq!(reparsed.skills(), vec!["plan", "review"]);
304        assert_eq!(reparsed.body(), "# Body\ntext");
305    }
306
307    #[test]
308    fn parse_without_frontmatter_keeps_body() {
309        let input = "# Markdown only\ntext";
310        let fm = parse(input).unwrap();
311        assert!(!fm.has_frontmatter());
312        assert!(fm.skills().is_empty());
313        assert_eq!(fm.body(), input);
314        assert_eq!(fm.render(), input);
315    }
316
317    #[test]
318    fn parse_empty_frontmatter_roundtrips_delimiters() {
319        let input = "---\n---\nbody";
320        let fm = Frontmatter::parse(input).unwrap();
321        assert!(fm.has_frontmatter());
322        assert!(fm.skills().is_empty());
323        assert_eq!(fm.body(), "body");
324        assert_eq!(fm.render(), input);
325    }
326
327    #[test]
328    fn parse_malformed_yaml_errors() {
329        let input = "---\ninvalid: [:\n---\nbody";
330        assert!(matches!(
331            Frontmatter::parse(input),
332            Err(FrontmatterError::MalformedYaml(_))
333        ));
334    }
335
336    #[test]
337    fn parse_flow_style_skills() {
338        let input = "---\nskills: [plan, review]\n---\nbody";
339        let fm = Frontmatter::parse(input).unwrap();
340        assert_eq!(fm.skills(), vec!["plan", "review"]);
341        assert_eq!(fm.skills_structured().load, vec!["plan", "review"]);
342        assert!(fm.skills_structured().available.is_empty());
343    }
344
345    #[test]
346    fn parse_structured_skills() {
347        let input = "---\nskills:\n  load: [principles]\n  available:\n    - planning\n    - spawn\n---\nbody";
348        let fm = Frontmatter::parse(input).unwrap();
349        assert_eq!(fm.skills(), vec!["principles", "planning", "spawn"]);
350        assert_eq!(fm.skills_structured().load, vec!["principles"]);
351        assert_eq!(fm.skills_structured().available, vec!["planning", "spawn"]);
352    }
353
354    #[test]
355    fn rewrite_structured_skills_preserves_split() {
356        let input = "---\nskills:\n  load: [plan]\n  available: [review]\n---\nbody\n";
357        let renames = IndexMap::from([
358            ("plan".to_string(), "plan__org".to_string()),
359            ("review".to_string(), "review__org".to_string()),
360        ]);
361
362        let rewritten = rewrite_content_skills(input, &renames).unwrap().unwrap();
363        let fm = Frontmatter::parse(&rewritten).unwrap();
364        assert_eq!(fm.skills_structured().load, vec!["plan__org"]);
365        assert_eq!(fm.skills_structured().available, vec!["review__org"]);
366    }
367
368    #[test]
369    fn rewrite_does_not_corrupt_substrings() {
370        let input = "---\nskills:\n- plan\n- planner\n- planning-extended\n---\nbody\n";
371        let renames = IndexMap::from([(
372            "plan".to_string(),
373            "plan__meridian-flow_meridian-base".to_string(),
374        )]);
375
376        let rewritten = rewrite_content_skills(input, &renames).unwrap().unwrap();
377        let fm = Frontmatter::parse(&rewritten).unwrap();
378        assert_eq!(
379            fm.skills(),
380            vec![
381                "plan__meridian-flow_meridian-base",
382                "planner",
383                "planning-extended"
384            ]
385        );
386    }
387}