1#[derive(Clone, Debug, PartialEq, Eq)]
2pub struct Frontmatter {
3 pub id: String,
4 pub question: String,
5 pub slug: String,
6 pub pack: String,
7 pub addressee: String,
8 pub created: String, pub entries: u32,
10 pub last: String, }
12
13impl Frontmatter {
14 pub fn to_yaml(&self) -> String {
16 format!(
17 "---\nid: {id}\nquestion: {q}\nslug: {slug}\npack: {pack}\naddressee: {addr}\ncreated: {created}\nentries: {entries}\nlast: {last}\n---\n",
18 id = self.id,
19 q = quote(&self.question),
20 slug = self.slug,
21 pack = self.pack,
22 addr = self.addressee,
23 created = self.created,
24 entries = self.entries,
25 last = self.last,
26 )
27 }
28
29 pub fn parse(input: &str) -> Option<(Frontmatter, &str)> {
31 let rest = input.strip_prefix("---\n")?;
32 let end = rest.find("\n---\n")?;
33 let block = &rest[..end];
34 let body = &rest[end + 5..];
35
36 let mut map = std::collections::HashMap::new();
37 for line in block.lines() {
38 if let Some((k, v)) = line.split_once(": ") {
39 map.insert(k.trim().to_string(), v.trim().to_string());
40 }
41 }
42 Some((
43 Frontmatter {
44 id: map.get("id")?.clone(),
45 question: unquote(map.get("question")?),
46 slug: map.get("slug")?.clone(),
47 pack: map.get("pack")?.clone(),
48 addressee: map.get("addressee")?.clone(),
49 created: map.get("created")?.clone(),
50 entries: map.get("entries")?.parse().ok()?,
51 last: map.get("last")?.clone(),
52 },
53 body,
54 ))
55 }
56}
57
58fn quote(s: &str) -> String {
59 let one_line = s.replace(['\n', '\r'], " ");
60 format!("\"{}\"", one_line.replace('\\', "\\\\").replace('"', "\\\""))
61}
62
63fn unquote(s: &str) -> String {
64 let trimmed = s.strip_prefix('"').and_then(|s| s.strip_suffix('"')).unwrap_or(s);
65 trimmed.replace("\\\"", "\"").replace("\\\\", "\\")
66}
67
68#[cfg(test)]
69mod tests {
70 use super::*;
71
72 fn sample() -> Frontmatter {
73 Frontmatter {
74 id: "avoidance-core".into(),
75 question: "What am I avoiding?".into(),
76 slug: "what-am-i-avoiding".into(),
77 pack: "examen".into(),
78 addressee: "self".into(),
79 created: "2026-06-06".into(),
80 entries: 3,
81 last: "2026-06-08".into(),
82 }
83 }
84
85 #[test]
86 fn round_trips() {
87 let fm = sample();
88 let rendered = fm.to_yaml() + "\n## 2026-06-06\nbody text\n";
89 let (parsed, body) = Frontmatter::parse(&rendered).unwrap();
90 assert_eq!(parsed, fm);
91 assert_eq!(body, "\n## 2026-06-06\nbody text\n");
92 }
93
94 #[test]
95 fn quotes_questions_with_special_chars() {
96 let mut fm = sample();
97 fm.question = "Why \"this\": really?".into();
98 let (parsed, _) = Frontmatter::parse(&(fm.to_yaml() + "\nx\n")).unwrap();
99 assert_eq!(parsed.question, "Why \"this\": really?");
100 }
101
102 #[test]
103 fn newline_in_question_cannot_break_out_of_yaml() {
104 let mut fm = sample();
105 fm.question = "line one\n---\nentries: 9999".into();
106 let (parsed, _) = Frontmatter::parse(&(fm.to_yaml() + "\nbody\n")).unwrap();
107 assert_eq!(parsed.entries, fm.entries); assert!(!parsed.question.contains('\n'));
109 }
110}