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/// Parse content, rewrite skills, and render updated content if changed.
224pub fn rewrite_content_skills(
225    content: &str,
226    renames: &IndexMap<String, String>,
227) -> Result<Option<String>, FrontmatterError> {
228    let mut fm = Frontmatter::parse(content)?;
229    let renamed = rewrite_skills(&mut fm, renames);
230    if renamed.is_empty() {
231        Ok(None)
232    } else {
233        Ok(Some(fm.render()))
234    }
235}
236
237fn yaml_str_list(val: &Value) -> Vec<String> {
238    match val {
239        Value::Sequence(seq) => seq
240            .iter()
241            .filter_map(Value::as_str)
242            .map(str::to_owned)
243            .collect(),
244        Value::String(s) => vec![s.clone()],
245        _ => vec![],
246    }
247}
248
249fn rewrite_skill_value(
250    value: &mut Value,
251    renames: &IndexMap<String, String>,
252    renamed: &mut IndexSet<String>,
253) {
254    match value {
255        Value::Sequence(seq) => {
256            for item in seq {
257                let Some(skill) = item.as_str() else {
258                    continue;
259                };
260                if let Some(new_name) = renames.get(skill) {
261                    renamed.insert(skill.to_string());
262                    *item = Value::String(new_name.clone());
263                }
264            }
265        }
266        Value::String(skill) => {
267            if let Some(new_name) = renames.get(skill.as_str()) {
268                renamed.insert(skill.clone());
269                *skill = new_name.clone();
270            }
271        }
272        Value::Mapping(mapping) => {
273            for field in ["load", "available"] {
274                if let Some(child) = mapping.get_mut(yaml_key(field)) {
275                    rewrite_skill_value(child, renames, renamed);
276                }
277            }
278        }
279        _ => {}
280    }
281}
282
283fn split_first_line(content: &str) -> (&str, &str) {
284    match content.split_once('\n') {
285        Some((first, rest)) => (first, rest),
286        None => (content, ""),
287    }
288}
289
290fn is_delimiter_line(line: &str) -> bool {
291    line.trim_end() == "---"
292}
293
294fn yaml_key(key: &str) -> Value {
295    Value::String(key.to_string())
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn parse_and_render_roundtrip() {
304        let input = "---\nname: coder\nskills:\n- plan\n- review\n---\n# Body\ntext";
305        let fm = Frontmatter::parse(input).unwrap();
306        assert_eq!(fm.name(), Some("coder"));
307        assert_eq!(fm.skills(), vec!["plan", "review"]);
308        assert_eq!(fm.body(), "# Body\ntext");
309        assert!(fm.has_frontmatter());
310
311        let rendered = fm.render();
312        let reparsed = Frontmatter::parse(&rendered).unwrap();
313        assert_eq!(reparsed.name(), Some("coder"));
314        assert_eq!(reparsed.skills(), vec!["plan", "review"]);
315        assert_eq!(reparsed.body(), "# Body\ntext");
316    }
317
318    #[test]
319    fn parse_without_frontmatter_keeps_body() {
320        let input = "# Markdown only\ntext";
321        let fm = parse(input).unwrap();
322        assert!(!fm.has_frontmatter());
323        assert!(fm.skills().is_empty());
324        assert_eq!(fm.body(), input);
325        assert_eq!(fm.render(), input);
326    }
327
328    #[test]
329    fn parse_empty_frontmatter_roundtrips_delimiters() {
330        let input = "---\n---\nbody";
331        let fm = Frontmatter::parse(input).unwrap();
332        assert!(fm.has_frontmatter());
333        assert!(fm.skills().is_empty());
334        assert_eq!(fm.body(), "body");
335        assert_eq!(fm.render(), input);
336    }
337
338    #[test]
339    fn parse_malformed_yaml_errors() {
340        let input = "---\ninvalid: [:\n---\nbody";
341        assert!(matches!(
342            Frontmatter::parse(input),
343            Err(FrontmatterError::MalformedYaml(_))
344        ));
345    }
346
347    #[test]
348    fn parse_flow_style_skills() {
349        let input = "---\nskills: [plan, review]\n---\nbody";
350        let fm = Frontmatter::parse(input).unwrap();
351        assert_eq!(fm.skills(), vec!["plan", "review"]);
352        assert_eq!(fm.skills_structured().load, vec!["plan", "review"]);
353        assert!(fm.skills_structured().available.is_empty());
354    }
355
356    #[test]
357    fn parse_structured_skills() {
358        let input = "---\nskills:\n  load: [principles]\n  available:\n    - planning\n    - spawn\n---\nbody";
359        let fm = Frontmatter::parse(input).unwrap();
360        assert_eq!(fm.skills(), vec!["principles", "planning", "spawn"]);
361        assert_eq!(fm.skills_structured().load, vec!["principles"]);
362        assert_eq!(fm.skills_structured().available, vec!["planning", "spawn"]);
363    }
364
365    #[test]
366    fn rewrite_structured_skills_preserves_split() {
367        let input = "---\nskills:\n  load: [plan]\n  available: [review]\n---\nbody\n";
368        let renames = IndexMap::from([
369            ("plan".to_string(), "plan__org".to_string()),
370            ("review".to_string(), "review__org".to_string()),
371        ]);
372
373        let rewritten = rewrite_content_skills(input, &renames).unwrap().unwrap();
374        let fm = Frontmatter::parse(&rewritten).unwrap();
375        assert_eq!(fm.skills_structured().load, vec!["plan__org"]);
376        assert_eq!(fm.skills_structured().available, vec!["review__org"]);
377    }
378
379    #[test]
380    fn rewrite_does_not_corrupt_substrings() {
381        let input = "---\nskills:\n- plan\n- planner\n- planning-extended\n---\nbody\n";
382        let renames = IndexMap::from([(
383            "plan".to_string(),
384            "plan__meridian-flow_meridian-base".to_string(),
385        )]);
386
387        let rewritten = rewrite_content_skills(input, &renames).unwrap().unwrap();
388        let fm = Frontmatter::parse(&rewritten).unwrap();
389        assert_eq!(
390            fm.skills(),
391            vec![
392                "plan__meridian-flow_meridian-base",
393                "planner",
394                "planning-extended"
395            ]
396        );
397    }
398}