chore: fix all 702 clippy warnings across codebase - auto-fix 475 via cargo clippy --fix - fix remaining 227 manually: uninlined_format_args, redundant_closure, match_same_arms, underscore_binding, format_push_string, items_after_statements, needless_pass_by_value, clone_on_copy, case_sensitive_extension, single_match/let-else, write_with_newline, and other clippy lints
162 lines
5.8 KiB
Rust
162 lines
5.8 KiB
Rust
//! 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<EditLogEntry>,
|
|
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<EditLogEntry> {
|
|
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<EditLogEntry> = Vec::new();
|
|
for line in reader.lines() {
|
|
let Ok(line) = line else { continue };
|
|
if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&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{}.txt", i),
|
|
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);
|
|
}
|
|
}
|