2026-07-11 13:16:10 +07:00
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
|
|
|
|
#[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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct EditLog {
|
|
|
|
|
pub entries: Vec<EditLogEntry>,
|
|
|
|
|
pub path: std::path::PathBuf,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl EditLog {
|
|
|
|
|
pub fn new(session_dir: &std::path::Path) -> Self {
|
|
|
|
|
EditLog {
|
|
|
|
|
entries: Vec::new(),
|
|
|
|
|
path: session_dir.join("edits.jsonl"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> {
|
|
|
|
|
let line = serde_json::to_string(&entry)? + "\n";
|
|
|
|
|
let parent = self.path.parent().unwrap();
|
|
|
|
|
std::fs::create_dir_all(parent)?;
|
|
|
|
|
let mut file = std::fs::OpenOptions::new()
|
|
|
|
|
.create(true)
|
|
|
|
|
.append(true)
|
|
|
|
|
.open(&self.path)?;
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
file.write_all(line.as_bytes())?;
|
|
|
|
|
self.entries.push(entry);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
|
|
|
|
|
let content = std::fs::read_to_string(path)?;
|
|
|
|
|
let entries: Vec<EditLogEntry> = content
|
|
|
|
|
.lines()
|
|
|
|
|
.filter_map(|l| serde_json::from_str(l).ok())
|
|
|
|
|
.collect();
|
|
|
|
|
Ok(EditLog {
|
|
|
|
|
entries,
|
|
|
|
|
path: path.to_path_buf(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn recent(&self, n: usize) -> &[EditLogEntry] {
|
|
|
|
|
let len = self.entries.len();
|
|
|
|
|
let start = len.saturating_sub(n);
|
|
|
|
|
&self.entries[start..]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn len(&self) -> usize {
|
|
|
|
|
self.entries.len()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-11 22:10:17 +07:00
|
|
|
|
|
|
|
|
#[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);
|
|
|
|
|
assert_eq!(log.recent(5).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);
|
|
|
|
|
|
|
|
|
|
let loaded = EditLog::load(&log.path).unwrap();
|
|
|
|
|
assert_eq!(loaded.len(), 1);
|
|
|
|
|
assert_eq!(loaded.entries[0].reason, "test reason");
|
|
|
|
|
assert_eq!(loaded.entries[0].tool, "write");
|
|
|
|
|
assert_eq!(loaded.entries[0].path, "test.txt");
|
|
|
|
|
|
|
|
|
|
let recent = log.recent(1);
|
|
|
|
|
assert_eq!(recent.len(), 1);
|
|
|
|
|
assert_eq!(recent[0].bytes_delta, 42);
|
|
|
|
|
|
|
|
|
|
let empty = log.recent(0);
|
|
|
|
|
assert_eq!(empty.len(), 0);
|
|
|
|
|
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);
|
|
|
|
|
let recent = log.recent(3);
|
|
|
|
|
assert_eq!(recent.len(), 3);
|
|
|
|
|
assert_eq!(recent[0].reason, "reason 2");
|
|
|
|
|
assert_eq!(recent[2].reason, "reason 4");
|
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
|
|
|
}
|
|
|
|
|
}
|