Skip to main content

aft/
checkpoint.rs

1use std::collections::HashMap;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::time::Duration;
7
8use crate::backup::{BackupStore, CapturedRegularFile};
9use crate::error::AftError;
10use crate::fs_lock;
11
12const CHECKPOINT_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
13
14/// Metadata about a checkpoint, returned by list/create/restore.
15#[derive(Debug, Clone)]
16pub struct CheckpointInfo {
17    pub name: String,
18    pub file_count: usize,
19    pub created_at: u64,
20    /// Paths that could not be snapshotted (e.g. deleted since last edit),
21    /// paired with the OS-level error that stopped us from reading them.
22    /// Empty on successful round-trips. Populated only on `create()` — the
23    /// `list()` / `restore()` paths leave it empty.
24    pub skipped: Vec<(PathBuf, String)>,
25}
26
27/// A stored checkpoint: a snapshot of multiple file contents and metadata.
28#[derive(Debug, Clone)]
29struct Checkpoint {
30    name: String,
31    file_contents: HashMap<PathBuf, CheckpointFile>,
32    created_at: u64,
33}
34
35#[derive(Debug, Clone)]
36struct CheckpointFile {
37    metadata: fs::Metadata,
38    kind: CheckpointFileKind,
39}
40
41#[derive(Debug, Clone)]
42enum CheckpointFileKind {
43    Regular {
44        bytes: Arc<[u8]>,
45    },
46    Symlink {
47        target: PathBuf,
48        target_is_dir: bool,
49    },
50}
51
52impl CheckpointFile {
53    fn read(path: &Path) -> io::Result<Self> {
54        let metadata = fs::symlink_metadata(path)?;
55        let file_type = metadata.file_type();
56        if file_type.is_symlink() {
57            let target = fs::read_link(path)?;
58            let target_is_dir = fs::metadata(path)
59                .map(|target_metadata| target_metadata.is_dir())
60                .unwrap_or(false);
61            return Ok(Self {
62                metadata,
63                kind: CheckpointFileKind::Symlink {
64                    target,
65                    target_is_dir,
66                },
67            });
68        }
69
70        if metadata.is_file() {
71            let capture = CapturedRegularFile::read(path)?.ok_or_else(|| {
72                io::Error::new(
73                    io::ErrorKind::InvalidInput,
74                    "file changed while being captured",
75                )
76            })?;
77            return Ok(Self::from_fresh_capture(capture));
78        }
79
80        Err(io::Error::new(
81            io::ErrorKind::InvalidInput,
82            "not a regular file or symlink",
83        ))
84    }
85
86    /// Build a checkpoint from bytes captured earlier in the command.
87    ///
88    /// Size and modification time are checked immediately before the bytes enter
89    /// the checkpoint. If either changed, the capture is refreshed from disk so
90    /// rollback and undo never preserve stale pre-edit content. This constructor
91    /// is only for regular files; symlinks continue through [`Self::read`].
92    fn from_captured(path: &Path, capture: &mut CapturedRegularFile) -> io::Result<Self> {
93        capture.refresh_if_stale(path)?;
94        Ok(Self {
95            metadata: capture.metadata().clone(),
96            kind: CheckpointFileKind::Regular {
97                bytes: capture.shared_bytes(),
98            },
99        })
100    }
101
102    fn from_fresh_capture(capture: CapturedRegularFile) -> Self {
103        Self {
104            metadata: capture.metadata().clone(),
105            kind: CheckpointFileKind::Regular {
106                bytes: capture.shared_bytes(),
107            },
108        }
109    }
110
111    fn read_optional(path: &Path) -> io::Result<Option<Self>> {
112        match Self::read(path) {
113            Ok(snapshot) => Ok(Some(snapshot)),
114            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
115            Err(error) => Err(error),
116        }
117    }
118}
119
120/// Workspace-wide, per-session checkpoint store.
121///
122/// Partitioned by session (issue #14): two OpenCode sessions sharing one bridge
123/// can both create checkpoints named `snap1` without collision, and restoring
124/// from one session does not leak the other's file set. Checkpoints are kept
125/// in memory only — a bridge crash drops all of them, which is a deliberate
126/// trade-off to keep this refactor bounded. Durable checkpoints are a possible
127/// follow-up.
128#[derive(Debug)]
129pub struct CheckpointStore {
130    /// session -> name -> checkpoint
131    checkpoints: HashMap<String, HashMap<String, Checkpoint>>,
132    lock_path: PathBuf,
133    lock_timeout: Duration,
134}
135
136/// Owns a checkpoint mutation lock and removes its project scope directory after
137/// the filesystem lock has released. The directory exists only to scope the
138/// lockfile to one project because named checkpoints themselves live in memory.
139struct CheckpointLockGuard {
140    guard: Option<fs_lock::LockGuard>,
141    scope_dir: Option<PathBuf>,
142}
143
144impl Drop for CheckpointLockGuard {
145    fn drop(&mut self) {
146        // LockGuard::drop must join the heartbeat before removing the lockfile.
147        // Drop it first, then make the best-effort directory cleanup so a new
148        // owner can keep the scope directory when it races this release.
149        if let Some(guard) = self.guard.take() {
150            drop(guard);
151        }
152        if let Some(scope_dir) = &self.scope_dir {
153            remove_empty_scope_dir(scope_dir);
154        }
155    }
156}
157
158impl CheckpointStore {
159    pub fn new() -> Self {
160        let project_root = std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir());
161        let project_key = crate::path_identity::project_scope_key(&project_root);
162        let lock_path = crate::bash_background::storage_dir(None)
163            .join("checkpoints")
164            .join(project_key)
165            .join("checkpoint.lock");
166        Self::with_lock_path(lock_path, CHECKPOINT_LOCK_TIMEOUT)
167    }
168
169    /// Point this store's mutation lock at a private path. Tests use this for
170    /// isolation instead of mutating the process-global `AFT_CACHE_DIR` env
171    /// var, which races parallel lib tests that resolve storage paths.
172    #[cfg(test)]
173    pub(crate) fn set_lock_path_for_test(&mut self, lock_path: PathBuf) {
174        self.lock_path = lock_path;
175    }
176
177    fn with_lock_path(lock_path: PathBuf, lock_timeout: Duration) -> Self {
178        CheckpointStore {
179            checkpoints: HashMap::new(),
180            lock_path,
181            lock_timeout,
182        }
183    }
184
185    fn acquire_mutation_lock(&self) -> Result<CheckpointLockGuard, AftError> {
186        let scope_dir = self.lock_path.parent().map(Path::to_path_buf);
187        if let Some(parent) = scope_dir.as_deref() {
188            fs::create_dir_all(parent).map_err(|error| AftError::IoError {
189                path: parent.display().to_string(),
190                message: format!("failed to create checkpoint lock directory: {error}"),
191            })?;
192        }
193
194        let guard =
195            fs_lock::try_acquire(&self.lock_path, self.lock_timeout).map_err(
196                |error| match error {
197                    fs_lock::AcquireError::Timeout => AftError::IoError {
198                        path: self.lock_path.display().to_string(),
199                        message: "timed out acquiring checkpoint mutation lock".to_string(),
200                    },
201                    fs_lock::AcquireError::Io(error) => AftError::IoError {
202                        path: self.lock_path.display().to_string(),
203                        message: format!("failed to acquire checkpoint mutation lock: {error}"),
204                    },
205                },
206            )?;
207
208        Ok(CheckpointLockGuard {
209            guard: Some(guard),
210            scope_dir,
211        })
212    }
213
214    /// Create a checkpoint by reading the given files, scoped to `session`.
215    ///
216    /// If `files` is empty, snapshots all tracked files for **that session**
217    /// from the BackupStore (other sessions' tracked files are not visible).
218    /// Overwrites any existing checkpoint with the same name in this session.
219    ///
220    /// Unreadable paths (e.g. deleted since their last edit) are skipped with
221    /// a warning instead of failing the whole checkpoint. The paths and their
222    /// errors are returned via `CheckpointInfo::skipped` so callers can
223    /// surface them. A checkpoint is only rejected outright when *every*
224    /// requested path fails — that case still returns a `FileNotFound`
225    /// error so callers can distinguish "partial success" from "nothing
226    /// snapshotted at all".
227    pub fn create(
228        &mut self,
229        session: &str,
230        name: &str,
231        files: Vec<PathBuf>,
232        backup_store: &BackupStore,
233    ) -> Result<CheckpointInfo, AftError> {
234        self.create_impl(session, name, files, backup_store, None)
235    }
236
237    pub(crate) fn create_from_captures(
238        &mut self,
239        session: &str,
240        name: &str,
241        files: Vec<PathBuf>,
242        backup_store: &BackupStore,
243        captures: &mut HashMap<PathBuf, CapturedRegularFile>,
244    ) -> Result<CheckpointInfo, AftError> {
245        self.create_impl(session, name, files, backup_store, Some(captures))
246    }
247
248    fn create_impl(
249        &mut self,
250        session: &str,
251        name: &str,
252        files: Vec<PathBuf>,
253        backup_store: &BackupStore,
254        mut captures: Option<&mut HashMap<PathBuf, CapturedRegularFile>>,
255    ) -> Result<CheckpointInfo, AftError> {
256        let _mutation_lock = self.acquire_mutation_lock()?;
257        let explicit_request = !files.is_empty();
258        let file_list = if files.is_empty() {
259            backup_store.tracked_files(session)
260        } else {
261            files
262        };
263
264        let mut file_contents = HashMap::new();
265        let mut skipped: Vec<(PathBuf, String)> = Vec::new();
266        for path in &file_list {
267            let seeded = captures
268                .as_deref_mut()
269                .and_then(|captures| captures.get_mut(path))
270                .map(|capture| CheckpointFile::from_captured(path, capture));
271            let snapshot = match seeded {
272                Some(Err(error)) if error.kind() == io::ErrorKind::InvalidInput => {
273                    if let Some(captures) = captures.as_deref_mut() {
274                        captures.remove(path);
275                    }
276                    CheckpointFile::read(path)
277                }
278                Some(result) => result,
279                None => CheckpointFile::read(path),
280            };
281            match snapshot {
282                Ok(snapshot) => {
283                    file_contents.insert(path.clone(), snapshot);
284                }
285                Err(e) => {
286                    crate::slog_warn!(
287                        "checkpoint {}: skipping unreadable file {}: {}",
288                        name,
289                        path.display(),
290                        e
291                    );
292                    skipped.push((path.clone(), e.to_string()));
293                }
294            }
295        }
296
297        // If the caller explicitly named a single file and it was unreadable,
298        // that's a real error — surface it rather than silently returning an
299        // empty checkpoint. For empty `files` (tracked-file fallback) with no
300        // readable files at all, the empty-file checkpoint is a legitimate
301        // "nothing to snapshot" outcome and we keep it.
302        if explicit_request && file_contents.is_empty() && !skipped.is_empty() {
303            let (path, err) = &skipped[0];
304            return Err(AftError::FileNotFound {
305                path: format!("{}: {}", path.display(), err),
306            });
307        }
308
309        let created_at = current_timestamp();
310        let file_count = file_contents.len();
311
312        let checkpoint = Checkpoint {
313            name: name.to_string(),
314            file_contents,
315            created_at,
316        };
317
318        self.checkpoints
319            .entry(session.to_string())
320            .or_default()
321            .insert(name.to_string(), checkpoint);
322
323        if skipped.is_empty() {
324            crate::slog_info!("checkpoint created: {} ({} files)", name, file_count);
325        } else {
326            crate::slog_info!(
327                "checkpoint created: {} ({} files, {} skipped)",
328                name,
329                file_count,
330                skipped.len()
331            );
332        }
333
334        Ok(CheckpointInfo {
335            name: name.to_string(),
336            file_count,
337            created_at,
338            skipped,
339        })
340    }
341
342    /// Restore a checkpoint by overwriting files with stored content.
343    pub fn restore(&self, session: &str, name: &str) -> Result<CheckpointInfo, AftError> {
344        let _mutation_lock = self.acquire_mutation_lock()?;
345        let checkpoint = self.get(session, name)?;
346        let mut paths = checkpoint.file_contents.keys().cloned().collect::<Vec<_>>();
347        paths.sort();
348
349        restore_paths_atomically(checkpoint, &paths)?;
350
351        crate::slog_info!("checkpoint restored: {}", name);
352
353        Ok(CheckpointInfo {
354            name: checkpoint.name.clone(),
355            file_count: checkpoint.file_contents.len(),
356            created_at: checkpoint.created_at,
357            skipped: Vec::new(),
358        })
359    }
360
361    /// Restore a checkpoint using a caller-validated path list.
362    pub fn restore_validated(
363        &self,
364        session: &str,
365        name: &str,
366        validated_paths: &[PathBuf],
367    ) -> Result<CheckpointInfo, AftError> {
368        let _mutation_lock = self.acquire_mutation_lock()?;
369        let checkpoint = self.get(session, name)?;
370
371        for path in validated_paths {
372            checkpoint
373                .file_contents
374                .get(path)
375                .ok_or_else(|| AftError::FileNotFound {
376                    path: path.display().to_string(),
377                })?;
378        }
379        restore_paths_atomically(checkpoint, validated_paths)?;
380
381        crate::slog_info!("checkpoint restored: {}", name);
382
383        Ok(CheckpointInfo {
384            name: checkpoint.name.clone(),
385            file_count: checkpoint.file_contents.len(),
386            created_at: checkpoint.created_at,
387            skipped: Vec::new(),
388        })
389    }
390
391    /// Return the file paths stored for a checkpoint.
392    pub fn file_paths(&self, session: &str, name: &str) -> Result<Vec<PathBuf>, AftError> {
393        let checkpoint = self.get(session, name)?;
394        Ok(checkpoint.file_contents.keys().cloned().collect())
395    }
396
397    /// Return absolute file paths stored for a checkpoint without restoring it.
398    pub fn absolute_file_paths(&self, session: &str, name: &str) -> Result<Vec<PathBuf>, AftError> {
399        let mut paths: Vec<PathBuf> = self
400            .file_paths(session, name)?
401            .into_iter()
402            .map(absolute_checkpoint_path)
403            .collect();
404        paths.sort();
405        Ok(paths)
406    }
407
408    /// Delete a checkpoint from a session. Returns true when a checkpoint was removed.
409    pub fn delete(&mut self, session: &str, name: &str) -> bool {
410        let Some(session_checkpoints) = self.checkpoints.get_mut(session) else {
411            return false;
412        };
413        let removed = session_checkpoints.remove(name).is_some();
414        if session_checkpoints.is_empty() {
415            self.checkpoints.remove(session);
416        }
417        removed
418    }
419
420    /// List all checkpoints for this session with metadata.
421    pub fn list(&self, session: &str) -> Vec<CheckpointInfo> {
422        self.checkpoints
423            .get(session)
424            .map(|s| {
425                s.values()
426                    .map(|cp| CheckpointInfo {
427                        name: cp.name.clone(),
428                        file_count: cp.file_contents.len(),
429                        created_at: cp.created_at,
430                        skipped: Vec::new(),
431                    })
432                    .collect()
433            })
434            .unwrap_or_default()
435    }
436
437    /// Total checkpoint count across all sessions (for `/aft-status`).
438    pub fn total_count(&self) -> usize {
439        self.checkpoints.values().map(|s| s.len()).sum()
440    }
441
442    /// Remove checkpoints older than `ttl_hours` across all sessions.
443    /// Empty session entries are pruned after cleanup. The filesystem sweep
444    /// removes empty project scope directories left behind by released locks.
445    pub fn cleanup(&mut self, ttl_hours: u32) {
446        let now = current_timestamp();
447        let ttl_secs = ttl_hours as u64 * 3600;
448        self.checkpoints.retain(|_, session_cps| {
449            session_cps.retain(|_, cp| now.saturating_sub(cp.created_at) < ttl_secs);
450            !session_cps.is_empty()
451        });
452
453        if let Some(checkpoints_root) = self.lock_path.parent().and_then(Path::parent) {
454            sweep_empty_scope_dirs(checkpoints_root);
455        }
456    }
457
458    fn get(&self, session: &str, name: &str) -> Result<&Checkpoint, AftError> {
459        self.checkpoints
460            .get(session)
461            .and_then(|s| s.get(name))
462            .ok_or_else(|| AftError::CheckpointNotFound {
463                name: name.to_string(),
464            })
465    }
466}
467
468fn absolute_checkpoint_path(path: PathBuf) -> PathBuf {
469    if path.is_absolute() {
470        return normalize_checkpoint_path(&path);
471    }
472    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
473    normalize_checkpoint_path(&cwd.join(path))
474}
475
476fn normalize_checkpoint_path(path: &Path) -> PathBuf {
477    let mut normalized = PathBuf::new();
478    for component in path.components() {
479        match component {
480            std::path::Component::CurDir => {}
481            std::path::Component::ParentDir => {
482                if !normalized.pop() {
483                    normalized.push(component.as_os_str());
484                }
485            }
486            other => normalized.push(other.as_os_str()),
487        }
488    }
489    normalized
490}
491
492fn restore_paths_atomically(checkpoint: &Checkpoint, paths: &[PathBuf]) -> Result<(), AftError> {
493    let mut pre_restore_snapshot: HashMap<PathBuf, Option<CheckpointFile>> = HashMap::new();
494    for path in paths {
495        let current = CheckpointFile::read_optional(path).map_err(|error| AftError::IoError {
496            path: path.display().to_string(),
497            message: format!("failed to snapshot pre-restore file metadata: {error}"),
498        })?;
499        pre_restore_snapshot.insert(path.clone(), current);
500    }
501
502    let mut restored_paths: Vec<PathBuf> = Vec::new();
503    let mut created_dirs: Vec<PathBuf> = Vec::new();
504    for path in paths {
505        let snapshot =
506            checkpoint
507                .file_contents
508                .get(path)
509                .ok_or_else(|| AftError::FileNotFound {
510                    path: path.display().to_string(),
511                })?;
512        if let Err(e) = write_restored_file(path, snapshot, &mut created_dirs) {
513            let mut rollback_errors = Vec::new();
514            if let Some(snapshot) = pre_restore_snapshot.get(path) {
515                if let Err(rollback_error) = restore_snapshot_file(path, snapshot.as_ref()) {
516                    rollback_errors.push(format!("{}: {}", path.display(), rollback_error));
517                }
518            }
519            for restored_path in restored_paths.iter().rev() {
520                if let Some(snapshot) = pre_restore_snapshot.get(restored_path) {
521                    if let Err(rollback_error) =
522                        restore_snapshot_file(restored_path, snapshot.as_ref())
523                    {
524                        rollback_errors.push(format!(
525                            "{}: {}",
526                            restored_path.display(),
527                            rollback_error
528                        ));
529                    }
530                }
531            }
532            let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
533            if rollback_errors.is_empty() && dirs_rollback_ok {
534                return Err(e);
535            }
536            return Err(AftError::IoError {
537                path: path.display().to_string(),
538                message: format!(
539                    "{}; restore_checkpoint rollback_succeeded: {}; rollback_errors: {}",
540                    e,
541                    rollback_errors.is_empty() && dirs_rollback_ok,
542                    if rollback_errors.is_empty() {
543                        "none".to_string()
544                    } else {
545                        rollback_errors.join("; ")
546                    }
547                ),
548            });
549        }
550        restored_paths.push(path.clone());
551    }
552
553    Ok(())
554}
555
556fn restore_snapshot_file(path: &Path, snapshot: Option<&CheckpointFile>) -> Result<(), AftError> {
557    match snapshot {
558        Some(snapshot) => write_restored_file(path, snapshot, &mut Vec::new()),
559        None => remove_file_if_exists(path).map_err(|error| AftError::IoError {
560            path: path.display().to_string(),
561            message: format!("failed to remove file during checkpoint restore rollback: {error}"),
562        }),
563    }
564}
565
566fn write_restored_file(
567    path: &Path,
568    snapshot: &CheckpointFile,
569    created_dirs: &mut Vec<PathBuf>,
570) -> Result<(), AftError> {
571    create_parent_dirs(path, created_dirs)?;
572
573    match &snapshot.kind {
574        CheckpointFileKind::Regular { bytes } => {
575            if path_is_symlink(path) {
576                remove_file_if_exists(path).map_err(|error| AftError::IoError {
577                    path: path.display().to_string(),
578                    message: format!("failed to replace symlink with regular file: {error}"),
579                })?;
580            }
581            fs::write(path, bytes).map_err(|error| AftError::IoError {
582                path: path.display().to_string(),
583                message: format!("failed to restore checkpoint file contents: {error}"),
584            })?;
585            fs::set_permissions(path, snapshot.metadata.permissions()).map_err(|error| {
586                AftError::IoError {
587                    path: path.display().to_string(),
588                    message: format!("failed to restore checkpoint file permissions: {error}"),
589                }
590            })
591        }
592        CheckpointFileKind::Symlink {
593            target,
594            target_is_dir,
595        } => {
596            remove_file_if_exists(path).map_err(|error| AftError::IoError {
597                path: path.display().to_string(),
598                message: format!("failed to replace file with checkpoint symlink: {error}"),
599            })?;
600            create_symlink(target, path, *target_is_dir).map_err(|error| AftError::IoError {
601                path: path.display().to_string(),
602                message: format!("failed to restore checkpoint symlink: {error}"),
603            })
604        }
605    }
606}
607
608fn create_parent_dirs(path: &Path, created_dirs: &mut Vec<PathBuf>) -> Result<(), AftError> {
609    if let Some(parent) = path.parent() {
610        let missing_dirs = missing_parent_dirs(parent);
611        fs::create_dir_all(parent).map_err(|error| AftError::IoError {
612            path: parent.display().to_string(),
613            message: format!("failed to create checkpoint restore parent directories: {error}"),
614        })?;
615        created_dirs.extend(missing_dirs);
616    }
617    Ok(())
618}
619
620fn path_is_symlink(path: &Path) -> bool {
621    fs::symlink_metadata(path)
622        .map(|metadata| metadata.file_type().is_symlink())
623        .unwrap_or(false)
624}
625
626fn remove_file_if_exists(path: &Path) -> io::Result<()> {
627    match fs::remove_file(path) {
628        Ok(()) => Ok(()),
629        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
630        Err(error) => Err(error),
631    }
632}
633
634#[cfg(unix)]
635fn create_symlink(target: &Path, link: &Path, target_is_dir: bool) -> io::Result<()> {
636    let _ = target_is_dir;
637    std::os::unix::fs::symlink(target, link)
638}
639
640#[cfg(windows)]
641fn create_symlink(target: &Path, link: &Path, target_is_dir: bool) -> io::Result<()> {
642    if target_is_dir {
643        std::os::windows::fs::symlink_dir(target, link)
644    } else {
645        std::os::windows::fs::symlink_file(target, link)
646    }
647}
648
649#[cfg(not(any(unix, windows)))]
650fn create_symlink(_target: &Path, _link: &Path, _target_is_dir: bool) -> io::Result<()> {
651    Err(io::Error::new(
652        io::ErrorKind::Unsupported,
653        "checkpoint symlink restore is unsupported on this platform",
654    ))
655}
656
657fn missing_parent_dirs(parent: &Path) -> Vec<PathBuf> {
658    let mut dirs = Vec::new();
659    let mut current = Some(parent);
660
661    while let Some(dir) = current {
662        if dir.as_os_str().is_empty() || dir.exists() {
663            break;
664        }
665        dirs.push(dir.to_path_buf());
666        current = dir.parent();
667    }
668
669    dirs
670}
671
672fn rollback_created_dirs(dirs: &[PathBuf]) -> bool {
673    let mut dirs = dirs.to_vec();
674    dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
675    dirs.dedup();
676
677    let mut ok = true;
678    for dir in dirs {
679        match std::fs::remove_dir(&dir) {
680            Ok(()) => {}
681            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
682            Err(_) => ok = false,
683        }
684    }
685    ok
686}
687
688/// Remove one project scope directory without ever deleting its contents.
689/// Another process may acquire the lock or create a file between inspection and
690/// removal, so every failure is intentionally ignored.
691fn remove_empty_scope_dir(scope_dir: &Path) {
692    let _ = fs::remove_dir(scope_dir);
693}
694
695/// Sweep only the direct children of the checkpoints root. Scope directories
696/// contain lockfiles, not durable checkpoint data, so an empty one is safe to
697/// remove while a non-empty one is left untouched by `remove_dir`.
698fn sweep_empty_scope_dirs(checkpoints_root: &Path) {
699    let entries = match fs::read_dir(checkpoints_root) {
700        Ok(entries) => entries,
701        Err(_) => return,
702    };
703
704    for entry in entries.flatten() {
705        let Ok(file_type) = entry.file_type() else {
706            continue;
707        };
708        if file_type.is_dir() {
709            remove_empty_scope_dir(&entry.path());
710        }
711    }
712}
713
714fn current_timestamp() -> u64 {
715    std::time::SystemTime::now()
716        .duration_since(std::time::UNIX_EPOCH)
717        .unwrap_or_default()
718        .as_secs()
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724    use crate::protocol::DEFAULT_SESSION_ID;
725    use std::fs;
726
727    fn temp_file(name: &str, content: &str) -> (PathBuf, tempfile::TempDir) {
728        let dir = tempfile::Builder::new()
729            .prefix("aft_checkpoint_tests_")
730            .tempdir()
731            .expect("create checkpoint temp dir");
732        let path = dir.path().join(name);
733        fs::write(&path, content).unwrap();
734        (path, dir)
735    }
736
737    fn checkpoint_store() -> (CheckpointStore, tempfile::TempDir) {
738        let dir = tempfile::tempdir().unwrap();
739        let lock_path = dir
740            .path()
741            .join("checkpoints")
742            .join("test-project")
743            .join("checkpoint.lock");
744        (
745            CheckpointStore::with_lock_path(lock_path, CHECKPOINT_LOCK_TIMEOUT),
746            dir,
747        )
748    }
749
750    fn checkpoint_file(content: &str) -> CheckpointFile {
751        let file = tempfile::NamedTempFile::new().unwrap();
752        fs::write(file.path(), content).unwrap();
753        CheckpointFile::read(file.path()).unwrap()
754    }
755
756    #[test]
757    fn create_and_restore_round_trip() {
758        let (path1, _dir1) = temp_file("cp_rt1.txt", "hello");
759        let (path2, _dir2) = temp_file("cp_rt2.txt", "world");
760
761        let backup_store = BackupStore::new();
762        let (mut store, _store_dir) = checkpoint_store();
763
764        let info = store
765            .create(
766                DEFAULT_SESSION_ID,
767                "snap1",
768                vec![path1.clone(), path2.clone()],
769                &backup_store,
770            )
771            .unwrap();
772        assert_eq!(info.name, "snap1");
773        assert_eq!(info.file_count, 2);
774
775        // Modify files
776        fs::write(&path1, "changed1").unwrap();
777        fs::write(&path2, "changed2").unwrap();
778
779        // Restore
780        let info = store.restore(DEFAULT_SESSION_ID, "snap1").unwrap();
781        assert_eq!(info.file_count, 2);
782        assert_eq!(fs::read_to_string(&path1).unwrap(), "hello");
783        assert_eq!(fs::read_to_string(&path2).unwrap(), "world");
784    }
785
786    #[test]
787    fn overwrite_existing_name() {
788        let (path, _dir) = temp_file("cp_overwrite.txt", "v1");
789        let backup_store = BackupStore::new();
790        let (mut store, _store_dir) = checkpoint_store();
791
792        store
793            .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
794            .unwrap();
795        fs::write(&path, "v2").unwrap();
796        store
797            .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
798            .unwrap();
799
800        // Restore should give v2 (the overwritten checkpoint)
801        fs::write(&path, "v3").unwrap();
802        store.restore(DEFAULT_SESSION_ID, "dup").unwrap();
803        assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
804    }
805
806    #[test]
807    fn list_returns_metadata_scoped_to_session() {
808        let (path, _dir) = temp_file("cp_list.txt", "data");
809        let backup_store = BackupStore::new();
810        let (mut store, _store_dir) = checkpoint_store();
811
812        store
813            .create(DEFAULT_SESSION_ID, "a", vec![path.clone()], &backup_store)
814            .unwrap();
815        store
816            .create(DEFAULT_SESSION_ID, "b", vec![path.clone()], &backup_store)
817            .unwrap();
818        store
819            .create("other_session", "c", vec![path.clone()], &backup_store)
820            .unwrap();
821
822        let default_list = store.list(DEFAULT_SESSION_ID);
823        assert_eq!(default_list.len(), 2);
824        let names: Vec<&str> = default_list.iter().map(|i| i.name.as_str()).collect();
825        assert!(names.contains(&"a"));
826        assert!(names.contains(&"b"));
827
828        let other_list = store.list("other_session");
829        assert_eq!(other_list.len(), 1);
830        assert_eq!(other_list[0].name, "c");
831    }
832
833    #[test]
834    fn sessions_isolate_checkpoint_names() {
835        // Same checkpoint name in two sessions does not collide on restore.
836        let (path_a, _dir_a) = temp_file("cp_isolated_a.txt", "a-original");
837        let (path_b, _dir_b) = temp_file("cp_isolated_b.txt", "b-original");
838        let backup_store = BackupStore::new();
839        let (mut store, _store_dir) = checkpoint_store();
840
841        // Both sessions create a checkpoint with the same name but different files.
842        store
843            .create("session_a", "snap", vec![path_a.clone()], &backup_store)
844            .unwrap();
845        store
846            .create("session_b", "snap", vec![path_b.clone()], &backup_store)
847            .unwrap();
848
849        fs::write(&path_a, "a-modified").unwrap();
850        fs::write(&path_b, "b-modified").unwrap();
851
852        // Restoring session A's "snap" only touches path_a.
853        store.restore("session_a", "snap").unwrap();
854        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
855        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
856
857        // Restoring session B's "snap" only touches path_b.
858        fs::write(&path_a, "a-modified").unwrap();
859        store.restore("session_b", "snap").unwrap();
860        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
861        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
862    }
863
864    #[test]
865    fn checkpoint_lock_scope_is_removed_after_release() {
866        let dir = tempfile::tempdir().unwrap();
867        let scope_dir = dir.path().join("checkpoints").join("project-scope");
868        let lock_path = scope_dir.join("checkpoint.lock");
869        let path = dir.path().join("checkpoint.txt");
870        fs::write(&path, "data").unwrap();
871        let backup_store = BackupStore::new();
872        let mut store = CheckpointStore::with_lock_path(lock_path, CHECKPOINT_LOCK_TIMEOUT);
873
874        store
875            .create(DEFAULT_SESSION_ID, "released", vec![path], &backup_store)
876            .unwrap();
877
878        assert!(!scope_dir.exists(), "released lock scope should be removed");
879    }
880
881    #[test]
882    fn cleanup_removes_expired_across_sessions() {
883        let (path, _dir) = temp_file("cp_cleanup.txt", "data");
884        let backup_store = BackupStore::new();
885        let (mut store, _store_dir) = checkpoint_store();
886
887        store
888            .create(
889                DEFAULT_SESSION_ID,
890                "recent",
891                vec![path.clone()],
892                &backup_store,
893            )
894            .unwrap();
895
896        // Manually insert an expired checkpoint in another session.
897        store
898            .checkpoints
899            .entry("other".to_string())
900            .or_default()
901            .insert(
902                "old".to_string(),
903                Checkpoint {
904                    name: "old".to_string(),
905                    file_contents: HashMap::new(),
906                    created_at: 1000, // far in the past
907                },
908            );
909
910        assert_eq!(store.total_count(), 2);
911        store.cleanup(24); // 24 hours
912        assert_eq!(store.total_count(), 1);
913        assert_eq!(store.list(DEFAULT_SESSION_ID)[0].name, "recent");
914        assert!(store.list("other").is_empty());
915    }
916
917    #[test]
918    fn cleanup_sweeps_empty_scope_dirs_but_keeps_live_lock_scope() {
919        let dir = tempfile::tempdir().unwrap();
920        let checkpoints_root = dir.path().join("checkpoints");
921        let empty_a = checkpoints_root.join("empty-a");
922        let empty_b = checkpoints_root.join("empty-b");
923        let live_scope = checkpoints_root.join("live-scope");
924        fs::create_dir_all(&empty_a).unwrap();
925        fs::create_dir_all(&empty_b).unwrap();
926        fs::create_dir_all(&live_scope).unwrap();
927        fs::write(live_scope.join("checkpoint.lock"), "live lock").unwrap();
928
929        let lock_path = checkpoints_root
930            .join("current-scope")
931            .join("checkpoint.lock");
932        let mut store = CheckpointStore::with_lock_path(lock_path, CHECKPOINT_LOCK_TIMEOUT);
933        store.cleanup(24);
934
935        assert!(!empty_a.exists());
936        assert!(!empty_b.exists());
937        assert!(live_scope.is_dir());
938        assert!(live_scope.join("checkpoint.lock").is_file());
939    }
940
941    #[test]
942    fn cleanup_ignores_non_empty_scope_dir_removal_failure() {
943        let dir = tempfile::tempdir().unwrap();
944        let checkpoints_root = dir.path().join("checkpoints");
945        let scope_dir = checkpoints_root.join("racing-scope");
946        fs::create_dir_all(&scope_dir).unwrap();
947        // Model the post-race state where a concurrent lock acquisition adds
948        // this file after the root readdir but before remove_dir.
949        fs::write(scope_dir.join("checkpoint.lock"), "lock appeared").unwrap();
950
951        let lock_path = checkpoints_root
952            .join("current-scope")
953            .join("checkpoint.lock");
954        let mut store = CheckpointStore::with_lock_path(lock_path, CHECKPOINT_LOCK_TIMEOUT);
955        store.cleanup(24);
956
957        assert!(scope_dir.is_dir());
958        assert!(scope_dir.join("checkpoint.lock").is_file());
959    }
960
961    #[test]
962    fn restore_nonexistent_returns_error() {
963        let (store, _store_dir) = checkpoint_store();
964        let result = store.restore(DEFAULT_SESSION_ID, "nope");
965        assert!(result.is_err());
966        match result.unwrap_err() {
967            AftError::CheckpointNotFound { name } => {
968                assert_eq!(name, "nope");
969            }
970            other => panic!("expected CheckpointNotFound, got: {:?}", other),
971        }
972    }
973
974    #[test]
975    fn restore_nonexistent_in_other_session_returns_error() {
976        // A "snap" that exists in session A must NOT be visible from session B.
977        let (path, _dir) = temp_file("cp_cross_session.txt", "data");
978        let backup_store = BackupStore::new();
979        let (mut store, _store_dir) = checkpoint_store();
980        store
981            .create("session_a", "only_a", vec![path], &backup_store)
982            .unwrap();
983        assert!(store.restore("session_b", "only_a").is_err());
984    }
985
986    #[test]
987    fn create_skips_missing_files_from_backup_tracked_set() {
988        // Simulate the reported issue #15-follow-up: an agent deletes a
989        // previously-edited file, then calls checkpoint with no explicit
990        // file list. Before the fix, the stale backup-tracked entry caused
991        // the whole checkpoint to fail on the missing path. Now the checkpoint
992        // succeeds with the readable file and reports the skipped one.
993        let (readable, _readable_dir) = temp_file("cp_skip_readable.txt", "still_here");
994        let (deleted, _deleted_dir) = temp_file("cp_skip_deleted.txt", "about_to_vanish");
995
996        // Backup store canonicalizes keys, so the skipped path in the
997        // checkpoint result is the canonical form, not the raw temp path.
998        let deleted_canonical = fs::canonicalize(&deleted).unwrap();
999
1000        let mut backup_store = BackupStore::new();
1001        backup_store
1002            .snapshot(DEFAULT_SESSION_ID, &readable, "auto")
1003            .unwrap();
1004        backup_store
1005            .snapshot(DEFAULT_SESSION_ID, &deleted, "auto")
1006            .unwrap();
1007
1008        fs::remove_file(&deleted).unwrap();
1009
1010        let (mut store, _store_dir) = checkpoint_store();
1011        let info = store
1012            .create(DEFAULT_SESSION_ID, "partial", vec![], &backup_store)
1013            .expect("checkpoint should succeed despite one missing file");
1014        assert_eq!(info.file_count, 1);
1015        assert_eq!(info.skipped.len(), 1);
1016        assert_eq!(info.skipped[0].0, deleted_canonical);
1017        assert!(!info.skipped[0].1.is_empty());
1018    }
1019
1020    #[test]
1021    fn create_with_explicit_single_missing_file_errors() {
1022        // When the caller names a single file explicitly and it can't be read,
1023        // fail loudly — an empty checkpoint isn't what the caller asked for.
1024        let dir = tempfile::tempdir().unwrap();
1025        let missing = dir.path().join("cp_explicit_missing_does_not_exist.txt");
1026
1027        let backup_store = BackupStore::new();
1028        let (mut store, _store_dir) = checkpoint_store();
1029        let result = store.create(
1030            DEFAULT_SESSION_ID,
1031            "explicit",
1032            vec![missing.clone()],
1033            &backup_store,
1034        );
1035
1036        assert!(result.is_err());
1037        match result.unwrap_err() {
1038            AftError::FileNotFound { path } => {
1039                assert!(path.contains(&missing.display().to_string()));
1040            }
1041            other => panic!("expected FileNotFound, got: {:?}", other),
1042        }
1043    }
1044
1045    #[test]
1046    fn create_with_explicit_mixed_files_keeps_readable_and_reports_skipped() {
1047        // Explicit file list with one readable + one missing: keep the
1048        // readable one in the checkpoint, report the missing one under
1049        // `skipped` instead of failing outright.
1050        let (good, _good_dir) = temp_file("cp_mixed_good.txt", "ok");
1051        let missing_dir = tempfile::tempdir().unwrap();
1052        let missing = missing_dir.path().join("cp_mixed_missing.txt");
1053
1054        let backup_store = BackupStore::new();
1055        let (mut store, _store_dir) = checkpoint_store();
1056        let info = store
1057            .create(
1058                DEFAULT_SESSION_ID,
1059                "mixed",
1060                vec![good.clone(), missing.clone()],
1061                &backup_store,
1062            )
1063            .expect("mixed checkpoint should succeed when any file is readable");
1064        assert_eq!(info.file_count, 1);
1065        assert_eq!(info.skipped.len(), 1);
1066        assert_eq!(info.skipped[0].0, missing);
1067    }
1068
1069    #[test]
1070    fn create_with_empty_files_uses_backup_tracked() {
1071        let (path, _dir) = temp_file("cp_tracked.txt", "tracked_content");
1072        let mut backup_store = BackupStore::new();
1073        backup_store
1074            .snapshot(DEFAULT_SESSION_ID, &path, "auto")
1075            .unwrap();
1076
1077        let (mut store, _store_dir) = checkpoint_store();
1078        let info = store
1079            .create(DEFAULT_SESSION_ID, "from_tracked", vec![], &backup_store)
1080            .unwrap();
1081        assert!(info.file_count >= 1);
1082
1083        // Modify and restore
1084        fs::write(&path, "modified").unwrap();
1085        store.restore(DEFAULT_SESSION_ID, "from_tracked").unwrap();
1086        assert_eq!(fs::read_to_string(&path).unwrap(), "tracked_content");
1087    }
1088
1089    #[test]
1090    fn restore_recreates_missing_parent_directories() {
1091        let dir = tempfile::tempdir().unwrap();
1092        let path = dir.path().join("nested").join("deeper").join("file.txt");
1093        fs::create_dir_all(path.parent().unwrap()).unwrap();
1094        fs::write(&path, "original nested content").unwrap();
1095
1096        let backup_store = BackupStore::new();
1097        let (mut store, _store_dir) = checkpoint_store();
1098        store
1099            .create(
1100                DEFAULT_SESSION_ID,
1101                "nested",
1102                vec![path.clone()],
1103                &backup_store,
1104            )
1105            .unwrap();
1106
1107        fs::remove_dir_all(dir.path().join("nested")).unwrap();
1108
1109        store.restore(DEFAULT_SESSION_ID, "nested").unwrap();
1110        assert_eq!(
1111            fs::read_to_string(&path).unwrap(),
1112            "original nested content"
1113        );
1114    }
1115
1116    #[cfg(unix)]
1117    #[test]
1118    fn checkpoint_restore_rolls_back_on_partial_failure() {
1119        use std::os::unix::fs::PermissionsExt;
1120
1121        let dir = tempfile::tempdir().unwrap();
1122        let path_a = dir.path().join("a.txt");
1123        let path_b = dir.path().join("b.txt");
1124        fs::write(&path_a, "checkpoint-a").unwrap();
1125        fs::write(&path_b, "checkpoint-b").unwrap();
1126
1127        let backup_store = BackupStore::new();
1128        let (mut store, _store_dir) = checkpoint_store();
1129        store
1130            .create(
1131                DEFAULT_SESSION_ID,
1132                "partial_failure",
1133                vec![path_a.clone(), path_b.clone()],
1134                &backup_store,
1135            )
1136            .unwrap();
1137
1138        fs::write(&path_a, "pre-restore-a").unwrap();
1139        fs::write(&path_b, "pre-restore-b").unwrap();
1140        let mut readonly = fs::metadata(&path_b).unwrap().permissions();
1141        readonly.set_mode(0o444);
1142        fs::set_permissions(&path_b, readonly).unwrap();
1143
1144        let result = store.restore(DEFAULT_SESSION_ID, "partial_failure");
1145        let mut writable = fs::metadata(&path_b).unwrap().permissions();
1146        writable.set_mode(0o644);
1147        fs::set_permissions(&path_b, writable).unwrap();
1148
1149        assert!(result.is_err(), "restore should surface write failure");
1150        assert_eq!(fs::read_to_string(&path_a).unwrap(), "pre-restore-a");
1151        assert_eq!(fs::read_to_string(&path_b).unwrap(), "pre-restore-b");
1152    }
1153
1154    #[test]
1155    fn checkpoint_create_and_restore_use_mutation_lock() {
1156        let dir = tempfile::tempdir().unwrap();
1157        let lock_path = dir.path().join("locks").join("checkpoint.lock");
1158        fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1159        let mut store =
1160            CheckpointStore::with_lock_path(lock_path.clone(), Duration::from_millis(50));
1161        let backup_store = BackupStore::new();
1162        let path = dir.path().join("locked.txt");
1163        fs::write(&path, "original").unwrap();
1164
1165        let held_lock =
1166            fs_lock::try_acquire(&lock_path, Duration::from_secs(1)).expect("hold checkpoint lock");
1167        let create_result = store.create(
1168            DEFAULT_SESSION_ID,
1169            "locked",
1170            vec![path.clone()],
1171            &backup_store,
1172        );
1173        assert!(matches!(create_result, Err(AftError::IoError { .. })));
1174        drop(held_lock);
1175
1176        store
1177            .create(
1178                DEFAULT_SESSION_ID,
1179                "locked",
1180                vec![path.clone()],
1181                &backup_store,
1182            )
1183            .unwrap();
1184        fs::write(&path, "changed").unwrap();
1185
1186        fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1187        let held_lock =
1188            fs_lock::try_acquire(&lock_path, Duration::from_secs(1)).expect("hold checkpoint lock");
1189        let restore_result = store.restore(DEFAULT_SESSION_ID, "locked");
1190        assert!(matches!(restore_result, Err(AftError::IoError { .. })));
1191        drop(held_lock);
1192
1193        store.restore(DEFAULT_SESSION_ID, "locked").unwrap();
1194        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
1195    }
1196
1197    #[cfg(unix)]
1198    #[test]
1199    fn checkpoint_restore_preserves_regular_file_permissions() {
1200        use std::os::unix::fs::PermissionsExt;
1201
1202        let dir = tempfile::tempdir().unwrap();
1203        let path = dir.path().join("mode.txt");
1204        fs::write(&path, "original").unwrap();
1205        let mut original_permissions = fs::metadata(&path).unwrap().permissions();
1206        original_permissions.set_mode(0o600);
1207        fs::set_permissions(&path, original_permissions).unwrap();
1208
1209        let backup_store = BackupStore::new();
1210        let (mut store, _store_dir) = checkpoint_store();
1211        store
1212            .create(
1213                DEFAULT_SESSION_ID,
1214                "mode",
1215                vec![path.clone()],
1216                &backup_store,
1217            )
1218            .unwrap();
1219
1220        fs::write(&path, "changed").unwrap();
1221        let mut changed_permissions = fs::metadata(&path).unwrap().permissions();
1222        changed_permissions.set_mode(0o644);
1223        fs::set_permissions(&path, changed_permissions).unwrap();
1224
1225        store.restore(DEFAULT_SESSION_ID, "mode").unwrap();
1226
1227        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
1228        let restored_mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1229        assert_eq!(restored_mode, 0o600);
1230    }
1231
1232    #[cfg(unix)]
1233    #[test]
1234    fn checkpoint_restore_recreates_symlink() {
1235        let dir = tempfile::tempdir().unwrap();
1236        let target = dir.path().join("target.txt");
1237        let link = dir.path().join("link.txt");
1238        fs::write(&target, "target content").unwrap();
1239        std::os::unix::fs::symlink(&target, &link).unwrap();
1240
1241        let backup_store = BackupStore::new();
1242        let (mut store, _store_dir) = checkpoint_store();
1243        store
1244            .create(
1245                DEFAULT_SESSION_ID,
1246                "symlink",
1247                vec![link.clone()],
1248                &backup_store,
1249            )
1250            .unwrap();
1251
1252        fs::remove_file(&link).unwrap();
1253        fs::write(&link, "plain file").unwrap();
1254
1255        store.restore(DEFAULT_SESSION_ID, "symlink").unwrap();
1256
1257        assert!(fs::symlink_metadata(&link)
1258            .unwrap()
1259            .file_type()
1260            .is_symlink());
1261        assert_eq!(fs::read_link(&link).unwrap(), target);
1262        assert_eq!(fs::read_to_string(&link).unwrap(), "target content");
1263    }
1264
1265    #[test]
1266    fn captured_regular_file_is_shared_by_checkpoint_and_backup() {
1267        let (path, _dir) = temp_file("shared-capture.txt", "original bytes");
1268        crate::backup::reset_capture_read_count(&path);
1269        let mut capture = CapturedRegularFile::read(&path).unwrap().unwrap();
1270        assert_eq!(crate::backup::capture_read_count(&path), 1);
1271
1272        let checkpoint = CheckpointFile::from_captured(&path, &mut capture).unwrap();
1273        let mut backup = BackupStore::new();
1274        backup
1275            .snapshot_with_op_from_capture(
1276                DEFAULT_SESSION_ID,
1277                &path,
1278                "shared capture",
1279                Some("shared-op"),
1280                &capture,
1281            )
1282            .unwrap();
1283
1284        let history = backup.history(DEFAULT_SESSION_ID, &path);
1285        let CheckpointFileKind::Regular { bytes } = checkpoint.kind else {
1286            panic!("regular capture must create a regular checkpoint");
1287        };
1288        assert_eq!(bytes.as_ref(), b"original bytes");
1289        assert_eq!(history[0].content_bytes.as_ref(), b"original bytes");
1290        assert!(Arc::ptr_eq(&bytes, &history[0].content_bytes));
1291        assert_eq!(crate::backup::capture_read_count(&path), 1);
1292    }
1293
1294    #[test]
1295    fn stale_capture_refreshes_before_checkpoint_and_backup() {
1296        let (path, _dir) = temp_file("stale-capture.txt", "old");
1297        crate::backup::reset_capture_read_count(&path);
1298        let mut capture = CapturedRegularFile::read(&path).unwrap().unwrap();
1299        fs::write(&path, "fresh disk truth").unwrap();
1300
1301        let checkpoint = CheckpointFile::from_captured(&path, &mut capture).unwrap();
1302        let mut backup = BackupStore::new();
1303        backup
1304            .snapshot_with_op_from_capture(
1305                DEFAULT_SESSION_ID,
1306                &path,
1307                "freshened capture",
1308                Some("fresh-op"),
1309                &capture,
1310            )
1311            .unwrap();
1312
1313        let history = backup.history(DEFAULT_SESSION_ID, &path);
1314        let CheckpointFileKind::Regular { bytes } = checkpoint.kind else {
1315            panic!("regular capture must create a regular checkpoint");
1316        };
1317        assert_eq!(bytes.as_ref(), b"fresh disk truth");
1318        assert_eq!(history[0].content_bytes.as_ref(), b"fresh disk truth");
1319        assert!(Arc::ptr_eq(&bytes, &history[0].content_bytes));
1320        assert_eq!(crate::backup::capture_read_count(&path), 2);
1321    }
1322
1323    #[test]
1324    fn checkpoint_restore_failure_removes_created_parent_dirs() {
1325        let dir = tempfile::tempdir().unwrap();
1326        let missing_root = dir.path().join("created");
1327        let path_a = missing_root.join("nested").join("a.txt");
1328        let path_b = dir.path().join("blocking-dir");
1329        fs::create_dir(&path_b).unwrap();
1330
1331        let checkpoint = Checkpoint {
1332            name: "dir-cleanup".to_string(),
1333            file_contents: HashMap::from([
1334                (path_a.clone(), checkpoint_file("checkpoint-a")),
1335                (path_b.clone(), checkpoint_file("checkpoint-b")),
1336            ]),
1337            created_at: current_timestamp(),
1338        };
1339
1340        let result = restore_paths_atomically(&checkpoint, &[path_a.clone(), path_b.clone()]);
1341
1342        assert!(
1343            result.is_err(),
1344            "second restore write should fail on directory"
1345        );
1346        assert!(!path_a.exists(), "restored file should be rolled back");
1347        assert!(
1348            !missing_root.exists(),
1349            "new parent directories should be removed on rollback"
1350        );
1351        assert!(path_b.is_dir(), "pre-existing blocking directory remains");
1352    }
1353}