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