1use 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)]
37pub struct CheckpointRecord {
38 pub turn: usize,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub pre: Option<CheckpointId>,
44 #[serde(alias = "post")]
46 pub id: CheckpointId,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub message: Option<String>,
51 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub touched_files: Vec<String>,
54 pub touched_via: TouchedVia,
58 #[serde(default, skip_serializing_if = "is_zero_i64")]
60 pub started_at: i64,
61 #[serde(default, skip_serializing_if = "is_zero_i64")]
63 pub finished_at: i64,
64 #[serde(default, skip_serializing_if = "is_zero_i64")]
66 pub saved_at: i64,
67}
68
69fn is_zero_i64(v: &i64) -> bool {
70 *v == 0
71}
72
73#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
74#[serde(rename_all = "kebab-case")]
75pub enum TouchedVia {
76 Structured,
77 ShellDiff,
78}
79
80#[derive(Debug, Clone)]
82pub struct CheckpointLogWriter {
83 path: PathBuf,
84}
85
86impl CheckpointLogWriter {
87 pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
89 let path = path.into();
90 if let Some(parent) = path.parent() {
91 std::fs::create_dir_all(parent).map_err(Error::Io)?;
92 }
93 let _ = OpenOptions::new()
95 .append(true)
96 .create(true)
97 .open(&path)
98 .map_err(Error::Io)?;
99 Ok(Self { path })
100 }
101
102 pub fn append(&self, rec: &CheckpointRecord) -> Result<()> {
105 let mut f = OpenOptions::new()
106 .append(true)
107 .create(true)
108 .open(&self.path)
109 .map_err(Error::Io)?;
110 let mut w = BufWriter::new(&mut f);
111 serde_json::to_writer(&mut w, rec).map_err(Error::Json)?;
112 w.write_all(b"\n").map_err(Error::Io)?;
113 w.flush().map_err(Error::Io)?;
114 Ok(())
115 }
116}
117
118pub fn read_log(path: &Path) -> Result<Vec<CheckpointRecord>> {
120 if !path.exists() {
121 return Ok(vec![]);
122 }
123 let f = File::open(path).map_err(Error::Io)?;
124 let r = BufReader::new(f);
125 let mut out = Vec::new();
126 for (i, line) in r.lines().enumerate() {
127 let line = line.map_err(Error::Io)?;
128 if line.trim().is_empty() {
129 continue;
130 }
131 let rec: CheckpointRecord = serde_json::from_str(&line).map_err(|e| Error::Tool {
132 name: "checkpoint-log".into(),
133 call_id: None,
134 message: format!("malformed log line {}: {e}", i + 1),
135 })?;
136 out.push(rec);
137 }
138 Ok(out)
139}
140
141pub fn truncate_to_turn(path: &Path, cutoff: usize) -> Result<()> {
144 let recs = read_log(path)?;
145 let kept: Vec<&CheckpointRecord> = recs.iter().filter(|r| r.turn < cutoff).collect();
146
147 let tmp = path.with_extension("jsonl.tmp");
148 {
149 let f = File::create(&tmp).map_err(Error::Io)?;
150 let mut w = BufWriter::new(f);
151 for r in &kept {
152 serde_json::to_writer(&mut w, r).map_err(Error::Json)?;
153 w.write_all(b"\n").map_err(Error::Io)?;
154 }
155 w.flush().map_err(Error::Io)?;
156 }
157 std::fs::rename(&tmp, path).map_err(Error::Io)?;
158 Ok(())
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 fn rec(turn: usize, id: &str) -> CheckpointRecord {
166 CheckpointRecord {
167 turn,
168 pre: None,
169 id: CheckpointId(id.to_string()),
170 message: None,
171 touched_files: vec!["a.txt".into()],
172 touched_via: TouchedVia::Structured,
173 started_at: 0,
174 finished_at: 0,
175 saved_at: 0,
176 }
177 }
178
179 #[test]
180 fn write_then_read_round_trip() {
181 let dir = tempfile::tempdir().unwrap();
182 let path = dir.path().join("checkpoints.jsonl");
183 let w = CheckpointLogWriter::open(&path).unwrap();
184 w.append(&rec(0, "aaa")).unwrap();
185 w.append(&rec(1, "bbb")).unwrap();
186 let read = read_log(&path).unwrap();
187 assert_eq!(read.len(), 2);
188 assert_eq!(read[0].turn, 0);
189 assert_eq!(read[1].id.0, "bbb");
190 }
191
192 #[test]
193 fn read_missing_file_returns_empty() {
194 let dir = tempfile::tempdir().unwrap();
195 let path = dir.path().join("does-not-exist.jsonl");
196 assert!(read_log(&path).unwrap().is_empty());
197 }
198
199 #[test]
200 fn truncate_drops_later_turns() {
201 let dir = tempfile::tempdir().unwrap();
202 let path = dir.path().join("c.jsonl");
203 let w = CheckpointLogWriter::open(&path).unwrap();
204 for t in 0..5 {
205 w.append(&rec(t, &format!("p{t}"))).unwrap();
206 }
207 truncate_to_turn(&path, 3).unwrap();
208 let kept = read_log(&path).unwrap();
209 assert_eq!(kept.len(), 3);
210 assert!(kept.iter().all(|r| r.turn < 3));
211 }
212
213 #[test]
214 fn truncate_to_zero_empties_file() {
215 let dir = tempfile::tempdir().unwrap();
216 let path = dir.path().join("c.jsonl");
217 let w = CheckpointLogWriter::open(&path).unwrap();
218 w.append(&rec(0, "x")).unwrap();
219 truncate_to_turn(&path, 0).unwrap();
220 assert!(read_log(&path).unwrap().is_empty());
221 }
222
223 #[test]
224 fn is_zero_i64_returns_true_for_zero() {
225 assert!(is_zero_i64(&0_i64));
226 }
227
228 #[test]
229 fn is_zero_i64_returns_false_for_positive() {
230 assert!(!is_zero_i64(&1_i64));
231 assert!(!is_zero_i64(&1000_i64));
232 }
233
234 #[test]
235 fn is_zero_i64_returns_false_for_negative() {
236 assert!(!is_zero_i64(&-1_i64));
237 }
238
239 #[test]
240 fn nonzero_timestamps_survive_round_trip() {
241 let dir = tempfile::tempdir().unwrap();
242 let path = dir.path().join("ts.jsonl");
243 let w = CheckpointLogWriter::open(&path).unwrap();
244 let mut r = rec(0, "ts-test");
245 r.started_at = 1_700_000_000;
246 r.finished_at = 1_700_000_001;
247 r.saved_at = 1_700_000_002;
248 w.append(&r).unwrap();
249 let rows = read_log(&path).unwrap();
250 assert_eq!(rows[0].started_at, 1_700_000_000);
251 assert_eq!(rows[0].finished_at, 1_700_000_001);
252 assert_eq!(rows[0].saved_at, 1_700_000_002);
253 }
254
255 #[test]
256 fn zero_timestamps_are_skipped_in_serialization() {
257 let r = rec(0, "z");
258 let json = serde_json::to_string(&r).unwrap();
259 assert!(
260 !json.contains("started_at"),
261 "zero started_at should be skipped"
262 );
263 assert!(
264 !json.contains("finished_at"),
265 "zero finished_at should be skipped"
266 );
267 assert!(
268 !json.contains("saved_at"),
269 "zero saved_at should be skipped"
270 );
271 }
272
273 #[test]
274 fn nonzero_timestamps_are_included_in_serialization() {
275 let mut r = rec(0, "z");
278 r.started_at = 1_700_000_000;
279 r.finished_at = 1_700_001_000;
280 r.saved_at = 1_700_002_000;
281 let json = serde_json::to_string(&r).unwrap();
282 assert!(
283 json.contains("started_at"),
284 "non-zero started_at must appear in serialization; got: {json}"
285 );
286 assert!(
287 json.contains("finished_at"),
288 "non-zero finished_at must appear in serialization; got: {json}"
289 );
290 assert!(
291 json.contains("saved_at"),
292 "non-zero saved_at must appear in serialization; got: {json}"
293 );
294 }
295
296 #[test]
297 fn read_log_skips_blank_lines() {
298 let dir = tempfile::tempdir().unwrap();
300 let path = dir.path().join("ck.jsonl");
301 let writer = CheckpointLogWriter::open(&path).unwrap();
302 writer.append(&rec(1, "a")).unwrap();
303
304 use std::io::Write;
306 let mut f = std::fs::OpenOptions::new()
307 .append(true)
308 .open(&path)
309 .unwrap();
310 writeln!(f).unwrap(); drop(f);
312
313 writer.append(&rec(2, "b")).unwrap();
314
315 let recs = read_log(&path).unwrap();
316 assert_eq!(recs.len(), 2, "blank line must be silently skipped");
317 assert_eq!(recs[0].turn, 1);
318 assert_eq!(recs[1].turn, 2);
319 }
320
321 #[test]
322 fn read_log_returns_error_for_malformed_json() {
323 let dir = tempfile::tempdir().unwrap();
325 let path = dir.path().join("bad.jsonl");
326 std::fs::write(&path, "not-valid-json\n").unwrap();
327 let result = read_log(&path);
328 assert!(result.is_err(), "malformed JSON must return an Err");
329 }
330
331 #[test]
332 fn truncate_keeps_records_below_cutoff_exactly() {
333 let dir = tempfile::tempdir().unwrap();
335 let path = dir.path().join("ck.jsonl");
336 let writer = CheckpointLogWriter::open(&path).unwrap();
337 for turn in 0..5 {
338 writer.append(&rec(turn, "x")).unwrap();
339 }
340 truncate_to_turn(&path, 3).unwrap();
342 let recs = read_log(&path).unwrap();
343 assert_eq!(recs.len(), 3);
344 assert_eq!(recs[2].turn, 2, "turn 2 must be kept (< 3)");
345 }
346}