1use std::fs;
6use std::path::Path;
7
8use crate::error::RecallError;
9
10pub const DEFAULT_MAX_ENTRIES: usize = 5;
11const ENTRY_SEPARATOR: &str = "\n---\n\n";
12
13pub struct EphemeralEntry {
14 pub session_id: String,
15 pub date: String,
16 pub duration: String,
17 pub message_count: u32,
18 pub archive_file: String,
19 pub summary: String,
20}
21
22impl EphemeralEntry {
23 #[must_use]
24 pub fn render(&self) -> String {
25 let display_date = self.date.replace('T', " ").replace('Z', " UTC");
26 format!(
27 "## Session {} — {}\n**Duration**: ~{} | **Messages**: {} | **Archive**: {}\n**Summary**: {}",
28 self.session_id, display_date, self.duration, self.message_count,
29 self.archive_file, self.summary
30 )
31 }
32}
33
34pub fn append_entry(ephemeral_path: &Path, entry: &EphemeralEntry) -> Result<(), RecallError> {
36 let existing = if ephemeral_path.exists() {
37 fs::read_to_string(ephemeral_path)?
38 } else {
39 String::new()
40 };
41
42 let new_content = if existing.trim().is_empty() {
43 entry.render()
44 } else {
45 format!(
46 "{}{}{}",
47 existing.trim_end(),
48 ENTRY_SEPARATOR,
49 entry.render()
50 )
51 };
52
53 fs::write(ephemeral_path, format!("{new_content}\n"))?;
54
55 Ok(())
56}
57
58#[must_use]
60pub fn parse_entries(content: &str) -> Vec<&str> {
61 if content.trim().is_empty() {
62 return Vec::new();
63 }
64
65 content
66 .split("\n---\n")
67 .map(|e| e.trim())
68 .filter(|e| !e.is_empty())
69 .collect()
70}
71
72pub fn count_entries(ephemeral_path: &Path) -> Result<usize, RecallError> {
74 if !ephemeral_path.exists() {
75 return Ok(0);
76 }
77 let content = fs::read_to_string(ephemeral_path)?;
78 Ok(parse_entries(&content).len())
79}
80
81pub fn trim_to_limit(ephemeral_path: &Path, max_entries: usize) -> Result<(), RecallError> {
83 if !ephemeral_path.exists() {
84 return Ok(());
85 }
86
87 let content = fs::read_to_string(ephemeral_path)?;
88
89 let entries = parse_entries(&content);
90 if entries.len() <= max_entries {
91 return Ok(());
92 }
93
94 let kept: Vec<&str> = entries[entries.len() - max_entries..].to_vec();
96 let new_content = kept.join(ENTRY_SEPARATOR);
97
98 fs::write(ephemeral_path, format!("{new_content}\n"))?;
99
100 Ok(())
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 fn make_entry(id: &str, num: u32) -> EphemeralEntry {
108 EphemeralEntry {
109 session_id: id.to_string(),
110 date: "2026-03-05T14:30:00Z".to_string(),
111 duration: "10m".to_string(),
112 message_count: num,
113 archive_file: format!("conversation-{num:03}.md"),
114 summary: format!("Session {id} summary"),
115 }
116 }
117
118 #[test]
119 fn append_to_empty_file() {
120 let tmp = tempfile::tempdir().unwrap();
121 let path = tmp.path().join("EPHEMERAL.md");
122
123 append_entry(&path, &make_entry("aaa", 1)).unwrap();
124
125 let content = fs::read_to_string(&path).unwrap();
126 assert!(content.contains("## Session aaa"));
127 assert!(content.contains("conversation-001.md"));
128 }
129
130 #[test]
131 fn append_to_existing() {
132 let tmp = tempfile::tempdir().unwrap();
133 let path = tmp.path().join("EPHEMERAL.md");
134
135 append_entry(&path, &make_entry("aaa", 1)).unwrap();
136 append_entry(&path, &make_entry("bbb", 2)).unwrap();
137
138 let content = fs::read_to_string(&path).unwrap();
139 assert!(content.contains("## Session aaa"));
140 assert!(content.contains("## Session bbb"));
141 assert!(content.contains("\n---\n"));
142 }
143
144 #[test]
145 fn parse_entries_basic() {
146 let content = "## Session aaa\nstuff\n---\n\n## Session bbb\nmore stuff";
147 let entries = parse_entries(content);
148 assert_eq!(entries.len(), 2);
149 assert!(entries[0].contains("aaa"));
150 assert!(entries[1].contains("bbb"));
151 }
152
153 #[test]
154 fn parse_entries_empty() {
155 assert_eq!(parse_entries("").len(), 0);
156 assert_eq!(parse_entries(" \n ").len(), 0);
157 }
158
159 #[test]
160 fn count_entries_basic() {
161 let tmp = tempfile::tempdir().unwrap();
162 let path = tmp.path().join("EPHEMERAL.md");
163
164 assert_eq!(count_entries(&path).unwrap(), 0);
165
166 append_entry(&path, &make_entry("a", 1)).unwrap();
167 assert_eq!(count_entries(&path).unwrap(), 1);
168
169 append_entry(&path, &make_entry("b", 2)).unwrap();
170 assert_eq!(count_entries(&path).unwrap(), 2);
171 }
172
173 #[test]
174 fn trim_below_limit_is_noop() {
175 let tmp = tempfile::tempdir().unwrap();
176 let path = tmp.path().join("EPHEMERAL.md");
177
178 append_entry(&path, &make_entry("a", 1)).unwrap();
179 append_entry(&path, &make_entry("b", 2)).unwrap();
180
181 let before = fs::read_to_string(&path).unwrap();
182 trim_to_limit(&path, 5).unwrap();
183 let after = fs::read_to_string(&path).unwrap();
184 assert_eq!(before, after);
185 }
186
187 #[test]
188 fn trim_at_limit_is_noop() {
189 let tmp = tempfile::tempdir().unwrap();
190 let path = tmp.path().join("EPHEMERAL.md");
191
192 for i in 0..5 {
193 append_entry(&path, &make_entry(&format!("s{i}"), i + 1)).unwrap();
194 }
195
196 assert_eq!(count_entries(&path).unwrap(), 5);
197 trim_to_limit(&path, 5).unwrap();
198 assert_eq!(count_entries(&path).unwrap(), 5);
199 }
200
201 #[test]
202 fn trim_over_limit_removes_oldest() {
203 let tmp = tempfile::tempdir().unwrap();
204 let path = tmp.path().join("EPHEMERAL.md");
205
206 for i in 0..7 {
207 append_entry(&path, &make_entry(&format!("s{i}"), i + 1)).unwrap();
208 }
209
210 assert_eq!(count_entries(&path).unwrap(), 7);
211 trim_to_limit(&path, 5).unwrap();
212 assert_eq!(count_entries(&path).unwrap(), 5);
213
214 let content = fs::read_to_string(&path).unwrap();
215 assert!(!content.contains("Session s0"));
216 assert!(!content.contains("Session s1"));
217 assert!(content.contains("Session s2"));
218 assert!(content.contains("Session s6"));
219 }
220
221 #[test]
222 fn trim_nonexistent_file_is_ok() {
223 let tmp = tempfile::tempdir().unwrap();
224 let path = tmp.path().join("EPHEMERAL.md");
225 assert!(trim_to_limit(&path, 5).is_ok());
226 }
227}