1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use crate::backup::BackupStore;
5use crate::error::AftError;
6
7#[derive(Debug, Clone)]
9pub struct CheckpointInfo {
10 pub name: String,
11 pub file_count: usize,
12 pub created_at: u64,
13 pub skipped: Vec<(PathBuf, String)>,
18}
19
20#[derive(Debug, Clone)]
22struct Checkpoint {
23 name: String,
24 file_contents: HashMap<PathBuf, String>,
25 created_at: u64,
26}
27
28#[derive(Debug)]
37pub struct CheckpointStore {
38 checkpoints: HashMap<String, HashMap<String, Checkpoint>>,
40}
41
42impl CheckpointStore {
43 pub fn new() -> Self {
44 CheckpointStore {
45 checkpoints: HashMap::new(),
46 }
47 }
48
49 pub fn create(
63 &mut self,
64 session: &str,
65 name: &str,
66 files: Vec<PathBuf>,
67 backup_store: &BackupStore,
68 ) -> Result<CheckpointInfo, AftError> {
69 let explicit_request = !files.is_empty();
70 let file_list = if files.is_empty() {
71 backup_store.tracked_files(session)
72 } else {
73 files
74 };
75
76 let mut file_contents = HashMap::new();
77 let mut skipped: Vec<(PathBuf, String)> = Vec::new();
78 for path in &file_list {
79 match std::fs::read_to_string(path) {
80 Ok(content) => {
81 file_contents.insert(path.clone(), content);
82 }
83 Err(e) => {
84 crate::slog_warn!(
85 "checkpoint {}: skipping unreadable file {}: {}",
86 name,
87 path.display(),
88 e
89 );
90 skipped.push((path.clone(), e.to_string()));
91 }
92 }
93 }
94
95 if explicit_request && file_contents.is_empty() && !skipped.is_empty() {
101 let (path, err) = &skipped[0];
102 return Err(AftError::FileNotFound {
103 path: format!("{}: {}", path.display(), err),
104 });
105 }
106
107 let created_at = current_timestamp();
108 let file_count = file_contents.len();
109
110 let checkpoint = Checkpoint {
111 name: name.to_string(),
112 file_contents,
113 created_at,
114 };
115
116 self.checkpoints
117 .entry(session.to_string())
118 .or_default()
119 .insert(name.to_string(), checkpoint);
120
121 if skipped.is_empty() {
122 crate::slog_info!("checkpoint created: {} ({} files)", name, file_count);
123 } else {
124 crate::slog_info!(
125 "checkpoint created: {} ({} files, {} skipped)",
126 name,
127 file_count,
128 skipped.len()
129 );
130 }
131
132 Ok(CheckpointInfo {
133 name: name.to_string(),
134 file_count,
135 created_at,
136 skipped,
137 })
138 }
139
140 pub fn restore(&self, session: &str, name: &str) -> Result<CheckpointInfo, AftError> {
142 let checkpoint = self.get(session, name)?;
143 let mut paths = checkpoint.file_contents.keys().cloned().collect::<Vec<_>>();
144 paths.sort();
145
146 restore_paths_atomically(checkpoint, &paths)?;
147
148 crate::slog_info!("checkpoint restored: {}", name);
149
150 Ok(CheckpointInfo {
151 name: checkpoint.name.clone(),
152 file_count: checkpoint.file_contents.len(),
153 created_at: checkpoint.created_at,
154 skipped: Vec::new(),
155 })
156 }
157
158 pub fn restore_validated(
160 &self,
161 session: &str,
162 name: &str,
163 validated_paths: &[PathBuf],
164 ) -> Result<CheckpointInfo, AftError> {
165 let checkpoint = self.get(session, name)?;
166
167 for path in validated_paths {
168 checkpoint
169 .file_contents
170 .get(path)
171 .ok_or_else(|| AftError::FileNotFound {
172 path: path.display().to_string(),
173 })?;
174 }
175 restore_paths_atomically(checkpoint, validated_paths)?;
176
177 crate::slog_info!("checkpoint restored: {}", name);
178
179 Ok(CheckpointInfo {
180 name: checkpoint.name.clone(),
181 file_count: checkpoint.file_contents.len(),
182 created_at: checkpoint.created_at,
183 skipped: Vec::new(),
184 })
185 }
186
187 pub fn file_paths(&self, session: &str, name: &str) -> Result<Vec<PathBuf>, AftError> {
189 let checkpoint = self.get(session, name)?;
190 Ok(checkpoint.file_contents.keys().cloned().collect())
191 }
192
193 pub fn delete(&mut self, session: &str, name: &str) -> bool {
195 let Some(session_checkpoints) = self.checkpoints.get_mut(session) else {
196 return false;
197 };
198 let removed = session_checkpoints.remove(name).is_some();
199 if session_checkpoints.is_empty() {
200 self.checkpoints.remove(session);
201 }
202 removed
203 }
204
205 pub fn list(&self, session: &str) -> Vec<CheckpointInfo> {
207 self.checkpoints
208 .get(session)
209 .map(|s| {
210 s.values()
211 .map(|cp| CheckpointInfo {
212 name: cp.name.clone(),
213 file_count: cp.file_contents.len(),
214 created_at: cp.created_at,
215 skipped: Vec::new(),
216 })
217 .collect()
218 })
219 .unwrap_or_default()
220 }
221
222 pub fn total_count(&self) -> usize {
224 self.checkpoints.values().map(|s| s.len()).sum()
225 }
226
227 pub fn cleanup(&mut self, ttl_hours: u32) {
230 let now = current_timestamp();
231 let ttl_secs = ttl_hours as u64 * 3600;
232 self.checkpoints.retain(|_, session_cps| {
233 session_cps.retain(|_, cp| now.saturating_sub(cp.created_at) < ttl_secs);
234 !session_cps.is_empty()
235 });
236 }
237
238 fn get(&self, session: &str, name: &str) -> Result<&Checkpoint, AftError> {
239 self.checkpoints
240 .get(session)
241 .and_then(|s| s.get(name))
242 .ok_or_else(|| AftError::CheckpointNotFound {
243 name: name.to_string(),
244 })
245 }
246}
247
248fn restore_paths_atomically(checkpoint: &Checkpoint, paths: &[PathBuf]) -> Result<(), AftError> {
249 let mut pre_restore_snapshot: HashMap<PathBuf, Option<String>> = HashMap::new();
250 for path in paths {
251 let current = if path.exists() {
252 Some(
253 std::fs::read_to_string(path).map_err(|_| AftError::FileNotFound {
254 path: path.display().to_string(),
255 })?,
256 )
257 } else {
258 None
259 };
260 pre_restore_snapshot.insert(path.clone(), current);
261 }
262
263 let mut restored_paths: Vec<PathBuf> = Vec::new();
264 let mut created_dirs: Vec<PathBuf> = Vec::new();
265 for path in paths {
266 let content = checkpoint
267 .file_contents
268 .get(path)
269 .ok_or_else(|| AftError::FileNotFound {
270 path: path.display().to_string(),
271 })?;
272 if let Err(e) = write_restored_file(path, content, &mut created_dirs) {
273 for restored_path in restored_paths.iter().rev() {
274 if let Some(snapshot) = pre_restore_snapshot.get(restored_path) {
275 let _ = restore_snapshot_file(restored_path, snapshot.as_deref());
276 }
277 }
278 rollback_created_dirs(&created_dirs);
279 return Err(e);
280 }
281 restored_paths.push(path.clone());
282 }
283
284 Ok(())
285}
286
287fn restore_snapshot_file(path: &Path, content: Option<&str>) -> Result<(), AftError> {
288 match content {
289 Some(content) => write_restored_file(path, content, &mut Vec::new()),
290 None => match std::fs::remove_file(path) {
291 Ok(()) => Ok(()),
292 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
293 Err(_) => Err(AftError::FileNotFound {
294 path: path.display().to_string(),
295 }),
296 },
297 }
298}
299
300fn write_restored_file(
301 path: &Path,
302 content: &str,
303 created_dirs: &mut Vec<PathBuf>,
304) -> Result<(), AftError> {
305 if let Some(parent) = path.parent() {
306 let missing_dirs = missing_parent_dirs(parent);
307 std::fs::create_dir_all(parent).map_err(|_| AftError::FileNotFound {
308 path: path.display().to_string(),
309 })?;
310 created_dirs.extend(missing_dirs);
311 }
312 std::fs::write(path, content).map_err(|_| AftError::FileNotFound {
313 path: path.display().to_string(),
314 })
315}
316
317fn missing_parent_dirs(parent: &Path) -> Vec<PathBuf> {
318 let mut dirs = Vec::new();
319 let mut current = Some(parent);
320
321 while let Some(dir) = current {
322 if dir.as_os_str().is_empty() || dir.exists() {
323 break;
324 }
325 dirs.push(dir.to_path_buf());
326 current = dir.parent();
327 }
328
329 dirs
330}
331
332fn rollback_created_dirs(dirs: &[PathBuf]) {
333 let mut dirs = dirs.to_vec();
334 dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
335 dirs.dedup();
336
337 for dir in dirs {
338 let _ = std::fs::remove_dir(&dir);
339 }
340}
341
342fn current_timestamp() -> u64 {
343 std::time::SystemTime::now()
344 .duration_since(std::time::UNIX_EPOCH)
345 .unwrap_or_default()
346 .as_secs()
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::protocol::DEFAULT_SESSION_ID;
353 use std::fs;
354
355 fn temp_file(name: &str, content: &str) -> PathBuf {
356 let dir = std::env::temp_dir().join("aft_checkpoint_tests");
357 fs::create_dir_all(&dir).unwrap();
358 let path = dir.join(name);
359 fs::write(&path, content).unwrap();
360 path
361 }
362
363 #[test]
364 fn create_and_restore_round_trip() {
365 let path1 = temp_file("cp_rt1.txt", "hello");
366 let path2 = temp_file("cp_rt2.txt", "world");
367
368 let backup_store = BackupStore::new();
369 let mut store = CheckpointStore::new();
370
371 let info = store
372 .create(
373 DEFAULT_SESSION_ID,
374 "snap1",
375 vec![path1.clone(), path2.clone()],
376 &backup_store,
377 )
378 .unwrap();
379 assert_eq!(info.name, "snap1");
380 assert_eq!(info.file_count, 2);
381
382 fs::write(&path1, "changed1").unwrap();
384 fs::write(&path2, "changed2").unwrap();
385
386 let info = store.restore(DEFAULT_SESSION_ID, "snap1").unwrap();
388 assert_eq!(info.file_count, 2);
389 assert_eq!(fs::read_to_string(&path1).unwrap(), "hello");
390 assert_eq!(fs::read_to_string(&path2).unwrap(), "world");
391 }
392
393 #[test]
394 fn overwrite_existing_name() {
395 let path = temp_file("cp_overwrite.txt", "v1");
396 let backup_store = BackupStore::new();
397 let mut store = CheckpointStore::new();
398
399 store
400 .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
401 .unwrap();
402 fs::write(&path, "v2").unwrap();
403 store
404 .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
405 .unwrap();
406
407 fs::write(&path, "v3").unwrap();
409 store.restore(DEFAULT_SESSION_ID, "dup").unwrap();
410 assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
411 }
412
413 #[test]
414 fn list_returns_metadata_scoped_to_session() {
415 let path = temp_file("cp_list.txt", "data");
416 let backup_store = BackupStore::new();
417 let mut store = CheckpointStore::new();
418
419 store
420 .create(DEFAULT_SESSION_ID, "a", vec![path.clone()], &backup_store)
421 .unwrap();
422 store
423 .create(DEFAULT_SESSION_ID, "b", vec![path.clone()], &backup_store)
424 .unwrap();
425 store
426 .create("other_session", "c", vec![path.clone()], &backup_store)
427 .unwrap();
428
429 let default_list = store.list(DEFAULT_SESSION_ID);
430 assert_eq!(default_list.len(), 2);
431 let names: Vec<&str> = default_list.iter().map(|i| i.name.as_str()).collect();
432 assert!(names.contains(&"a"));
433 assert!(names.contains(&"b"));
434
435 let other_list = store.list("other_session");
436 assert_eq!(other_list.len(), 1);
437 assert_eq!(other_list[0].name, "c");
438 }
439
440 #[test]
441 fn sessions_isolate_checkpoint_names() {
442 let path_a = temp_file("cp_isolated_a.txt", "a-original");
444 let path_b = temp_file("cp_isolated_b.txt", "b-original");
445 let backup_store = BackupStore::new();
446 let mut store = CheckpointStore::new();
447
448 store
450 .create("session_a", "snap", vec![path_a.clone()], &backup_store)
451 .unwrap();
452 store
453 .create("session_b", "snap", vec![path_b.clone()], &backup_store)
454 .unwrap();
455
456 fs::write(&path_a, "a-modified").unwrap();
457 fs::write(&path_b, "b-modified").unwrap();
458
459 store.restore("session_a", "snap").unwrap();
461 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
462 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
463
464 fs::write(&path_a, "a-modified").unwrap();
466 store.restore("session_b", "snap").unwrap();
467 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
468 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
469 }
470
471 #[test]
472 fn cleanup_removes_expired_across_sessions() {
473 let path = temp_file("cp_cleanup.txt", "data");
474 let backup_store = BackupStore::new();
475 let mut store = CheckpointStore::new();
476
477 store
478 .create(
479 DEFAULT_SESSION_ID,
480 "recent",
481 vec![path.clone()],
482 &backup_store,
483 )
484 .unwrap();
485
486 store
488 .checkpoints
489 .entry("other".to_string())
490 .or_default()
491 .insert(
492 "old".to_string(),
493 Checkpoint {
494 name: "old".to_string(),
495 file_contents: HashMap::new(),
496 created_at: 1000, },
498 );
499
500 assert_eq!(store.total_count(), 2);
501 store.cleanup(24); assert_eq!(store.total_count(), 1);
503 assert_eq!(store.list(DEFAULT_SESSION_ID)[0].name, "recent");
504 assert!(store.list("other").is_empty());
505 }
506
507 #[test]
508 fn restore_nonexistent_returns_error() {
509 let store = CheckpointStore::new();
510 let result = store.restore(DEFAULT_SESSION_ID, "nope");
511 assert!(result.is_err());
512 match result.unwrap_err() {
513 AftError::CheckpointNotFound { name } => {
514 assert_eq!(name, "nope");
515 }
516 other => panic!("expected CheckpointNotFound, got: {:?}", other),
517 }
518 }
519
520 #[test]
521 fn restore_nonexistent_in_other_session_returns_error() {
522 let path = temp_file("cp_cross_session.txt", "data");
524 let backup_store = BackupStore::new();
525 let mut store = CheckpointStore::new();
526 store
527 .create("session_a", "only_a", vec![path], &backup_store)
528 .unwrap();
529 assert!(store.restore("session_b", "only_a").is_err());
530 }
531
532 #[test]
533 fn create_skips_missing_files_from_backup_tracked_set() {
534 let readable = temp_file("cp_skip_readable.txt", "still_here");
540 let deleted = temp_file("cp_skip_deleted.txt", "about_to_vanish");
541
542 let deleted_canonical = fs::canonicalize(&deleted).unwrap();
545
546 let mut backup_store = BackupStore::new();
547 backup_store
548 .snapshot(DEFAULT_SESSION_ID, &readable, "auto")
549 .unwrap();
550 backup_store
551 .snapshot(DEFAULT_SESSION_ID, &deleted, "auto")
552 .unwrap();
553
554 fs::remove_file(&deleted).unwrap();
555
556 let mut store = CheckpointStore::new();
557 let info = store
558 .create(DEFAULT_SESSION_ID, "partial", vec![], &backup_store)
559 .expect("checkpoint should succeed despite one missing file");
560 assert_eq!(info.file_count, 1);
561 assert_eq!(info.skipped.len(), 1);
562 assert_eq!(info.skipped[0].0, deleted_canonical);
563 assert!(!info.skipped[0].1.is_empty());
564 }
565
566 #[test]
567 fn create_with_explicit_single_missing_file_errors() {
568 let missing = std::env::temp_dir()
571 .join("aft_checkpoint_tests/cp_explicit_missing_does_not_exist.txt");
572 let _ = fs::remove_file(&missing);
573
574 let backup_store = BackupStore::new();
575 let mut store = CheckpointStore::new();
576 let result = store.create(
577 DEFAULT_SESSION_ID,
578 "explicit",
579 vec![missing.clone()],
580 &backup_store,
581 );
582
583 assert!(result.is_err());
584 match result.unwrap_err() {
585 AftError::FileNotFound { path } => {
586 assert!(path.contains(&missing.display().to_string()));
587 }
588 other => panic!("expected FileNotFound, got: {:?}", other),
589 }
590 }
591
592 #[test]
593 fn create_with_explicit_mixed_files_keeps_readable_and_reports_skipped() {
594 let good = temp_file("cp_mixed_good.txt", "ok");
598 let missing = std::env::temp_dir().join("aft_checkpoint_tests/cp_mixed_missing.txt");
599 let _ = fs::remove_file(&missing);
600
601 let backup_store = BackupStore::new();
602 let mut store = CheckpointStore::new();
603 let info = store
604 .create(
605 DEFAULT_SESSION_ID,
606 "mixed",
607 vec![good.clone(), missing.clone()],
608 &backup_store,
609 )
610 .expect("mixed checkpoint should succeed when any file is readable");
611 assert_eq!(info.file_count, 1);
612 assert_eq!(info.skipped.len(), 1);
613 assert_eq!(info.skipped[0].0, missing);
614 }
615
616 #[test]
617 fn create_with_empty_files_uses_backup_tracked() {
618 let path = temp_file("cp_tracked.txt", "tracked_content");
619 let mut backup_store = BackupStore::new();
620 backup_store
621 .snapshot(DEFAULT_SESSION_ID, &path, "auto")
622 .unwrap();
623
624 let mut store = CheckpointStore::new();
625 let info = store
626 .create(DEFAULT_SESSION_ID, "from_tracked", vec![], &backup_store)
627 .unwrap();
628 assert!(info.file_count >= 1);
629
630 fs::write(&path, "modified").unwrap();
632 store.restore(DEFAULT_SESSION_ID, "from_tracked").unwrap();
633 assert_eq!(fs::read_to_string(&path).unwrap(), "tracked_content");
634 }
635
636 #[test]
637 fn restore_recreates_missing_parent_directories() {
638 let dir = tempfile::tempdir().unwrap();
639 let path = dir.path().join("nested").join("deeper").join("file.txt");
640 fs::create_dir_all(path.parent().unwrap()).unwrap();
641 fs::write(&path, "original nested content").unwrap();
642
643 let backup_store = BackupStore::new();
644 let mut store = CheckpointStore::new();
645 store
646 .create(
647 DEFAULT_SESSION_ID,
648 "nested",
649 vec![path.clone()],
650 &backup_store,
651 )
652 .unwrap();
653
654 fs::remove_dir_all(dir.path().join("nested")).unwrap();
655
656 store.restore(DEFAULT_SESSION_ID, "nested").unwrap();
657 assert_eq!(
658 fs::read_to_string(&path).unwrap(),
659 "original nested content"
660 );
661 }
662
663 #[cfg(unix)]
664 #[test]
665 fn checkpoint_restore_rolls_back_on_partial_failure() {
666 use std::os::unix::fs::PermissionsExt;
667
668 let dir = tempfile::tempdir().unwrap();
669 let path_a = dir.path().join("a.txt");
670 let path_b = dir.path().join("b.txt");
671 fs::write(&path_a, "checkpoint-a").unwrap();
672 fs::write(&path_b, "checkpoint-b").unwrap();
673
674 let backup_store = BackupStore::new();
675 let mut store = CheckpointStore::new();
676 store
677 .create(
678 DEFAULT_SESSION_ID,
679 "partial_failure",
680 vec![path_a.clone(), path_b.clone()],
681 &backup_store,
682 )
683 .unwrap();
684
685 fs::write(&path_a, "pre-restore-a").unwrap();
686 fs::write(&path_b, "pre-restore-b").unwrap();
687 let mut readonly = fs::metadata(&path_b).unwrap().permissions();
688 readonly.set_mode(0o444);
689 fs::set_permissions(&path_b, readonly).unwrap();
690
691 let result = store.restore(DEFAULT_SESSION_ID, "partial_failure");
692 let mut writable = fs::metadata(&path_b).unwrap().permissions();
693 writable.set_mode(0o644);
694 fs::set_permissions(&path_b, writable).unwrap();
695
696 assert!(result.is_err(), "restore should surface write failure");
697 assert_eq!(fs::read_to_string(&path_a).unwrap(), "pre-restore-a");
698 assert_eq!(fs::read_to_string(&path_b).unwrap(), "pre-restore-b");
699 }
700
701 #[test]
702 fn checkpoint_restore_failure_removes_created_parent_dirs() {
703 let dir = tempfile::tempdir().unwrap();
704 let missing_root = dir.path().join("created");
705 let path_a = missing_root.join("nested").join("a.txt");
706 let path_b = dir.path().join("blocking-dir");
707 fs::create_dir(&path_b).unwrap();
708
709 let checkpoint = Checkpoint {
710 name: "dir-cleanup".to_string(),
711 file_contents: HashMap::from([
712 (path_a.clone(), "checkpoint-a".to_string()),
713 (path_b.clone(), "checkpoint-b".to_string()),
714 ]),
715 created_at: current_timestamp(),
716 };
717
718 let result = restore_paths_atomically(&checkpoint, &[path_a.clone(), path_b.clone()]);
719
720 assert!(
721 result.is_err(),
722 "second restore write should fail on directory"
723 );
724 assert!(!path_a.exists(), "restored file should be rolled back");
725 assert!(
726 !missing_root.exists(),
727 "new parent directories should be removed on rollback"
728 );
729 assert!(path_b.is_dir(), "pre-existing blocking directory remains");
730 }
731}