#![allow( clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap )] //! 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) { if entries.len() >= MAX_MEMORY_ENTRIES { 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"; 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() } /// Returns `true` if the edit log is empty. pub fn is_empty(&self) -> bool { self.entries.is_empty() } }