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_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}