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 log::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 log::info!("checkpoint created: {} ({} files)", name, file_count);
123 } else {
124 log::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
144 for (path, content) in &checkpoint.file_contents {
145 write_restored_file(path, content)?;
146 }
147
148 log::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 let content =
169 checkpoint
170 .file_contents
171 .get(path)
172 .ok_or_else(|| AftError::FileNotFound {
173 path: path.display().to_string(),
174 })?;
175 write_restored_file(path, content)?;
176 }
177
178 log::info!("checkpoint restored: {}", name);
179
180 Ok(CheckpointInfo {
181 name: checkpoint.name.clone(),
182 file_count: checkpoint.file_contents.len(),
183 created_at: checkpoint.created_at,
184 skipped: Vec::new(),
185 })
186 }
187
188 pub fn file_paths(&self, session: &str, name: &str) -> Result<Vec<PathBuf>, AftError> {
190 let checkpoint = self.get(session, name)?;
191 Ok(checkpoint.file_contents.keys().cloned().collect())
192 }
193
194 pub fn delete(&mut self, session: &str, name: &str) -> bool {
196 let Some(session_checkpoints) = self.checkpoints.get_mut(session) else {
197 return false;
198 };
199 let removed = session_checkpoints.remove(name).is_some();
200 if session_checkpoints.is_empty() {
201 self.checkpoints.remove(session);
202 }
203 removed
204 }
205
206 pub fn list(&self, session: &str) -> Vec<CheckpointInfo> {
208 self.checkpoints
209 .get(session)
210 .map(|s| {
211 s.values()
212 .map(|cp| CheckpointInfo {
213 name: cp.name.clone(),
214 file_count: cp.file_contents.len(),
215 created_at: cp.created_at,
216 skipped: Vec::new(),
217 })
218 .collect()
219 })
220 .unwrap_or_default()
221 }
222
223 pub fn total_count(&self) -> usize {
225 self.checkpoints.values().map(|s| s.len()).sum()
226 }
227
228 pub fn cleanup(&mut self, ttl_hours: u32) {
231 let now = current_timestamp();
232 let ttl_secs = ttl_hours as u64 * 3600;
233 self.checkpoints.retain(|_, session_cps| {
234 session_cps.retain(|_, cp| now.saturating_sub(cp.created_at) < ttl_secs);
235 !session_cps.is_empty()
236 });
237 }
238
239 fn get(&self, session: &str, name: &str) -> Result<&Checkpoint, AftError> {
240 self.checkpoints
241 .get(session)
242 .and_then(|s| s.get(name))
243 .ok_or_else(|| AftError::CheckpointNotFound {
244 name: name.to_string(),
245 })
246 }
247}
248
249fn write_restored_file(path: &Path, content: &str) -> Result<(), AftError> {
250 if let Some(parent) = path.parent() {
251 std::fs::create_dir_all(parent).map_err(|_| AftError::FileNotFound {
252 path: path.display().to_string(),
253 })?;
254 }
255 std::fs::write(path, content).map_err(|_| AftError::FileNotFound {
256 path: path.display().to_string(),
257 })
258}
259
260fn current_timestamp() -> u64 {
261 std::time::SystemTime::now()
262 .duration_since(std::time::UNIX_EPOCH)
263 .unwrap_or_default()
264 .as_secs()
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::protocol::DEFAULT_SESSION_ID;
271 use std::fs;
272
273 fn temp_file(name: &str, content: &str) -> PathBuf {
274 let dir = std::env::temp_dir().join("aft_checkpoint_tests");
275 fs::create_dir_all(&dir).unwrap();
276 let path = dir.join(name);
277 fs::write(&path, content).unwrap();
278 path
279 }
280
281 #[test]
282 fn create_and_restore_round_trip() {
283 let path1 = temp_file("cp_rt1.txt", "hello");
284 let path2 = temp_file("cp_rt2.txt", "world");
285
286 let backup_store = BackupStore::new();
287 let mut store = CheckpointStore::new();
288
289 let info = store
290 .create(
291 DEFAULT_SESSION_ID,
292 "snap1",
293 vec![path1.clone(), path2.clone()],
294 &backup_store,
295 )
296 .unwrap();
297 assert_eq!(info.name, "snap1");
298 assert_eq!(info.file_count, 2);
299
300 fs::write(&path1, "changed1").unwrap();
302 fs::write(&path2, "changed2").unwrap();
303
304 let info = store.restore(DEFAULT_SESSION_ID, "snap1").unwrap();
306 assert_eq!(info.file_count, 2);
307 assert_eq!(fs::read_to_string(&path1).unwrap(), "hello");
308 assert_eq!(fs::read_to_string(&path2).unwrap(), "world");
309 }
310
311 #[test]
312 fn overwrite_existing_name() {
313 let path = temp_file("cp_overwrite.txt", "v1");
314 let backup_store = BackupStore::new();
315 let mut store = CheckpointStore::new();
316
317 store
318 .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
319 .unwrap();
320 fs::write(&path, "v2").unwrap();
321 store
322 .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
323 .unwrap();
324
325 fs::write(&path, "v3").unwrap();
327 store.restore(DEFAULT_SESSION_ID, "dup").unwrap();
328 assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
329 }
330
331 #[test]
332 fn list_returns_metadata_scoped_to_session() {
333 let path = temp_file("cp_list.txt", "data");
334 let backup_store = BackupStore::new();
335 let mut store = CheckpointStore::new();
336
337 store
338 .create(DEFAULT_SESSION_ID, "a", vec![path.clone()], &backup_store)
339 .unwrap();
340 store
341 .create(DEFAULT_SESSION_ID, "b", vec![path.clone()], &backup_store)
342 .unwrap();
343 store
344 .create("other_session", "c", vec![path.clone()], &backup_store)
345 .unwrap();
346
347 let default_list = store.list(DEFAULT_SESSION_ID);
348 assert_eq!(default_list.len(), 2);
349 let names: Vec<&str> = default_list.iter().map(|i| i.name.as_str()).collect();
350 assert!(names.contains(&"a"));
351 assert!(names.contains(&"b"));
352
353 let other_list = store.list("other_session");
354 assert_eq!(other_list.len(), 1);
355 assert_eq!(other_list[0].name, "c");
356 }
357
358 #[test]
359 fn sessions_isolate_checkpoint_names() {
360 let path_a = temp_file("cp_isolated_a.txt", "a-original");
362 let path_b = temp_file("cp_isolated_b.txt", "b-original");
363 let backup_store = BackupStore::new();
364 let mut store = CheckpointStore::new();
365
366 store
368 .create("session_a", "snap", vec![path_a.clone()], &backup_store)
369 .unwrap();
370 store
371 .create("session_b", "snap", vec![path_b.clone()], &backup_store)
372 .unwrap();
373
374 fs::write(&path_a, "a-modified").unwrap();
375 fs::write(&path_b, "b-modified").unwrap();
376
377 store.restore("session_a", "snap").unwrap();
379 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
380 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
381
382 fs::write(&path_a, "a-modified").unwrap();
384 store.restore("session_b", "snap").unwrap();
385 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
386 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
387 }
388
389 #[test]
390 fn cleanup_removes_expired_across_sessions() {
391 let path = temp_file("cp_cleanup.txt", "data");
392 let backup_store = BackupStore::new();
393 let mut store = CheckpointStore::new();
394
395 store
396 .create(
397 DEFAULT_SESSION_ID,
398 "recent",
399 vec![path.clone()],
400 &backup_store,
401 )
402 .unwrap();
403
404 store
406 .checkpoints
407 .entry("other".to_string())
408 .or_default()
409 .insert(
410 "old".to_string(),
411 Checkpoint {
412 name: "old".to_string(),
413 file_contents: HashMap::new(),
414 created_at: 1000, },
416 );
417
418 assert_eq!(store.total_count(), 2);
419 store.cleanup(24); assert_eq!(store.total_count(), 1);
421 assert_eq!(store.list(DEFAULT_SESSION_ID)[0].name, "recent");
422 assert!(store.list("other").is_empty());
423 }
424
425 #[test]
426 fn restore_nonexistent_returns_error() {
427 let store = CheckpointStore::new();
428 let result = store.restore(DEFAULT_SESSION_ID, "nope");
429 assert!(result.is_err());
430 match result.unwrap_err() {
431 AftError::CheckpointNotFound { name } => {
432 assert_eq!(name, "nope");
433 }
434 other => panic!("expected CheckpointNotFound, got: {:?}", other),
435 }
436 }
437
438 #[test]
439 fn restore_nonexistent_in_other_session_returns_error() {
440 let path = temp_file("cp_cross_session.txt", "data");
442 let backup_store = BackupStore::new();
443 let mut store = CheckpointStore::new();
444 store
445 .create("session_a", "only_a", vec![path], &backup_store)
446 .unwrap();
447 assert!(store.restore("session_b", "only_a").is_err());
448 }
449
450 #[test]
451 fn create_skips_missing_files_from_backup_tracked_set() {
452 let readable = temp_file("cp_skip_readable.txt", "still_here");
458 let deleted = temp_file("cp_skip_deleted.txt", "about_to_vanish");
459
460 let deleted_canonical = fs::canonicalize(&deleted).unwrap();
463
464 let mut backup_store = BackupStore::new();
465 backup_store
466 .snapshot(DEFAULT_SESSION_ID, &readable, "auto")
467 .unwrap();
468 backup_store
469 .snapshot(DEFAULT_SESSION_ID, &deleted, "auto")
470 .unwrap();
471
472 fs::remove_file(&deleted).unwrap();
473
474 let mut store = CheckpointStore::new();
475 let info = store
476 .create(DEFAULT_SESSION_ID, "partial", vec![], &backup_store)
477 .expect("checkpoint should succeed despite one missing file");
478 assert_eq!(info.file_count, 1);
479 assert_eq!(info.skipped.len(), 1);
480 assert_eq!(info.skipped[0].0, deleted_canonical);
481 assert!(!info.skipped[0].1.is_empty());
482 }
483
484 #[test]
485 fn create_with_explicit_single_missing_file_errors() {
486 let missing = std::env::temp_dir()
489 .join("aft_checkpoint_tests/cp_explicit_missing_does_not_exist.txt");
490 let _ = fs::remove_file(&missing);
491
492 let backup_store = BackupStore::new();
493 let mut store = CheckpointStore::new();
494 let result = store.create(
495 DEFAULT_SESSION_ID,
496 "explicit",
497 vec![missing.clone()],
498 &backup_store,
499 );
500
501 assert!(result.is_err());
502 match result.unwrap_err() {
503 AftError::FileNotFound { path } => {
504 assert!(path.contains(&missing.display().to_string()));
505 }
506 other => panic!("expected FileNotFound, got: {:?}", other),
507 }
508 }
509
510 #[test]
511 fn create_with_explicit_mixed_files_keeps_readable_and_reports_skipped() {
512 let good = temp_file("cp_mixed_good.txt", "ok");
516 let missing = std::env::temp_dir().join("aft_checkpoint_tests/cp_mixed_missing.txt");
517 let _ = fs::remove_file(&missing);
518
519 let backup_store = BackupStore::new();
520 let mut store = CheckpointStore::new();
521 let info = store
522 .create(
523 DEFAULT_SESSION_ID,
524 "mixed",
525 vec![good.clone(), missing.clone()],
526 &backup_store,
527 )
528 .expect("mixed checkpoint should succeed when any file is readable");
529 assert_eq!(info.file_count, 1);
530 assert_eq!(info.skipped.len(), 1);
531 assert_eq!(info.skipped[0].0, missing);
532 }
533
534 #[test]
535 fn create_with_empty_files_uses_backup_tracked() {
536 let path = temp_file("cp_tracked.txt", "tracked_content");
537 let mut backup_store = BackupStore::new();
538 backup_store
539 .snapshot(DEFAULT_SESSION_ID, &path, "auto")
540 .unwrap();
541
542 let mut store = CheckpointStore::new();
543 let info = store
544 .create(DEFAULT_SESSION_ID, "from_tracked", vec![], &backup_store)
545 .unwrap();
546 assert!(info.file_count >= 1);
547
548 fs::write(&path, "modified").unwrap();
550 store.restore(DEFAULT_SESSION_ID, "from_tracked").unwrap();
551 assert_eq!(fs::read_to_string(&path).unwrap(), "tracked_content");
552 }
553
554 #[test]
555 fn restore_recreates_missing_parent_directories() {
556 let dir = tempfile::tempdir().unwrap();
557 let path = dir.path().join("nested").join("deeper").join("file.txt");
558 fs::create_dir_all(path.parent().unwrap()).unwrap();
559 fs::write(&path, "original nested content").unwrap();
560
561 let backup_store = BackupStore::new();
562 let mut store = CheckpointStore::new();
563 store
564 .create(
565 DEFAULT_SESSION_ID,
566 "nested",
567 vec![path.clone()],
568 &backup_store,
569 )
570 .unwrap();
571
572 fs::remove_dir_all(dir.path().join("nested")).unwrap();
573
574 store.restore(DEFAULT_SESSION_ID, "nested").unwrap();
575 assert_eq!(
576 fs::read_to_string(&path).unwrap(),
577 "original nested content"
578 );
579 }
580}