//! Append-only JSONL edit log recording every file mutation made by tools, //! for audit and undo/history purposes. use serde::{Deserialize, Serialize}; /// A single recorded file edit: which tool made it, to which path, why, /// and a content hash/size delta for verification. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EditLogEntry { pub ts: i64, pub tool: String, pub path: String, pub reason: String, pub content_sha256: String, pub bytes_delta: i64, pub origin: String, pub session_id: String, } /// Maximum number of edit entries held in memory at once. /// Beyond this limit, old entries are dropped from the in-memory cache /// to prevent unbounded memory growth in long sessions. const MAX_MEMORY_ENTRIES: usize = 10_000; /// In-memory view of a session's edit log, backed by `edits.jsonl` on disk. #[derive(Debug, Clone)] pub struct EditLog { pub entries: Vec, pub path: std::path::PathBuf, } impl EditLog { /// Open (or start tracking) the edit log for a session directory, /// replaying any existing `edits.jsonl` into memory (capped at /// `MAX_MEMORY_ENTRIES` to prevent OOM). pub fn new(session_dir: &std::path::Path) -> Self { let path = session_dir.join("edits.jsonl"); let entries = Self::load_from_disk(&path); EditLog { entries, path } } /// Reads lines of edits.jsonl into memory, keeping only the most recent /// `MAX_MEMORY_ENTRIES` entries. The full history is preserved on disk /// regardless of the in-memory limit. fn load_from_disk(path: &std::path::Path) -> Vec { use std::io::{BufRead, BufReader}; let Ok(file) = std::fs::File::open(path) else { return Vec::new() }; let reader = BufReader::new(file); let mut entries: Vec = Vec::new(); for line in reader.lines() { let Ok(line) = line else { continue }; if let Ok(entry) = serde_json::from_str::(&line) { // Keep only the most recent entries in memory if entries.len() >= MAX_MEMORY_ENTRIES { // Drop oldest (front) to make room entries.remove(0); } entries.push(entry); } } entries } /// Append one entry to `edits.jsonl` on disk and to the in-memory log, /// with fsync for crash safety. /// /// Flow: serialize `entry` to a JSON line → ensure parent dir exists → /// open the file in append mode → write the line → fsync → push into /// `self.entries`. /// /// Why: appending (not rewriting) keeps the log durable and cheap even /// as it grows across a long session; fsync ensures the entry survives /// a crash rather than lingering in the page cache. /// /// Return: `Ok(())` on success; an `io::Error` if serialization or /// any filesystem operation fails. pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> { use std::io::Write; let line = serde_json::to_string(&entry)? + "\n"; // Ensure parent directory exists; fall back to the current // directory if path has no parent (should not happen in practice // since EditLog::new always joins to a session dir). if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent)?; } let mut file = std::fs::OpenOptions::new() .create(true) .append(true) .open(&self.path)?; file.write_all(line.as_bytes())?; file.sync_all()?; self.entries.push(entry); Ok(()) } /// Number of edit entries recorded so far in this log. pub fn len(&self) -> usize { self.entries.len() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_editlog_new_empty() { let dir = std::env::temp_dir().join("editlog_test"); let _ = std::fs::create_dir_all(&dir); let log = EditLog::new(&dir); assert_eq!(log.len(), 0); let _ = std::fs::remove_dir_all(&dir); } #[test] fn test_editlog_append_and_reload() { let dir = std::env::temp_dir().join("editlog_append_test"); let _ = std::fs::create_dir_all(&dir); let mut log = EditLog::new(&dir); let entry = EditLogEntry { ts: 1, tool: "write".to_string(), path: "test.txt".to_string(), reason: "test reason".to_string(), content_sha256: "abc123".to_string(), bytes_delta: 42, origin: "main".to_string(), session_id: "sess-1".to_string(), }; log.append(entry.clone()).unwrap(); assert_eq!(log.len(), 1); assert_eq!(log.entries[0].reason, "test reason"); assert_eq!(log.entries[0].tool, "write"); assert_eq!(log.entries[0].path, "test.txt"); assert_eq!(log.entries[0].bytes_delta, 42); let _ = std::fs::remove_dir_all(&dir); } #[test] fn test_editlog_multiple_entries() { let dir = std::env::temp_dir().join("editlog_multiple_test"); let _ = std::fs::create_dir_all(&dir); let mut log = EditLog::new(&dir); for i in 0..5 { log.append(EditLogEntry { ts: i, tool: "edit".to_string(), path: format!("file{i}.txt"), reason: format!("reason {i}"), content_sha256: "hash".to_string(), bytes_delta: 10 + i, origin: "main".to_string(), session_id: "sess-1".to_string(), }).unwrap(); } assert_eq!(log.len(), 5); assert_eq!(log.entries[0].reason, "reason 0"); assert_eq!(log.entries[4].reason, "reason 4"); let _ = std::fs::remove_dir_all(&dir); } }