1use serde::{Deserialize, Serialize};
9use std::fmt;
10use std::path::{Path, PathBuf};
11
12pub type AgentCheckpoint = Checkpoint;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct Checkpoint {
18 pub id: String,
20 pub agent_name: String,
22 pub session_id: String,
24 pub step: usize,
26 pub messages: Vec<CheckpointMessage>,
28 pub tool_calls: Vec<ToolCallRecord>,
30 pub partial_results: Vec<String>,
32 pub timestamp: u64,
34 #[serde(default)]
36 pub ttl_secs: Option<u64>,
37 pub status: CheckpointStatus,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct CheckpointMessage {
43 pub role: String, pub content: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct ToolCallRecord {
49 pub tool_name: String,
50 pub arguments: String,
51 pub result: Option<String>,
52 pub success: bool,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub enum CheckpointStatus {
57 InProgress,
59 Completed,
61 Failed(String),
63 Halted(String),
65}
66
67
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
71pub struct CheckpointMetadata {
72 pub agent_id: String,
73 pub run_id: String,
74 pub step: usize,
75 pub timestamp: u64,
76 pub ttl_secs: Option<u64>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum CheckpointError {
83 NotFound(String),
84 Corrupt(String),
85 IoError(String),
86}
87
88impl fmt::Display for CheckpointError {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 match self {
91 Self::NotFound(msg) => write!(f, "checkpoint not found: {msg}"),
92 Self::Corrupt(msg) => write!(f, "checkpoint corrupt: {msg}"),
93 Self::IoError(msg) => write!(f, "checkpoint io error: {msg}"),
94 }
95 }
96}
97
98impl std::error::Error for CheckpointError {}
99
100impl fmt::Display for Checkpoint {
101 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 write!(
103 f,
104 "Checkpoint {{ id: {}, agent: {}, session: {}, step: {}, status: {:?} }}",
105 self.id, self.agent_name, self.session_id, self.step, self.status
106 )
107 }
108}
109
110impl fmt::Display for CheckpointMetadata {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 write!(
113 f,
114 "CheckpointMetadata {{ agent_id: {}, run_id: {}, step: {}, ts: {} }}",
115 self.agent_id, self.run_id, self.step, self.timestamp
116 )
117 }
118}
119
120impl From<&Checkpoint> for CheckpointMetadata {
121 fn from(cp: &Checkpoint) -> Self {
122 Self {
123 agent_id: cp.agent_name.clone(),
124 run_id: cp.session_id.clone(),
125 step: cp.step,
126 timestamp: cp.timestamp,
127 ttl_secs: cp.ttl_secs,
128 }
129 }
130}
131
132pub fn checkpoint_key(agent_id: &str, run_id: &str) -> String {
134 format!("{agent_id}/{run_id}")
135}
136
137#[allow(clippy::too_many_arguments)]
139pub fn create_checkpoint(
140 agent_id: &str,
141 run_id: &str,
142 step: usize,
143 messages: Vec<CheckpointMessage>,
144 tool_calls: Vec<ToolCallRecord>,
145 partial_results: Vec<String>,
146 timestamp: u64,
147 status: CheckpointStatus,
148) -> AgentCheckpoint {
149 create_checkpoint_with_ttl(
150 agent_id, run_id, step, messages, tool_calls, partial_results, timestamp, None, status,
151 )
152}
153
154#[allow(clippy::too_many_arguments)]
156pub fn create_checkpoint_with_ttl(
157 agent_id: &str,
158 run_id: &str,
159 step: usize,
160 messages: Vec<CheckpointMessage>,
161 tool_calls: Vec<ToolCallRecord>,
162 partial_results: Vec<String>,
163 timestamp: u64,
164 ttl_secs: Option<u64>,
165 status: CheckpointStatus,
166) -> AgentCheckpoint {
167 let key = checkpoint_key(agent_id, run_id);
168 AgentCheckpoint {
169 id: format!("{key}:step{step}"),
170 agent_name: agent_id.to_string(),
171 session_id: run_id.to_string(),
172 step,
173 messages,
174 tool_calls,
175 partial_results,
176 timestamp,
177 ttl_secs,
178 status,
179 }
180}
181
182pub fn serialize_state(checkpoint: &AgentCheckpoint) -> Result<Vec<u8>, CheckpointError> {
184 serde_json::to_vec(checkpoint).map_err(|e| CheckpointError::IoError(e.to_string()))
185}
186
187pub fn restore_checkpoint(bytes: &[u8]) -> Result<AgentCheckpoint, CheckpointError> {
189 if bytes.is_empty() {
190 return Err(CheckpointError::Corrupt("empty payload".into()));
191 }
192 serde_json::from_slice(bytes).map_err(|e| CheckpointError::Corrupt(e.to_string()))
193}
194
195pub fn is_expired(metadata: &CheckpointMetadata, now: u64) -> bool {
197 metadata
198 .ttl_secs
199 .is_some_and(|ttl| now > metadata.timestamp.saturating_add(ttl))
200}
201
202pub fn expired_metadata<'a>(
204 entries: impl IntoIterator<Item = &'a CheckpointMetadata>,
205 now: u64,
206) -> Vec<&'a CheckpointMetadata> {
207 entries.into_iter().filter(|m| is_expired(m, now)).collect()
208}
209
210fn checkpoint_step_filename(run_id: &str, step: usize) -> String {
211 format!("{run_id}_{step}.json")
212}
213
214pub struct CheckpointManager {
216 checkpoint_dir: PathBuf,
218}
219
220impl CheckpointManager {
221 pub fn new(checkpoint_dir: &Path) -> std::io::Result<Self> {
223 std::fs::create_dir_all(checkpoint_dir)?;
224 Ok(Self {
225 checkpoint_dir: checkpoint_dir.to_path_buf(),
226 })
227 }
228
229 pub fn default_dir() -> std::io::Result<Self> {
231 let dir = dirs_or_default().join("checkpoints");
232 Self::new(&dir)
233 }
234
235 pub fn save(&self, checkpoint: &Checkpoint) -> std::io::Result<()> {
237 let filename = checkpoint_step_filename(&checkpoint.session_id, checkpoint.step);
238 let path = self.checkpoint_dir.join(&filename);
239 let bytes = serialize_state(checkpoint)
240 .map_err(|e| std::io::Error::other(e.to_string()))?;
241 std::fs::write(&path, bytes)?;
242
243 let latest_path = self.checkpoint_dir.join(format!("{}_latest.json", checkpoint.session_id));
245 std::fs::write(&latest_path, &filename)?;
246
247 Ok(())
248 }
249
250 pub fn load_latest(&self, session_id: &str) -> std::io::Result<Option<Checkpoint>> {
252 let latest_path = self.checkpoint_dir.join(format!("{}_latest.json", session_id));
253 if !latest_path.exists() {
254 return Ok(None);
255 }
256
257 let filename = std::fs::read_to_string(&latest_path)?;
258 let checkpoint_path = self.checkpoint_dir.join(filename.trim());
259 if !checkpoint_path.exists() {
260 return Ok(None);
261 }
262
263 let bytes = std::fs::read(&checkpoint_path)?;
264 let checkpoint = restore_checkpoint(&bytes)
265 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
266 Ok(Some(checkpoint))
267 }
268
269 pub fn list_checkpoints(&self, session_id: &str) -> std::io::Result<Vec<Checkpoint>> {
271 let mut checkpoints = Vec::new();
272 let prefix = format!("{}_", session_id);
273
274 for entry in std::fs::read_dir(&self.checkpoint_dir)? {
275 let entry = entry?;
276 let name = entry.file_name().to_string_lossy().to_string();
277 if name.starts_with(&prefix) && name.ends_with(".json") && !name.contains("latest") {
278 let bytes = std::fs::read(entry.path())?;
279 if let Ok(cp) = restore_checkpoint(&bytes) {
280 checkpoints.push(cp);
281 }
282 }
283 }
284
285 checkpoints.sort_by_key(|c| c.step);
286 Ok(checkpoints)
287 }
288
289 pub fn cleanup(&self, session_id: &str) -> std::io::Result<usize> {
291 let mut removed = 0;
292 let prefix = format!("{}_", session_id);
293
294 for entry in std::fs::read_dir(&self.checkpoint_dir)? {
295 let entry = entry?;
296 let name = entry.file_name().to_string_lossy().to_string();
297 if name.starts_with(&prefix) {
298 std::fs::remove_file(entry.path())?;
299 removed += 1;
300 }
301 }
302
303 Ok(removed)
304 }
305
306
307 pub fn cleanup_expired(&self, now: u64) -> std::io::Result<usize> {
309 let mut removed = 0;
310 for entry in std::fs::read_dir(&self.checkpoint_dir)? {
311 let entry = entry?;
312 let path = entry.path();
313 let name = entry.file_name().to_string_lossy().to_string();
314 if !name.ends_with(".json") || name.contains("latest") {
315 continue;
316 }
317 let bytes = match std::fs::read(&path) {
318 Ok(b) => b,
319 Err(_) => continue,
320 };
321 let Ok(cp) = restore_checkpoint(&bytes) else {
322 continue;
323 };
324 if is_expired(&CheckpointMetadata::from(&cp), now) {
325 std::fs::remove_file(path)?;
326 removed += 1;
327 }
328 }
329 Ok(removed)
330 }
331
332 pub fn has_checkpoint(&self, session_id: &str) -> bool {
334 let latest_path = self.checkpoint_dir.join(format!("{}_latest.json", session_id));
335 latest_path.exists()
336 }
337}
338
339fn dirs_or_default() -> PathBuf {
340 dirs::data_dir()
341 .unwrap_or_else(|| PathBuf::from("/tmp"))
342 .join("ares")
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use std::time::{SystemTime, UNIX_EPOCH};
349
350 fn now_secs() -> u64 {
351 SystemTime::now()
352 .duration_since(UNIX_EPOCH)
353 .unwrap()
354 .as_secs()
355 }
356
357 fn temp_dir() -> tempfile::TempDir {
358 tempfile::tempdir().unwrap()
359 }
360
361 fn sample_messages() -> Vec<CheckpointMessage> {
362 vec![
363 CheckpointMessage {
364 role: "user".into(),
365 content: "Hello".into(),
366 },
367 CheckpointMessage {
368 role: "assistant".into(),
369 content: "Hi there".into(),
370 },
371 ]
372 }
373
374 fn sample_tool_calls() -> Vec<ToolCallRecord> {
375 vec![ToolCallRecord {
376 tool_name: "search".into(),
377 arguments: "query".into(),
378 result: Some("found it".into()),
379 success: true,
380 }]
381 }
382
383 fn sample_checkpoint(session: &str, step: usize) -> Checkpoint {
384 create_checkpoint(
385 "test-agent",
386 session,
387 step,
388 sample_messages(),
389 sample_tool_calls(),
390 vec!["partial output".into()],
391 now_secs(),
392 CheckpointStatus::InProgress,
393 )
394 }
395
396 #[test]
397 fn checkpoint_key_uses_agent_id_run_id_format() {
398 assert_eq!(checkpoint_key("agent-a", "run-42"), "agent-a/run-42");
399 }
400
401 #[test]
402 fn checkpoint_key_unique_per_pair() {
403 assert_ne!(checkpoint_key("a1", "r1"), checkpoint_key("a1", "r2"));
404 assert_ne!(checkpoint_key("a1", "r1"), checkpoint_key("a2", "r1"));
405 }
406
407 #[test]
408 fn create_checkpoint_sets_ids_and_fields() {
409 let cp = create_checkpoint(
410 "researcher",
411 "run-9",
412 2,
413 sample_messages(),
414 sample_tool_calls(),
415 vec!["out".into()],
416 1_700_000_000,
417 CheckpointStatus::InProgress,
418 );
419 assert_eq!(cp.agent_name, "researcher");
420 assert_eq!(cp.session_id, "run-9");
421 assert_eq!(cp.step, 2);
422 assert!(cp.id.contains("researcher/run-9"));
423 }
424
425 #[test]
426 fn serialize_state_and_restore_checkpoint_are_symmetric() {
427 let cp = sample_checkpoint("sym", 1);
428 let bytes = serialize_state(&cp).unwrap();
429 assert_eq!(restore_checkpoint(&bytes).unwrap(), cp);
430 }
431
432 #[test]
433 fn restore_checkpoint_rejects_empty_bytes() {
434 assert!(matches!(
435 restore_checkpoint(&[]),
436 Err(CheckpointError::Corrupt(_))
437 ));
438 }
439
440 #[test]
441 fn restore_checkpoint_rejects_invalid_json() {
442 assert!(matches!(
443 restore_checkpoint(b"{not json"),
444 Err(CheckpointError::Corrupt(_))
445 ));
446 }
447
448 #[test]
449 fn agent_checkpoint_serde_json_roundtrip() {
450 let cp = sample_checkpoint("serde-cp", 0);
451 let json = serde_json::to_string(&cp).unwrap();
452 let back: AgentCheckpoint = serde_json::from_str(&json).unwrap();
453 assert_eq!(back, cp);
454 }
455
456 #[test]
457 fn checkpoint_metadata_serde_json_roundtrip() {
458 let meta = CheckpointMetadata {
459 agent_id: "agent-x".into(),
460 run_id: "run-y".into(),
461 step: 5,
462 timestamp: 100,
463 ttl_secs: Some(3600),
464 };
465 let back: CheckpointMetadata = serde_json::from_str(&serde_json::to_string(&meta).unwrap()).unwrap();
466 assert_eq!(back, meta);
467 }
468
469 #[test]
470 fn checkpoint_metadata_from_checkpoint() {
471 let cp = sample_checkpoint("run-meta", 3);
472 let meta = CheckpointMetadata::from(&cp);
473 assert_eq!(meta.agent_id, "test-agent");
474 assert_eq!(meta.run_id, "run-meta");
475 assert_eq!(meta.step, 3);
476 assert!(meta.ttl_secs.is_none());
477 }
478
479 #[test]
480 fn is_expired_false_without_ttl() {
481 let meta = CheckpointMetadata {
482 agent_id: "a".into(),
483 run_id: "r".into(),
484 step: 0,
485 timestamp: 100,
486 ttl_secs: None,
487 };
488 assert!(!is_expired(&meta, 999_999));
489 }
490
491 #[test]
492 fn is_expired_true_past_ttl() {
493 let meta = CheckpointMetadata {
494 agent_id: "a".into(),
495 run_id: "r".into(),
496 step: 0,
497 timestamp: 100,
498 ttl_secs: Some(60),
499 };
500 assert!(!is_expired(&meta, 160));
501 assert!(is_expired(&meta, 161));
502 }
503
504 #[test]
505 fn expired_metadata_filters_only_stale() {
506 let fresh = CheckpointMetadata {
507 agent_id: "a".into(),
508 run_id: "fresh".into(),
509 step: 0,
510 timestamp: 180,
511 ttl_secs: Some(60),
512 };
513 let stale = CheckpointMetadata {
514 agent_id: "a".into(),
515 run_id: "stale".into(),
516 step: 0,
517 timestamp: 100,
518 ttl_secs: Some(60),
519 };
520 let expired = expired_metadata([&fresh, &stale], 200);
521 assert_eq!(expired.len(), 1);
522 assert_eq!(expired[0].run_id, "stale");
523 }
524
525 #[test]
526 fn checkpoint_error_not_found_display() {
527 let err = CheckpointError::NotFound("sess-1".into());
528 assert_eq!(err.to_string(), "checkpoint not found: sess-1");
529 }
530
531 #[test]
532 fn checkpoint_error_corrupt_display() {
533 let err = CheckpointError::Corrupt("bad json".into());
534 assert_eq!(err.to_string(), "checkpoint corrupt: bad json");
535 }
536
537 #[test]
538 fn checkpoint_error_io_error_display() {
539 let err = CheckpointError::IoError("disk full".into());
540 assert_eq!(err.to_string(), "checkpoint io error: disk full");
541 }
542
543 #[test]
544 fn checkpoint_error_debug_contains_variant() {
545 let dbg = format!("{:?}", CheckpointError::NotFound("x".into()));
546 assert!(dbg.contains("NotFound"));
547 }
548
549 #[test]
550 fn checkpoint_clone_equals_original() {
551 let cp = sample_checkpoint("clone", 0);
552 assert_eq!(cp.clone(), cp);
553 }
554
555 #[test]
556 fn checkpoint_metadata_clone_equals_original() {
557 let meta = CheckpointMetadata::from(&sample_checkpoint("clone-meta", 1));
558 assert_eq!(meta.clone(), meta);
559 }
560
561 #[test]
562 fn checkpoint_display_includes_ids() {
563 let s = sample_checkpoint("disp", 4).to_string();
564 assert!(s.contains("disp"));
565 assert!(s.contains("test-agent"));
566 }
567
568 #[test]
569 fn checkpoint_metadata_display_includes_run_id() {
570 let s = CheckpointMetadata::from(&sample_checkpoint("disp-meta", 0)).to_string();
571 assert!(s.contains("disp-meta"));
572 }
573
574 #[test]
575 fn checkpoint_debug_impl_available() {
576 let dbg = format!("{:?}", sample_checkpoint("dbg", 0));
577 assert!(dbg.contains("Checkpoint"));
578 }
579
580 #[test]
581 fn test_save_and_load() {
582 let dir = temp_dir();
583 let mgr = CheckpointManager::new(dir.path()).unwrap();
584 let cp = sample_checkpoint("sess1", 0);
585 mgr.save(&cp).unwrap();
586 let loaded = mgr.load_latest("sess1").unwrap().unwrap();
587 assert_eq!(loaded.session_id, "sess1");
588 assert_eq!(loaded.step, 0);
589 }
590
591 #[test]
592 fn test_load_nonexistent() {
593 let dir = temp_dir();
594 let mgr = CheckpointManager::new(dir.path()).unwrap();
595 assert!(mgr.load_latest("nonexistent").unwrap().is_none());
596 }
597
598 #[test]
599 fn test_multiple_steps() {
600 let dir = temp_dir();
601 let mgr = CheckpointManager::new(dir.path()).unwrap();
602 mgr.save(&sample_checkpoint("sess1", 0)).unwrap();
603 mgr.save(&sample_checkpoint("sess1", 1)).unwrap();
604 mgr.save(&sample_checkpoint("sess1", 2)).unwrap();
605 assert_eq!(mgr.load_latest("sess1").unwrap().unwrap().step, 2);
606 assert_eq!(mgr.list_checkpoints("sess1").unwrap().len(), 3);
607 }
608
609 #[test]
610 fn test_cleanup() {
611 let dir = temp_dir();
612 let mgr = CheckpointManager::new(dir.path()).unwrap();
613 mgr.save(&sample_checkpoint("sess1", 0)).unwrap();
614 mgr.save(&sample_checkpoint("sess1", 1)).unwrap();
615 assert!(mgr.cleanup("sess1").unwrap() >= 2);
616 assert!(!mgr.has_checkpoint("sess1"));
617 }
618
619 #[test]
620 fn cleanup_expired_removes_stale_files_only() {
621 let dir = temp_dir();
622 let mgr = CheckpointManager::new(dir.path()).unwrap();
623 let now = now_secs();
624 let stale = create_checkpoint_with_ttl(
625 "test-agent",
626 "expired-run",
627 0,
628 sample_messages(),
629 sample_tool_calls(),
630 vec![],
631 now.saturating_sub(10_000),
632 Some(60),
633 CheckpointStatus::InProgress,
634 );
635 mgr.save(&stale).unwrap();
636 mgr.save(&sample_checkpoint("fresh-run", 0)).unwrap();
637 assert_eq!(mgr.cleanup_expired(now).unwrap(), 1);
638 assert!(mgr.load_latest("expired-run").unwrap().is_none());
639 assert!(mgr.load_latest("fresh-run").unwrap().is_some());
640 }
641
642 #[test]
643 fn test_separate_sessions() {
644 let dir = temp_dir();
645 let mgr = CheckpointManager::new(dir.path()).unwrap();
646 mgr.save(&sample_checkpoint("sess1", 0)).unwrap();
647 mgr.save(&sample_checkpoint("sess2", 0)).unwrap();
648 mgr.cleanup("sess1").unwrap();
649 assert!(!mgr.has_checkpoint("sess1"));
650 assert!(mgr.has_checkpoint("sess2"));
651 }
652
653 #[test]
654 fn test_checkpoint_status_serialization() {
655 let dir = temp_dir();
656 let mgr = CheckpointManager::new(dir.path()).unwrap();
657 let mut cp = sample_checkpoint("sess1", 0);
658 cp.status = CheckpointStatus::Failed("OOM".into());
659 mgr.save(&cp).unwrap();
660 assert_eq!(
661 mgr.load_latest("sess1").unwrap().unwrap().status,
662 CheckpointStatus::Failed("OOM".into())
663 );
664 }
665
666 #[test]
667 fn test_restore_preserves_all_checkpoint_fields() {
668 let dir = temp_dir();
669 let mgr = CheckpointManager::new(dir.path()).unwrap();
670 let mut cp = sample_checkpoint("sess-restore", 3);
671 cp.id = "cp-full".into();
672 cp.agent_name = "researcher".into();
673 cp.partial_results = vec!["chunk-a".into(), "chunk-b".into()];
674 cp.status = CheckpointStatus::Completed;
675 mgr.save(&cp).unwrap();
676 let loaded = mgr.load_latest("sess-restore").unwrap().unwrap();
677 assert_eq!(loaded.id, cp.id);
678 assert_eq!(loaded.messages.len(), cp.messages.len());
679 assert_eq!(loaded.status, cp.status);
680 }
681
682 #[test]
683 fn test_checkpoint_status_completed_and_halted_round_trip() {
684 let dir = temp_dir();
685 let mgr = CheckpointManager::new(dir.path()).unwrap();
686 let mut completed = sample_checkpoint("sess-status", 0);
687 completed.status = CheckpointStatus::Completed;
688 mgr.save(&completed).unwrap();
689 assert_eq!(
690 mgr.load_latest("sess-status").unwrap().unwrap().status,
691 CheckpointStatus::Completed
692 );
693 let mut halted = sample_checkpoint("sess-status", 1);
694 halted.status = CheckpointStatus::Halted("loop detected".into());
695 mgr.save(&halted).unwrap();
696 assert_eq!(
697 mgr.load_latest("sess-status").unwrap().unwrap().status,
698 CheckpointStatus::Halted("loop detected".into())
699 );
700 }
701
702 #[test]
703 fn new_fails_when_checkpoint_path_is_a_file() {
704 let dir = temp_dir();
705 let file_path = dir.path().join("not_a_dir");
706 std::fs::write(&file_path, "blocking file").unwrap();
707 assert!(CheckpointManager::new(&file_path).is_err());
708 }
709
710 #[test]
711 fn load_latest_invalid_json_returns_invalid_data() {
712 let dir = temp_dir();
713 let mgr = CheckpointManager::new(dir.path()).unwrap();
714 let session = "bad-json";
715 std::fs::write(dir.path().join(format!("{session}_0.json")), "not valid json").unwrap();
716 std::fs::write(
717 dir.path().join(format!("{session}_latest.json")),
718 format!("{session}_0.json"),
719 )
720 .unwrap();
721 assert_eq!(
722 mgr.load_latest(session).unwrap_err().kind(),
723 std::io::ErrorKind::InvalidData
724 );
725 }
726
727 #[test]
728 fn load_latest_stale_pointer_returns_none() {
729 let dir = temp_dir();
730 let mgr = CheckpointManager::new(dir.path()).unwrap();
731 std::fs::write(
732 dir.path().join("stale-pointer_latest.json"),
733 "missing_checkpoint_file.json",
734 )
735 .unwrap();
736 assert!(mgr.load_latest("stale-pointer").unwrap().is_none());
737 }
738
739 #[test]
740 fn list_checkpoints_skips_corrupt_entries() {
741 let dir = temp_dir();
742 let mgr = CheckpointManager::new(dir.path()).unwrap();
743 let session = "sess-skip";
744 mgr.save(&sample_checkpoint(session, 0)).unwrap();
745 mgr.save(&sample_checkpoint(session, 1)).unwrap();
746 std::fs::write(dir.path().join(format!("{session}_corrupt.json")), "{bad").unwrap();
747 let listed = mgr.list_checkpoints(session).unwrap();
748 assert_eq!(listed.len(), 2);
749 }
750
751 #[test]
752 fn has_checkpoint_false_without_latest_pointer() {
753 let dir = temp_dir();
754 let mgr = CheckpointManager::new(dir.path()).unwrap();
755 mgr.save(&sample_checkpoint("sess-has", 0)).unwrap();
756 std::fs::remove_file(dir.path().join("sess-has_latest.json")).unwrap();
757 assert!(!mgr.has_checkpoint("sess-has"));
758 }
759
760 #[test]
761 fn default_dir_initializes_manager() {
762 let mgr = CheckpointManager::default_dir().expect("default_dir should succeed");
763 assert!(mgr.checkpoint_dir.to_string_lossy().ends_with("checkpoints"));
764 }
765
766 #[test]
767 fn manager_save_restore_uses_serialize_state_symmetry() {
768 let dir = temp_dir();
769 let mgr = CheckpointManager::new(dir.path()).unwrap();
770 let cp = sample_checkpoint("roundtrip", 7);
771 mgr.save(&cp).unwrap();
772 let bytes = std::fs::read(dir.path().join("roundtrip_7.json")).unwrap();
773 assert_eq!(restore_checkpoint(&bytes).unwrap(), cp);
774 }
775}