Skip to main content

recall_echo/
frontmatter.rs

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