Skip to main content

recall_echo/
frontmatter.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5/// YAML frontmatter for conversation archives.
6///
7/// Parses and renders a minimal subset: log number, date, session_id,
8/// message_count, duration, source, topics.
9/// No external YAML dependency — hand-rolled for the fixed schema.
10
11#[derive(Debug, Clone, PartialEq)]
12pub struct Frontmatter {
13    pub log: u32,
14    pub date: String,
15    pub session_id: String,
16    pub message_count: u32,
17    pub duration: String,
18    pub source: String,
19    pub topics: Vec<String>,
20}
21
22impl Frontmatter {
23    #[must_use]
24    pub fn render(&self) -> String {
25        let topics = if self.topics.is_empty() {
26            "[]".to_string()
27        } else {
28            let items: Vec<String> = self.topics.iter().map(|t| format!("\"{t}\"")).collect();
29            format!("[{}]", items.join(", "))
30        };
31
32        format!(
33            "---\nlog: {}\ndate: \"{}\"\nsession_id: \"{}\"\nmessage_count: {}\nduration: \"{}\"\nsource: \"{}\"\ntopics: {}\n---",
34            self.log, self.date, self.session_id, self.message_count, self.duration, self.source, topics
35        )
36    }
37}
38
39/// Parse frontmatter from file content. Returns None if no valid frontmatter found.
40#[must_use]
41pub fn parse(content: &str) -> Option<Frontmatter> {
42    let trimmed = content.trim();
43    if !trimmed.starts_with("---") {
44        return None;
45    }
46
47    let after_first = &trimmed[3..];
48    let end = after_first.find("---")?;
49    let block = &after_first[..end];
50
51    let mut log = None;
52    let mut date = None;
53    let mut session_id = None;
54    let mut message_count = None;
55    let mut duration = None;
56    let mut source = None;
57    let mut topics = Vec::new();
58
59    for line in block.lines() {
60        let line = line.trim();
61        if line.is_empty() {
62            continue;
63        }
64        let (key, val) = line.split_once(':')?;
65        let key = key.trim();
66        let val = val.trim().trim_matches('"');
67
68        match key {
69            "log" => log = val.parse().ok(),
70            "date" => date = Some(val.to_string()),
71            "session_id" => session_id = Some(val.to_string()),
72            "message_count" => message_count = val.parse().ok(),
73            "duration" => duration = Some(val.to_string()),
74            "source" => source = Some(val.to_string()),
75            "topics" => {
76                let inner = val.trim_matches(|c| c == '[' || c == ']');
77                if !inner.is_empty() {
78                    topics = inner
79                        .split(',')
80                        .map(|t| t.trim().trim_matches('"').to_string())
81                        .filter(|t| !t.is_empty())
82                        .collect();
83                }
84            }
85            _ => {}
86        }
87    }
88
89    Some(Frontmatter {
90        log: log?,
91        date: date?,
92        session_id: session_id.unwrap_or_default(),
93        message_count: message_count.unwrap_or(0),
94        duration: duration.unwrap_or_default(),
95        source: source.unwrap_or_default(),
96        topics,
97    })
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn render_parse_roundtrip() {
106        let fm = Frontmatter {
107            log: 42,
108            date: "2026-03-05T14:30:00Z".to_string(),
109            session_id: "abc123".to_string(),
110            message_count: 34,
111            duration: "45m".to_string(),
112            source: "jsonl".to_string(),
113            topics: vec!["auth".to_string(), "JWT".to_string()],
114        };
115        let rendered = fm.render();
116        let parsed = parse(&rendered).unwrap();
117        assert_eq!(fm, parsed);
118    }
119
120    #[test]
121    fn render_empty_topics() {
122        let fm = Frontmatter {
123            log: 1,
124            date: "2026-03-05T00:00:00Z".to_string(),
125            session_id: "xyz".to_string(),
126            message_count: 0,
127            duration: "< 1m".to_string(),
128            source: "jsonl".to_string(),
129            topics: vec![],
130        };
131        let rendered = fm.render();
132        assert!(rendered.contains("topics: []"));
133        let parsed = parse(&rendered).unwrap();
134        assert_eq!(parsed.topics, Vec::<String>::new());
135    }
136
137    #[test]
138    fn parse_missing_frontmatter() {
139        assert!(parse("no frontmatter here").is_none());
140    }
141
142    #[test]
143    fn parse_malformed_frontmatter() {
144        assert!(parse("---\nlog: abc\n---").is_none());
145    }
146}