1use std::collections::HashMap;
2use std::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 std::fs::write(path, content).map_err(|_| AftError::FileNotFound {
146 path: path.display().to_string(),
147 })?;
148 }
149
150 log::info!("checkpoint restored: {}", name);
151
152 Ok(CheckpointInfo {
153 name: checkpoint.name.clone(),
154 file_count: checkpoint.file_contents.len(),
155 created_at: checkpoint.created_at,
156 skipped: Vec::new(),
157 })
158 }
159
160 pub fn restore_validated(
162 &self,
163 session: &str,
164 name: &str,
165 validated_paths: &[PathBuf],
166 ) -> Result<CheckpointInfo, AftError> {
167 let checkpoint = self.get(session, name)?;
168
169 for path in validated_paths {
170 let content =
171 checkpoint
172 .file_contents
173 .get(path)
174 .ok_or_else(|| AftError::FileNotFound {
175 path: path.display().to_string(),
176 })?;
177 std::fs::write(path, content).map_err(|_| AftError::FileNotFound {
178 path: path.display().to_string(),
179 })?;
180 }
181
182 log::info!("checkpoint restored: {}", name);
183
184 Ok(CheckpointInfo {
185 name: checkpoint.name.clone(),
186 file_count: checkpoint.file_contents.len(),
187 created_at: checkpoint.created_at,
188 skipped: Vec::new(),
189 })
190 }
191
192 pub fn file_paths(&self, session: &str, name: &str) -> Result<Vec<PathBuf>, AftError> {
194 let checkpoint = self.get(session, name)?;
195 Ok(checkpoint.file_contents.keys().cloned().collect())
196 }
197
198 pub fn list(&self, session: &str) -> Vec<CheckpointInfo> {
200 self.checkpoints
201 .get(session)
202 .map(|s| {
203 s.values()
204 .map(|cp| CheckpointInfo {
205 name: cp.name.clone(),
206 file_count: cp.file_contents.len(),
207 created_at: cp.created_at,
208 skipped: Vec::new(),
209 })
210 .collect()
211 })
212 .unwrap_or_default()
213 }
214
215 pub fn total_count(&self) -> usize {
217 self.checkpoints.values().map(|s| s.len()).sum()
218 }
219
220 pub fn cleanup(&mut self, ttl_hours: u32) {
223 let now = current_timestamp();
224 let ttl_secs = ttl_hours as u64 * 3600;
225 self.checkpoints.retain(|_, session_cps| {
226 session_cps.retain(|_, cp| now.saturating_sub(cp.created_at) < ttl_secs);
227 !session_cps.is_empty()
228 });
229 }
230
231 fn get(&self, session: &str, name: &str) -> Result<&Checkpoint, AftError> {
232 self.checkpoints
233 .get(session)
234 .and_then(|s| s.get(name))
235 .ok_or_else(|| AftError::CheckpointNotFound {
236 name: name.to_string(),
237 })
238 }
239}
240
241fn current_timestamp() -> u64 {
242 std::time::SystemTime::now()
243 .duration_since(std::time::UNIX_EPOCH)
244 .unwrap_or_default()
245 .as_secs()
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use crate::protocol::DEFAULT_SESSION_ID;
252 use std::fs;
253
254 fn temp_file(name: &str, content: &str) -> PathBuf {
255 let dir = std::env::temp_dir().join("aft_checkpoint_tests");
256 fs::create_dir_all(&dir).unwrap();
257 let path = dir.join(name);
258 fs::write(&path, content).unwrap();
259 path
260 }
261
262 #[test]
263 fn create_and_restore_round_trip() {
264 let path1 = temp_file("cp_rt1.txt", "hello");
265 let path2 = temp_file("cp_rt2.txt", "world");
266
267 let backup_store = BackupStore::new();
268 let mut store = CheckpointStore::new();
269
270 let info = store
271 .create(
272 DEFAULT_SESSION_ID,
273 "snap1",
274 vec![path1.clone(), path2.clone()],
275 &backup_store,
276 )
277 .unwrap();
278 assert_eq!(info.name, "snap1");
279 assert_eq!(info.file_count, 2);
280
281 fs::write(&path1, "changed1").unwrap();
283 fs::write(&path2, "changed2").unwrap();
284
285 let info = store.restore(DEFAULT_SESSION_ID, "snap1").unwrap();
287 assert_eq!(info.file_count, 2);
288 assert_eq!(fs::read_to_string(&path1).unwrap(), "hello");
289 assert_eq!(fs::read_to_string(&path2).unwrap(), "world");
290 }
291
292 #[test]
293 fn overwrite_existing_name() {
294 let path = temp_file("cp_overwrite.txt", "v1");
295 let backup_store = BackupStore::new();
296 let mut store = CheckpointStore::new();
297
298 store
299 .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
300 .unwrap();
301 fs::write(&path, "v2").unwrap();
302 store
303 .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
304 .unwrap();
305
306 fs::write(&path, "v3").unwrap();
308 store.restore(DEFAULT_SESSION_ID, "dup").unwrap();
309 assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
310 }
311
312 #[test]
313 fn list_returns_metadata_scoped_to_session() {
314 let path = temp_file("cp_list.txt", "data");
315 let backup_store = BackupStore::new();
316 let mut store = CheckpointStore::new();
317
318 store
319 .create(DEFAULT_SESSION_ID, "a", vec![path.clone()], &backup_store)
320 .unwrap();
321 store
322 .create(DEFAULT_SESSION_ID, "b", vec![path.clone()], &backup_store)
323 .unwrap();
324 store
325 .create("other_session", "c", vec![path.clone()], &backup_store)
326 .unwrap();
327
328 let default_list = store.list(DEFAULT_SESSION_ID);
329 assert_eq!(default_list.len(), 2);
330 let names: Vec<&str> = default_list.iter().map(|i| i.name.as_str()).collect();
331 assert!(names.contains(&"a"));
332 assert!(names.contains(&"b"));
333
334 let other_list = store.list("other_session");
335 assert_eq!(other_list.len(), 1);
336 assert_eq!(other_list[0].name, "c");
337 }
338
339 #[test]
340 fn sessions_isolate_checkpoint_names() {
341 let path_a = temp_file("cp_isolated_a.txt", "a-original");
343 let path_b = temp_file("cp_isolated_b.txt", "b-original");
344 let backup_store = BackupStore::new();
345 let mut store = CheckpointStore::new();
346
347 store
349 .create("session_a", "snap", vec![path_a.clone()], &backup_store)
350 .unwrap();
351 store
352 .create("session_b", "snap", vec![path_b.clone()], &backup_store)
353 .unwrap();
354
355 fs::write(&path_a, "a-modified").unwrap();
356 fs::write(&path_b, "b-modified").unwrap();
357
358 store.restore("session_a", "snap").unwrap();
360 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
361 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
362
363 fs::write(&path_a, "a-modified").unwrap();
365 store.restore("session_b", "snap").unwrap();
366 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
367 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
368 }
369
370 #[test]
371 fn cleanup_removes_expired_across_sessions() {
372 let path = temp_file("cp_cleanup.txt", "data");
373 let backup_store = BackupStore::new();
374 let mut store = CheckpointStore::new();
375
376 store
377 .create(
378 DEFAULT_SESSION_ID,
379 "recent",
380 vec![path.clone()],
381 &backup_store,
382 )
383 .unwrap();
384
385 store
387 .checkpoints
388 .entry("other".to_string())
389 .or_default()
390 .insert(
391 "old".to_string(),
392 Checkpoint {
393 name: "old".to_string(),
394 file_contents: HashMap::new(),
395 created_at: 1000, },
397 );
398
399 assert_eq!(store.total_count(), 2);
400 store.cleanup(24); assert_eq!(store.total_count(), 1);
402 assert_eq!(store.list(DEFAULT_SESSION_ID)[0].name, "recent");
403 assert!(store.list("other").is_empty());
404 }
405
406 #[test]
407 fn restore_nonexistent_returns_error() {
408 let store = CheckpointStore::new();
409 let result = store.restore(DEFAULT_SESSION_ID, "nope");
410 assert!(result.is_err());
411 match result.unwrap_err() {
412 AftError::CheckpointNotFound { name } => {
413 assert_eq!(name, "nope");
414 }
415 other => panic!("expected CheckpointNotFound, got: {:?}", other),
416 }
417 }
418
419 #[test]
420 fn restore_nonexistent_in_other_session_returns_error() {
421 let path = temp_file("cp_cross_session.txt", "data");
423 let backup_store = BackupStore::new();
424 let mut store = CheckpointStore::new();
425 store
426 .create("session_a", "only_a", vec![path], &backup_store)
427 .unwrap();
428 assert!(store.restore("session_b", "only_a").is_err());
429 }
430
431 #[test]
432 fn create_skips_missing_files_from_backup_tracked_set() {
433 let readable = temp_file("cp_skip_readable.txt", "still_here");
439 let deleted = temp_file("cp_skip_deleted.txt", "about_to_vanish");
440
441 let deleted_canonical = fs::canonicalize(&deleted).unwrap();
444
445 let mut backup_store = BackupStore::new();
446 backup_store
447 .snapshot(DEFAULT_SESSION_ID, &readable, "auto")
448 .unwrap();
449 backup_store
450 .snapshot(DEFAULT_SESSION_ID, &deleted, "auto")
451 .unwrap();
452
453 fs::remove_file(&deleted).unwrap();
454
455 let mut store = CheckpointStore::new();
456 let info = store
457 .create(DEFAULT_SESSION_ID, "partial", vec![], &backup_store)
458 .expect("checkpoint should succeed despite one missing file");
459 assert_eq!(info.file_count, 1);
460 assert_eq!(info.skipped.len(), 1);
461 assert_eq!(info.skipped[0].0, deleted_canonical);
462 assert!(!info.skipped[0].1.is_empty());
463 }
464
465 #[test]
466 fn create_with_explicit_single_missing_file_errors() {
467 let missing = std::env::temp_dir()
470 .join("aft_checkpoint_tests/cp_explicit_missing_does_not_exist.txt");
471 let _ = fs::remove_file(&missing);
472
473 let backup_store = BackupStore::new();
474 let mut store = CheckpointStore::new();
475 let result = store.create(
476 DEFAULT_SESSION_ID,
477 "explicit",
478 vec![missing.clone()],
479 &backup_store,
480 );
481
482 assert!(result.is_err());
483 match result.unwrap_err() {
484 AftError::FileNotFound { path } => {
485 assert!(path.contains(&missing.display().to_string()));
486 }
487 other => panic!("expected FileNotFound, got: {:?}", other),
488 }
489 }
490
491 #[test]
492 fn create_with_explicit_mixed_files_keeps_readable_and_reports_skipped() {
493 let good = temp_file("cp_mixed_good.txt", "ok");
497 let missing = std::env::temp_dir().join("aft_checkpoint_tests/cp_mixed_missing.txt");
498 let _ = fs::remove_file(&missing);
499
500 let backup_store = BackupStore::new();
501 let mut store = CheckpointStore::new();
502 let info = store
503 .create(
504 DEFAULT_SESSION_ID,
505 "mixed",
506 vec![good.clone(), missing.clone()],
507 &backup_store,
508 )
509 .expect("mixed checkpoint should succeed when any file is readable");
510 assert_eq!(info.file_count, 1);
511 assert_eq!(info.skipped.len(), 1);
512 assert_eq!(info.skipped[0].0, missing);
513 }
514
515 #[test]
516 fn create_with_empty_files_uses_backup_tracked() {
517 let path = temp_file("cp_tracked.txt", "tracked_content");
518 let mut backup_store = BackupStore::new();
519 backup_store
520 .snapshot(DEFAULT_SESSION_ID, &path, "auto")
521 .unwrap();
522
523 let mut store = CheckpointStore::new();
524 let info = store
525 .create(DEFAULT_SESSION_ID, "from_tracked", vec![], &backup_store)
526 .unwrap();
527 assert!(info.file_count >= 1);
528
529 fs::write(&path, "modified").unwrap();
531 store.restore(DEFAULT_SESSION_ID, "from_tracked").unwrap();
532 assert_eq!(fs::read_to_string(&path).unwrap(), "tracked_content");
533 }
534}