Skip to main content

talk_core/
frontmatter.rs

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, // YYYY-MM-DD
9    pub entries: u32,
10    pub last: String,    // YYYY-MM-DD
11}
12
13impl Frontmatter {
14    /// Render as a `---`-delimited YAML block (trailing newline included).
15    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    /// Parse the leading `---`-delimited block. Returns (frontmatter, rest_of_body).
30    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); // injected "entries:" did not take effect
108        assert!(!parsed.question.contains('\n'));
109    }
110}