Skip to main content

recall_echo/
ephemeral.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5use 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
34/// Append a session entry to EPHEMERAL.md
35pub 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/// Parse EPHEMERAL.md content into individual entry strings.
59#[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
72/// Count current entries in EPHEMERAL.md
73pub 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
81/// Trim EPHEMERAL.md to max_entries, removing oldest entries (FIFO).
82pub 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    // Keep the last max_entries (most recent)
95    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}