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