Skip to main content

aft/
backup.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4
5use crate::error::AftError;
6use sha2::{Digest, Sha256};
7
8const MAX_UNDO_DEPTH: usize = 20;
9
10/// Current on-disk backup metadata schema version.
11///
12/// Bump this when the `meta.json` shape changes. Readers check the field and
13/// refuse or migrate older versions instead of misinterpreting them.
14const SCHEMA_VERSION: u32 = 2;
15
16/// A single backup entry for a file.
17#[derive(Debug, Clone)]
18pub struct BackupEntry {
19    pub backup_id: String,
20    pub content: String,
21    pub timestamp: u64,
22    pub description: String,
23}
24
25/// Per-(session, file) undo store with optional disk persistence.
26///
27/// Introduced alongside project-shared bridges (issue #14): one bridge can now
28/// serve many OpenCode sessions in the same project, so undo history must be
29/// partitioned by session to keep session A's edits invisible to session B.
30///
31/// The 20-entry cap is enforced **per (session, file)** deliberately — a global
32/// per-file LRU would re-couple sessions and let one busy session evict
33/// another's history.
34///
35/// Disk layout (schema v2):
36///   `<storage_dir>/backups/<session_hash>/session.json` — session metadata
37///   `<storage_dir>/backups/<session_hash>/<path_hash>/meta.json` — file path + count + session
38///   `<storage_dir>/backups/<session_hash>/<path_hash>/0.bak` … `19.bak` — snapshots
39///
40/// Legacy layouts from before sessionization (flat `<path_hash>/` directly under
41/// `backups/`) are migrated on first `set_storage_dir` call into the default
42/// session namespace.
43#[derive(Debug)]
44pub struct BackupStore {
45    /// session -> path -> entry stack
46    entries: HashMap<String, HashMap<PathBuf, Vec<BackupEntry>>>,
47    /// session -> path -> disk metadata
48    disk_index: HashMap<String, HashMap<PathBuf, DiskMeta>>,
49    /// session -> metadata
50    session_meta: HashMap<String, SessionMeta>,
51    counter: AtomicU64,
52    storage_dir: Option<PathBuf>,
53}
54
55#[derive(Debug, Clone)]
56struct DiskMeta {
57    dir: PathBuf,
58    count: usize,
59}
60
61#[derive(Debug, Clone, Default)]
62struct SessionMeta {
63    /// Unix timestamp of last read/write activity in this session namespace.
64    /// Maintained in-memory now, reserved for future inactivity-TTL cleanup.
65    last_accessed: u64,
66}
67
68impl BackupStore {
69    pub fn new() -> Self {
70        BackupStore {
71            entries: HashMap::new(),
72            disk_index: HashMap::new(),
73            session_meta: HashMap::new(),
74            counter: AtomicU64::new(0),
75            storage_dir: None,
76        }
77    }
78
79    /// Set storage directory for disk persistence (called during configure).
80    ///
81    /// Loads the disk index for all session namespaces, removes stale session
82    /// directories, and migrates any legacy pre-session (flat) layout into the
83    /// default namespace.
84    pub fn set_storage_dir(&mut self, dir: PathBuf, ttl_hours: u32) {
85        self.storage_dir = Some(dir);
86        self.gc_stale_sessions(ttl_hours);
87        self.migrate_legacy_layout_if_needed();
88        self.load_disk_index();
89    }
90
91    /// Snapshot the current contents of `path` under the given session namespace.
92    pub fn snapshot(
93        &mut self,
94        session: &str,
95        path: &Path,
96        description: &str,
97    ) -> Result<String, AftError> {
98        let content = std::fs::read_to_string(path).map_err(|_| AftError::FileNotFound {
99            path: path.display().to_string(),
100        })?;
101
102        let key = canonicalize_key(path);
103        let id = self.next_id();
104        let entry = BackupEntry {
105            backup_id: id.clone(),
106            content,
107            timestamp: current_timestamp(),
108            description: description.to_string(),
109        };
110
111        let session_entries = self.entries.entry(session.to_string()).or_default();
112        let stack = session_entries.entry(key.clone()).or_default();
113        if stack.len() >= MAX_UNDO_DEPTH {
114            stack.remove(0);
115        }
116        stack.push(entry);
117
118        // Persist to disk
119        let stack_clone = stack.clone();
120        self.write_snapshot_to_disk(session, &key, &stack_clone);
121        self.touch_session(session);
122
123        Ok(id)
124    }
125
126    /// Pop the most recent backup for `(session, path)` and restore the file.
127    /// Returns `(entry, optional_warning)`.
128    pub fn restore_latest(
129        &mut self,
130        session: &str,
131        path: &Path,
132    ) -> Result<(BackupEntry, Option<String>), AftError> {
133        let key = canonicalize_key(path);
134
135        // Try memory first
136        let in_memory = self
137            .entries
138            .get(session)
139            .and_then(|s| s.get(&key))
140            .map_or(false, |s| !s.is_empty());
141        if in_memory {
142            let result = self.do_restore(session, &key, path);
143            if result.is_ok() {
144                self.touch_session(session);
145            }
146            return result;
147        }
148
149        // Try disk fallback
150        if self.load_from_disk_if_needed(session, &key) {
151            // Check for external modification
152            let warning = self.check_external_modification(session, &key, path);
153            let (entry, _) = self.do_restore(session, &key, path)?;
154            self.touch_session(session);
155            return Ok((entry, warning));
156        }
157
158        Err(AftError::NoUndoHistory {
159            path: path.display().to_string(),
160        })
161    }
162
163    /// Return the backup history for `(session, path)` (oldest first).
164    pub fn history(&self, session: &str, path: &Path) -> Vec<BackupEntry> {
165        let key = canonicalize_key(path);
166        self.entries
167            .get(session)
168            .and_then(|s| s.get(&key))
169            .cloned()
170            .unwrap_or_default()
171    }
172
173    /// Return the number of on-disk backup entries for `(session, file)`.
174    pub fn disk_history_count(&self, session: &str, path: &Path) -> usize {
175        let key = canonicalize_key(path);
176        self.disk_index
177            .get(session)
178            .and_then(|s| s.get(&key))
179            .map(|m| m.count)
180            .unwrap_or(0)
181    }
182
183    /// Return all files that have at least one backup entry in this session
184    /// (memory + disk). Other sessions' files are not visible.
185    pub fn tracked_files(&self, session: &str) -> Vec<PathBuf> {
186        let mut files: std::collections::HashSet<PathBuf> = self
187            .entries
188            .get(session)
189            .map(|s| s.keys().cloned().collect())
190            .unwrap_or_default();
191        if let Some(disk) = self.disk_index.get(session) {
192            for key in disk.keys() {
193                files.insert(key.clone());
194            }
195        }
196        files.into_iter().collect()
197    }
198
199    /// Return all session namespaces that currently have any backup state
200    /// (memory or disk). Exposed for `/aft-status` aggregate reporting.
201    pub fn sessions_with_backups(&self) -> Vec<String> {
202        let mut sessions: std::collections::HashSet<String> =
203            self.entries.keys().cloned().collect();
204        for s in self.disk_index.keys() {
205            sessions.insert(s.clone());
206        }
207        sessions.into_iter().collect()
208    }
209
210    /// Total on-disk bytes across all sessions (best-effort, reads metadata only).
211    /// Used by `/aft-status` to surface storage footprint.
212    pub fn total_disk_bytes(&self) -> u64 {
213        let mut total = 0u64;
214        for session_dirs in self.disk_index.values() {
215            for meta in session_dirs.values() {
216                if let Ok(read_dir) = std::fs::read_dir(&meta.dir) {
217                    for entry in read_dir.flatten() {
218                        if let Ok(m) = entry.metadata() {
219                            if m.is_file() {
220                                total += m.len();
221                            }
222                        }
223                    }
224                }
225            }
226        }
227        total
228    }
229
230    fn next_id(&self) -> String {
231        let n = self.counter.fetch_add(1, Ordering::Relaxed);
232        format!("backup-{}", n)
233    }
234
235    fn touch_session(&mut self, session: &str) {
236        let now = current_timestamp();
237        self.session_meta
238            .entry(session.to_string())
239            .or_default()
240            .last_accessed = now;
241        self.write_session_marker(session, now);
242    }
243
244    // ---- Internal helpers ----
245
246    fn do_restore(
247        &mut self,
248        session: &str,
249        key: &Path,
250        path: &Path,
251    ) -> Result<(BackupEntry, Option<String>), AftError> {
252        let session_entries =
253            self.entries
254                .get_mut(session)
255                .ok_or_else(|| AftError::NoUndoHistory {
256                    path: path.display().to_string(),
257                })?;
258        let stack = session_entries
259            .get_mut(key)
260            .ok_or_else(|| AftError::NoUndoHistory {
261                path: path.display().to_string(),
262            })?;
263
264        let entry = stack
265            .last()
266            .cloned()
267            .ok_or_else(|| AftError::NoUndoHistory {
268                path: path.display().to_string(),
269            })?;
270
271        std::fs::write(path, &entry.content).map_err(|e| AftError::IoError {
272            path: path.display().to_string(),
273            message: e.to_string(),
274        })?;
275
276        stack.pop();
277        if stack.is_empty() {
278            session_entries.remove(key);
279            // Also prune the session map when its last file is gone.
280            if session_entries.is_empty() {
281                self.entries.remove(session);
282            }
283            self.remove_disk_backups(session, key);
284        } else {
285            let stack_clone = self
286                .entries
287                .get(session)
288                .and_then(|s| s.get(key))
289                .cloned()
290                .unwrap_or_default();
291            self.write_snapshot_to_disk(session, key, &stack_clone);
292        }
293
294        Ok((entry, None))
295    }
296
297    fn check_external_modification(
298        &self,
299        session: &str,
300        key: &Path,
301        path: &Path,
302    ) -> Option<String> {
303        if let (Some(stack), Ok(current)) = (
304            self.entries.get(session).and_then(|s| s.get(key)),
305            std::fs::read_to_string(path),
306        ) {
307            if let Some(latest) = stack.last() {
308                if latest.content != current {
309                    return Some("file was modified externally since last backup".to_string());
310                }
311            }
312        }
313        None
314    }
315
316    // ---- Disk persistence ----
317
318    fn backups_dir(&self) -> Option<PathBuf> {
319        self.storage_dir.as_ref().map(|d| d.join("backups"))
320    }
321
322    fn session_dir(&self, session: &str) -> Option<PathBuf> {
323        self.backups_dir()
324            .map(|d| d.join(Self::session_hash(session)))
325    }
326
327    fn session_hash(session: &str) -> String {
328        stable_hash_16(session.as_bytes())
329    }
330
331    fn path_hash(key: &Path) -> String {
332        // v0.16.0 intentionally switched from DefaultHasher to SHA-256 for
333        // stable on-disk names. Existing DefaultHasher backup directories are
334        // not migrated: backups are short-lived/session-scoped, so one-time
335        // loss of pre-upgrade undo history is acceptable.
336        stable_hash_16(key.to_string_lossy().as_bytes())
337    }
338
339    fn write_session_marker(&self, session: &str, last_accessed: u64) {
340        let Some(session_dir) = self.session_dir(session) else {
341            return;
342        };
343        if let Err(e) = std::fs::create_dir_all(&session_dir) {
344            log::warn!("[aft] failed to create session dir: {}", e);
345            return;
346        }
347        let marker = session_dir.join("session.json");
348        let json = serde_json::json!({
349            "schema_version": SCHEMA_VERSION,
350            "session_id": session,
351            "last_accessed": last_accessed,
352        });
353        if let Ok(s) = serde_json::to_string_pretty(&json) {
354            let tmp = session_dir.join("session.json.tmp");
355            if std::fs::write(&tmp, s).is_ok() {
356                let _ = std::fs::rename(&tmp, marker);
357            }
358        }
359    }
360
361    fn gc_stale_sessions(&mut self, ttl_hours: u32) {
362        let backups_dir = match self.backups_dir() {
363            Some(d) if d.exists() => d,
364            _ => return,
365        };
366        let ttl_secs = u64::from(if ttl_hours == 0 { 72 } else { ttl_hours }) * 60 * 60;
367        let cutoff = current_timestamp().saturating_sub(ttl_secs);
368        let entries = match std::fs::read_dir(&backups_dir) {
369            Ok(entries) => entries,
370            Err(_) => return,
371        };
372
373        for entry in entries.flatten() {
374            let session_dir = entry.path();
375            if !session_dir.is_dir() || session_dir.join("meta.json").exists() {
376                continue;
377            }
378            let Some(last_accessed) = Self::read_session_last_accessed(&session_dir) else {
379                continue;
380            };
381            if last_accessed >= cutoff {
382                continue;
383            }
384            if let Err(e) = std::fs::remove_dir_all(&session_dir) {
385                log::warn!(
386                    "[aft] failed to remove stale backup session {}: {}",
387                    session_dir.display(),
388                    e
389                );
390            } else {
391                log::warn!(
392                    "[aft] removed stale backup session {} (last_accessed={})",
393                    session_dir.display(),
394                    last_accessed
395                );
396            }
397        }
398    }
399
400    /// One-time migration: move pre-session flat layout into the default
401    /// session namespace. Called from `set_storage_dir` so existing backups
402    /// survive the upgrade.
403    ///
404    /// Detection: any directory directly under `backups/` that contains a
405    /// `meta.json` (as opposed to a `session.json` marker or subdirectories)
406    /// is treated as a legacy entry.
407    fn migrate_legacy_layout_if_needed(&mut self) {
408        let backups_dir = match self.backups_dir() {
409            Some(d) if d.exists() => d,
410            _ => return,
411        };
412        let default_session_dir =
413            backups_dir.join(Self::session_hash(crate::protocol::DEFAULT_SESSION_ID));
414
415        let entries = match std::fs::read_dir(&backups_dir) {
416            Ok(e) => e,
417            Err(_) => return,
418        };
419        let mut migrated = 0usize;
420        for entry in entries.flatten() {
421            let entry_path = entry.path();
422            // Skip non-directories and already-sessionized layouts.
423            if !entry_path.is_dir() {
424                continue;
425            }
426            if entry_path == default_session_dir {
427                continue;
428            }
429            let meta_path = entry_path.join("meta.json");
430            if !meta_path.exists() {
431                continue; // Already a session-hash dir (contains per-path subdirs), skip
432            }
433            // This is a legacy flat-layout path-hash directory. Move it under
434            // the default session namespace.
435            if let Err(e) = std::fs::create_dir_all(&default_session_dir) {
436                log::warn!("[aft] failed to create default session dir: {}", e);
437                return;
438            }
439            let leaf = match entry_path.file_name() {
440                Some(n) => n,
441                None => continue,
442            };
443            let target = default_session_dir.join(leaf);
444            if target.exists() {
445                // Already migrated on a prior run that was interrupted —
446                // leave both and let the regular load pick up the target.
447                continue;
448            }
449            match std::fs::rename(&entry_path, &target) {
450                Ok(()) => {
451                    // Bump meta.json to include session_id + schema_version.
452                    Self::upgrade_meta_file(
453                        &target.join("meta.json"),
454                        crate::protocol::DEFAULT_SESSION_ID,
455                    );
456                    migrated += 1;
457                }
458                Err(e) => {
459                    log::warn!(
460                        "[aft] failed to migrate legacy backup {}: {}",
461                        entry_path.display(),
462                        e
463                    );
464                }
465            }
466        }
467        if migrated > 0 {
468            log::info!(
469                "[aft] migrated {} legacy backup entries into default session namespace",
470                migrated
471            );
472            // Write a session.json marker so future scans don't re-migrate.
473            let marker = default_session_dir.join("session.json");
474            let json = serde_json::json!({
475                "schema_version": SCHEMA_VERSION,
476                "session_id": crate::protocol::DEFAULT_SESSION_ID,
477                "last_accessed": current_timestamp(),
478            });
479            if let Ok(s) = serde_json::to_string_pretty(&json) {
480                let _ = std::fs::write(&marker, s);
481            }
482        }
483    }
484
485    fn upgrade_meta_file(meta_path: &Path, session_id: &str) {
486        let content = match std::fs::read_to_string(meta_path) {
487            Ok(c) => c,
488            Err(_) => return,
489        };
490        let mut parsed: serde_json::Value = match serde_json::from_str(&content) {
491            Ok(v) => v,
492            Err(_) => return,
493        };
494        if let Some(obj) = parsed.as_object_mut() {
495            obj.entry("schema_version")
496                .or_insert(serde_json::json!(SCHEMA_VERSION));
497            obj.insert("session_id".to_string(), serde_json::json!(session_id));
498        }
499        if let Ok(s) = serde_json::to_string_pretty(&parsed) {
500            let tmp = meta_path.with_extension("json.tmp");
501            if std::fs::write(&tmp, &s).is_ok() {
502                let _ = std::fs::rename(&tmp, meta_path);
503            }
504        }
505    }
506
507    fn load_disk_index(&mut self) {
508        let backups_dir = match self.backups_dir() {
509            Some(d) if d.exists() => d,
510            _ => return,
511        };
512        let session_dirs = match std::fs::read_dir(&backups_dir) {
513            Ok(e) => e,
514            Err(_) => return,
515        };
516        let mut total_entries = 0usize;
517        for session_entry in session_dirs.flatten() {
518            let session_dir = session_entry.path();
519            if !session_dir.is_dir() {
520                continue;
521            }
522            // Recover the session_id from session.json if present, otherwise skip
523            // (can't invert the hash to recover the original).
524            let session_id = Self::read_session_marker(&session_dir)
525                .unwrap_or_else(|| crate::protocol::DEFAULT_SESSION_ID.to_string());
526
527            let path_dirs = match std::fs::read_dir(&session_dir) {
528                Ok(e) => e,
529                Err(_) => continue,
530            };
531            let per_session = self.disk_index.entry(session_id.clone()).or_default();
532            for path_entry in path_dirs.flatten() {
533                let path_dir = path_entry.path();
534                if !path_dir.is_dir() {
535                    continue;
536                }
537                let meta_path = path_dir.join("meta.json");
538                if let Ok(content) = std::fs::read_to_string(&meta_path) {
539                    if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
540                        if let (Some(path_str), Some(count)) = (
541                            meta.get("path").and_then(|v| v.as_str()),
542                            meta.get("count").and_then(|v| v.as_u64()),
543                        ) {
544                            per_session.insert(
545                                PathBuf::from(path_str),
546                                DiskMeta {
547                                    dir: path_dir.clone(),
548                                    count: count as usize,
549                                },
550                            );
551                            total_entries += 1;
552                        }
553                    }
554                }
555            }
556        }
557        if total_entries > 0 {
558            log::info!(
559                "[aft] loaded {} backup entries across {} session(s) from disk",
560                total_entries,
561                self.disk_index.len()
562            );
563        }
564    }
565
566    fn read_session_marker(session_dir: &Path) -> Option<String> {
567        let marker = session_dir.join("session.json");
568        let content = std::fs::read_to_string(&marker).ok()?;
569        let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
570        parsed
571            .get("session_id")
572            .and_then(|v| v.as_str())
573            .map(|s| s.to_string())
574    }
575
576    fn read_session_last_accessed(session_dir: &Path) -> Option<u64> {
577        let marker = session_dir.join("session.json");
578        let content = std::fs::read_to_string(&marker).ok()?;
579        let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
580        parsed.get("last_accessed").and_then(|v| v.as_u64())
581    }
582
583    fn load_from_disk_if_needed(&mut self, session: &str, key: &Path) -> bool {
584        let meta = match self
585            .disk_index
586            .get(session)
587            .and_then(|s| s.get(key))
588            .cloned()
589        {
590            Some(m) if m.count > 0 => m,
591            _ => return false,
592        };
593
594        let mut entries = Vec::new();
595        for i in 0..meta.count {
596            let bak_path = meta.dir.join(format!("{}.bak", i));
597            if let Ok(content) = std::fs::read_to_string(&bak_path) {
598                entries.push(BackupEntry {
599                    backup_id: format!("disk-{}", i),
600                    content,
601                    timestamp: 0,
602                    description: "restored from disk".to_string(),
603                });
604            }
605        }
606
607        if entries.is_empty() {
608            return false;
609        }
610
611        self.entries
612            .entry(session.to_string())
613            .or_default()
614            .insert(key.to_path_buf(), entries);
615        true
616    }
617
618    fn write_snapshot_to_disk(&mut self, session: &str, key: &Path, stack: &[BackupEntry]) {
619        let session_dir = match self.session_dir(session) {
620            Some(d) => d,
621            None => return,
622        };
623
624        // Ensure session dir + marker exist.
625        if let Err(e) = std::fs::create_dir_all(&session_dir) {
626            log::warn!("[aft] failed to create session dir: {}", e);
627            return;
628        }
629        let marker = session_dir.join("session.json");
630        if !marker.exists() {
631            let json = serde_json::json!({
632                "schema_version": SCHEMA_VERSION,
633                "session_id": session,
634                "last_accessed": current_timestamp(),
635            });
636            if let Ok(s) = serde_json::to_string_pretty(&json) {
637                let _ = std::fs::write(&marker, s);
638            }
639        }
640
641        let hash = Self::path_hash(key);
642        let dir = session_dir.join(&hash);
643        if let Err(e) = std::fs::create_dir_all(&dir) {
644            log::warn!("[aft] failed to create backup dir: {}", e);
645            return;
646        }
647
648        for (i, entry) in stack.iter().enumerate() {
649            let bak_path = dir.join(format!("{}.bak", i));
650            let tmp_path = dir.join(format!("{}.bak.tmp", i));
651            if std::fs::write(&tmp_path, &entry.content).is_ok() {
652                let _ = std::fs::rename(&tmp_path, &bak_path);
653            }
654        }
655
656        // Clean up extra .bak files if stack shrank.
657        for i in stack.len()..MAX_UNDO_DEPTH {
658            let old = dir.join(format!("{}.bak", i));
659            if old.exists() {
660                let _ = std::fs::remove_file(&old);
661            }
662        }
663
664        let meta = serde_json::json!({
665            "schema_version": SCHEMA_VERSION,
666            "session_id": session,
667            "path": key.display().to_string(),
668            "count": stack.len(),
669        });
670        let meta_path = dir.join("meta.json");
671        let meta_tmp = dir.join("meta.json.tmp");
672        if let Ok(content) = serde_json::to_string_pretty(&meta) {
673            if std::fs::write(&meta_tmp, &content).is_ok() {
674                let _ = std::fs::rename(&meta_tmp, &meta_path);
675            }
676        }
677
678        // Keep the in-memory disk_index in sync so tracked_files() and
679        // disk_history_count() immediately reflect what we just wrote.
680        self.disk_index
681            .entry(session.to_string())
682            .or_default()
683            .insert(
684                key.to_path_buf(),
685                DiskMeta {
686                    dir,
687                    count: stack.len(),
688                },
689            );
690    }
691
692    fn remove_disk_backups(&mut self, session: &str, key: &Path) {
693        let removed = self.disk_index.get_mut(session).and_then(|s| s.remove(key));
694        if let Some(meta) = removed {
695            let _ = std::fs::remove_dir_all(&meta.dir);
696        } else if let Some(session_dir) = self.session_dir(session) {
697            let hash = Self::path_hash(key);
698            let dir = session_dir.join(&hash);
699            if dir.exists() {
700                let _ = std::fs::remove_dir_all(&dir);
701            }
702        }
703
704        // If this session has no more disk entries, drop the map slot (session
705        // dir itself is kept so the marker survives future sessions).
706        let empty = self
707            .disk_index
708            .get(session)
709            .map(|s| s.is_empty())
710            .unwrap_or(false);
711        if empty {
712            self.disk_index.remove(session);
713        }
714    }
715}
716
717fn canonicalize_key(path: &Path) -> PathBuf {
718    std::fs::canonicalize(path).unwrap_or_else(|err| {
719        log::debug!(
720            "backup canonicalize_key fallback for {}: {}",
721            path.display(),
722            err
723        );
724        path.to_path_buf()
725    })
726}
727
728fn current_timestamp() -> u64 {
729    std::time::SystemTime::now()
730        .duration_since(std::time::UNIX_EPOCH)
731        .unwrap_or_default()
732        .as_secs()
733}
734
735fn stable_hash_16(bytes: &[u8]) -> String {
736    let digest = Sha256::digest(bytes);
737    digest[..8]
738        .iter()
739        .map(|byte| format!("{:02x}", byte))
740        .collect()
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746    use crate::protocol::DEFAULT_SESSION_ID;
747    use std::fs;
748
749    fn temp_file(name: &str, content: &str) -> PathBuf {
750        let dir = std::env::temp_dir().join("aft_backup_tests");
751        fs::create_dir_all(&dir).unwrap();
752        let path = dir.join(name);
753        fs::write(&path, content).unwrap();
754        path
755    }
756
757    #[test]
758    fn snapshot_and_restore_round_trip() {
759        let path = temp_file("round_trip.txt", "original");
760        let mut store = BackupStore::new();
761
762        let id = store
763            .snapshot(DEFAULT_SESSION_ID, &path, "before edit")
764            .unwrap();
765        assert!(id.starts_with("backup-"));
766
767        fs::write(&path, "modified").unwrap();
768        assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
769
770        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
771        assert_eq!(entry.content, "original");
772        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
773    }
774
775    #[test]
776    fn multiple_snapshots_preserve_order() {
777        let path = temp_file("order.txt", "v1");
778        let mut store = BackupStore::new();
779
780        store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
781        fs::write(&path, "v2").unwrap();
782        store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
783        fs::write(&path, "v3").unwrap();
784        store.snapshot(DEFAULT_SESSION_ID, &path, "third").unwrap();
785
786        let history = store.history(DEFAULT_SESSION_ID, &path);
787        assert_eq!(history.len(), 3);
788        assert_eq!(history[0].content, "v1");
789        assert_eq!(history[1].content, "v2");
790        assert_eq!(history[2].content, "v3");
791    }
792
793    #[test]
794    fn restore_pops_from_stack() {
795        let path = temp_file("pop.txt", "v1");
796        let mut store = BackupStore::new();
797
798        store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
799        fs::write(&path, "v2").unwrap();
800        store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
801
802        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
803        assert_eq!(entry.description, "second");
804        assert_eq!(entry.content, "v2");
805
806        let history = store.history(DEFAULT_SESSION_ID, &path);
807        assert_eq!(history.len(), 1);
808    }
809
810    #[test]
811    fn empty_history_returns_empty_vec() {
812        let store = BackupStore::new();
813        let path = Path::new("/tmp/aft_backup_tests/nonexistent_history.txt");
814        assert!(store.history(DEFAULT_SESSION_ID, path).is_empty());
815    }
816
817    #[test]
818    fn snapshot_nonexistent_file_returns_error() {
819        let mut store = BackupStore::new();
820        let path = Path::new("/tmp/aft_backup_tests/absolutely_does_not_exist.txt");
821        assert!(store.snapshot(DEFAULT_SESSION_ID, path, "test").is_err());
822    }
823
824    #[test]
825    fn tracked_files_lists_snapshotted_paths() {
826        let path1 = temp_file("tracked1.txt", "a");
827        let path2 = temp_file("tracked2.txt", "b");
828        let mut store = BackupStore::new();
829
830        store.snapshot(DEFAULT_SESSION_ID, &path1, "snap1").unwrap();
831        store.snapshot(DEFAULT_SESSION_ID, &path2, "snap2").unwrap();
832        assert_eq!(store.tracked_files(DEFAULT_SESSION_ID).len(), 2);
833    }
834
835    #[test]
836    fn sessions_are_isolated() {
837        let path = temp_file("isolated.txt", "original");
838        let mut store = BackupStore::new();
839
840        store.snapshot("session_a", &path, "a's snapshot").unwrap();
841
842        // Session B sees no history for this file.
843        assert!(store.history("session_b", &path).is_empty());
844        assert_eq!(store.tracked_files("session_b").len(), 0);
845
846        // Session B's restore_latest fails with NoUndoHistory.
847        let err = store.restore_latest("session_b", &path);
848        assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
849
850        // Session A still sees its own snapshot.
851        assert_eq!(store.history("session_a", &path).len(), 1);
852        assert_eq!(store.tracked_files("session_a").len(), 1);
853    }
854
855    #[test]
856    fn per_session_per_file_cap_is_independent() {
857        // Two sessions fill up their own stacks independently; hitting the cap
858        // in session A does not evict anything from session B.
859        let path = temp_file("cap_indep.txt", "v0");
860        let mut store = BackupStore::new();
861
862        for i in 0..(MAX_UNDO_DEPTH + 5) {
863            fs::write(&path, format!("a{}", i)).unwrap();
864            store.snapshot("session_a", &path, "a").unwrap();
865        }
866        fs::write(&path, "b_initial").unwrap();
867        store.snapshot("session_b", &path, "b").unwrap();
868
869        // Session A should be capped at MAX_UNDO_DEPTH.
870        assert_eq!(store.history("session_a", &path).len(), MAX_UNDO_DEPTH);
871        // Session B should still have its single entry.
872        assert_eq!(store.history("session_b", &path).len(), 1);
873    }
874
875    #[test]
876    fn sessions_with_backups_lists_all_namespaces() {
877        let path_a = temp_file("sessions_list_a.txt", "a");
878        let path_b = temp_file("sessions_list_b.txt", "b");
879        let mut store = BackupStore::new();
880
881        store.snapshot("alice", &path_a, "from alice").unwrap();
882        store.snapshot("bob", &path_b, "from bob").unwrap();
883
884        let sessions = store.sessions_with_backups();
885        assert_eq!(sessions.len(), 2);
886        assert!(sessions.iter().any(|s| s == "alice"));
887        assert!(sessions.iter().any(|s| s == "bob"));
888    }
889
890    #[test]
891    fn disk_persistence_survives_reload() {
892        let dir = std::env::temp_dir().join("aft_backup_disk_test");
893        let _ = fs::remove_dir_all(&dir);
894        fs::create_dir_all(&dir).unwrap();
895
896        let file_path = temp_file("disk_persist.txt", "original");
897
898        // Create store with storage, snapshot under default session, drop.
899        {
900            let mut store = BackupStore::new();
901            store.set_storage_dir(dir.clone(), 72);
902            store
903                .snapshot(DEFAULT_SESSION_ID, &file_path, "before edit")
904                .unwrap();
905        }
906
907        // Modify the file externally.
908        fs::write(&file_path, "externally modified").unwrap();
909
910        // Create new store, load from disk, restore.
911        let mut store2 = BackupStore::new();
912        store2.set_storage_dir(dir.clone(), 72);
913
914        let (entry, warning) = store2
915            .restore_latest(DEFAULT_SESSION_ID, &file_path)
916            .unwrap();
917        assert_eq!(entry.content, "original");
918        assert!(warning.is_some()); // modified externally
919        assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
920
921        let _ = fs::remove_dir_all(&dir);
922    }
923
924    #[test]
925    fn legacy_flat_layout_migrates_to_default_session() {
926        // Simulate a pre-session on-disk layout (schema v1) and verify it's
927        // moved under the default session namespace on set_storage_dir.
928        let dir = std::env::temp_dir().join("aft_backup_migration_test");
929        let _ = fs::remove_dir_all(&dir);
930        fs::create_dir_all(&dir).unwrap();
931        let backups = dir.join("backups");
932        fs::create_dir_all(&backups).unwrap();
933
934        // Fake legacy entry for some path hash.
935        let legacy_hash = "deadbeefcafebabe";
936        let legacy_dir = backups.join(legacy_hash);
937        fs::create_dir_all(&legacy_dir).unwrap();
938        fs::write(legacy_dir.join("0.bak"), "original content").unwrap();
939        let legacy_meta = serde_json::json!({
940            "path": "/tmp/migrated_file.txt",
941            "count": 1,
942        });
943        fs::write(
944            legacy_dir.join("meta.json"),
945            serde_json::to_string_pretty(&legacy_meta).unwrap(),
946        )
947        .unwrap();
948
949        // Run migration.
950        let mut store = BackupStore::new();
951        store.set_storage_dir(dir.clone(), 72);
952
953        // After migration, the legacy dir should be gone from the top level,
954        // and the entry should now live under the default-session hash dir.
955        let default_session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
956        assert!(default_session_dir.exists());
957        assert!(default_session_dir.join(legacy_hash).exists());
958        assert!(!backups.join(legacy_hash).exists());
959
960        // The upgraded meta.json should now include session_id + schema_version.
961        let meta_content =
962            fs::read_to_string(default_session_dir.join(legacy_hash).join("meta.json")).unwrap();
963        let meta: serde_json::Value = serde_json::from_str(&meta_content).unwrap();
964        assert_eq!(meta["session_id"], DEFAULT_SESSION_ID);
965        assert_eq!(meta["schema_version"], SCHEMA_VERSION);
966
967        let _ = fs::remove_dir_all(&dir);
968    }
969
970    #[test]
971    fn set_storage_dir_removes_stale_backup_sessions() {
972        let dir = std::env::temp_dir().join("aft_backup_gc_test");
973        let _ = fs::remove_dir_all(&dir);
974        let backups = dir.join("backups");
975        fs::create_dir_all(&backups).unwrap();
976
977        let stale_session_dir = backups.join("stale-session");
978        fs::create_dir_all(&stale_session_dir).unwrap();
979        let stale_marker = serde_json::json!({
980            "schema_version": SCHEMA_VERSION,
981            "session_id": "stale",
982            "last_accessed": 1,
983        });
984        fs::write(
985            stale_session_dir.join("session.json"),
986            serde_json::to_string_pretty(&stale_marker).unwrap(),
987        )
988        .unwrap();
989
990        let mut store = BackupStore::new();
991        store.set_storage_dir(dir.clone(), 1);
992
993        assert!(!stale_session_dir.exists());
994        let _ = fs::remove_dir_all(&dir);
995    }
996}