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/// Errors from frontmatter parsing.
13#[derive(Debug, thiserror::Error)]
14pub enum FrontmatterError {
15    #[error("malformed YAML frontmatter: {0}")]
16    MalformedYaml(#[from] serde_yaml::Error),
17
18    #[error("frontmatter is not a YAML mapping")]
19    NotAMapping,
20}
21
22/// Parse markdown content into frontmatter and body.
23pub fn parse(content: &str) -> Result<Frontmatter, FrontmatterError> {
24    Frontmatter::parse(content)
25}
26
27impl Frontmatter {
28    /// Parse a markdown document into frontmatter + body.
29    pub fn parse(content: &str) -> Result<Self, FrontmatterError> {
30        let (first_line, after_first_line) = split_first_line(content);
31        if !is_delimiter_line(first_line) {
32            return Ok(Self {
33                yaml: Mapping::new(),
34                body: content.to_string(),
35                has_frontmatter: false,
36            });
37        }
38
39        let mut yaml_end = None;
40        let mut offset = 0usize;
41        for line in after_first_line.split_inclusive('\n') {
42            if is_delimiter_line(line) {
43                yaml_end = Some((offset, line.len()));
44                break;
45            }
46            offset += line.len();
47        }
48
49        let Some((yaml_len, closing_len)) = yaml_end else {
50            return Ok(Self {
51                yaml: Mapping::new(),
52                body: content.to_string(),
53                has_frontmatter: false,
54            });
55        };
56
57        let yaml_text = &after_first_line[..yaml_len];
58        let body_start = yaml_len + closing_len;
59        let body = after_first_line[body_start..].to_string();
60
61        if yaml_text.trim().is_empty() {
62            return Ok(Self {
63                yaml: Mapping::new(),
64                body,
65                has_frontmatter: true,
66            });
67        }
68
69        let value: Value = serde_yaml::from_str(yaml_text)?;
70        let yaml = match value {
71            Value::Mapping(mapping) => mapping,
72            Value::Null => Mapping::new(),
73            _ => return Err(FrontmatterError::NotAMapping),
74        };
75
76        Ok(Self {
77            yaml,
78            body,
79            has_frontmatter: true,
80        })
81    }
82
83    /// Read the `skills` list.
84    pub fn skills(&self) -> Vec<String> {
85        self.get("skills")
86            .and_then(Value::as_sequence)
87            .map(|skills| {
88                skills
89                    .iter()
90                    .filter_map(Value::as_str)
91                    .map(str::to_owned)
92                    .collect()
93            })
94            .unwrap_or_default()
95    }
96
97    /// Replace the `skills` list.
98    pub fn set_skills(&mut self, skills: Vec<String>) {
99        let key = yaml_key("skills");
100        if skills.is_empty() {
101            self.yaml.remove(&key);
102            return;
103        }
104
105        let sequence = skills.into_iter().map(Value::String).collect();
106        self.yaml.insert(key, Value::Sequence(sequence));
107    }
108
109    /// Read the `name` field if present.
110    pub fn name(&self) -> Option<&str> {
111        self.get("name").and_then(Value::as_str)
112    }
113
114    /// Read any YAML field by key.
115    pub fn get(&self, key: &str) -> Option<&Value> {
116        self.yaml.get(yaml_key(key))
117    }
118
119    /// Markdown body after frontmatter.
120    pub fn body(&self) -> &str {
121        &self.body
122    }
123
124    /// Whether this document contains frontmatter delimiters.
125    pub fn has_frontmatter(&self) -> bool {
126        self.has_frontmatter
127    }
128
129    /// Serialize back to full markdown.
130    pub fn render(&self) -> String {
131        if !self.has_frontmatter && self.yaml.is_empty() {
132            return self.body.clone();
133        }
134
135        let mut out = String::from("---\n");
136        if !self.yaml.is_empty() {
137            let mut yaml = serde_yaml::to_string(&self.yaml)
138                .expect("serializing frontmatter mapping should succeed");
139            if let Some(stripped) = yaml.strip_prefix("---\n") {
140                yaml = stripped.to_string();
141            }
142            out.push_str(&yaml);
143            if !yaml.ends_with('\n') {
144                out.push('\n');
145            }
146        }
147        out.push_str("---\n");
148        out.push_str(&self.body);
149        out
150    }
151}
152
153/// Rename skills in frontmatter using exact-match replacement.
154pub fn rewrite_skills(
155    fm: &mut Frontmatter,
156    renames: &IndexMap<String, String>,
157) -> IndexSet<String> {
158    let mut renamed = IndexSet::new();
159    let mut skills = fm.skills();
160
161    for skill in &mut skills {
162        if let Some(new_name) = renames.get(skill.as_str()) {
163            renamed.insert(skill.clone());
164            *skill = new_name.clone();
165        }
166    }
167
168    if !renamed.is_empty() {
169        fm.set_skills(skills);
170    }
171
172    renamed
173}
174
175/// Parse content, rewrite skills, and render updated content if changed.
176pub fn rewrite_content_skills(
177    content: &str,
178    renames: &IndexMap<String, String>,
179) -> Result<Option<String>, FrontmatterError> {
180    let mut fm = Frontmatter::parse(content)?;
181    let renamed = rewrite_skills(&mut fm, renames);
182    if renamed.is_empty() {
183        Ok(None)
184    } else {
185        Ok(Some(fm.render()))
186    }
187}
188
189fn split_first_line(content: &str) -> (&str, &str) {
190    match content.split_once('\n') {
191        Some((first, rest)) => (first, rest),
192        None => (content, ""),
193    }
194}
195
196fn is_delimiter_line(line: &str) -> bool {
197    line.trim_end() == "---"
198}
199
200fn yaml_key(key: &str) -> Value {
201    Value::String(key.to_string())
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn parse_and_render_roundtrip() {
210        let input = "---\nname: coder\nskills:\n- plan\n- review\n---\n# Body\ntext";
211        let fm = Frontmatter::parse(input).unwrap();
212        assert_eq!(fm.name(), Some("coder"));
213        assert_eq!(fm.skills(), vec!["plan", "review"]);
214        assert_eq!(fm.body(), "# Body\ntext");
215        assert!(fm.has_frontmatter());
216
217        let rendered = fm.render();
218        let reparsed = Frontmatter::parse(&rendered).unwrap();
219        assert_eq!(reparsed.name(), Some("coder"));
220        assert_eq!(reparsed.skills(), vec!["plan", "review"]);
221        assert_eq!(reparsed.body(), "# Body\ntext");
222    }
223
224    #[test]
225    fn parse_without_frontmatter_keeps_body() {
226        let input = "# Markdown only\ntext";
227        let fm = parse(input).unwrap();
228        assert!(!fm.has_frontmatter());
229        assert!(fm.skills().is_empty());
230        assert_eq!(fm.body(), input);
231        assert_eq!(fm.render(), input);
232    }
233
234    #[test]
235    fn parse_empty_frontmatter_roundtrips_delimiters() {
236        let input = "---\n---\nbody";
237        let fm = Frontmatter::parse(input).unwrap();
238        assert!(fm.has_frontmatter());
239        assert!(fm.skills().is_empty());
240        assert_eq!(fm.body(), "body");
241        assert_eq!(fm.render(), input);
242    }
243
244    #[test]
245    fn parse_malformed_yaml_errors() {
246        let input = "---\ninvalid: [:\n---\nbody";
247        assert!(matches!(
248            Frontmatter::parse(input),
249            Err(FrontmatterError::MalformedYaml(_))
250        ));
251    }
252
253    #[test]
254    fn parse_flow_style_skills() {
255        let input = "---\nskills: [plan, review]\n---\nbody";
256        let fm = Frontmatter::parse(input).unwrap();
257        assert_eq!(fm.skills(), vec!["plan", "review"]);
258    }
259
260    #[test]
261    fn rewrite_does_not_corrupt_substrings() {
262        let input = "---\nskills:\n- plan\n- planner\n- planning-extended\n---\nbody\n";
263        let renames = IndexMap::from([(
264            "plan".to_string(),
265            "plan__meridian-flow_meridian-base".to_string(),
266        )]);
267
268        let rewritten = rewrite_content_skills(input, &renames).unwrap().unwrap();
269        let fm = Frontmatter::parse(&rewritten).unwrap();
270        assert_eq!(
271            fm.skills(),
272            vec![
273                "plan__meridian-flow_meridian-base",
274                "planner",
275                "planning-extended"
276            ]
277        );
278    }
279}