Skip to main content

recall_echo/
ephemeral.rs

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
30/// Append a session entry to EPHEMERAL.md
31pub 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/// Parse EPHEMERAL.md content into individual entry strings.
55#[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
68/// Count current entries in EPHEMERAL.md
69pub 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
77/// Trim EPHEMERAL.md to max_entries, removing oldest entries (FIFO).
78pub 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    // Keep the last max_entries (most recent)
91    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}