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 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
198pub 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
212pub 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}