Skip to main content

aft/
checkpoint.rs

1use std::collections::HashMap;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use crate::backup::BackupStore;
8use crate::error::AftError;
9use crate::fs_lock;
10
11const CHECKPOINT_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
12
13/// Metadata about a checkpoint, returned by list/create/restore.
14#[derive(Debug, Clone)]
15pub struct CheckpointInfo {
16    pub name: String,
17    pub file_count: usize,
18    pub created_at: u64,
19    /// Paths that could not be snapshotted (e.g. deleted since last edit),
20    /// paired with the OS-level error that stopped us from reading them.
21    /// Empty on successful round-trips. Populated only on `create()` — the
22    /// `list()` / `restore()` paths leave it empty.
23    pub skipped: Vec<(PathBuf, String)>,
24}
25
26/// A stored checkpoint: a snapshot of multiple file contents and metadata.
27#[derive(Debug, Clone)]
28struct Checkpoint {
29    name: String,
30    file_contents: HashMap<PathBuf, CheckpointFile>,
31    created_at: u64,
32}
33
34#[derive(Debug, Clone)]
35struct CheckpointFile {
36    metadata: fs::Metadata,
37    kind: CheckpointFileKind,
38}
39
40#[derive(Debug, Clone)]
41enum CheckpointFileKind {
42    Regular {
43        bytes: Vec<u8>,
44    },
45    Symlink {
46        target: PathBuf,
47        target_is_dir: bool,
48    },
49}
50
51impl CheckpointFile {
52    fn read(path: &Path) -> io::Result<Self> {
53        let metadata = fs::symlink_metadata(path)?;
54        let file_type = metadata.file_type();
55        if file_type.is_symlink() {
56            let target = fs::read_link(path)?;
57            let target_is_dir = fs::metadata(path)
58                .map(|target_metadata| target_metadata.is_dir())
59                .unwrap_or(false);
60            return Ok(Self {
61                metadata,
62                kind: CheckpointFileKind::Symlink {
63                    target,
64                    target_is_dir,
65                },
66            });
67        }
68
69        if metadata.is_file() {
70            let bytes = fs::read(path)?;
71            return Ok(Self {
72                metadata,
73                kind: CheckpointFileKind::Regular { bytes },
74            });
75        }
76
77        Err(io::Error::new(
78            io::ErrorKind::InvalidInput,
79            "not a regular file or symlink",
80        ))
81    }
82
83    fn read_optional(path: &Path) -> io::Result<Option<Self>> {
84        match Self::read(path) {
85            Ok(snapshot) => Ok(Some(snapshot)),
86            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
87            Err(error) => Err(error),
88        }
89    }
90}
91
92/// Workspace-wide, per-session checkpoint store.
93///
94/// Partitioned by session (issue #14): two OpenCode sessions sharing one bridge
95/// can both create checkpoints named `snap1` without collision, and restoring
96/// from one session does not leak the other's file set. Checkpoints are kept
97/// in memory only — a bridge crash drops all of them, which is a deliberate
98/// trade-off to keep this refactor bounded. Durable checkpoints are a possible
99/// follow-up.
100#[derive(Debug)]
101pub struct CheckpointStore {
102    /// session -> name -> checkpoint
103    checkpoints: HashMap<String, HashMap<String, Checkpoint>>,
104    lock_path: PathBuf,
105    lock_timeout: Duration,
106}
107
108impl CheckpointStore {
109    pub fn new() -> Self {
110        let project_root = std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir());
111        let project_key = crate::search_index::project_cache_key(&project_root);
112        let lock_path = crate::bash_background::storage_dir(None)
113            .join("checkpoints")
114            .join(project_key)
115            .join("checkpoint.lock");
116        Self::with_lock_path(lock_path, CHECKPOINT_LOCK_TIMEOUT)
117    }
118
119    fn with_lock_path(lock_path: PathBuf, lock_timeout: Duration) -> Self {
120        CheckpointStore {
121            checkpoints: HashMap::new(),
122            lock_path,
123            lock_timeout,
124        }
125    }
126
127    fn acquire_mutation_lock(&self) -> Result<fs_lock::LockGuard, AftError> {
128        if let Some(parent) = self.lock_path.parent() {
129            fs::create_dir_all(parent).map_err(|error| AftError::IoError {
130                path: parent.display().to_string(),
131                message: format!("failed to create checkpoint lock directory: {error}"),
132            })?;
133        }
134
135        fs_lock::try_acquire(&self.lock_path, self.lock_timeout).map_err(|error| match error {
136            fs_lock::AcquireError::Timeout => AftError::IoError {
137                path: self.lock_path.display().to_string(),
138                message: "timed out acquiring checkpoint mutation lock".to_string(),
139            },
140            fs_lock::AcquireError::Io(error) => AftError::IoError {
141                path: self.lock_path.display().to_string(),
142                message: format!("failed to acquire checkpoint mutation lock: {error}"),
143            },
144        })
145    }
146
147    /// Create a checkpoint by reading the given files, scoped to `session`.
148    ///
149    /// If `files` is empty, snapshots all tracked files for **that session**
150    /// from the BackupStore (other sessions' tracked files are not visible).
151    /// Overwrites any existing checkpoint with the same name in this session.
152    ///
153    /// Unreadable paths (e.g. deleted since their last edit) are skipped with
154    /// a warning instead of failing the whole checkpoint. The paths and their
155    /// errors are returned via `CheckpointInfo::skipped` so callers can
156    /// surface them. A checkpoint is only rejected outright when *every*
157    /// requested path fails — that case still returns a `FileNotFound`
158    /// error so callers can distinguish "partial success" from "nothing
159    /// snapshotted at all".
160    pub fn create(
161        &mut self,
162        session: &str,
163        name: &str,
164        files: Vec<PathBuf>,
165        backup_store: &BackupStore,
166    ) -> Result<CheckpointInfo, AftError> {
167        let _mutation_lock = self.acquire_mutation_lock()?;
168        let explicit_request = !files.is_empty();
169        let file_list = if files.is_empty() {
170            backup_store.tracked_files(session)
171        } else {
172            files
173        };
174
175        let mut file_contents = HashMap::new();
176        let mut skipped: Vec<(PathBuf, String)> = Vec::new();
177        for path in &file_list {
178            match CheckpointFile::read(path) {
179                Ok(snapshot) => {
180                    file_contents.insert(path.clone(), snapshot);
181                }
182                Err(e) => {
183                    crate::slog_warn!(
184                        "checkpoint {}: skipping unreadable file {}: {}",
185                        name,
186                        path.display(),
187                        e
188                    );
189                    skipped.push((path.clone(), e.to_string()));
190                }
191            }
192        }
193
194        // If the caller explicitly named a single file and it was unreadable,
195        // that's a real error — surface it rather than silently returning an
196        // empty checkpoint. For empty `files` (tracked-file fallback) with no
197        // readable files at all, the empty-file checkpoint is a legitimate
198        // "nothing to snapshot" outcome and we keep it.
199        if explicit_request && file_contents.is_empty() && !skipped.is_empty() {
200            let (path, err) = &skipped[0];
201            return Err(AftError::FileNotFound {
202                path: format!("{}: {}", path.display(), err),
203            });
204        }
205
206        let created_at = current_timestamp();
207        let file_count = file_contents.len();
208
209        let checkpoint = Checkpoint {
210            name: name.to_string(),
211            file_contents,
212            created_at,
213        };
214
215        self.checkpoints
216            .entry(session.to_string())
217            .or_default()
218            .insert(name.to_string(), checkpoint);
219
220        if skipped.is_empty() {
221            crate::slog_info!("checkpoint created: {} ({} files)", name, file_count);
222        } else {
223            crate::slog_info!(
224                "checkpoint created: {} ({} files, {} skipped)",
225                name,
226                file_count,
227                skipped.len()
228            );
229        }
230
231        Ok(CheckpointInfo {
232            name: name.to_string(),
233            file_count,
234            created_at,
235            skipped,
236        })
237    }
238
239    /// Restore a checkpoint by overwriting files with stored content.
240    pub fn restore(&self, session: &str, name: &str) -> Result<CheckpointInfo, AftError> {
241        let _mutation_lock = self.acquire_mutation_lock()?;
242        let checkpoint = self.get(session, name)?;
243        let mut paths = checkpoint.file_contents.keys().cloned().collect::<Vec<_>>();
244        paths.sort();
245
246        restore_paths_atomically(checkpoint, &paths)?;
247
248        crate::slog_info!("checkpoint restored: {}", name);
249
250        Ok(CheckpointInfo {
251            name: checkpoint.name.clone(),
252            file_count: checkpoint.file_contents.len(),
253            created_at: checkpoint.created_at,
254            skipped: Vec::new(),
255        })
256    }
257
258    /// Restore a checkpoint using a caller-validated path list.
259    pub fn restore_validated(
260        &self,
261        session: &str,
262        name: &str,
263        validated_paths: &[PathBuf],
264    ) -> Result<CheckpointInfo, AftError> {
265        let _mutation_lock = self.acquire_mutation_lock()?;
266        let checkpoint = self.get(session, name)?;
267
268        for path in validated_paths {
269            checkpoint
270                .file_contents
271                .get(path)
272                .ok_or_else(|| AftError::FileNotFound {
273                    path: path.display().to_string(),
274                })?;
275        }
276        restore_paths_atomically(checkpoint, validated_paths)?;
277
278        crate::slog_info!("checkpoint restored: {}", name);
279
280        Ok(CheckpointInfo {
281            name: checkpoint.name.clone(),
282            file_count: checkpoint.file_contents.len(),
283            created_at: checkpoint.created_at,
284            skipped: Vec::new(),
285        })
286    }
287
288    /// Return the file paths stored for a checkpoint.
289    pub fn file_paths(&self, session: &str, name: &str) -> Result<Vec<PathBuf>, AftError> {
290        let checkpoint = self.get(session, name)?;
291        Ok(checkpoint.file_contents.keys().cloned().collect())
292    }
293
294    /// Delete a checkpoint from a session. Returns true when a checkpoint was removed.
295    pub fn delete(&mut self, session: &str, name: &str) -> bool {
296        let Some(session_checkpoints) = self.checkpoints.get_mut(session) else {
297            return false;
298        };
299        let removed = session_checkpoints.remove(name).is_some();
300        if session_checkpoints.is_empty() {
301            self.checkpoints.remove(session);
302        }
303        removed
304    }
305
306    /// List all checkpoints for this session with metadata.
307    pub fn list(&self, session: &str) -> Vec<CheckpointInfo> {
308        self.checkpoints
309            .get(session)
310            .map(|s| {
311                s.values()
312                    .map(|cp| CheckpointInfo {
313                        name: cp.name.clone(),
314                        file_count: cp.file_contents.len(),
315                        created_at: cp.created_at,
316                        skipped: Vec::new(),
317                    })
318                    .collect()
319            })
320            .unwrap_or_default()
321    }
322
323    /// Total checkpoint count across all sessions (for `/aft-status`).
324    pub fn total_count(&self) -> usize {
325        self.checkpoints.values().map(|s| s.len()).sum()
326    }
327
328    /// Remove checkpoints older than `ttl_hours` across all sessions.
329    /// Empty session entries are pruned after cleanup.
330    pub fn cleanup(&mut self, ttl_hours: u32) {
331        let now = current_timestamp();
332        let ttl_secs = ttl_hours as u64 * 3600;
333        self.checkpoints.retain(|_, session_cps| {
334            session_cps.retain(|_, cp| now.saturating_sub(cp.created_at) < ttl_secs);
335            !session_cps.is_empty()
336        });
337    }
338
339    fn get(&self, session: &str, name: &str) -> Result<&Checkpoint, AftError> {
340        self.checkpoints
341            .get(session)
342            .and_then(|s| s.get(name))
343            .ok_or_else(|| AftError::CheckpointNotFound {
344                name: name.to_string(),
345            })
346    }
347}
348
349fn restore_paths_atomically(checkpoint: &Checkpoint, paths: &[PathBuf]) -> Result<(), AftError> {
350    let mut pre_restore_snapshot: HashMap<PathBuf, Option<CheckpointFile>> = HashMap::new();
351    for path in paths {
352        let current = CheckpointFile::read_optional(path).map_err(|error| AftError::IoError {
353            path: path.display().to_string(),
354            message: format!("failed to snapshot pre-restore file metadata: {error}"),
355        })?;
356        pre_restore_snapshot.insert(path.clone(), current);
357    }
358
359    let mut restored_paths: Vec<PathBuf> = Vec::new();
360    let mut created_dirs: Vec<PathBuf> = Vec::new();
361    for path in paths {
362        let snapshot =
363            checkpoint
364                .file_contents
365                .get(path)
366                .ok_or_else(|| AftError::FileNotFound {
367                    path: path.display().to_string(),
368                })?;
369        if let Err(e) = write_restored_file(path, snapshot, &mut created_dirs) {
370            let mut rollback_errors = Vec::new();
371            if let Some(snapshot) = pre_restore_snapshot.get(path) {
372                if let Err(rollback_error) = restore_snapshot_file(path, snapshot.as_ref()) {
373                    rollback_errors.push(format!("{}: {}", path.display(), rollback_error));
374                }
375            }
376            for restored_path in restored_paths.iter().rev() {
377                if let Some(snapshot) = pre_restore_snapshot.get(restored_path) {
378                    if let Err(rollback_error) =
379                        restore_snapshot_file(restored_path, snapshot.as_ref())
380                    {
381                        rollback_errors.push(format!(
382                            "{}: {}",
383                            restored_path.display(),
384                            rollback_error
385                        ));
386                    }
387                }
388            }
389            let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
390            if rollback_errors.is_empty() && dirs_rollback_ok {
391                return Err(e);
392            }
393            return Err(AftError::IoError {
394                path: path.display().to_string(),
395                message: format!(
396                    "{}; restore_checkpoint rollback_succeeded: {}; rollback_errors: {}",
397                    e,
398                    rollback_errors.is_empty() && dirs_rollback_ok,
399                    if rollback_errors.is_empty() {
400                        "none".to_string()
401                    } else {
402                        rollback_errors.join("; ")
403                    }
404                ),
405            });
406        }
407        restored_paths.push(path.clone());
408    }
409
410    Ok(())
411}
412
413fn restore_snapshot_file(path: &Path, snapshot: Option<&CheckpointFile>) -> Result<(), AftError> {
414    match snapshot {
415        Some(snapshot) => write_restored_file(path, snapshot, &mut Vec::new()),
416        None => remove_file_if_exists(path).map_err(|error| AftError::IoError {
417            path: path.display().to_string(),
418            message: format!("failed to remove file during checkpoint restore rollback: {error}"),
419        }),
420    }
421}
422
423fn write_restored_file(
424    path: &Path,
425    snapshot: &CheckpointFile,
426    created_dirs: &mut Vec<PathBuf>,
427) -> Result<(), AftError> {
428    create_parent_dirs(path, created_dirs)?;
429
430    match &snapshot.kind {
431        CheckpointFileKind::Regular { bytes } => {
432            if path_is_symlink(path) {
433                remove_file_if_exists(path).map_err(|error| AftError::IoError {
434                    path: path.display().to_string(),
435                    message: format!("failed to replace symlink with regular file: {error}"),
436                })?;
437            }
438            fs::write(path, bytes).map_err(|error| AftError::IoError {
439                path: path.display().to_string(),
440                message: format!("failed to restore checkpoint file contents: {error}"),
441            })?;
442            fs::set_permissions(path, snapshot.metadata.permissions()).map_err(|error| {
443                AftError::IoError {
444                    path: path.display().to_string(),
445                    message: format!("failed to restore checkpoint file permissions: {error}"),
446                }
447            })
448        }
449        CheckpointFileKind::Symlink {
450            target,
451            target_is_dir,
452        } => {
453            remove_file_if_exists(path).map_err(|error| AftError::IoError {
454                path: path.display().to_string(),
455                message: format!("failed to replace file with checkpoint symlink: {error}"),
456            })?;
457            create_symlink(target, path, *target_is_dir).map_err(|error| AftError::IoError {
458                path: path.display().to_string(),
459                message: format!("failed to restore checkpoint symlink: {error}"),
460            })
461        }
462    }
463}
464
465fn create_parent_dirs(path: &Path, created_dirs: &mut Vec<PathBuf>) -> Result<(), AftError> {
466    if let Some(parent) = path.parent() {
467        let missing_dirs = missing_parent_dirs(parent);
468        fs::create_dir_all(parent).map_err(|error| AftError::IoError {
469            path: parent.display().to_string(),
470            message: format!("failed to create checkpoint restore parent directories: {error}"),
471        })?;
472        created_dirs.extend(missing_dirs);
473    }
474    Ok(())
475}
476
477fn path_is_symlink(path: &Path) -> bool {
478    fs::symlink_metadata(path)
479        .map(|metadata| metadata.file_type().is_symlink())
480        .unwrap_or(false)
481}
482
483fn remove_file_if_exists(path: &Path) -> io::Result<()> {
484    match fs::remove_file(path) {
485        Ok(()) => Ok(()),
486        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
487        Err(error) => Err(error),
488    }
489}
490
491#[cfg(unix)]
492fn create_symlink(target: &Path, link: &Path, target_is_dir: bool) -> io::Result<()> {
493    let _ = target_is_dir;
494    std::os::unix::fs::symlink(target, link)
495}
496
497#[cfg(windows)]
498fn create_symlink(target: &Path, link: &Path, target_is_dir: bool) -> io::Result<()> {
499    if target_is_dir {
500        std::os::windows::fs::symlink_dir(target, link)
501    } else {
502        std::os::windows::fs::symlink_file(target, link)
503    }
504}
505
506#[cfg(not(any(unix, windows)))]
507fn create_symlink(_target: &Path, _link: &Path, _target_is_dir: bool) -> io::Result<()> {
508    Err(io::Error::new(
509        io::ErrorKind::Unsupported,
510        "checkpoint symlink restore is unsupported on this platform",
511    ))
512}
513
514fn missing_parent_dirs(parent: &Path) -> Vec<PathBuf> {
515    let mut dirs = Vec::new();
516    let mut current = Some(parent);
517
518    while let Some(dir) = current {
519        if dir.as_os_str().is_empty() || dir.exists() {
520            break;
521        }
522        dirs.push(dir.to_path_buf());
523        current = dir.parent();
524    }
525
526    dirs
527}
528
529fn rollback_created_dirs(dirs: &[PathBuf]) -> bool {
530    let mut dirs = dirs.to_vec();
531    dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
532    dirs.dedup();
533
534    let mut ok = true;
535    for dir in dirs {
536        match std::fs::remove_dir(&dir) {
537            Ok(()) => {}
538            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
539            Err(_) => ok = false,
540        }
541    }
542    ok
543}
544
545fn current_timestamp() -> u64 {
546    std::time::SystemTime::now()
547        .duration_since(std::time::UNIX_EPOCH)
548        .unwrap_or_default()
549        .as_secs()
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use crate::protocol::DEFAULT_SESSION_ID;
556    use std::fs;
557
558    fn temp_file(name: &str, content: &str) -> (PathBuf, tempfile::TempDir) {
559        let dir = tempfile::Builder::new()
560            .prefix("aft_checkpoint_tests_")
561            .tempdir()
562            .expect("create checkpoint temp dir");
563        let path = dir.path().join(name);
564        fs::write(&path, content).unwrap();
565        (path, dir)
566    }
567
568    fn checkpoint_store() -> (CheckpointStore, tempfile::TempDir) {
569        let dir = tempfile::tempdir().unwrap();
570        let lock_path = dir.path().join("checkpoint.lock");
571        (
572            CheckpointStore::with_lock_path(lock_path, CHECKPOINT_LOCK_TIMEOUT),
573            dir,
574        )
575    }
576
577    fn checkpoint_file(content: &str) -> CheckpointFile {
578        let file = tempfile::NamedTempFile::new().unwrap();
579        fs::write(file.path(), content).unwrap();
580        CheckpointFile::read(file.path()).unwrap()
581    }
582
583    #[test]
584    fn create_and_restore_round_trip() {
585        let (path1, _dir1) = temp_file("cp_rt1.txt", "hello");
586        let (path2, _dir2) = temp_file("cp_rt2.txt", "world");
587
588        let backup_store = BackupStore::new();
589        let (mut store, _store_dir) = checkpoint_store();
590
591        let info = store
592            .create(
593                DEFAULT_SESSION_ID,
594                "snap1",
595                vec![path1.clone(), path2.clone()],
596                &backup_store,
597            )
598            .unwrap();
599        assert_eq!(info.name, "snap1");
600        assert_eq!(info.file_count, 2);
601
602        // Modify files
603        fs::write(&path1, "changed1").unwrap();
604        fs::write(&path2, "changed2").unwrap();
605
606        // Restore
607        let info = store.restore(DEFAULT_SESSION_ID, "snap1").unwrap();
608        assert_eq!(info.file_count, 2);
609        assert_eq!(fs::read_to_string(&path1).unwrap(), "hello");
610        assert_eq!(fs::read_to_string(&path2).unwrap(), "world");
611    }
612
613    #[test]
614    fn overwrite_existing_name() {
615        let (path, _dir) = temp_file("cp_overwrite.txt", "v1");
616        let backup_store = BackupStore::new();
617        let (mut store, _store_dir) = checkpoint_store();
618
619        store
620            .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
621            .unwrap();
622        fs::write(&path, "v2").unwrap();
623        store
624            .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
625            .unwrap();
626
627        // Restore should give v2 (the overwritten checkpoint)
628        fs::write(&path, "v3").unwrap();
629        store.restore(DEFAULT_SESSION_ID, "dup").unwrap();
630        assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
631    }
632
633    #[test]
634    fn list_returns_metadata_scoped_to_session() {
635        let (path, _dir) = temp_file("cp_list.txt", "data");
636        let backup_store = BackupStore::new();
637        let (mut store, _store_dir) = checkpoint_store();
638
639        store
640            .create(DEFAULT_SESSION_ID, "a", vec![path.clone()], &backup_store)
641            .unwrap();
642        store
643            .create(DEFAULT_SESSION_ID, "b", vec![path.clone()], &backup_store)
644            .unwrap();
645        store
646            .create("other_session", "c", vec![path.clone()], &backup_store)
647            .unwrap();
648
649        let default_list = store.list(DEFAULT_SESSION_ID);
650        assert_eq!(default_list.len(), 2);
651        let names: Vec<&str> = default_list.iter().map(|i| i.name.as_str()).collect();
652        assert!(names.contains(&"a"));
653        assert!(names.contains(&"b"));
654
655        let other_list = store.list("other_session");
656        assert_eq!(other_list.len(), 1);
657        assert_eq!(other_list[0].name, "c");
658    }
659
660    #[test]
661    fn sessions_isolate_checkpoint_names() {
662        // Same checkpoint name in two sessions does not collide on restore.
663        let (path_a, _dir_a) = temp_file("cp_isolated_a.txt", "a-original");
664        let (path_b, _dir_b) = temp_file("cp_isolated_b.txt", "b-original");
665        let backup_store = BackupStore::new();
666        let (mut store, _store_dir) = checkpoint_store();
667
668        // Both sessions create a checkpoint with the same name but different files.
669        store
670            .create("session_a", "snap", vec![path_a.clone()], &backup_store)
671            .unwrap();
672        store
673            .create("session_b", "snap", vec![path_b.clone()], &backup_store)
674            .unwrap();
675
676        fs::write(&path_a, "a-modified").unwrap();
677        fs::write(&path_b, "b-modified").unwrap();
678
679        // Restoring session A's "snap" only touches path_a.
680        store.restore("session_a", "snap").unwrap();
681        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
682        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
683
684        // Restoring session B's "snap" only touches path_b.
685        fs::write(&path_a, "a-modified").unwrap();
686        store.restore("session_b", "snap").unwrap();
687        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
688        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
689    }
690
691    #[test]
692    fn cleanup_removes_expired_across_sessions() {
693        let (path, _dir) = temp_file("cp_cleanup.txt", "data");
694        let backup_store = BackupStore::new();
695        let (mut store, _store_dir) = checkpoint_store();
696
697        store
698            .create(
699                DEFAULT_SESSION_ID,
700                "recent",
701                vec![path.clone()],
702                &backup_store,
703            )
704            .unwrap();
705
706        // Manually insert an expired checkpoint in another session.
707        store
708            .checkpoints
709            .entry("other".to_string())
710            .or_default()
711            .insert(
712                "old".to_string(),
713                Checkpoint {
714                    name: "old".to_string(),
715                    file_contents: HashMap::new(),
716                    created_at: 1000, // far in the past
717                },
718            );
719
720        assert_eq!(store.total_count(), 2);
721        store.cleanup(24); // 24 hours
722        assert_eq!(store.total_count(), 1);
723        assert_eq!(store.list(DEFAULT_SESSION_ID)[0].name, "recent");
724        assert!(store.list("other").is_empty());
725    }
726
727    #[test]
728    fn restore_nonexistent_returns_error() {
729        let (store, _store_dir) = checkpoint_store();
730        let result = store.restore(DEFAULT_SESSION_ID, "nope");
731        assert!(result.is_err());
732        match result.unwrap_err() {
733            AftError::CheckpointNotFound { name } => {
734                assert_eq!(name, "nope");
735            }
736            other => panic!("expected CheckpointNotFound, got: {:?}", other),
737        }
738    }
739
740    #[test]
741    fn restore_nonexistent_in_other_session_returns_error() {
742        // A "snap" that exists in session A must NOT be visible from session B.
743        let (path, _dir) = temp_file("cp_cross_session.txt", "data");
744        let backup_store = BackupStore::new();
745        let (mut store, _store_dir) = checkpoint_store();
746        store
747            .create("session_a", "only_a", vec![path], &backup_store)
748            .unwrap();
749        assert!(store.restore("session_b", "only_a").is_err());
750    }
751
752    #[test]
753    fn create_skips_missing_files_from_backup_tracked_set() {
754        // Simulate the reported issue #15-follow-up: an agent deletes a
755        // previously-edited file, then calls checkpoint with no explicit
756        // file list. Before the fix, the stale backup-tracked entry caused
757        // the whole checkpoint to fail on the missing path. Now the checkpoint
758        // succeeds with the readable file and reports the skipped one.
759        let (readable, _readable_dir) = temp_file("cp_skip_readable.txt", "still_here");
760        let (deleted, _deleted_dir) = temp_file("cp_skip_deleted.txt", "about_to_vanish");
761
762        // Backup store canonicalizes keys, so the skipped path in the
763        // checkpoint result is the canonical form, not the raw temp path.
764        let deleted_canonical = fs::canonicalize(&deleted).unwrap();
765
766        let mut backup_store = BackupStore::new();
767        backup_store
768            .snapshot(DEFAULT_SESSION_ID, &readable, "auto")
769            .unwrap();
770        backup_store
771            .snapshot(DEFAULT_SESSION_ID, &deleted, "auto")
772            .unwrap();
773
774        fs::remove_file(&deleted).unwrap();
775
776        let (mut store, _store_dir) = checkpoint_store();
777        let info = store
778            .create(DEFAULT_SESSION_ID, "partial", vec![], &backup_store)
779            .expect("checkpoint should succeed despite one missing file");
780        assert_eq!(info.file_count, 1);
781        assert_eq!(info.skipped.len(), 1);
782        assert_eq!(info.skipped[0].0, deleted_canonical);
783        assert!(!info.skipped[0].1.is_empty());
784    }
785
786    #[test]
787    fn create_with_explicit_single_missing_file_errors() {
788        // When the caller names a single file explicitly and it can't be read,
789        // fail loudly — an empty checkpoint isn't what the caller asked for.
790        let dir = tempfile::tempdir().unwrap();
791        let missing = dir.path().join("cp_explicit_missing_does_not_exist.txt");
792
793        let backup_store = BackupStore::new();
794        let (mut store, _store_dir) = checkpoint_store();
795        let result = store.create(
796            DEFAULT_SESSION_ID,
797            "explicit",
798            vec![missing.clone()],
799            &backup_store,
800        );
801
802        assert!(result.is_err());
803        match result.unwrap_err() {
804            AftError::FileNotFound { path } => {
805                assert!(path.contains(&missing.display().to_string()));
806            }
807            other => panic!("expected FileNotFound, got: {:?}", other),
808        }
809    }
810
811    #[test]
812    fn create_with_explicit_mixed_files_keeps_readable_and_reports_skipped() {
813        // Explicit file list with one readable + one missing: keep the
814        // readable one in the checkpoint, report the missing one under
815        // `skipped` instead of failing outright.
816        let (good, _good_dir) = temp_file("cp_mixed_good.txt", "ok");
817        let missing_dir = tempfile::tempdir().unwrap();
818        let missing = missing_dir.path().join("cp_mixed_missing.txt");
819
820        let backup_store = BackupStore::new();
821        let (mut store, _store_dir) = checkpoint_store();
822        let info = store
823            .create(
824                DEFAULT_SESSION_ID,
825                "mixed",
826                vec![good.clone(), missing.clone()],
827                &backup_store,
828            )
829            .expect("mixed checkpoint should succeed when any file is readable");
830        assert_eq!(info.file_count, 1);
831        assert_eq!(info.skipped.len(), 1);
832        assert_eq!(info.skipped[0].0, missing);
833    }
834
835    #[test]
836    fn create_with_empty_files_uses_backup_tracked() {
837        let (path, _dir) = temp_file("cp_tracked.txt", "tracked_content");
838        let mut backup_store = BackupStore::new();
839        backup_store
840            .snapshot(DEFAULT_SESSION_ID, &path, "auto")
841            .unwrap();
842
843        let (mut store, _store_dir) = checkpoint_store();
844        let info = store
845            .create(DEFAULT_SESSION_ID, "from_tracked", vec![], &backup_store)
846            .unwrap();
847        assert!(info.file_count >= 1);
848
849        // Modify and restore
850        fs::write(&path, "modified").unwrap();
851        store.restore(DEFAULT_SESSION_ID, "from_tracked").unwrap();
852        assert_eq!(fs::read_to_string(&path).unwrap(), "tracked_content");
853    }
854
855    #[test]
856    fn restore_recreates_missing_parent_directories() {
857        let dir = tempfile::tempdir().unwrap();
858        let path = dir.path().join("nested").join("deeper").join("file.txt");
859        fs::create_dir_all(path.parent().unwrap()).unwrap();
860        fs::write(&path, "original nested content").unwrap();
861
862        let backup_store = BackupStore::new();
863        let (mut store, _store_dir) = checkpoint_store();
864        store
865            .create(
866                DEFAULT_SESSION_ID,
867                "nested",
868                vec![path.clone()],
869                &backup_store,
870            )
871            .unwrap();
872
873        fs::remove_dir_all(dir.path().join("nested")).unwrap();
874
875        store.restore(DEFAULT_SESSION_ID, "nested").unwrap();
876        assert_eq!(
877            fs::read_to_string(&path).unwrap(),
878            "original nested content"
879        );
880    }
881
882    #[cfg(unix)]
883    #[test]
884    fn checkpoint_restore_rolls_back_on_partial_failure() {
885        use std::os::unix::fs::PermissionsExt;
886
887        let dir = tempfile::tempdir().unwrap();
888        let path_a = dir.path().join("a.txt");
889        let path_b = dir.path().join("b.txt");
890        fs::write(&path_a, "checkpoint-a").unwrap();
891        fs::write(&path_b, "checkpoint-b").unwrap();
892
893        let backup_store = BackupStore::new();
894        let (mut store, _store_dir) = checkpoint_store();
895        store
896            .create(
897                DEFAULT_SESSION_ID,
898                "partial_failure",
899                vec![path_a.clone(), path_b.clone()],
900                &backup_store,
901            )
902            .unwrap();
903
904        fs::write(&path_a, "pre-restore-a").unwrap();
905        fs::write(&path_b, "pre-restore-b").unwrap();
906        let mut readonly = fs::metadata(&path_b).unwrap().permissions();
907        readonly.set_mode(0o444);
908        fs::set_permissions(&path_b, readonly).unwrap();
909
910        let result = store.restore(DEFAULT_SESSION_ID, "partial_failure");
911        let mut writable = fs::metadata(&path_b).unwrap().permissions();
912        writable.set_mode(0o644);
913        fs::set_permissions(&path_b, writable).unwrap();
914
915        assert!(result.is_err(), "restore should surface write failure");
916        assert_eq!(fs::read_to_string(&path_a).unwrap(), "pre-restore-a");
917        assert_eq!(fs::read_to_string(&path_b).unwrap(), "pre-restore-b");
918    }
919
920    #[test]
921    fn checkpoint_create_and_restore_use_mutation_lock() {
922        let dir = tempfile::tempdir().unwrap();
923        let lock_path = dir.path().join("locks").join("checkpoint.lock");
924        fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
925        let mut store =
926            CheckpointStore::with_lock_path(lock_path.clone(), Duration::from_millis(50));
927        let backup_store = BackupStore::new();
928        let path = dir.path().join("locked.txt");
929        fs::write(&path, "original").unwrap();
930
931        let held_lock =
932            fs_lock::try_acquire(&lock_path, Duration::from_secs(1)).expect("hold checkpoint lock");
933        let create_result = store.create(
934            DEFAULT_SESSION_ID,
935            "locked",
936            vec![path.clone()],
937            &backup_store,
938        );
939        assert!(matches!(create_result, Err(AftError::IoError { .. })));
940        drop(held_lock);
941
942        store
943            .create(
944                DEFAULT_SESSION_ID,
945                "locked",
946                vec![path.clone()],
947                &backup_store,
948            )
949            .unwrap();
950        fs::write(&path, "changed").unwrap();
951
952        let held_lock =
953            fs_lock::try_acquire(&lock_path, Duration::from_secs(1)).expect("hold checkpoint lock");
954        let restore_result = store.restore(DEFAULT_SESSION_ID, "locked");
955        assert!(matches!(restore_result, Err(AftError::IoError { .. })));
956        drop(held_lock);
957
958        store.restore(DEFAULT_SESSION_ID, "locked").unwrap();
959        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
960    }
961
962    #[cfg(unix)]
963    #[test]
964    fn checkpoint_restore_preserves_regular_file_permissions() {
965        use std::os::unix::fs::PermissionsExt;
966
967        let dir = tempfile::tempdir().unwrap();
968        let path = dir.path().join("mode.txt");
969        fs::write(&path, "original").unwrap();
970        let mut original_permissions = fs::metadata(&path).unwrap().permissions();
971        original_permissions.set_mode(0o600);
972        fs::set_permissions(&path, original_permissions).unwrap();
973
974        let backup_store = BackupStore::new();
975        let (mut store, _store_dir) = checkpoint_store();
976        store
977            .create(
978                DEFAULT_SESSION_ID,
979                "mode",
980                vec![path.clone()],
981                &backup_store,
982            )
983            .unwrap();
984
985        fs::write(&path, "changed").unwrap();
986        let mut changed_permissions = fs::metadata(&path).unwrap().permissions();
987        changed_permissions.set_mode(0o644);
988        fs::set_permissions(&path, changed_permissions).unwrap();
989
990        store.restore(DEFAULT_SESSION_ID, "mode").unwrap();
991
992        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
993        let restored_mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
994        assert_eq!(restored_mode, 0o600);
995    }
996
997    #[cfg(unix)]
998    #[test]
999    fn checkpoint_restore_recreates_symlink() {
1000        let dir = tempfile::tempdir().unwrap();
1001        let target = dir.path().join("target.txt");
1002        let link = dir.path().join("link.txt");
1003        fs::write(&target, "target content").unwrap();
1004        std::os::unix::fs::symlink(&target, &link).unwrap();
1005
1006        let backup_store = BackupStore::new();
1007        let (mut store, _store_dir) = checkpoint_store();
1008        store
1009            .create(
1010                DEFAULT_SESSION_ID,
1011                "symlink",
1012                vec![link.clone()],
1013                &backup_store,
1014            )
1015            .unwrap();
1016
1017        fs::remove_file(&link).unwrap();
1018        fs::write(&link, "plain file").unwrap();
1019
1020        store.restore(DEFAULT_SESSION_ID, "symlink").unwrap();
1021
1022        assert!(fs::symlink_metadata(&link)
1023            .unwrap()
1024            .file_type()
1025            .is_symlink());
1026        assert_eq!(fs::read_link(&link).unwrap(), target);
1027        assert_eq!(fs::read_to_string(&link).unwrap(), "target content");
1028    }
1029
1030    #[test]
1031    fn checkpoint_restore_failure_removes_created_parent_dirs() {
1032        let dir = tempfile::tempdir().unwrap();
1033        let missing_root = dir.path().join("created");
1034        let path_a = missing_root.join("nested").join("a.txt");
1035        let path_b = dir.path().join("blocking-dir");
1036        fs::create_dir(&path_b).unwrap();
1037
1038        let checkpoint = Checkpoint {
1039            name: "dir-cleanup".to_string(),
1040            file_contents: HashMap::from([
1041                (path_a.clone(), checkpoint_file("checkpoint-a")),
1042                (path_b.clone(), checkpoint_file("checkpoint-b")),
1043            ]),
1044            created_at: current_timestamp(),
1045        };
1046
1047        let result = restore_paths_atomically(&checkpoint, &[path_a.clone(), path_b.clone()]);
1048
1049        assert!(
1050            result.is_err(),
1051            "second restore write should fail on directory"
1052        );
1053        assert!(!path_a.exists(), "restored file should be rolled back");
1054        assert!(
1055            !missing_root.exists(),
1056            "new parent directories should be removed on rollback"
1057        );
1058        assert!(path_b.is_dir(), "pre-existing blocking directory remains");
1059    }
1060}