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 for path in paths {
265 let content = checkpoint
266 .file_contents
267 .get(path)
268 .ok_or_else(|| AftError::FileNotFound {
269 path: path.display().to_string(),
270 })?;
271 if let Err(e) = write_restored_file(path, content) {
272 for restored_path in restored_paths.iter().rev() {
273 if let Some(snapshot) = pre_restore_snapshot.get(restored_path) {
274 let _ = restore_snapshot_file(restored_path, snapshot.as_deref());
275 }
276 }
277 return Err(e);
278 }
279 restored_paths.push(path.clone());
280 }
281
282 Ok(())
283}
284
285fn restore_snapshot_file(path: &Path, content: Option<&str>) -> Result<(), AftError> {
286 match content {
287 Some(content) => write_restored_file(path, content),
288 None => match std::fs::remove_file(path) {
289 Ok(()) => Ok(()),
290 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
291 Err(_) => Err(AftError::FileNotFound {
292 path: path.display().to_string(),
293 }),
294 },
295 }
296}
297
298fn write_restored_file(path: &Path, content: &str) -> Result<(), AftError> {
299 if let Some(parent) = path.parent() {
300 std::fs::create_dir_all(parent).map_err(|_| AftError::FileNotFound {
301 path: path.display().to_string(),
302 })?;
303 }
304 std::fs::write(path, content).map_err(|_| AftError::FileNotFound {
305 path: path.display().to_string(),
306 })
307}
308
309fn current_timestamp() -> u64 {
310 std::time::SystemTime::now()
311 .duration_since(std::time::UNIX_EPOCH)
312 .unwrap_or_default()
313 .as_secs()
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::protocol::DEFAULT_SESSION_ID;
320 use std::fs;
321
322 fn temp_file(name: &str, content: &str) -> PathBuf {
323 let dir = std::env::temp_dir().join("aft_checkpoint_tests");
324 fs::create_dir_all(&dir).unwrap();
325 let path = dir.join(name);
326 fs::write(&path, content).unwrap();
327 path
328 }
329
330 #[test]
331 fn create_and_restore_round_trip() {
332 let path1 = temp_file("cp_rt1.txt", "hello");
333 let path2 = temp_file("cp_rt2.txt", "world");
334
335 let backup_store = BackupStore::new();
336 let mut store = CheckpointStore::new();
337
338 let info = store
339 .create(
340 DEFAULT_SESSION_ID,
341 "snap1",
342 vec![path1.clone(), path2.clone()],
343 &backup_store,
344 )
345 .unwrap();
346 assert_eq!(info.name, "snap1");
347 assert_eq!(info.file_count, 2);
348
349 fs::write(&path1, "changed1").unwrap();
351 fs::write(&path2, "changed2").unwrap();
352
353 let info = store.restore(DEFAULT_SESSION_ID, "snap1").unwrap();
355 assert_eq!(info.file_count, 2);
356 assert_eq!(fs::read_to_string(&path1).unwrap(), "hello");
357 assert_eq!(fs::read_to_string(&path2).unwrap(), "world");
358 }
359
360 #[test]
361 fn overwrite_existing_name() {
362 let path = temp_file("cp_overwrite.txt", "v1");
363 let backup_store = BackupStore::new();
364 let mut store = CheckpointStore::new();
365
366 store
367 .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
368 .unwrap();
369 fs::write(&path, "v2").unwrap();
370 store
371 .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
372 .unwrap();
373
374 fs::write(&path, "v3").unwrap();
376 store.restore(DEFAULT_SESSION_ID, "dup").unwrap();
377 assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
378 }
379
380 #[test]
381 fn list_returns_metadata_scoped_to_session() {
382 let path = temp_file("cp_list.txt", "data");
383 let backup_store = BackupStore::new();
384 let mut store = CheckpointStore::new();
385
386 store
387 .create(DEFAULT_SESSION_ID, "a", vec![path.clone()], &backup_store)
388 .unwrap();
389 store
390 .create(DEFAULT_SESSION_ID, "b", vec![path.clone()], &backup_store)
391 .unwrap();
392 store
393 .create("other_session", "c", vec![path.clone()], &backup_store)
394 .unwrap();
395
396 let default_list = store.list(DEFAULT_SESSION_ID);
397 assert_eq!(default_list.len(), 2);
398 let names: Vec<&str> = default_list.iter().map(|i| i.name.as_str()).collect();
399 assert!(names.contains(&"a"));
400 assert!(names.contains(&"b"));
401
402 let other_list = store.list("other_session");
403 assert_eq!(other_list.len(), 1);
404 assert_eq!(other_list[0].name, "c");
405 }
406
407 #[test]
408 fn sessions_isolate_checkpoint_names() {
409 let path_a = temp_file("cp_isolated_a.txt", "a-original");
411 let path_b = temp_file("cp_isolated_b.txt", "b-original");
412 let backup_store = BackupStore::new();
413 let mut store = CheckpointStore::new();
414
415 store
417 .create("session_a", "snap", vec![path_a.clone()], &backup_store)
418 .unwrap();
419 store
420 .create("session_b", "snap", vec![path_b.clone()], &backup_store)
421 .unwrap();
422
423 fs::write(&path_a, "a-modified").unwrap();
424 fs::write(&path_b, "b-modified").unwrap();
425
426 store.restore("session_a", "snap").unwrap();
428 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
429 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
430
431 fs::write(&path_a, "a-modified").unwrap();
433 store.restore("session_b", "snap").unwrap();
434 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
435 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
436 }
437
438 #[test]
439 fn cleanup_removes_expired_across_sessions() {
440 let path = temp_file("cp_cleanup.txt", "data");
441 let backup_store = BackupStore::new();
442 let mut store = CheckpointStore::new();
443
444 store
445 .create(
446 DEFAULT_SESSION_ID,
447 "recent",
448 vec![path.clone()],
449 &backup_store,
450 )
451 .unwrap();
452
453 store
455 .checkpoints
456 .entry("other".to_string())
457 .or_default()
458 .insert(
459 "old".to_string(),
460 Checkpoint {
461 name: "old".to_string(),
462 file_contents: HashMap::new(),
463 created_at: 1000, },
465 );
466
467 assert_eq!(store.total_count(), 2);
468 store.cleanup(24); assert_eq!(store.total_count(), 1);
470 assert_eq!(store.list(DEFAULT_SESSION_ID)[0].name, "recent");
471 assert!(store.list("other").is_empty());
472 }
473
474 #[test]
475 fn restore_nonexistent_returns_error() {
476 let store = CheckpointStore::new();
477 let result = store.restore(DEFAULT_SESSION_ID, "nope");
478 assert!(result.is_err());
479 match result.unwrap_err() {
480 AftError::CheckpointNotFound { name } => {
481 assert_eq!(name, "nope");
482 }
483 other => panic!("expected CheckpointNotFound, got: {:?}", other),
484 }
485 }
486
487 #[test]
488 fn restore_nonexistent_in_other_session_returns_error() {
489 let path = temp_file("cp_cross_session.txt", "data");
491 let backup_store = BackupStore::new();
492 let mut store = CheckpointStore::new();
493 store
494 .create("session_a", "only_a", vec![path], &backup_store)
495 .unwrap();
496 assert!(store.restore("session_b", "only_a").is_err());
497 }
498
499 #[test]
500 fn create_skips_missing_files_from_backup_tracked_set() {
501 let readable = temp_file("cp_skip_readable.txt", "still_here");
507 let deleted = temp_file("cp_skip_deleted.txt", "about_to_vanish");
508
509 let deleted_canonical = fs::canonicalize(&deleted).unwrap();
512
513 let mut backup_store = BackupStore::new();
514 backup_store
515 .snapshot(DEFAULT_SESSION_ID, &readable, "auto")
516 .unwrap();
517 backup_store
518 .snapshot(DEFAULT_SESSION_ID, &deleted, "auto")
519 .unwrap();
520
521 fs::remove_file(&deleted).unwrap();
522
523 let mut store = CheckpointStore::new();
524 let info = store
525 .create(DEFAULT_SESSION_ID, "partial", vec![], &backup_store)
526 .expect("checkpoint should succeed despite one missing file");
527 assert_eq!(info.file_count, 1);
528 assert_eq!(info.skipped.len(), 1);
529 assert_eq!(info.skipped[0].0, deleted_canonical);
530 assert!(!info.skipped[0].1.is_empty());
531 }
532
533 #[test]
534 fn create_with_explicit_single_missing_file_errors() {
535 let missing = std::env::temp_dir()
538 .join("aft_checkpoint_tests/cp_explicit_missing_does_not_exist.txt");
539 let _ = fs::remove_file(&missing);
540
541 let backup_store = BackupStore::new();
542 let mut store = CheckpointStore::new();
543 let result = store.create(
544 DEFAULT_SESSION_ID,
545 "explicit",
546 vec![missing.clone()],
547 &backup_store,
548 );
549
550 assert!(result.is_err());
551 match result.unwrap_err() {
552 AftError::FileNotFound { path } => {
553 assert!(path.contains(&missing.display().to_string()));
554 }
555 other => panic!("expected FileNotFound, got: {:?}", other),
556 }
557 }
558
559 #[test]
560 fn create_with_explicit_mixed_files_keeps_readable_and_reports_skipped() {
561 let good = temp_file("cp_mixed_good.txt", "ok");
565 let missing = std::env::temp_dir().join("aft_checkpoint_tests/cp_mixed_missing.txt");
566 let _ = fs::remove_file(&missing);
567
568 let backup_store = BackupStore::new();
569 let mut store = CheckpointStore::new();
570 let info = store
571 .create(
572 DEFAULT_SESSION_ID,
573 "mixed",
574 vec![good.clone(), missing.clone()],
575 &backup_store,
576 )
577 .expect("mixed checkpoint should succeed when any file is readable");
578 assert_eq!(info.file_count, 1);
579 assert_eq!(info.skipped.len(), 1);
580 assert_eq!(info.skipped[0].0, missing);
581 }
582
583 #[test]
584 fn create_with_empty_files_uses_backup_tracked() {
585 let path = temp_file("cp_tracked.txt", "tracked_content");
586 let mut backup_store = BackupStore::new();
587 backup_store
588 .snapshot(DEFAULT_SESSION_ID, &path, "auto")
589 .unwrap();
590
591 let mut store = CheckpointStore::new();
592 let info = store
593 .create(DEFAULT_SESSION_ID, "from_tracked", vec![], &backup_store)
594 .unwrap();
595 assert!(info.file_count >= 1);
596
597 fs::write(&path, "modified").unwrap();
599 store.restore(DEFAULT_SESSION_ID, "from_tracked").unwrap();
600 assert_eq!(fs::read_to_string(&path).unwrap(), "tracked_content");
601 }
602
603 #[test]
604 fn restore_recreates_missing_parent_directories() {
605 let dir = tempfile::tempdir().unwrap();
606 let path = dir.path().join("nested").join("deeper").join("file.txt");
607 fs::create_dir_all(path.parent().unwrap()).unwrap();
608 fs::write(&path, "original nested content").unwrap();
609
610 let backup_store = BackupStore::new();
611 let mut store = CheckpointStore::new();
612 store
613 .create(
614 DEFAULT_SESSION_ID,
615 "nested",
616 vec![path.clone()],
617 &backup_store,
618 )
619 .unwrap();
620
621 fs::remove_dir_all(dir.path().join("nested")).unwrap();
622
623 store.restore(DEFAULT_SESSION_ID, "nested").unwrap();
624 assert_eq!(
625 fs::read_to_string(&path).unwrap(),
626 "original nested content"
627 );
628 }
629
630 #[cfg(unix)]
631 #[test]
632 fn checkpoint_restore_rolls_back_on_partial_failure() {
633 use std::os::unix::fs::PermissionsExt;
634
635 let dir = tempfile::tempdir().unwrap();
636 let path_a = dir.path().join("a.txt");
637 let path_b = dir.path().join("b.txt");
638 fs::write(&path_a, "checkpoint-a").unwrap();
639 fs::write(&path_b, "checkpoint-b").unwrap();
640
641 let backup_store = BackupStore::new();
642 let mut store = CheckpointStore::new();
643 store
644 .create(
645 DEFAULT_SESSION_ID,
646 "partial_failure",
647 vec![path_a.clone(), path_b.clone()],
648 &backup_store,
649 )
650 .unwrap();
651
652 fs::write(&path_a, "pre-restore-a").unwrap();
653 fs::write(&path_b, "pre-restore-b").unwrap();
654 let mut readonly = fs::metadata(&path_b).unwrap().permissions();
655 readonly.set_mode(0o444);
656 fs::set_permissions(&path_b, readonly).unwrap();
657
658 let result = store.restore(DEFAULT_SESSION_ID, "partial_failure");
659 let mut writable = fs::metadata(&path_b).unwrap().permissions();
660 writable.set_mode(0o644);
661 fs::set_permissions(&path_b, writable).unwrap();
662
663 assert!(result.is_err(), "restore should surface write failure");
664 assert_eq!(fs::read_to_string(&path_a).unwrap(), "pre-restore-a");
665 assert_eq!(fs::read_to_string(&path_b).unwrap(), "pre-restore-b");
666 }
667}