Skip to main content

axon/
session_store.rs

1//! Session state / memory persistence — file-backed key-value store.
2//!
3//! Provides in-memory session state for `remember`/`recall` steps and
4//! file-backed persistence for `persist`/`retrieve`/`mutate`/`purge` steps.
5//!
6//! Storage format: JSON file (`.axon-session.json`) next to the source file.
7//! Each entry: { key, value, timestamp, source_step }.
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12/// A single memory entry in the session store.
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14pub struct MemoryEntry {
15    pub key: String,
16    pub value: String,
17    pub timestamp: u64,
18    pub source_step: String,
19}
20
21/// Session store — holds in-memory state and manages file persistence.
22#[derive(Debug)]
23pub struct SessionStore {
24    /// In-memory entries (remember/recall — ephemeral within a run).
25    memory: HashMap<String, MemoryEntry>,
26    /// Persistent entries (persist/retrieve — file-backed across runs).
27    store: HashMap<String, MemoryEntry>,
28    /// Path to the persistent store file.
29    store_path: PathBuf,
30    /// Whether the persistent store has been modified (needs flush).
31    dirty: bool,
32}
33
34impl SessionStore {
35    /// Create a new session store. Loads existing persistent data if present.
36    pub fn new(source_file: &str) -> Self {
37        let store_path = Self::store_path_for(source_file);
38        let store = Self::load_store(&store_path);
39
40        SessionStore {
41            memory: HashMap::new(),
42            store,
43            store_path,
44            dirty: false,
45        }
46    }
47
48    /// Derive the store file path from the source file path.
49    fn store_path_for(source_file: &str) -> PathBuf {
50        let p = Path::new(source_file);
51        let stem = p.file_stem().unwrap_or_default().to_string_lossy();
52        let dir = p.parent().unwrap_or_else(|| Path::new("."));
53        dir.join(format!(".{stem}.session.json"))
54    }
55
56    /// Load persistent store from disk, or return empty if not found.
57    fn load_store(path: &Path) -> HashMap<String, MemoryEntry> {
58        match std::fs::read_to_string(path) {
59            Ok(json) => {
60                let entries: Vec<MemoryEntry> = serde_json::from_str(&json).unwrap_or_default();
61                entries.into_iter().map(|e| (e.key.clone(), e)).collect()
62            }
63            Err(_) => HashMap::new(),
64        }
65    }
66
67    /// Flush persistent store to disk.
68    pub fn flush(&self) -> Result<(), String> {
69        if !self.dirty {
70            return Ok(());
71        }
72        let entries: Vec<&MemoryEntry> = self.store.values().collect();
73        let json = serde_json::to_string_pretty(&entries)
74            .map_err(|e| format!("Failed to serialize session store: {e}"))?;
75        std::fs::write(&self.store_path, json)
76            .map_err(|e| format!("Failed to write session store: {e}"))?;
77        Ok(())
78    }
79
80    // ── Remember / Recall (ephemeral in-memory) ─────────────────────────
81
82    /// Store a value in ephemeral memory.
83    pub fn remember(&mut self, key: &str, value: &str, source_step: &str) {
84        let entry = MemoryEntry {
85            key: key.to_string(),
86            value: value.to_string(),
87            timestamp: current_timestamp(),
88            source_step: source_step.to_string(),
89        };
90        self.memory.insert(key.to_string(), entry);
91    }
92
93    /// Recall a value from ephemeral memory. Returns None if not found.
94    pub fn recall(&self, key: &str) -> Option<&MemoryEntry> {
95        self.memory.get(key)
96    }
97
98    /// List all ephemeral memory entries.
99    pub fn memory_entries(&self) -> Vec<&MemoryEntry> {
100        self.memory.values().collect()
101    }
102
103    // ── Persist / Retrieve / Mutate / Purge (file-backed) ───────────────
104
105    /// Persist a value to the file-backed store.
106    pub fn persist(&mut self, key: &str, value: &str, source_step: &str) {
107        let entry = MemoryEntry {
108            key: key.to_string(),
109            value: value.to_string(),
110            timestamp: current_timestamp(),
111            source_step: source_step.to_string(),
112        };
113        self.store.insert(key.to_string(), entry);
114        self.dirty = true;
115    }
116
117    /// Retrieve a value from the file-backed store.
118    pub fn retrieve(&self, key: &str) -> Option<&MemoryEntry> {
119        self.store.get(key)
120    }
121
122    /// Retrieve all entries matching a simple query (substring match on key or value).
123    pub fn retrieve_query(&self, query: &str) -> Vec<&MemoryEntry> {
124        let q = query.to_lowercase();
125        self.store
126            .values()
127            .filter(|e| e.key.to_lowercase().contains(&q) || e.value.to_lowercase().contains(&q))
128            .collect()
129    }
130
131    /// Mutate (update) an existing entry in the store.
132    /// Returns true if the key existed and was updated.
133    pub fn mutate(&mut self, key: &str, new_value: &str, source_step: &str) -> bool {
134        if self.store.contains_key(key) {
135            self.persist(key, new_value, source_step);
136            true
137        } else {
138            false
139        }
140    }
141
142    /// Purge (delete) an entry from the store.
143    /// Returns true if the key existed and was removed.
144    pub fn purge(&mut self, key: &str) -> bool {
145        if self.store.remove(key).is_some() {
146            self.dirty = true;
147            true
148        } else {
149            false
150        }
151    }
152
153    /// Purge all entries matching a query (substring match).
154    /// Returns the number of entries removed.
155    pub fn purge_query(&mut self, query: &str) -> usize {
156        let q = query.to_lowercase();
157        let keys_to_remove: Vec<String> = self
158            .store
159            .iter()
160            .filter(|(_, e)| e.key.to_lowercase().contains(&q) || e.value.to_lowercase().contains(&q))
161            .map(|(k, _)| k.clone())
162            .collect();
163        let count = keys_to_remove.len();
164        for k in keys_to_remove {
165            self.store.remove(&k);
166        }
167        if count > 0 {
168            self.dirty = true;
169        }
170        count
171    }
172
173    /// Number of entries in the persistent store.
174    pub fn store_count(&self) -> usize {
175        self.store.len()
176    }
177
178    /// Number of entries in ephemeral memory.
179    pub fn memory_count(&self) -> usize {
180        self.memory.len()
181    }
182
183    /// Path to the store file (for display).
184    pub fn store_path(&self) -> &Path {
185        &self.store_path
186    }
187}
188
189fn current_timestamp() -> u64 {
190    std::time::SystemTime::now()
191        .duration_since(std::time::UNIX_EPOCH)
192        .unwrap_or_default()
193        .as_secs()
194}
195
196// ── Tests ──────────────────────────────────────────────────────────────────
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn temp_store(name: &str) -> SessionStore {
203        let tmp = std::env::temp_dir().join(format!("axon_test_{name}.axon"));
204        SessionStore::new(tmp.to_str().unwrap())
205    }
206
207    #[test]
208    fn remember_and_recall() {
209        let mut store = temp_store("rem_recall");
210        store.remember("key1", "value1", "step_a");
211        let entry = store.recall("key1").unwrap();
212        assert_eq!(entry.value, "value1");
213        assert_eq!(entry.source_step, "step_a");
214        assert!(store.recall("nonexistent").is_none());
215    }
216
217    #[test]
218    fn remember_overwrites() {
219        let mut store = temp_store("rem_overwrite");
220        store.remember("k", "v1", "s1");
221        store.remember("k", "v2", "s2");
222        assert_eq!(store.recall("k").unwrap().value, "v2");
223    }
224
225    #[test]
226    fn persist_and_retrieve() {
227        let mut store = temp_store("persist_ret");
228        store.persist("data", "hello world", "persist_step");
229        let entry = store.retrieve("data").unwrap();
230        assert_eq!(entry.value, "hello world");
231        assert!(store.retrieve("missing").is_none());
232    }
233
234    #[test]
235    fn retrieve_query_matches() {
236        let mut store = temp_store("ret_query");
237        store.persist("analysis_result", "the answer is 42", "s1");
238        store.persist("user_pref", "dark mode", "s2");
239        store.persist("analysis_notes", "see appendix", "s3");
240
241        let results = store.retrieve_query("analysis");
242        assert_eq!(results.len(), 2);
243    }
244
245    #[test]
246    fn mutate_existing() {
247        let mut store = temp_store("mutate");
248        store.persist("k", "old", "s1");
249        assert!(store.mutate("k", "new", "s2"));
250        assert_eq!(store.retrieve("k").unwrap().value, "new");
251    }
252
253    #[test]
254    fn mutate_missing_returns_false() {
255        let mut store = temp_store("mutate_miss");
256        assert!(!store.mutate("nope", "val", "s1"));
257    }
258
259    #[test]
260    fn purge_existing() {
261        let mut store = temp_store("purge");
262        store.persist("k", "v", "s1");
263        assert!(store.purge("k"));
264        assert!(store.retrieve("k").is_none());
265    }
266
267    #[test]
268    fn purge_missing_returns_false() {
269        let mut store = temp_store("purge_miss");
270        assert!(!store.purge("nope"));
271    }
272
273    #[test]
274    fn purge_query_removes_matching() {
275        let mut store = temp_store("purge_q");
276        store.persist("temp_a", "x", "s1");
277        store.persist("temp_b", "y", "s2");
278        store.persist("keep_c", "z", "s3");
279        let removed = store.purge_query("temp");
280        assert_eq!(removed, 2);
281        assert_eq!(store.store_count(), 1);
282    }
283
284    #[test]
285    fn flush_and_reload() {
286        let tmp = std::env::temp_dir().join("axon_test_flush.axon");
287        let store_path = {
288            let mut store = SessionStore::new(tmp.to_str().unwrap());
289            store.persist("persistent_key", "persistent_value", "test");
290            store.flush().unwrap();
291            store.store_path().to_path_buf()
292        };
293
294        // Reload from disk
295        let store2 = SessionStore::new(tmp.to_str().unwrap());
296        let entry = store2.retrieve("persistent_key").unwrap();
297        assert_eq!(entry.value, "persistent_value");
298
299        // Cleanup
300        let _ = std::fs::remove_file(&store_path);
301    }
302
303    #[test]
304    fn memory_count_and_store_count() {
305        let mut store = temp_store("counts");
306        store.remember("a", "1", "s");
307        store.remember("b", "2", "s");
308        store.persist("x", "10", "s");
309        assert_eq!(store.memory_count(), 2);
310        assert_eq!(store.store_count(), 1);
311    }
312
313    #[test]
314    fn store_path_derives_from_source() {
315        let store = SessionStore::new("/path/to/myprogram.axon");
316        let path_str = store.store_path().to_string_lossy();
317        assert!(path_str.contains(".myprogram.session.json"));
318    }
319
320    #[test]
321    fn timestamp_is_recent() {
322        let mut store = temp_store("timestamp");
323        store.remember("k", "v", "s");
324        let ts = store.recall("k").unwrap().timestamp;
325        assert!(ts > 1700000000); // After ~2023
326    }
327}