1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9pub struct FrontMatter {
10 #[serde(default)]
11 pub id: Option<String>,
12 #[serde(default)]
13 pub question: Option<String>,
14 #[serde(default)]
16 pub token_estimate: Option<u32>,
17 #[serde(default)]
18 pub tags: Vec<String>,
19 #[serde(default)]
20 pub requires: Vec<String>,
21 #[serde(default)]
22 pub related: Vec<String>,
23}
24
25pub fn parse(content: &str) -> (FrontMatter, String) {
28 let content = content.trim_start();
29 if !content.starts_with("---") {
30 return (FrontMatter::default(), content.to_string());
31 }
32
33 let after_open = &content[3..];
34 let Some(end_pos) = after_open.find("\n---") else {
36 return (FrontMatter::default(), content.to_string());
37 };
38
39 let yaml = after_open[..end_pos].trim();
40 let body = after_open[end_pos + 4..]
41 .trim_start_matches('\n')
42 .to_string();
43
44 let fm = parse_yaml(yaml);
45 (fm, body)
46}
47
48fn parse_yaml(yaml: &str) -> FrontMatter {
49 let mut fm = FrontMatter::default();
50 for line in yaml.lines() {
51 let line = line.trim();
52 if line.is_empty() || line.starts_with('#') {
53 continue;
54 }
55 let Some(colon) = line.find(':') else {
56 continue;
57 };
58 let key = line[..colon].trim();
59 let value = line[colon + 1..].trim();
60 match key {
61 "id" => fm.id = Some(value.to_string()),
62 "question" => fm.question = Some(value.to_string()),
63 "token_estimate" => fm.token_estimate = value.parse().ok(),
64 "tags" => fm.tags = parse_list(value),
65 "requires" => fm.requires = parse_list(value),
66 "related" => fm.related = parse_list(value),
67 _ => {}
68 }
69 }
70 fm
71}
72
73fn parse_list(value: &str) -> Vec<String> {
74 let value = value.trim();
75 if value.starts_with('[') && value.ends_with(']') {
76 let inner = &value[1..value.len() - 1];
77 inner
78 .split(',')
79 .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
80 .filter(|s| !s.is_empty())
81 .collect()
82 } else if !value.is_empty() {
83 vec![value.to_string()]
84 } else {
85 vec![]
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[test]
94 fn parses_full_frontmatter() {
95 let src = "---\nid: add-auth\ntoken_estimate: 120\ntags: [auth, security]\nrelated: [add-migration]\n---\n\n## Body text";
96 let (fm, body) = parse(src);
97 assert_eq!(fm.id.as_deref(), Some("add-auth"));
98 assert_eq!(fm.token_estimate, Some(120));
99 assert_eq!(fm.tags, vec!["auth", "security"]);
100 assert_eq!(fm.related, vec!["add-migration"]);
101 assert!(body.contains("Body text"));
102 }
103
104 #[test]
105 fn returns_defaults_when_no_frontmatter() {
106 let src = "## Just a body";
107 let (fm, body) = parse(src);
108 assert!(fm.id.is_none());
109 assert_eq!(body, "## Just a body");
110 }
111
112 #[test]
113 fn handles_question_field() {
114 let src = "---\nquestion: How do I add auth?\n---\nAnswer here.";
115 let (fm, body) = parse(src);
116 assert_eq!(fm.question.as_deref(), Some("How do I add auth?"));
117 assert_eq!(body, "Answer here.");
118 }
119}