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    /// Return absolute file paths stored for a checkpoint without restoring it.
295    pub fn absolute_file_paths(&self, session: &str, name: &str) -> Result<Vec<PathBuf>, AftError> {
296        let mut paths: Vec<PathBuf> = self
297            .file_paths(session, name)?
298            .into_iter()
299            .map(absolute_checkpoint_path)
300            .collect();
301        paths.sort();
302        Ok(paths)
303    }
304
305    /// Delete a checkpoint from a session. Returns true when a checkpoint was removed.
306    pub fn delete(&mut self, session: &str, name: &str) -> bool {
307        let Some(session_checkpoints) = self.checkpoints.get_mut(session) else {
308            return false;
309        };
310        let removed = session_checkpoints.remove(name).is_some();
311        if session_checkpoints.is_empty() {
312            self.checkpoints.remove(session);
313        }
314        removed
315    }
316
317    /// List all checkpoints for this session with metadata.
318    pub fn list(&self, session: &str) -> Vec<CheckpointInfo> {
319        self.checkpoints
320            .get(session)
321            .map(|s| {
322                s.values()
323                    .map(|cp| CheckpointInfo {
324                        name: cp.name.clone(),
325                        file_count: cp.file_contents.len(),
326                        created_at: cp.created_at,
327                        skipped: Vec::new(),
328                    })
329                    .collect()
330            })
331            .unwrap_or_default()
332    }
333
334    /// Total checkpoint count across all sessions (for `/aft-status`).
335    pub fn total_count(&self) -> usize {
336        self.checkpoints.values().map(|s| s.len()).sum()
337    }
338
339    /// Remove checkpoints older than `ttl_hours` across all sessions.
340    /// Empty session entries are pruned after cleanup.
341    pub fn cleanup(&mut self, ttl_hours: u32) {
342        let now = current_timestamp();
343        let ttl_secs = ttl_hours as u64 * 3600;
344        self.checkpoints.retain(|_, session_cps| {
345            session_cps.retain(|_, cp| now.saturating_sub(cp.created_at) < ttl_secs);
346            !session_cps.is_empty()
347        });
348    }
349
350    fn get(&self, session: &str, name: &str) -> Result<&Checkpoint, AftError> {
351        self.checkpoints
352            .get(session)
353            .and_then(|s| s.get(name))
354            .ok_or_else(|| AftError::CheckpointNotFound {
355                name: name.to_string(),
356            })
357    }
358}
359
360fn absolute_checkpoint_path(path: PathBuf) -> PathBuf {
361    if path.is_absolute() {
362        return normalize_checkpoint_path(&path);
363    }
364    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
365    normalize_checkpoint_path(&cwd.join(path))
366}
367
368fn normalize_checkpoint_path(path: &Path) -> PathBuf {
369    let mut normalized = PathBuf::new();
370    for component in path.components() {
371        match component {
372            std::path::Component::CurDir => {}
373            std::path::Component::ParentDir => {
374                if !normalized.pop() {
375                    normalized.push(component.as_os_str());
376                }
377            }
378            other => normalized.push(other.as_os_str()),
379        }
380    }
381    normalized
382}
383
384fn restore_paths_atomically(checkpoint: &Checkpoint, paths: &[PathBuf]) -> Result<(), AftError> {
385    let mut pre_restore_snapshot: HashMap<PathBuf, Option<CheckpointFile>> = HashMap::new();
386    for path in paths {
387        let current = CheckpointFile::read_optional(path).map_err(|error| AftError::IoError {
388            path: path.display().to_string(),
389            message: format!("failed to snapshot pre-restore file metadata: {error}"),
390        })?;
391        pre_restore_snapshot.insert(path.clone(), current);
392    }
393
394    let mut restored_paths: Vec<PathBuf> = Vec::new();
395    let mut created_dirs: Vec<PathBuf> = Vec::new();
396    for path in paths {
397        let snapshot =
398            checkpoint
399                .file_contents
400                .get(path)
401                .ok_or_else(|| AftError::FileNotFound {
402                    path: path.display().to_string(),
403                })?;
404        if let Err(e) = write_restored_file(path, snapshot, &mut created_dirs) {
405            let mut rollback_errors = Vec::new();
406            if let Some(snapshot) = pre_restore_snapshot.get(path) {
407                if let Err(rollback_error) = restore_snapshot_file(path, snapshot.as_ref()) {
408                    rollback_errors.push(format!("{}: {}", path.display(), rollback_error));
409                }
410            }
411            for restored_path in restored_paths.iter().rev() {
412                if let Some(snapshot) = pre_restore_snapshot.get(restored_path) {
413                    if let Err(rollback_error) =
414                        restore_snapshot_file(restored_path, snapshot.as_ref())
415                    {
416                        rollback_errors.push(format!(
417                            "{}: {}",
418                            restored_path.display(),
419                            rollback_error
420                        ));
421                    }
422                }
423            }
424            let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
425            if rollback_errors.is_empty() && dirs_rollback_ok {
426                return Err(e);
427            }
428            return Err(AftError::IoError {
429                path: path.display().to_string(),
430                message: format!(
431                    "{}; restore_checkpoint rollback_succeeded: {}; rollback_errors: {}",
432                    e,
433                    rollback_errors.is_empty() && dirs_rollback_ok,
434                    if rollback_errors.is_empty() {
435                        "none".to_string()
436                    } else {
437                        rollback_errors.join("; ")
438                    }
439                ),
440            });
441        }
442        restored_paths.push(path.clone());
443    }
444
445    Ok(())
446}
447
448fn restore_snapshot_file(path: &Path, snapshot: Option<&CheckpointFile>) -> Result<(), AftError> {
449    match snapshot {
450        Some(snapshot) => write_restored_file(path, snapshot, &mut Vec::new()),
451        None => remove_file_if_exists(path).map_err(|error| AftError::IoError {
452            path: path.display().to_string(),
453            message: format!("failed to remove file during checkpoint restore rollback: {error}"),
454        }),
455    }
456}
457
458fn write_restored_file(
459    path: &Path,
460    snapshot: &CheckpointFile,
461    created_dirs: &mut Vec<PathBuf>,
462) -> Result<(), AftError> {
463    create_parent_dirs(path, created_dirs)?;
464
465    match &snapshot.kind {
466        CheckpointFileKind::Regular { bytes } => {
467            if path_is_symlink(path) {
468                remove_file_if_exists(path).map_err(|error| AftError::IoError {
469                    path: path.display().to_string(),
470                    message: format!("failed to replace symlink with regular file: {error}"),
471                })?;
472            }
473            fs::write(path, bytes).map_err(|error| AftError::IoError {
474                path: path.display().to_string(),
475                message: format!("failed to restore checkpoint file contents: {error}"),
476            })?;
477            fs::set_permissions(path, snapshot.metadata.permissions()).map_err(|error| {
478                AftError::IoError {
479                    path: path.display().to_string(),
480                    message: format!("failed to restore checkpoint file permissions: {error}"),
481                }
482            })
483        }
484        CheckpointFileKind::Symlink {
485            target,
486            target_is_dir,
487        } => {
488            remove_file_if_exists(path).map_err(|error| AftError::IoError {
489                path: path.display().to_string(),
490                message: format!("failed to replace file with checkpoint symlink: {error}"),
491            })?;
492            create_symlink(target, path, *target_is_dir).map_err(|error| AftError::IoError {
493                path: path.display().to_string(),
494                message: format!("failed to restore checkpoint symlink: {error}"),
495            })
496        }
497    }
498}
499
500fn create_parent_dirs(path: &Path, created_dirs: &mut Vec<PathBuf>) -> Result<(), AftError> {
501    if let Some(parent) = path.parent() {
502        let missing_dirs = missing_parent_dirs(parent);
503        fs::create_dir_all(parent).map_err(|error| AftError::IoError {
504            path: parent.display().to_string(),
505            message: format!("failed to create checkpoint restore parent directories: {error}"),
506        })?;
507        created_dirs.extend(missing_dirs);
508    }
509    Ok(())
510}
511
512fn path_is_symlink(path: &Path) -> bool {
513    fs::symlink_metadata(path)
514        .map(|metadata| metadata.file_type().is_symlink())
515        .unwrap_or(false)
516}
517
518fn remove_file_if_exists(path: &Path) -> io::Result<()> {
519    match fs::remove_file(path) {
520        Ok(()) => Ok(()),
521        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
522        Err(error) => Err(error),
523    }
524}
525
526#[cfg(unix)]
527fn create_symlink(target: &Path, link: &Path, target_is_dir: bool) -> io::Result<()> {
528    let _ = target_is_dir;
529    std::os::unix::fs::symlink(target, link)
530}
531
532#[cfg(windows)]
533fn create_symlink(target: &Path, link: &Path, target_is_dir: bool) -> io::Result<()> {
534    if target_is_dir {
535        std::os::windows::fs::symlink_dir(target, link)
536    } else {
537        std::os::windows::fs::symlink_file(target, link)
538    }
539}
540
541#[cfg(not(any(unix, windows)))]
542fn create_symlink(_target: &Path, _link: &Path, _target_is_dir: bool) -> io::Result<()> {
543    Err(io::Error::new(
544        io::ErrorKind::Unsupported,
545        "checkpoint symlink restore is unsupported on this platform",
546    ))
547}
548
549fn missing_parent_dirs(parent: &Path) -> Vec<PathBuf> {
550    let mut dirs = Vec::new();
551    let mut current = Some(parent);
552
553    while let Some(dir) = current {
554        if dir.as_os_str().is_empty() || dir.exists() {
555            break;
556        }
557        dirs.push(dir.to_path_buf());
558        current = dir.parent();
559    }
560
561    dirs
562}
563
564fn rollback_created_dirs(dirs: &[PathBuf]) -> bool {
565    let mut dirs = dirs.to_vec();
566    dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
567    dirs.dedup();
568
569    let mut ok = true;
570    for dir in dirs {
571        match std::fs::remove_dir(&dir) {
572            Ok(()) => {}
573            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
574            Err(_) => ok = false,
575        }
576    }
577    ok
578}
579
580fn current_timestamp() -> u64 {
581    std::time::SystemTime::now()
582        .duration_since(std::time::UNIX_EPOCH)
583        .unwrap_or_default()
584        .as_secs()
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use crate::protocol::DEFAULT_SESSION_ID;
591    use std::fs;
592
593    fn temp_file(name: &str, content: &str) -> (PathBuf, tempfile::TempDir) {
594        let dir = tempfile::Builder::new()
595            .prefix("aft_checkpoint_tests_")
596            .tempdir()
597            .expect("create checkpoint temp dir");
598        let path = dir.path().join(name);
599        fs::write(&path, content).unwrap();
600        (path, dir)
601    }
602
603    fn checkpoint_store() -> (CheckpointStore, tempfile::TempDir) {
604        let dir = tempfile::tempdir().unwrap();
605        let lock_path = dir.path().join("checkpoint.lock");
606        (
607            CheckpointStore::with_lock_path(lock_path, CHECKPOINT_LOCK_TIMEOUT),
608            dir,
609        )
610    }
611
612    fn checkpoint_file(content: &str) -> CheckpointFile {
613        let file = tempfile::NamedTempFile::new().unwrap();
614        fs::write(file.path(), content).unwrap();
615        CheckpointFile::read(file.path()).unwrap()
616    }
617
618    #[test]
619    fn create_and_restore_round_trip() {
620        let (path1, _dir1) = temp_file("cp_rt1.txt", "hello");
621        let (path2, _dir2) = temp_file("cp_rt2.txt", "world");
622
623        let backup_store = BackupStore::new();
624        let (mut store, _store_dir) = checkpoint_store();
625
626        let info = store
627            .create(
628                DEFAULT_SESSION_ID,
629                "snap1",
630                vec![path1.clone(), path2.clone()],
631                &backup_store,
632            )
633            .unwrap();
634        assert_eq!(info.name, "snap1");
635        assert_eq!(info.file_count, 2);
636
637        // Modify files
638        fs::write(&path1, "changed1").unwrap();
639        fs::write(&path2, "changed2").unwrap();
640
641        // Restore
642        let info = store.restore(DEFAULT_SESSION_ID, "snap1").unwrap();
643        assert_eq!(info.file_count, 2);
644        assert_eq!(fs::read_to_string(&path1).unwrap(), "hello");
645        assert_eq!(fs::read_to_string(&path2).unwrap(), "world");
646    }
647
648    #[test]
649    fn overwrite_existing_name() {
650        let (path, _dir) = temp_file("cp_overwrite.txt", "v1");
651        let backup_store = BackupStore::new();
652        let (mut store, _store_dir) = checkpoint_store();
653
654        store
655            .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
656            .unwrap();
657        fs::write(&path, "v2").unwrap();
658        store
659            .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
660            .unwrap();
661
662        // Restore should give v2 (the overwritten checkpoint)
663        fs::write(&path, "v3").unwrap();
664        store.restore(DEFAULT_SESSION_ID, "dup").unwrap();
665        assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
666    }
667
668    #[test]
669    fn list_returns_metadata_scoped_to_session() {
670        let (path, _dir) = temp_file("cp_list.txt", "data");
671        let backup_store = BackupStore::new();
672        let (mut store, _store_dir) = checkpoint_store();
673
674        store
675            .create(DEFAULT_SESSION_ID, "a", vec![path.clone()], &backup_store)
676            .unwrap();
677        store
678            .create(DEFAULT_SESSION_ID, "b", vec![path.clone()], &backup_store)
679            .unwrap();
680        store
681            .create("other_session", "c", vec![path.clone()], &backup_store)
682            .unwrap();
683
684        let default_list = store.list(DEFAULT_SESSION_ID);
685        assert_eq!(default_list.len(), 2);
686        let names: Vec<&str> = default_list.iter().map(|i| i.name.as_str()).collect();
687        assert!(names.contains(&"a"));
688        assert!(names.contains(&"b"));
689
690        let other_list = store.list("other_session");
691        assert_eq!(other_list.len(), 1);
692        assert_eq!(other_list[0].name, "c");
693    }
694
695    #[test]
696    fn sessions_isolate_checkpoint_names() {
697        // Same checkpoint name in two sessions does not collide on restore.
698        let (path_a, _dir_a) = temp_file("cp_isolated_a.txt", "a-original");
699        let (path_b, _dir_b) = temp_file("cp_isolated_b.txt", "b-original");
700        let backup_store = BackupStore::new();
701        let (mut store, _store_dir) = checkpoint_store();
702
703        // Both sessions create a checkpoint with the same name but different files.
704        store
705            .create("session_a", "snap", vec![path_a.clone()], &backup_store)
706            .unwrap();
707        store
708            .create("session_b", "snap", vec![path_b.clone()], &backup_store)
709            .unwrap();
710
711        fs::write(&path_a, "a-modified").unwrap();
712        fs::write(&path_b, "b-modified").unwrap();
713
714        // Restoring session A's "snap" only touches path_a.
715        store.restore("session_a", "snap").unwrap();
716        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
717        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
718
719        // Restoring session B's "snap" only touches path_b.
720        fs::write(&path_a, "a-modified").unwrap();
721        store.restore("session_b", "snap").unwrap();
722        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
723        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
724    }
725
726    #[test]
727    fn cleanup_removes_expired_across_sessions() {
728        let (path, _dir) = temp_file("cp_cleanup.txt", "data");
729        let backup_store = BackupStore::new();
730        let (mut store, _store_dir) = checkpoint_store();
731
732        store
733            .create(
734                DEFAULT_SESSION_ID,
735                "recent",
736                vec![path.clone()],
737                &backup_store,
738            )
739            .unwrap();
740
741        // Manually insert an expired checkpoint in another session.
742        store
743            .checkpoints
744            .entry("other".to_string())
745            .or_default()
746            .insert(
747                "old".to_string(),
748                Checkpoint {
749                    name: "old".to_string(),
750                    file_contents: HashMap::new(),
751                    created_at: 1000, // far in the past
752                },
753            );
754
755        assert_eq!(store.total_count(), 2);
756        store.cleanup(24); // 24 hours
757        assert_eq!(store.total_count(), 1);
758        assert_eq!(store.list(DEFAULT_SESSION_ID)[0].name, "recent");
759        assert!(store.list("other").is_empty());
760    }
761
762    #[test]
763    fn restore_nonexistent_returns_error() {
764        let (store, _store_dir) = checkpoint_store();
765        let result = store.restore(DEFAULT_SESSION_ID, "nope");
766        assert!(result.is_err());
767        match result.unwrap_err() {
768            AftError::CheckpointNotFound { name } => {
769                assert_eq!(name, "nope");
770            }
771            other => panic!("expected CheckpointNotFound, got: {:?}", other),
772        }
773    }
774
775    #[test]
776    fn restore_nonexistent_in_other_session_returns_error() {
777        // A "snap" that exists in session A must NOT be visible from session B.
778        let (path, _dir) = temp_file("cp_cross_session.txt", "data");
779        let backup_store = BackupStore::new();
780        let (mut store, _store_dir) = checkpoint_store();
781        store
782            .create("session_a", "only_a", vec![path], &backup_store)
783            .unwrap();
784        assert!(store.restore("session_b", "only_a").is_err());
785    }
786
787    #[test]
788    fn create_skips_missing_files_from_backup_tracked_set() {
789        // Simulate the reported issue #15-follow-up: an agent deletes a
790        // previously-edited file, then calls checkpoint with no explicit
791        // file list. Before the fix, the stale backup-tracked entry caused
792        // the whole checkpoint to fail on the missing path. Now the checkpoint
793        // succeeds with the readable file and reports the skipped one.
794        let (readable, _readable_dir) = temp_file("cp_skip_readable.txt", "still_here");
795        let (deleted, _deleted_dir) = temp_file("cp_skip_deleted.txt", "about_to_vanish");
796
797        // Backup store canonicalizes keys, so the skipped path in the
798        // checkpoint result is the canonical form, not the raw temp path.
799        let deleted_canonical = fs::canonicalize(&deleted).unwrap();
800
801        let mut backup_store = BackupStore::new();
802        backup_store
803            .snapshot(DEFAULT_SESSION_ID, &readable, "auto")
804            .unwrap();
805        backup_store
806            .snapshot(DEFAULT_SESSION_ID, &deleted, "auto")
807            .unwrap();
808
809        fs::remove_file(&deleted).unwrap();
810
811        let (mut store, _store_dir) = checkpoint_store();
812        let info = store
813            .create(DEFAULT_SESSION_ID, "partial", vec![], &backup_store)
814            .expect("checkpoint should succeed despite one missing file");
815        assert_eq!(info.file_count, 1);
816        assert_eq!(info.skipped.len(), 1);
817        assert_eq!(info.skipped[0].0, deleted_canonical);
818        assert!(!info.skipped[0].1.is_empty());
819    }
820
821    #[test]
822    fn create_with_explicit_single_missing_file_errors() {
823        // When the caller names a single file explicitly and it can't be read,
824        // fail loudly — an empty checkpoint isn't what the caller asked for.
825        let dir = tempfile::tempdir().unwrap();
826        let missing = dir.path().join("cp_explicit_missing_does_not_exist.txt");
827
828        let backup_store = BackupStore::new();
829        let (mut store, _store_dir) = checkpoint_store();
830        let result = store.create(
831            DEFAULT_SESSION_ID,
832            "explicit",
833            vec![missing.clone()],
834            &backup_store,
835        );
836
837        assert!(result.is_err());
838        match result.unwrap_err() {
839            AftError::FileNotFound { path } => {
840                assert!(path.contains(&missing.display().to_string()));
841            }
842            other => panic!("expected FileNotFound, got: {:?}", other),
843        }
844    }
845
846    #[test]
847    fn create_with_explicit_mixed_files_keeps_readable_and_reports_skipped() {
848        // Explicit file list with one readable + one missing: keep the
849        // readable one in the checkpoint, report the missing one under
850        // `skipped` instead of failing outright.
851        let (good, _good_dir) = temp_file("cp_mixed_good.txt", "ok");
852        let missing_dir = tempfile::tempdir().unwrap();
853        let missing = missing_dir.path().join("cp_mixed_missing.txt");
854
855        let backup_store = BackupStore::new();
856        let (mut store, _store_dir) = checkpoint_store();
857        let info = store
858            .create(
859                DEFAULT_SESSION_ID,
860                "mixed",
861                vec![good.clone(), missing.clone()],
862                &backup_store,
863            )
864            .expect("mixed checkpoint should succeed when any file is readable");
865        assert_eq!(info.file_count, 1);
866        assert_eq!(info.skipped.len(), 1);
867        assert_eq!(info.skipped[0].0, missing);
868    }
869
870    #[test]
871    fn create_with_empty_files_uses_backup_tracked() {
872        let (path, _dir) = temp_file("cp_tracked.txt", "tracked_content");
873        let mut backup_store = BackupStore::new();
874        backup_store
875            .snapshot(DEFAULT_SESSION_ID, &path, "auto")
876            .unwrap();
877
878        let (mut store, _store_dir) = checkpoint_store();
879        let info = store
880            .create(DEFAULT_SESSION_ID, "from_tracked", vec![], &backup_store)
881            .unwrap();
882        assert!(info.file_count >= 1);
883
884        // Modify and restore
885        fs::write(&path, "modified").unwrap();
886        store.restore(DEFAULT_SESSION_ID, "from_tracked").unwrap();
887        assert_eq!(fs::read_to_string(&path).unwrap(), "tracked_content");
888    }
889
890    #[test]
891    fn restore_recreates_missing_parent_directories() {
892        let dir = tempfile::tempdir().unwrap();
893        let path = dir.path().join("nested").join("deeper").join("file.txt");
894        fs::create_dir_all(path.parent().unwrap()).unwrap();
895        fs::write(&path, "original nested content").unwrap();
896
897        let backup_store = BackupStore::new();
898        let (mut store, _store_dir) = checkpoint_store();
899        store
900            .create(
901                DEFAULT_SESSION_ID,
902                "nested",
903                vec![path.clone()],
904                &backup_store,
905            )
906            .unwrap();
907
908        fs::remove_dir_all(dir.path().join("nested")).unwrap();
909
910        store.restore(DEFAULT_SESSION_ID, "nested").unwrap();
911        assert_eq!(
912            fs::read_to_string(&path).unwrap(),
913            "original nested content"
914        );
915    }
916
917    #[cfg(unix)]
918    #[test]
919    fn checkpoint_restore_rolls_back_on_partial_failure() {
920        use std::os::unix::fs::PermissionsExt;
921
922        let dir = tempfile::tempdir().unwrap();
923        let path_a = dir.path().join("a.txt");
924        let path_b = dir.path().join("b.txt");
925        fs::write(&path_a, "checkpoint-a").unwrap();
926        fs::write(&path_b, "checkpoint-b").unwrap();
927
928        let backup_store = BackupStore::new();
929        let (mut store, _store_dir) = checkpoint_store();
930        store
931            .create(
932                DEFAULT_SESSION_ID,
933                "partial_failure",
934                vec![path_a.clone(), path_b.clone()],
935                &backup_store,
936            )
937            .unwrap();
938
939        fs::write(&path_a, "pre-restore-a").unwrap();
940        fs::write(&path_b, "pre-restore-b").unwrap();
941        let mut readonly = fs::metadata(&path_b).unwrap().permissions();
942        readonly.set_mode(0o444);
943        fs::set_permissions(&path_b, readonly).unwrap();
944
945        let result = store.restore(DEFAULT_SESSION_ID, "partial_failure");
946        let mut writable = fs::metadata(&path_b).unwrap().permissions();
947        writable.set_mode(0o644);
948        fs::set_permissions(&path_b, writable).unwrap();
949
950        assert!(result.is_err(), "restore should surface write failure");
951        assert_eq!(fs::read_to_string(&path_a).unwrap(), "pre-restore-a");
952        assert_eq!(fs::read_to_string(&path_b).unwrap(), "pre-restore-b");
953    }
954
955    #[test]
956    fn checkpoint_create_and_restore_use_mutation_lock() {
957        let dir = tempfile::tempdir().unwrap();
958        let lock_path = dir.path().join("locks").join("checkpoint.lock");
959        fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
960        let mut store =
961            CheckpointStore::with_lock_path(lock_path.clone(), Duration::from_millis(50));
962        let backup_store = BackupStore::new();
963        let path = dir.path().join("locked.txt");
964        fs::write(&path, "original").unwrap();
965
966        let held_lock =
967            fs_lock::try_acquire(&lock_path, Duration::from_secs(1)).expect("hold checkpoint lock");
968        let create_result = store.create(
969            DEFAULT_SESSION_ID,
970            "locked",
971            vec![path.clone()],
972            &backup_store,
973        );
974        assert!(matches!(create_result, Err(AftError::IoError { .. })));
975        drop(held_lock);
976
977        store
978            .create(
979                DEFAULT_SESSION_ID,
980                "locked",
981                vec![path.clone()],
982                &backup_store,
983            )
984            .unwrap();
985        fs::write(&path, "changed").unwrap();
986
987        let held_lock =
988            fs_lock::try_acquire(&lock_path, Duration::from_secs(1)).expect("hold checkpoint lock");
989        let restore_result = store.restore(DEFAULT_SESSION_ID, "locked");
990        assert!(matches!(restore_result, Err(AftError::IoError { .. })));
991        drop(held_lock);
992
993        store.restore(DEFAULT_SESSION_ID, "locked").unwrap();
994        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
995    }
996
997    #[cfg(unix)]
998    #[test]
999    fn checkpoint_restore_preserves_regular_file_permissions() {
1000        use std::os::unix::fs::PermissionsExt;
1001
1002        let dir = tempfile::tempdir().unwrap();
1003        let path = dir.path().join("mode.txt");
1004        fs::write(&path, "original").unwrap();
1005        let mut original_permissions = fs::metadata(&path).unwrap().permissions();
1006        original_permissions.set_mode(0o600);
1007        fs::set_permissions(&path, original_permissions).unwrap();
1008
1009        let backup_store = BackupStore::new();
1010        let (mut store, _store_dir) = checkpoint_store();
1011        store
1012            .create(
1013                DEFAULT_SESSION_ID,
1014                "mode",
1015                vec![path.clone()],
1016                &backup_store,
1017            )
1018            .unwrap();
1019
1020        fs::write(&path, "changed").unwrap();
1021        let mut changed_permissions = fs::metadata(&path).unwrap().permissions();
1022        changed_permissions.set_mode(0o644);
1023        fs::set_permissions(&path, changed_permissions).unwrap();
1024
1025        store.restore(DEFAULT_SESSION_ID, "mode").unwrap();
1026
1027        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
1028        let restored_mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1029        assert_eq!(restored_mode, 0o600);
1030    }
1031
1032    #[cfg(unix)]
1033    #[test]
1034    fn checkpoint_restore_recreates_symlink() {
1035        let dir = tempfile::tempdir().unwrap();
1036        let target = dir.path().join("target.txt");
1037        let link = dir.path().join("link.txt");
1038        fs::write(&target, "target content").unwrap();
1039        std::os::unix::fs::symlink(&target, &link).unwrap();
1040
1041        let backup_store = BackupStore::new();
1042        let (mut store, _store_dir) = checkpoint_store();
1043        store
1044            .create(
1045                DEFAULT_SESSION_ID,
1046                "symlink",
1047                vec![link.clone()],
1048                &backup_store,
1049            )
1050            .unwrap();
1051
1052        fs::remove_file(&link).unwrap();
1053        fs::write(&link, "plain file").unwrap();
1054
1055        store.restore(DEFAULT_SESSION_ID, "symlink").unwrap();
1056
1057        assert!(fs::symlink_metadata(&link)
1058            .unwrap()
1059            .file_type()
1060            .is_symlink());
1061        assert_eq!(fs::read_link(&link).unwrap(), target);
1062        assert_eq!(fs::read_to_string(&link).unwrap(), "target content");
1063    }
1064
1065    #[test]
1066    fn checkpoint_restore_failure_removes_created_parent_dirs() {
1067        let dir = tempfile::tempdir().unwrap();
1068        let missing_root = dir.path().join("created");
1069        let path_a = missing_root.join("nested").join("a.txt");
1070        let path_b = dir.path().join("blocking-dir");
1071        fs::create_dir(&path_b).unwrap();
1072
1073        let checkpoint = Checkpoint {
1074            name: "dir-cleanup".to_string(),
1075            file_contents: HashMap::from([
1076                (path_a.clone(), checkpoint_file("checkpoint-a")),
1077                (path_b.clone(), checkpoint_file("checkpoint-b")),
1078            ]),
1079            created_at: current_timestamp(),
1080        };
1081
1082        let result = restore_paths_atomically(&checkpoint, &[path_a.clone(), path_b.clone()]);
1083
1084        assert!(
1085            result.is_err(),
1086            "second restore write should fail on directory"
1087        );
1088        assert!(!path_a.exists(), "restored file should be rolled back");
1089        assert!(
1090            !missing_root.exists(),
1091            "new parent directories should be removed on rollback"
1092        );
1093        assert!(path_b.is_dir(), "pre-existing blocking directory remains");
1094    }
1095}