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