mars_agents/frontmatter/
mod.rs1use indexmap::{IndexMap, IndexSet};
2use serde_yaml::{Mapping, Value};
3
4#[derive(Debug, Clone)]
6pub struct Frontmatter {
7 yaml: Mapping,
8 body: String,
9 has_frontmatter: bool,
10}
11
12#[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
22pub fn parse(content: &str) -> Result<Frontmatter, FrontmatterError> {
24 Frontmatter::parse(content)
25}
26
27impl Frontmatter {
28 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 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 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 pub fn name(&self) -> Option<&str> {
111 self.get("name").and_then(Value::as_str)
112 }
113
114 pub fn get(&self, key: &str) -> Option<&Value> {
116 self.yaml.get(yaml_key(key))
117 }
118
119 pub fn body(&self) -> &str {
121 &self.body
122 }
123
124 pub fn has_frontmatter(&self) -> bool {
126 self.has_frontmatter
127 }
128
129 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
153pub 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
175pub 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 =
264 IndexMap::from([("plan".to_string(), "plan__haowjy_meridian-base".to_string())]);
265
266 let rewritten = rewrite_content_skills(input, &renames).unwrap().unwrap();
267 let fm = Frontmatter::parse(&rewritten).unwrap();
268 assert_eq!(
269 fm.skills(),
270 vec!["plan__haowjy_meridian-base", "planner", "planning-extended"]
271 );
272 }
273}