recall_echo/
frontmatter.rs1#[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#[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}