recursive/
checkpoint_log.rs1use serde::{Deserialize, Serialize};
15use std::fs::{File, OpenOptions};
16use std::io::{BufRead, BufReader, BufWriter, Write};
17use std::path::{Path, PathBuf};
18
19use crate::checkpoint::CheckpointId;
20use crate::error::{Error, Result};
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct CheckpointRecord {
25 pub turn: usize,
27 pub pre: Option<CheckpointId>,
31 pub post: CheckpointId,
33 pub touched_files: Vec<String>,
35 pub touched_via: TouchedVia,
39 pub started_at: i64,
41 pub finished_at: i64,
43}
44
45#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
46#[serde(rename_all = "kebab-case")]
47pub enum TouchedVia {
48 Structured,
49 ShellDiff,
50}
51
52#[derive(Debug)]
54pub struct CheckpointLogWriter {
55 path: PathBuf,
56}
57
58impl CheckpointLogWriter {
59 pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
61 let path = path.into();
62 if let Some(parent) = path.parent() {
63 std::fs::create_dir_all(parent).map_err(Error::Io)?;
64 }
65 let _ = OpenOptions::new()
67 .append(true)
68 .create(true)
69 .open(&path)
70 .map_err(Error::Io)?;
71 Ok(Self { path })
72 }
73
74 pub fn append(&self, rec: &CheckpointRecord) -> Result<()> {
77 let mut f = OpenOptions::new()
78 .append(true)
79 .create(true)
80 .open(&self.path)
81 .map_err(Error::Io)?;
82 let mut w = BufWriter::new(&mut f);
83 serde_json::to_writer(&mut w, rec).map_err(Error::Json)?;
84 w.write_all(b"\n").map_err(Error::Io)?;
85 w.flush().map_err(Error::Io)?;
86 Ok(())
87 }
88}
89
90pub fn read_log(path: &Path) -> Result<Vec<CheckpointRecord>> {
92 if !path.exists() {
93 return Ok(vec![]);
94 }
95 let f = File::open(path).map_err(Error::Io)?;
96 let r = BufReader::new(f);
97 let mut out = Vec::new();
98 for (i, line) in r.lines().enumerate() {
99 let line = line.map_err(Error::Io)?;
100 if line.trim().is_empty() {
101 continue;
102 }
103 let rec: CheckpointRecord = serde_json::from_str(&line).map_err(|e| Error::Tool {
104 name: "checkpoint-log".into(),
105 message: format!("malformed log line {}: {e}", i + 1),
106 })?;
107 out.push(rec);
108 }
109 Ok(out)
110}
111
112pub fn truncate_to_turn(path: &Path, cutoff: usize) -> Result<()> {
115 let recs = read_log(path)?;
116 let kept: Vec<&CheckpointRecord> = recs.iter().filter(|r| r.turn < cutoff).collect();
117
118 let tmp = path.with_extension("jsonl.tmp");
119 {
120 let f = File::create(&tmp).map_err(Error::Io)?;
121 let mut w = BufWriter::new(f);
122 for r in &kept {
123 serde_json::to_writer(&mut w, r).map_err(Error::Json)?;
124 w.write_all(b"\n").map_err(Error::Io)?;
125 }
126 w.flush().map_err(Error::Io)?;
127 }
128 std::fs::rename(&tmp, path).map_err(Error::Io)?;
129 Ok(())
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 fn rec(turn: usize, post: &str) -> CheckpointRecord {
137 CheckpointRecord {
138 turn,
139 pre: None,
140 post: CheckpointId(post.to_string()),
141 touched_files: vec!["a.txt".into()],
142 touched_via: TouchedVia::Structured,
143 started_at: 0,
144 finished_at: 0,
145 }
146 }
147
148 #[test]
149 fn write_then_read_round_trip() {
150 let dir = tempfile::tempdir().unwrap();
151 let path = dir.path().join("checkpoints.jsonl");
152 let w = CheckpointLogWriter::open(&path).unwrap();
153 w.append(&rec(0, "aaa")).unwrap();
154 w.append(&rec(1, "bbb")).unwrap();
155 let read = read_log(&path).unwrap();
156 assert_eq!(read.len(), 2);
157 assert_eq!(read[0].turn, 0);
158 assert_eq!(read[1].post.0, "bbb");
159 }
160
161 #[test]
162 fn read_missing_file_returns_empty() {
163 let dir = tempfile::tempdir().unwrap();
164 let path = dir.path().join("does-not-exist.jsonl");
165 assert!(read_log(&path).unwrap().is_empty());
166 }
167
168 #[test]
169 fn truncate_drops_later_turns() {
170 let dir = tempfile::tempdir().unwrap();
171 let path = dir.path().join("c.jsonl");
172 let w = CheckpointLogWriter::open(&path).unwrap();
173 for t in 0..5 {
174 w.append(&rec(t, &format!("p{t}"))).unwrap();
175 }
176 truncate_to_turn(&path, 3).unwrap();
177 let kept = read_log(&path).unwrap();
178 assert_eq!(kept.len(), 3);
179 assert!(kept.iter().all(|r| r.turn < 3));
180 }
181
182 #[test]
183 fn truncate_to_zero_empties_file() {
184 let dir = tempfile::tempdir().unwrap();
185 let path = dir.path().join("c.jsonl");
186 let w = CheckpointLogWriter::open(&path).unwrap();
187 w.append(&rec(0, "x")).unwrap();
188 truncate_to_turn(&path, 0).unwrap();
189 assert!(read_log(&path).unwrap().is_empty());
190 }
191}