Skip to main content

aft/
checkpoint.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use crate::backup::BackupStore;
5use crate::error::AftError;
6
7/// Metadata about a checkpoint, returned by list/create/restore.
8#[derive(Debug, Clone)]
9pub struct CheckpointInfo {
10    pub name: String,
11    pub file_count: usize,
12    pub created_at: u64,
13    /// Paths that could not be snapshotted (e.g. deleted since last edit),
14    /// paired with the OS-level error that stopped us from reading them.
15    /// Empty on successful round-trips. Populated only on `create()` — the
16    /// `list()` / `restore()` paths leave it empty.
17    pub skipped: Vec<(PathBuf, String)>,
18}
19
20/// A stored checkpoint: a snapshot of multiple file contents.
21#[derive(Debug, Clone)]
22struct Checkpoint {
23    name: String,
24    file_contents: HashMap<PathBuf, String>,
25    created_at: u64,
26}
27
28/// Workspace-wide, per-session checkpoint store.
29///
30/// Partitioned by session (issue #14): two OpenCode sessions sharing one bridge
31/// can both create checkpoints named `snap1` without collision, and restoring
32/// from one session does not leak the other's file set. Checkpoints are kept
33/// in memory only — a bridge crash drops all of them, which is a deliberate
34/// trade-off to keep this refactor bounded. Durable checkpoints are a possible
35/// follow-up.
36#[derive(Debug)]
37pub struct CheckpointStore {
38    /// session -> name -> checkpoint
39    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    /// Create a checkpoint by reading the given files, scoped to `session`.
50    ///
51    /// If `files` is empty, snapshots all tracked files for **that session**
52    /// from the BackupStore (other sessions' tracked files are not visible).
53    /// Overwrites any existing checkpoint with the same name in this session.
54    ///
55    /// Unreadable paths (e.g. deleted since their last edit) are skipped with
56    /// a warning instead of failing the whole checkpoint. The paths and their
57    /// errors are returned via `CheckpointInfo::skipped` so callers can
58    /// surface them. A checkpoint is only rejected outright when *every*
59    /// requested path fails — that case still returns a `FileNotFound`
60    /// error so callers can distinguish "partial success" from "nothing
61    /// snapshotted at all".
62    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 the caller explicitly named a single file and it was unreadable,
96        // that's a real error — surface it rather than silently returning an
97        // empty checkpoint. For empty `files` (tracked-file fallback) with no
98        // readable files at all, the empty-file checkpoint is a legitimate
99        // "nothing to snapshot" outcome and we keep it.
100        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    /// Restore a checkpoint by overwriting files with stored content.
141    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    /// Restore a checkpoint using a caller-validated path list.
159    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    /// Return the file paths stored for a checkpoint.
188    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    /// Delete a checkpoint from a session. Returns true when a checkpoint was removed.
194    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    /// List all checkpoints for this session with metadata.
206    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    /// Total checkpoint count across all sessions (for `/aft-status`).
223    pub fn total_count(&self) -> usize {
224        self.checkpoints.values().map(|s| s.len()).sum()
225    }
226
227    /// Remove checkpoints older than `ttl_hours` across all sessions.
228    /// Empty session entries are pruned after cleanup.
229    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        // Modify files
383        fs::write(&path1, "changed1").unwrap();
384        fs::write(&path2, "changed2").unwrap();
385
386        // Restore
387        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        // Restore should give v2 (the overwritten checkpoint)
408        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        // Same checkpoint name in two sessions does not collide on restore.
443        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        // Both sessions create a checkpoint with the same name but different files.
449        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        // Restoring session A's "snap" only touches path_a.
460        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        // Restoring session B's "snap" only touches path_b.
465        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        // Manually insert an expired checkpoint in another session.
487        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, // far in the past
497                },
498            );
499
500        assert_eq!(store.total_count(), 2);
501        store.cleanup(24); // 24 hours
502        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        // A "snap" that exists in session A must NOT be visible from session B.
523        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        // Simulate the reported issue #15-follow-up: an agent deletes a
535        // previously-edited file, then calls checkpoint with no explicit
536        // file list. Before the fix, the stale backup-tracked entry caused
537        // the whole checkpoint to fail on the missing path. Now the checkpoint
538        // succeeds with the readable file and reports the skipped one.
539        let readable = temp_file("cp_skip_readable.txt", "still_here");
540        let deleted = temp_file("cp_skip_deleted.txt", "about_to_vanish");
541
542        // Backup store canonicalizes keys, so the skipped path in the
543        // checkpoint result is the canonical form, not the raw temp path.
544        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        // When the caller names a single file explicitly and it can't be read,
569        // fail loudly — an empty checkpoint isn't what the caller asked for.
570        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        // Explicit file list with one readable + one missing: keep the
595        // readable one in the checkpoint, report the missing one under
596        // `skipped` instead of failing outright.
597        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        // Modify and restore
631        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}