use serde::{Deserialize, Serialize};
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use crate::checkpoint::CheckpointId;
use crate::error::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CheckpointRecord {
pub turn: usize,
pub pre: Option<CheckpointId>,
pub post: CheckpointId,
pub touched_files: Vec<String>,
pub touched_via: TouchedVia,
pub started_at: i64,
pub finished_at: i64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum TouchedVia {
Structured,
ShellDiff,
}
#[derive(Debug)]
pub struct CheckpointLogWriter {
path: PathBuf,
}
impl CheckpointLogWriter {
pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
let path = path.into();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(Error::Io)?;
}
let _ = OpenOptions::new()
.append(true)
.create(true)
.open(&path)
.map_err(Error::Io)?;
Ok(Self { path })
}
pub fn append(&self, rec: &CheckpointRecord) -> Result<()> {
let mut f = OpenOptions::new()
.append(true)
.create(true)
.open(&self.path)
.map_err(Error::Io)?;
let mut w = BufWriter::new(&mut f);
serde_json::to_writer(&mut w, rec).map_err(Error::Json)?;
w.write_all(b"\n").map_err(Error::Io)?;
w.flush().map_err(Error::Io)?;
Ok(())
}
}
pub fn read_log(path: &Path) -> Result<Vec<CheckpointRecord>> {
if !path.exists() {
return Ok(vec![]);
}
let f = File::open(path).map_err(Error::Io)?;
let r = BufReader::new(f);
let mut out = Vec::new();
for (i, line) in r.lines().enumerate() {
let line = line.map_err(Error::Io)?;
if line.trim().is_empty() {
continue;
}
let rec: CheckpointRecord = serde_json::from_str(&line).map_err(|e| Error::Tool {
name: "checkpoint-log".into(),
message: format!("malformed log line {}: {e}", i + 1),
})?;
out.push(rec);
}
Ok(out)
}
pub fn truncate_to_turn(path: &Path, cutoff: usize) -> Result<()> {
let recs = read_log(path)?;
let kept: Vec<&CheckpointRecord> = recs.iter().filter(|r| r.turn < cutoff).collect();
let tmp = path.with_extension("jsonl.tmp");
{
let f = File::create(&tmp).map_err(Error::Io)?;
let mut w = BufWriter::new(f);
for r in &kept {
serde_json::to_writer(&mut w, r).map_err(Error::Json)?;
w.write_all(b"\n").map_err(Error::Io)?;
}
w.flush().map_err(Error::Io)?;
}
std::fs::rename(&tmp, path).map_err(Error::Io)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(turn: usize, post: &str) -> CheckpointRecord {
CheckpointRecord {
turn,
pre: None,
post: CheckpointId(post.to_string()),
touched_files: vec!["a.txt".into()],
touched_via: TouchedVia::Structured,
started_at: 0,
finished_at: 0,
}
}
#[test]
fn write_then_read_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("checkpoints.jsonl");
let w = CheckpointLogWriter::open(&path).unwrap();
w.append(&rec(0, "aaa")).unwrap();
w.append(&rec(1, "bbb")).unwrap();
let read = read_log(&path).unwrap();
assert_eq!(read.len(), 2);
assert_eq!(read[0].turn, 0);
assert_eq!(read[1].post.0, "bbb");
}
#[test]
fn read_missing_file_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.jsonl");
assert!(read_log(&path).unwrap().is_empty());
}
#[test]
fn truncate_drops_later_turns() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("c.jsonl");
let w = CheckpointLogWriter::open(&path).unwrap();
for t in 0..5 {
w.append(&rec(t, &format!("p{t}"))).unwrap();
}
truncate_to_turn(&path, 3).unwrap();
let kept = read_log(&path).unwrap();
assert_eq!(kept.len(), 3);
assert!(kept.iter().all(|r| r.turn < 3));
}
#[test]
fn truncate_to_zero_empties_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("c.jsonl");
let w = CheckpointLogWriter::open(&path).unwrap();
w.append(&rec(0, "x")).unwrap();
truncate_to_turn(&path, 0).unwrap();
assert!(read_log(&path).unwrap().is_empty());
}
}