1use 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, 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#[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
47pub fn parse(content: &str) -> Result<Frontmatter, FrontmatterError> {
49 Frontmatter::parse(content)
50}
51
52impl Frontmatter {
53 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 pub fn skills(&self) -> Vec<String> {
110 self.skills_structured().all()
111 }
112
113 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 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 pub fn name(&self) -> Option<&str> {
148 self.get("name").and_then(Value::as_str)
149 }
150
151 pub fn get(&self, key: &str) -> Option<&Value> {
153 self.yaml.get(yaml_key(key))
154 }
155
156 pub fn body(&self) -> &str {
158 &self.body
159 }
160
161 pub fn has_frontmatter(&self) -> bool {
163 self.has_frontmatter
164 }
165
166 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 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 pub fn remove(&mut self, key: &str) -> Option<Value> {
182 self.yaml.remove(yaml_key(key))
183 }
184
185 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
209pub 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
223pub 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
231pub 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
245pub 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}