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 = 3;
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    pub op_id: Option<String>,
24    pub kind: BackupEntryKind,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum BackupEntryKind {
29    Content,
30    Tombstone,
31}
32
33#[derive(Debug, Clone)]
34pub struct RestoredOperation {
35    pub op_id: String,
36    pub restored: Vec<RestoredFile>,
37    pub warnings: Vec<String>,
38}
39
40#[derive(Debug, Clone)]
41pub struct RestoredFile {
42    pub path: PathBuf,
43    pub backup_id: String,
44}
45
46/// Per-(session, file) undo store with optional disk persistence.
47///
48/// Introduced alongside project-shared bridges (issue #14): one bridge can now
49/// serve many OpenCode sessions in the same project, so undo history must be
50/// partitioned by session to keep session A's edits invisible to session B.
51///
52/// The 20-entry cap is enforced **per (session, file)** deliberately — a global
53/// per-file LRU would re-couple sessions and let one busy session evict
54/// another's history.
55///
56/// Disk layout (schema v2):
57///   `<storage_dir>/backups/<session_hash>/session.json` — session metadata
58///   `<storage_dir>/backups/<session_hash>/<path_hash>/meta.json` — file path + count + session
59///   `<storage_dir>/backups/<session_hash>/<path_hash>/0.bak` … `19.bak` — snapshots
60///
61/// Legacy layouts from before sessionization (flat `<path_hash>/` directly under
62/// `backups/`) are migrated on first `set_storage_dir` call into the default
63/// session namespace.
64#[derive(Debug)]
65pub struct BackupStore {
66    /// session -> path -> entry stack
67    entries: HashMap<String, HashMap<PathBuf, Vec<BackupEntry>>>,
68    /// session -> path -> disk metadata
69    disk_index: HashMap<String, HashMap<PathBuf, DiskMeta>>,
70    /// session -> metadata
71    session_meta: HashMap<String, SessionMeta>,
72    counter: AtomicU64,
73    storage_dir: Option<PathBuf>,
74}
75
76#[derive(Debug, Clone)]
77struct DiskMeta {
78    dir: PathBuf,
79    count: usize,
80}
81
82#[derive(Debug, Clone, Default)]
83struct SessionMeta {
84    /// Unix timestamp of last read/write activity in this session namespace.
85    /// Maintained in-memory now, reserved for future inactivity-TTL cleanup.
86    last_accessed: u64,
87}
88
89impl BackupStore {
90    pub fn new() -> Self {
91        BackupStore {
92            entries: HashMap::new(),
93            disk_index: HashMap::new(),
94            session_meta: HashMap::new(),
95            counter: AtomicU64::new(0),
96            storage_dir: None,
97        }
98    }
99
100    /// Set storage directory for disk persistence (called during configure).
101    ///
102    /// Loads the disk index for all session namespaces, removes stale session
103    /// directories, and migrates any legacy pre-session (flat) layout into the
104    /// default namespace.
105    pub fn set_storage_dir(&mut self, dir: PathBuf, ttl_hours: u32) {
106        self.storage_dir = Some(dir);
107        self.entries.clear();
108        self.disk_index.clear();
109        self.session_meta.clear();
110        self.gc_stale_sessions(ttl_hours);
111        self.migrate_legacy_layout_if_needed();
112        self.load_disk_index();
113    }
114
115    /// Snapshot the current contents of `path` under the given session namespace.
116    pub fn snapshot(
117        &mut self,
118        session: &str,
119        path: &Path,
120        description: &str,
121    ) -> Result<String, AftError> {
122        self.snapshot_with_op(session, path, description, None)
123    }
124
125    /// Snapshot the current contents of `path` under the given session namespace,
126    /// optionally tagging it with an operation id shared by all files touched by
127    /// one mutating tool call.
128    pub fn snapshot_with_op(
129        &mut self,
130        session: &str,
131        path: &Path,
132        description: &str,
133        op_id: Option<&str>,
134    ) -> Result<String, AftError> {
135        let content = std::fs::read_to_string(path).map_err(|_| AftError::FileNotFound {
136            path: path.display().to_string(),
137        })?;
138
139        let key = canonicalize_key(path);
140        let id = self.next_id();
141        let entry = BackupEntry {
142            backup_id: id.clone(),
143            content,
144            timestamp: current_timestamp(),
145            description: description.to_string(),
146            op_id: op_id.map(str::to_string),
147            kind: BackupEntryKind::Content,
148        };
149
150        let session_entries = self.entries.entry(session.to_string()).or_default();
151        let stack = session_entries.entry(key.clone()).or_default();
152        if stack.len() >= MAX_UNDO_DEPTH {
153            stack.remove(0);
154        }
155        stack.push(entry);
156
157        // Persist to disk
158        let stack_clone = stack.clone();
159        self.write_snapshot_to_disk(session, &key, &stack_clone);
160        self.touch_session(session);
161
162        Ok(id)
163    }
164
165    /// Record that `path` was created by the operation and should be removed
166    /// if that operation is undone. No file content is captured.
167    pub fn snapshot_op_tombstone(
168        &mut self,
169        session: &str,
170        op_id: &str,
171        path: &Path,
172        description: &str,
173    ) -> Result<String, AftError> {
174        let key = canonicalize_key(path);
175        let id = self.next_id();
176        let entry = BackupEntry {
177            backup_id: id.clone(),
178            content: String::new(),
179            timestamp: current_timestamp(),
180            description: description.to_string(),
181            op_id: Some(op_id.to_string()),
182            kind: BackupEntryKind::Tombstone,
183        };
184
185        let session_entries = self.entries.entry(session.to_string()).or_default();
186        let stack = session_entries.entry(key.clone()).or_default();
187        if stack.len() >= MAX_UNDO_DEPTH {
188            stack.remove(0);
189        }
190        stack.push(entry);
191
192        let stack_clone = stack.clone();
193        self.write_snapshot_to_disk(session, &key, &stack_clone);
194        self.touch_session(session);
195
196        Ok(id)
197    }
198
199    /// Restore every top-of-stack backup entry belonging to the most recent
200    /// operation in this session.
201    pub fn restore_last_operation(&mut self, session: &str) -> Result<RestoredOperation, AftError> {
202        let disk_keys: Vec<PathBuf> = self
203            .disk_index
204            .get(session)
205            .map(|files| files.keys().cloned().collect())
206            .unwrap_or_default();
207        for key in disk_keys {
208            self.load_from_disk_if_needed(session, &key);
209        }
210
211        let mut latest: Option<((u64, u64), String)> = None;
212        if let Some(files) = self.entries.get(session) {
213            for stack in files.values() {
214                for entry in stack {
215                    if let Some(op_id) = &entry.op_id {
216                        let seq = backup_sequence(&entry.backup_id).unwrap_or(0);
217                        let order = (entry.timestamp, seq);
218                        if latest
219                            .as_ref()
220                            .map_or(true, |(latest_order, _)| order > *latest_order)
221                        {
222                            latest = Some((order, op_id.clone()));
223                        }
224                    }
225                }
226            }
227        }
228
229        let Some((_, op_id)) = latest else {
230            return Err(AftError::NoUndoHistory {
231                path: "operation".to_string(),
232            });
233        };
234
235        let mut keys_to_restore: Vec<PathBuf> = self
236            .entries
237            .get(session)
238            .map(|files| {
239                files
240                    .iter()
241                    .filter_map(|(key, stack)| {
242                        stack.last().and_then(|entry| {
243                            (entry.op_id.as_deref() == Some(op_id.as_str())).then(|| key.clone())
244                        })
245                    })
246                    .collect()
247            })
248            .unwrap_or_default();
249        keys_to_restore.sort();
250
251        if keys_to_restore.is_empty() {
252            return Err(AftError::NoUndoHistory {
253                path: "operation".to_string(),
254            });
255        }
256
257        let mut content_targets = Vec::new();
258        let mut tombstone_targets = Vec::new();
259        for key in &keys_to_restore {
260            let entry = self
261                .entries
262                .get(session)
263                .and_then(|files| files.get(key))
264                .and_then(|stack| stack.last())
265                .cloned()
266                .ok_or_else(|| AftError::NoUndoHistory {
267                    path: key.display().to_string(),
268                })?;
269            match entry.kind {
270                BackupEntryKind::Content => {
271                    let existing_content = match std::fs::read(key) {
272                        Ok(content) => Some(content),
273                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
274                        Err(e) => {
275                            return Err(AftError::IoError {
276                                path: key.display().to_string(),
277                                message: e.to_string(),
278                            });
279                        }
280                    };
281                    let warning = self.check_external_modification(session, key, key);
282                    content_targets.push((key.clone(), entry, warning, existing_content));
283                }
284                BackupEntryKind::Tombstone => {
285                    let existing_content = if key.is_file() {
286                        Some(std::fs::read(key).map_err(|e| AftError::IoError {
287                            path: key.display().to_string(),
288                            message: e.to_string(),
289                        })?)
290                    } else {
291                        None
292                    };
293                    tombstone_targets.push((key.clone(), entry, existing_content));
294                }
295            }
296        }
297
298        let mut created_dirs = Vec::new();
299        for (key, _, _, _) in &content_targets {
300            if let Some(parent) = key.parent() {
301                if !parent.as_os_str().is_empty() {
302                    let missing_dirs = missing_parent_dirs(parent);
303                    if let Err(e) = std::fs::create_dir_all(parent) {
304                        let mut dirs_to_remove = created_dirs;
305                        dirs_to_remove.extend(missing_dirs);
306                        let rollback_ok = rollback_created_dirs(&dirs_to_remove);
307                        return Err(AftError::IoError {
308                            path: parent.display().to_string(),
309                            message: format!(
310                                "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
311                                e,
312                                !rollback_ok,
313                                rollback_ok
314                            ),
315                        });
316                    }
317                    created_dirs.extend(missing_dirs);
318                }
319            }
320        }
321
322        let mut written = Vec::new();
323        for (key, entry, _, existing_content) in &content_targets {
324            if let Err(e) = std::fs::write(key, &entry.content) {
325                let files_rollback_ok =
326                    rollback_transactional_restore(&written, Some((key, existing_content)));
327                let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
328                let rollback_ok = files_rollback_ok && dirs_rollback_ok;
329                return Err(AftError::IoError {
330                    path: key.display().to_string(),
331                    message: format!(
332                        "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
333                        e,
334                        !rollback_ok,
335                        rollback_ok
336                    ),
337                });
338            }
339            written.push((key.clone(), existing_content.clone()));
340        }
341
342        let mut deleted_tombstones = Vec::new();
343        for (key, _, existing_content) in &tombstone_targets {
344            match std::fs::remove_file(key) {
345                Ok(()) => deleted_tombstones.push((key.clone(), existing_content.clone())),
346                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
347                    deleted_tombstones.push((key.clone(), None));
348                }
349                Err(e) => {
350                    let files_rollback_ok = rollback_transactional_restore(&written, None);
351                    let tombstone_rollback_ok = rollback_deleted_tombstones(&deleted_tombstones);
352                    let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
353                    let rollback_ok =
354                        files_rollback_ok && tombstone_rollback_ok && dirs_rollback_ok;
355                    return Err(AftError::IoError {
356                        path: key.display().to_string(),
357                        message: format!(
358                            "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
359                            e,
360                            !rollback_ok,
361                            rollback_ok
362                        ),
363                    });
364                }
365            }
366        }
367
368        let mut restored = Vec::new();
369        let mut warnings = Vec::new();
370        for (key, entry, warning, _) in content_targets {
371            self.commit_restored_backup(session, &key);
372            if let Some(warning) = warning {
373                warnings.push(format!("{}: {}", key.display(), warning));
374            }
375            restored.push(RestoredFile {
376                path: key,
377                backup_id: entry.backup_id,
378            });
379        }
380        for (key, _, _) in tombstone_targets {
381            self.commit_restored_backup(session, &key);
382        }
383        self.touch_session(session);
384
385        Ok(RestoredOperation {
386            op_id,
387            restored,
388            warnings,
389        })
390    }
391
392    /// Pop the most recent backup for `(session, path)` and restore the file.
393    /// Returns `(entry, optional_warning)`.
394    pub fn restore_latest(
395        &mut self,
396        session: &str,
397        path: &Path,
398    ) -> Result<(BackupEntry, Option<String>), AftError> {
399        let key = canonicalize_key(path);
400
401        // Try memory first
402        let in_memory = self
403            .entries
404            .get(session)
405            .and_then(|s| s.get(&key))
406            .map_or(false, |s| !s.is_empty());
407        if in_memory {
408            let result = self.do_restore(session, &key, path);
409            if result.is_ok() {
410                self.touch_session(session);
411            }
412            return result;
413        }
414
415        // Try disk fallback
416        if self.load_from_disk_if_needed(session, &key) {
417            // Check for external modification
418            let warning = self.check_external_modification(session, &key, path);
419            let (entry, _) = self.do_restore(session, &key, path)?;
420            self.touch_session(session);
421            return Ok((entry, warning));
422        }
423
424        Err(AftError::NoUndoHistory {
425            path: path.display().to_string(),
426        })
427    }
428
429    /// Return the backup history for `(session, path)` (oldest first).
430    pub fn history(&self, session: &str, path: &Path) -> Vec<BackupEntry> {
431        let key = canonicalize_key(path);
432        self.entries
433            .get(session)
434            .and_then(|s| s.get(&key))
435            .cloned()
436            .unwrap_or_default()
437    }
438
439    /// Return the number of on-disk backup entries for `(session, file)`.
440    pub fn disk_history_count(&self, session: &str, path: &Path) -> usize {
441        let key = canonicalize_key(path);
442        self.disk_index
443            .get(session)
444            .and_then(|s| s.get(&key))
445            .map(|m| m.count)
446            .unwrap_or(0)
447    }
448
449    /// Return all files that have at least one backup entry in this session
450    /// (memory + disk). Other sessions' files are not visible.
451    pub fn tracked_files(&self, session: &str) -> Vec<PathBuf> {
452        let mut files: std::collections::HashSet<PathBuf> = self
453            .entries
454            .get(session)
455            .map(|s| s.keys().cloned().collect())
456            .unwrap_or_default();
457        if let Some(disk) = self.disk_index.get(session) {
458            for key in disk.keys() {
459                files.insert(key.clone());
460            }
461        }
462        files.into_iter().collect()
463    }
464
465    /// Return all session namespaces that currently have any backup state
466    /// (memory or disk). Exposed for `/aft-status` aggregate reporting.
467    pub fn sessions_with_backups(&self) -> Vec<String> {
468        let mut sessions: std::collections::HashSet<String> =
469            self.entries.keys().cloned().collect();
470        for s in self.disk_index.keys() {
471            sessions.insert(s.clone());
472        }
473        sessions.into_iter().collect()
474    }
475
476    /// Total on-disk bytes across all sessions (best-effort, reads metadata only).
477    /// Used by `/aft-status` to surface storage footprint.
478    pub fn total_disk_bytes(&self) -> u64 {
479        let mut total = 0u64;
480        for session_dirs in self.disk_index.values() {
481            for meta in session_dirs.values() {
482                if let Ok(read_dir) = std::fs::read_dir(&meta.dir) {
483                    for entry in read_dir.flatten() {
484                        if let Ok(m) = entry.metadata() {
485                            if m.is_file() {
486                                total += m.len();
487                            }
488                        }
489                    }
490                }
491            }
492        }
493        total
494    }
495
496    fn next_id(&self) -> String {
497        let n = self.counter.fetch_add(1, Ordering::Relaxed);
498        format!("backup-{}", n)
499    }
500
501    fn touch_session(&mut self, session: &str) {
502        let now = current_timestamp();
503        self.session_meta
504            .entry(session.to_string())
505            .or_default()
506            .last_accessed = now;
507        self.write_session_marker(session, now);
508    }
509
510    // ---- Internal helpers ----
511
512    fn do_restore(
513        &mut self,
514        session: &str,
515        key: &Path,
516        path: &Path,
517    ) -> Result<(BackupEntry, Option<String>), AftError> {
518        let session_entries =
519            self.entries
520                .get_mut(session)
521                .ok_or_else(|| AftError::NoUndoHistory {
522                    path: path.display().to_string(),
523                })?;
524        let stack = session_entries
525            .get_mut(key)
526            .ok_or_else(|| AftError::NoUndoHistory {
527                path: path.display().to_string(),
528            })?;
529
530        let entry = stack
531            .last()
532            .cloned()
533            .ok_or_else(|| AftError::NoUndoHistory {
534                path: path.display().to_string(),
535            })?;
536
537        // Ensure parent directory exists. This matters when restoring an
538        // operation that deleted a directory tree — the parent directories
539        // are gone by the time we try to write the file content back.
540        if let Some(parent) = path.parent() {
541            if !parent.as_os_str().is_empty() {
542                std::fs::create_dir_all(parent).map_err(|e| AftError::IoError {
543                    path: parent.display().to_string(),
544                    message: e.to_string(),
545                })?;
546            }
547        }
548        std::fs::write(path, &entry.content).map_err(|e| AftError::IoError {
549            path: path.display().to_string(),
550            message: e.to_string(),
551        })?;
552
553        stack.pop();
554        if stack.is_empty() {
555            session_entries.remove(key);
556            // Also prune the session map when its last file is gone.
557            if session_entries.is_empty() {
558                self.entries.remove(session);
559            }
560            self.remove_disk_backups(session, key);
561        } else {
562            let stack_clone = self
563                .entries
564                .get(session)
565                .and_then(|s| s.get(key))
566                .cloned()
567                .unwrap_or_default();
568            self.write_snapshot_to_disk(session, key, &stack_clone);
569        }
570
571        Ok((entry, None))
572    }
573
574    fn commit_restored_backup(&mut self, session: &str, key: &Path) {
575        let mut remove_key = false;
576        let mut remove_session = false;
577        let mut remaining_stack = None;
578
579        if let Some(session_entries) = self.entries.get_mut(session) {
580            if let Some(stack) = session_entries.get_mut(key) {
581                stack.pop();
582                if stack.is_empty() {
583                    remove_key = true;
584                } else {
585                    remaining_stack = Some(stack.clone());
586                }
587            }
588
589            if remove_key {
590                session_entries.remove(key);
591                remove_session = session_entries.is_empty();
592            }
593        }
594
595        if remove_session {
596            self.entries.remove(session);
597        }
598
599        if remove_key {
600            self.remove_disk_backups(session, key);
601        } else if let Some(stack) = remaining_stack {
602            self.write_snapshot_to_disk(session, key, &stack);
603        }
604    }
605
606    fn check_external_modification(
607        &self,
608        session: &str,
609        key: &Path,
610        path: &Path,
611    ) -> Option<String> {
612        if let (Some(stack), Ok(current)) = (
613            self.entries.get(session).and_then(|s| s.get(key)),
614            std::fs::read_to_string(path),
615        ) {
616            if let Some(latest) = stack.last() {
617                if latest.content != current {
618                    return Some("file was modified externally since last backup".to_string());
619                }
620            }
621        }
622        None
623    }
624
625    // ---- Disk persistence ----
626
627    fn backups_dir(&self) -> Option<PathBuf> {
628        self.storage_dir.as_ref().map(|d| d.join("backups"))
629    }
630
631    fn session_dir(&self, session: &str) -> Option<PathBuf> {
632        self.backups_dir()
633            .map(|d| d.join(Self::session_hash(session)))
634    }
635
636    fn session_hash(session: &str) -> String {
637        hash_session(session)
638    }
639
640    fn path_hash(key: &Path) -> String {
641        // v0.16.0 intentionally switched from DefaultHasher to SHA-256 for
642        // stable on-disk names. Existing DefaultHasher backup directories are
643        // not migrated: backups are short-lived/session-scoped, so one-time
644        // loss of pre-upgrade undo history is acceptable.
645        stable_hash_16(key.to_string_lossy().as_bytes())
646    }
647
648    fn write_session_marker(&self, session: &str, last_accessed: u64) {
649        let Some(session_dir) = self.session_dir(session) else {
650            return;
651        };
652        if let Err(e) = std::fs::create_dir_all(&session_dir) {
653            crate::slog_warn!("failed to create session dir: {}", e);
654            return;
655        }
656        let marker = session_dir.join("session.json");
657        let json = serde_json::json!({
658            "schema_version": SCHEMA_VERSION,
659            "session_id": session,
660            "last_accessed": last_accessed,
661        });
662        if let Ok(s) = serde_json::to_string_pretty(&json) {
663            let tmp = session_dir.join("session.json.tmp");
664            if std::fs::write(&tmp, s).is_ok() {
665                let _ = std::fs::rename(&tmp, marker);
666            }
667        }
668    }
669
670    fn gc_stale_sessions(&mut self, ttl_hours: u32) {
671        let backups_dir = match self.backups_dir() {
672            Some(d) if d.exists() => d,
673            _ => return,
674        };
675        let ttl_secs = u64::from(if ttl_hours == 0 { 72 } else { ttl_hours }) * 60 * 60;
676        let cutoff = current_timestamp().saturating_sub(ttl_secs);
677        let entries = match std::fs::read_dir(&backups_dir) {
678            Ok(entries) => entries,
679            Err(_) => return,
680        };
681
682        for entry in entries.flatten() {
683            let session_dir = entry.path();
684            if !session_dir.is_dir() || session_dir.join("meta.json").exists() {
685                continue;
686            }
687            let Some(last_accessed) = Self::read_session_last_accessed(&session_dir) else {
688                continue;
689            };
690            if last_accessed >= cutoff {
691                continue;
692            }
693            if let Err(e) = std::fs::remove_dir_all(&session_dir) {
694                crate::slog_warn!(
695                    "failed to remove stale backup session {}: {}",
696                    session_dir.display(),
697                    e
698                );
699            } else {
700                crate::slog_warn!(
701                    "removed stale backup session {} (last_accessed={})",
702                    session_dir.display(),
703                    last_accessed
704                );
705            }
706        }
707    }
708
709    /// One-time migration: move pre-session flat layout into the default
710    /// session namespace. Called from `set_storage_dir` so existing backups
711    /// survive the upgrade.
712    ///
713    /// Detection: any directory directly under `backups/` that contains a
714    /// `meta.json` (as opposed to a `session.json` marker or subdirectories)
715    /// is treated as a legacy entry.
716    fn migrate_legacy_layout_if_needed(&mut self) {
717        let backups_dir = match self.backups_dir() {
718            Some(d) if d.exists() => d,
719            _ => return,
720        };
721        let default_session_dir =
722            backups_dir.join(Self::session_hash(crate::protocol::DEFAULT_SESSION_ID));
723
724        let entries = match std::fs::read_dir(&backups_dir) {
725            Ok(e) => e,
726            Err(_) => return,
727        };
728        let mut migrated = 0usize;
729        for entry in entries.flatten() {
730            let entry_path = entry.path();
731            // Skip non-directories and already-sessionized layouts.
732            if !entry_path.is_dir() {
733                continue;
734            }
735            if entry_path == default_session_dir {
736                continue;
737            }
738            let meta_path = entry_path.join("meta.json");
739            if !meta_path.exists() {
740                continue; // Already a session-hash dir (contains per-path subdirs), skip
741            }
742            // This is a legacy flat-layout path-hash directory. Move it under
743            // the default session namespace.
744            if let Err(e) = std::fs::create_dir_all(&default_session_dir) {
745                crate::slog_warn!("failed to create default session dir: {}", e);
746                return;
747            }
748            let leaf = match entry_path.file_name() {
749                Some(n) => n,
750                None => continue,
751            };
752            let target = default_session_dir.join(leaf);
753            if target.exists() {
754                // Already migrated on a prior run that was interrupted —
755                // leave both and let the regular load pick up the target.
756                continue;
757            }
758            match std::fs::rename(&entry_path, &target) {
759                Ok(()) => {
760                    // Bump meta.json to include session_id + schema_version.
761                    Self::upgrade_meta_file(
762                        &target.join("meta.json"),
763                        crate::protocol::DEFAULT_SESSION_ID,
764                    );
765                    migrated += 1;
766                }
767                Err(e) => {
768                    crate::slog_warn!(
769                        "failed to migrate legacy backup {}: {}",
770                        entry_path.display(),
771                        e
772                    );
773                }
774            }
775        }
776        if migrated > 0 {
777            crate::slog_info!(
778                "migrated {} legacy backup entries into default session namespace",
779                migrated
780            );
781            // Write a session.json marker so future scans don't re-migrate.
782            let marker = default_session_dir.join("session.json");
783            let json = serde_json::json!({
784                "schema_version": SCHEMA_VERSION,
785                "session_id": crate::protocol::DEFAULT_SESSION_ID,
786                "last_accessed": current_timestamp(),
787            });
788            if let Ok(s) = serde_json::to_string_pretty(&json) {
789                let _ = std::fs::write(&marker, s);
790            }
791        }
792    }
793
794    fn upgrade_meta_file(meta_path: &Path, session_id: &str) {
795        let content = match std::fs::read_to_string(meta_path) {
796            Ok(c) => c,
797            Err(_) => return,
798        };
799        let mut parsed: serde_json::Value = match serde_json::from_str(&content) {
800            Ok(v) => v,
801            Err(_) => return,
802        };
803        if let Some(obj) = parsed.as_object_mut() {
804            let count = obj.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
805            obj.insert(
806                "schema_version".to_string(),
807                serde_json::json!(SCHEMA_VERSION),
808            );
809            obj.insert("session_id".to_string(), serde_json::json!(session_id));
810            obj.entry("entries").or_insert_with(|| {
811                serde_json::Value::Array(
812                    (0..count)
813                        .map(|i| {
814                            serde_json::json!({
815                                "backup_id": format!("disk-{}", i),
816                                "timestamp": 0,
817                                "description": "restored from disk",
818                                "op_id": null,
819                            })
820                        })
821                        .collect(),
822                )
823            });
824        }
825        if let Ok(s) = serde_json::to_string_pretty(&parsed) {
826            let tmp = meta_path.with_extension("json.tmp");
827            if std::fs::write(&tmp, &s).is_ok() {
828                let _ = std::fs::rename(&tmp, meta_path);
829            }
830        }
831    }
832
833    fn load_disk_index(&mut self) {
834        let backups_dir = match self.backups_dir() {
835            Some(d) if d.exists() => d,
836            _ => return,
837        };
838        let session_dirs = match std::fs::read_dir(&backups_dir) {
839            Ok(e) => e,
840            Err(_) => return,
841        };
842        let mut total_entries = 0usize;
843        for session_entry in session_dirs.flatten() {
844            let session_dir = session_entry.path();
845            if !session_dir.is_dir() {
846                continue;
847            }
848            // Recover the session_id from session.json if present, otherwise skip
849            // (can't invert the hash to recover the original).
850            let session_id = match Self::read_session_marker(&session_dir) {
851                Some(session_id) => session_id,
852                None => {
853                    crate::slog_warn!(
854                        "skipping backup session dir without readable session marker: {}",
855                        session_dir.display()
856                    );
857                    continue;
858                }
859            };
860
861            let path_dirs = match std::fs::read_dir(&session_dir) {
862                Ok(e) => e,
863                Err(_) => continue,
864            };
865            let per_session = self.disk_index.entry(session_id.clone()).or_default();
866            for path_entry in path_dirs.flatten() {
867                let path_dir = path_entry.path();
868                if !path_dir.is_dir() {
869                    continue;
870                }
871                let meta_path = path_dir.join("meta.json");
872                if let Ok(content) = std::fs::read_to_string(&meta_path) {
873                    if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
874                        if let (Some(path_str), Some(count)) = (
875                            meta.get("path").and_then(|v| v.as_str()),
876                            meta.get("count").and_then(|v| v.as_u64()),
877                        ) {
878                            per_session.insert(
879                                PathBuf::from(path_str),
880                                DiskMeta {
881                                    dir: path_dir.clone(),
882                                    count: count as usize,
883                                },
884                            );
885                            total_entries += 1;
886                        }
887                    }
888                }
889            }
890        }
891        if total_entries > 0 {
892            crate::slog_info!(
893                "loaded {} backup entries across {} session(s) from disk",
894                total_entries,
895                self.disk_index.len()
896            );
897        }
898    }
899
900    fn read_session_marker(session_dir: &Path) -> Option<String> {
901        let marker = session_dir.join("session.json");
902        let content = std::fs::read_to_string(&marker).ok()?;
903        let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
904        parsed
905            .get("session_id")
906            .and_then(|v| v.as_str())
907            .map(|s| s.to_string())
908    }
909
910    fn read_session_last_accessed(session_dir: &Path) -> Option<u64> {
911        let marker = session_dir.join("session.json");
912        let content = std::fs::read_to_string(&marker).ok()?;
913        let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
914        parsed.get("last_accessed").and_then(|v| v.as_u64())
915    }
916
917    fn load_from_disk_if_needed(&mut self, session: &str, key: &Path) -> bool {
918        let disk_meta = match self
919            .disk_index
920            .get(session)
921            .and_then(|s| s.get(key))
922            .cloned()
923        {
924            Some(m) if m.count > 0 => m,
925            _ => return false,
926        };
927
928        let mut entries = Vec::new();
929        let entry_meta = std::fs::read_to_string(disk_meta.dir.join("meta.json"))
930            .ok()
931            .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
932            .and_then(|meta| meta.get("entries").and_then(|v| v.as_array()).cloned())
933            .unwrap_or_default();
934
935        for i in 0..disk_meta.count {
936            let meta = entry_meta.get(i);
937            let kind = match meta.and_then(|m| m.get("kind")).and_then(|v| v.as_str()) {
938                Some("tombstone") => BackupEntryKind::Tombstone,
939                _ => BackupEntryKind::Content,
940            };
941            let content = match kind {
942                BackupEntryKind::Content => {
943                    let bak_path = disk_meta.dir.join(format!("{}.bak", i));
944                    match std::fs::read_to_string(&bak_path) {
945                        Ok(content) => content,
946                        Err(_) => continue,
947                    }
948                }
949                BackupEntryKind::Tombstone => String::new(),
950            };
951            entries.push(BackupEntry {
952                backup_id: meta
953                    .and_then(|m| m.get("backup_id"))
954                    .and_then(|v| v.as_str())
955                    .map(str::to_string)
956                    .unwrap_or_else(|| format!("disk-{}", i)),
957                content,
958                timestamp: meta
959                    .and_then(|m| m.get("timestamp"))
960                    .and_then(|v| v.as_u64())
961                    .unwrap_or(0),
962                description: meta
963                    .and_then(|m| m.get("description"))
964                    .and_then(|v| v.as_str())
965                    .unwrap_or("restored from disk")
966                    .to_string(),
967                op_id: meta
968                    .and_then(|m| m.get("op_id"))
969                    .and_then(|v| v.as_str())
970                    .map(str::to_string),
971                kind,
972            });
973        }
974
975        if entries.is_empty() {
976            return false;
977        }
978
979        self.entries
980            .entry(session.to_string())
981            .or_default()
982            .insert(key.to_path_buf(), entries);
983        true
984    }
985
986    fn write_snapshot_to_disk(&mut self, session: &str, key: &Path, stack: &[BackupEntry]) {
987        let session_dir = match self.session_dir(session) {
988            Some(d) => d,
989            None => return,
990        };
991
992        // Ensure session dir + marker exist.
993        if let Err(e) = std::fs::create_dir_all(&session_dir) {
994            crate::slog_warn!("failed to create session dir: {}", e);
995            return;
996        }
997        let marker = session_dir.join("session.json");
998        if !marker.exists() {
999            let json = serde_json::json!({
1000                "schema_version": SCHEMA_VERSION,
1001                "session_id": session,
1002                "last_accessed": current_timestamp(),
1003            });
1004            if let Ok(s) = serde_json::to_string_pretty(&json) {
1005                let _ = std::fs::write(&marker, s);
1006            }
1007        }
1008
1009        let hash = Self::path_hash(key);
1010        let dir = session_dir.join(&hash);
1011        if let Err(e) = std::fs::create_dir_all(&dir) {
1012            crate::slog_warn!("failed to create backup dir: {}", e);
1013            return;
1014        }
1015
1016        for (i, entry) in stack.iter().enumerate() {
1017            let bak_path = dir.join(format!("{}.bak", i));
1018            let tmp_path = dir.join(format!("{}.bak.tmp", i));
1019            match entry.kind {
1020                BackupEntryKind::Content => {
1021                    if std::fs::write(&tmp_path, &entry.content).is_ok() {
1022                        let _ = std::fs::rename(&tmp_path, &bak_path);
1023                    }
1024                }
1025                BackupEntryKind::Tombstone => {
1026                    let _ = std::fs::remove_file(&bak_path);
1027                    let _ = std::fs::remove_file(&tmp_path);
1028                }
1029            }
1030        }
1031
1032        // Clean up extra .bak files if stack shrank.
1033        for i in stack.len()..MAX_UNDO_DEPTH {
1034            let old = dir.join(format!("{}.bak", i));
1035            if old.exists() {
1036                let _ = std::fs::remove_file(&old);
1037            }
1038        }
1039
1040        let entries: Vec<serde_json::Value> = stack
1041            .iter()
1042            .map(|entry| {
1043                serde_json::json!({
1044                    "backup_id": entry.backup_id,
1045                    "timestamp": entry.timestamp,
1046                    "description": entry.description,
1047                    "op_id": entry.op_id,
1048                    "kind": match entry.kind {
1049                        BackupEntryKind::Content => "content",
1050                        BackupEntryKind::Tombstone => "tombstone",
1051                    },
1052                })
1053            })
1054            .collect();
1055        let meta = serde_json::json!({
1056            "schema_version": SCHEMA_VERSION,
1057            "session_id": session,
1058            "path": key.display().to_string(),
1059            "count": stack.len(),
1060            "entries": entries,
1061        });
1062        let meta_path = dir.join("meta.json");
1063        let meta_tmp = dir.join("meta.json.tmp");
1064        if let Ok(content) = serde_json::to_string_pretty(&meta) {
1065            if std::fs::write(&meta_tmp, &content).is_ok() {
1066                let _ = std::fs::rename(&meta_tmp, &meta_path);
1067            }
1068        }
1069
1070        // Keep the in-memory disk_index in sync so tracked_files() and
1071        // disk_history_count() immediately reflect what we just wrote.
1072        self.disk_index
1073            .entry(session.to_string())
1074            .or_default()
1075            .insert(
1076                key.to_path_buf(),
1077                DiskMeta {
1078                    dir,
1079                    count: stack.len(),
1080                },
1081            );
1082    }
1083
1084    fn remove_disk_backups(&mut self, session: &str, key: &Path) {
1085        let removed = self.disk_index.get_mut(session).and_then(|s| s.remove(key));
1086        if let Some(meta) = removed {
1087            let _ = std::fs::remove_dir_all(&meta.dir);
1088        } else if let Some(session_dir) = self.session_dir(session) {
1089            let hash = Self::path_hash(key);
1090            let dir = session_dir.join(&hash);
1091            if dir.exists() {
1092                let _ = std::fs::remove_dir_all(&dir);
1093            }
1094        }
1095
1096        // If this session has no more disk entries, drop the map slot (session
1097        // dir itself is kept so the marker survives future sessions).
1098        let empty = self
1099            .disk_index
1100            .get(session)
1101            .map(|s| s.is_empty())
1102            .unwrap_or(false);
1103        if empty {
1104            self.disk_index.remove(session);
1105        }
1106    }
1107}
1108
1109pub fn hash_session(session: &str) -> String {
1110    stable_hash_16(session.as_bytes())
1111}
1112
1113pub fn new_op_id() -> String {
1114    let mut bytes = [0u8; 4];
1115    if getrandom::fill(&mut bytes).is_err() {
1116        bytes = current_timestamp().to_le_bytes()[..4]
1117            .try_into()
1118            .unwrap_or([0; 4]);
1119    }
1120    let rand = u32::from_le_bytes(bytes);
1121    format!("op-{}-{:08x}", current_timestamp() * 1000, rand)
1122}
1123
1124fn canonicalize_key(path: &Path) -> PathBuf {
1125    std::fs::canonicalize(path).unwrap_or_else(|err| {
1126        log::debug!(
1127            "backup canonicalize_key fallback for {}: {}",
1128            path.display(),
1129            err
1130        );
1131        path.to_path_buf()
1132    })
1133}
1134
1135fn rollback_transactional_restore(
1136    written: &[(PathBuf, Option<Vec<u8>>)],
1137    attempted: Option<(&PathBuf, &Option<Vec<u8>>)>,
1138) -> bool {
1139    let mut ok = true;
1140
1141    if let Some((path, content)) = attempted {
1142        ok &= rollback_one_restore_write(path, content);
1143    }
1144
1145    for (path, content) in written.iter().rev() {
1146        ok &= rollback_one_restore_write(path, content);
1147    }
1148
1149    ok
1150}
1151
1152fn rollback_one_restore_write(path: &Path, content: &Option<Vec<u8>>) -> bool {
1153    match content {
1154        Some(content) => std::fs::write(path, content).is_ok(),
1155        None => match std::fs::remove_file(path) {
1156            Ok(()) => true,
1157            Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
1158            Err(_) => false,
1159        },
1160    }
1161}
1162
1163fn rollback_deleted_tombstones(deleted: &[(PathBuf, Option<Vec<u8>>)]) -> bool {
1164    let mut ok = true;
1165    for (path, content) in deleted.iter().rev() {
1166        if let Some(content) = content {
1167            if let Some(parent) = path.parent() {
1168                if !parent.as_os_str().is_empty() && std::fs::create_dir_all(parent).is_err() {
1169                    ok = false;
1170                    continue;
1171                }
1172            }
1173            if std::fs::write(path, content).is_err() {
1174                ok = false;
1175            }
1176        }
1177    }
1178    ok
1179}
1180
1181fn missing_parent_dirs(parent: &Path) -> Vec<PathBuf> {
1182    let mut dirs = Vec::new();
1183    let mut current = Some(parent);
1184
1185    while let Some(dir) = current {
1186        if dir.as_os_str().is_empty() || dir.exists() {
1187            break;
1188        }
1189        dirs.push(dir.to_path_buf());
1190        current = dir.parent();
1191    }
1192
1193    dirs
1194}
1195
1196fn rollback_created_dirs(dirs: &[PathBuf]) -> bool {
1197    let mut dirs = dirs.to_vec();
1198    dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
1199    dirs.dedup();
1200
1201    let mut ok = true;
1202    for dir in dirs {
1203        match std::fs::remove_dir(&dir) {
1204            Ok(()) => {}
1205            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1206            Err(_) => ok = false,
1207        }
1208    }
1209
1210    ok
1211}
1212
1213fn current_timestamp() -> u64 {
1214    std::time::SystemTime::now()
1215        .duration_since(std::time::UNIX_EPOCH)
1216        .unwrap_or_default()
1217        .as_secs()
1218}
1219
1220fn stable_hash_16(bytes: &[u8]) -> String {
1221    let digest = Sha256::digest(bytes);
1222    digest[..8]
1223        .iter()
1224        .map(|byte| format!("{:02x}", byte))
1225        .collect()
1226}
1227
1228fn backup_sequence(backup_id: &str) -> Option<u64> {
1229    backup_id
1230        .strip_prefix("backup-")
1231        .or_else(|| backup_id.strip_prefix("disk-"))
1232        .and_then(|s| s.parse().ok())
1233}
1234
1235#[cfg(test)]
1236mod tests {
1237    use super::*;
1238    use crate::protocol::DEFAULT_SESSION_ID;
1239    use std::fs;
1240    #[cfg(unix)]
1241    use std::os::unix::fs::PermissionsExt;
1242
1243    fn temp_file(name: &str, content: &str) -> PathBuf {
1244        let dir = std::env::temp_dir().join("aft_backup_tests");
1245        fs::create_dir_all(&dir).unwrap();
1246        let path = dir.join(name);
1247        fs::write(&path, content).unwrap();
1248        path
1249    }
1250
1251    #[test]
1252    fn snapshot_and_restore_round_trip() {
1253        let path = temp_file("round_trip.txt", "original");
1254        let mut store = BackupStore::new();
1255
1256        let id = store
1257            .snapshot(DEFAULT_SESSION_ID, &path, "before edit")
1258            .unwrap();
1259        assert!(id.starts_with("backup-"));
1260
1261        fs::write(&path, "modified").unwrap();
1262        assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
1263
1264        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
1265        assert_eq!(entry.content, "original");
1266        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
1267    }
1268
1269    #[test]
1270    fn multiple_snapshots_preserve_order() {
1271        let path = temp_file("order.txt", "v1");
1272        let mut store = BackupStore::new();
1273
1274        store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
1275        fs::write(&path, "v2").unwrap();
1276        store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
1277        fs::write(&path, "v3").unwrap();
1278        store.snapshot(DEFAULT_SESSION_ID, &path, "third").unwrap();
1279
1280        let history = store.history(DEFAULT_SESSION_ID, &path);
1281        assert_eq!(history.len(), 3);
1282        assert_eq!(history[0].content, "v1");
1283        assert_eq!(history[1].content, "v2");
1284        assert_eq!(history[2].content, "v3");
1285    }
1286
1287    #[test]
1288    fn restore_pops_from_stack() {
1289        let path = temp_file("pop.txt", "v1");
1290        let mut store = BackupStore::new();
1291
1292        store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
1293        fs::write(&path, "v2").unwrap();
1294        store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
1295
1296        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
1297        assert_eq!(entry.description, "second");
1298        assert_eq!(entry.content, "v2");
1299
1300        let history = store.history(DEFAULT_SESSION_ID, &path);
1301        assert_eq!(history.len(), 1);
1302    }
1303
1304    #[test]
1305    fn empty_history_returns_empty_vec() {
1306        let store = BackupStore::new();
1307        let path = Path::new("/tmp/aft_backup_tests/nonexistent_history.txt");
1308        assert!(store.history(DEFAULT_SESSION_ID, path).is_empty());
1309    }
1310
1311    #[test]
1312    fn snapshot_nonexistent_file_returns_error() {
1313        let mut store = BackupStore::new();
1314        let path = Path::new("/tmp/aft_backup_tests/absolutely_does_not_exist.txt");
1315        assert!(store.snapshot(DEFAULT_SESSION_ID, path, "test").is_err());
1316    }
1317
1318    #[test]
1319    fn tracked_files_lists_snapshotted_paths() {
1320        let path1 = temp_file("tracked1.txt", "a");
1321        let path2 = temp_file("tracked2.txt", "b");
1322        let mut store = BackupStore::new();
1323
1324        store.snapshot(DEFAULT_SESSION_ID, &path1, "snap1").unwrap();
1325        store.snapshot(DEFAULT_SESSION_ID, &path2, "snap2").unwrap();
1326        assert_eq!(store.tracked_files(DEFAULT_SESSION_ID).len(), 2);
1327    }
1328
1329    #[test]
1330    fn sessions_are_isolated() {
1331        let path = temp_file("isolated.txt", "original");
1332        let mut store = BackupStore::new();
1333
1334        store.snapshot("session_a", &path, "a's snapshot").unwrap();
1335
1336        // Session B sees no history for this file.
1337        assert!(store.history("session_b", &path).is_empty());
1338        assert_eq!(store.tracked_files("session_b").len(), 0);
1339
1340        // Session B's restore_latest fails with NoUndoHistory.
1341        let err = store.restore_latest("session_b", &path);
1342        assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
1343
1344        // Session A still sees its own snapshot.
1345        assert_eq!(store.history("session_a", &path).len(), 1);
1346        assert_eq!(store.tracked_files("session_a").len(), 1);
1347    }
1348
1349    #[test]
1350    fn per_session_per_file_cap_is_independent() {
1351        // Two sessions fill up their own stacks independently; hitting the cap
1352        // in session A does not evict anything from session B.
1353        let path = temp_file("cap_indep.txt", "v0");
1354        let mut store = BackupStore::new();
1355
1356        for i in 0..(MAX_UNDO_DEPTH + 5) {
1357            fs::write(&path, format!("a{}", i)).unwrap();
1358            store.snapshot("session_a", &path, "a").unwrap();
1359        }
1360        fs::write(&path, "b_initial").unwrap();
1361        store.snapshot("session_b", &path, "b").unwrap();
1362
1363        // Session A should be capped at MAX_UNDO_DEPTH.
1364        assert_eq!(store.history("session_a", &path).len(), MAX_UNDO_DEPTH);
1365        // Session B should still have its single entry.
1366        assert_eq!(store.history("session_b", &path).len(), 1);
1367    }
1368
1369    #[test]
1370    fn sessions_with_backups_lists_all_namespaces() {
1371        let path_a = temp_file("sessions_list_a.txt", "a");
1372        let path_b = temp_file("sessions_list_b.txt", "b");
1373        let mut store = BackupStore::new();
1374
1375        store.snapshot("alice", &path_a, "from alice").unwrap();
1376        store.snapshot("bob", &path_b, "from bob").unwrap();
1377
1378        let sessions = store.sessions_with_backups();
1379        assert_eq!(sessions.len(), 2);
1380        assert!(sessions.iter().any(|s| s == "alice"));
1381        assert!(sessions.iter().any(|s| s == "bob"));
1382    }
1383
1384    #[test]
1385    fn disk_persistence_survives_reload() {
1386        let dir = std::env::temp_dir().join("aft_backup_disk_test");
1387        let _ = fs::remove_dir_all(&dir);
1388        fs::create_dir_all(&dir).unwrap();
1389
1390        let file_path = temp_file("disk_persist.txt", "original");
1391
1392        // Create store with storage, snapshot under default session, drop.
1393        {
1394            let mut store = BackupStore::new();
1395            store.set_storage_dir(dir.clone(), 72);
1396            store
1397                .snapshot(DEFAULT_SESSION_ID, &file_path, "before edit")
1398                .unwrap();
1399        }
1400
1401        // Modify the file externally.
1402        fs::write(&file_path, "externally modified").unwrap();
1403
1404        // Create new store, load from disk, restore.
1405        let mut store2 = BackupStore::new();
1406        store2.set_storage_dir(dir.clone(), 72);
1407
1408        let (entry, warning) = store2
1409            .restore_latest(DEFAULT_SESSION_ID, &file_path)
1410            .unwrap();
1411        assert_eq!(entry.content, "original");
1412        assert!(warning.is_some()); // modified externally
1413        assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
1414
1415        let _ = fs::remove_dir_all(&dir);
1416    }
1417
1418    #[test]
1419    fn legacy_flat_layout_migrates_to_default_session() {
1420        // Simulate a pre-session on-disk layout (schema v1) and verify it's
1421        // moved under the default session namespace on set_storage_dir.
1422        let dir = std::env::temp_dir().join("aft_backup_migration_test");
1423        let _ = fs::remove_dir_all(&dir);
1424        fs::create_dir_all(&dir).unwrap();
1425        let backups = dir.join("backups");
1426        fs::create_dir_all(&backups).unwrap();
1427
1428        // Fake legacy entry for some path hash.
1429        let legacy_hash = "deadbeefcafebabe";
1430        let legacy_dir = backups.join(legacy_hash);
1431        fs::create_dir_all(&legacy_dir).unwrap();
1432        fs::write(legacy_dir.join("0.bak"), "original content").unwrap();
1433        let legacy_meta = serde_json::json!({
1434            "path": "/tmp/migrated_file.txt",
1435            "count": 1,
1436        });
1437        fs::write(
1438            legacy_dir.join("meta.json"),
1439            serde_json::to_string_pretty(&legacy_meta).unwrap(),
1440        )
1441        .unwrap();
1442
1443        // Run migration.
1444        let mut store = BackupStore::new();
1445        store.set_storage_dir(dir.clone(), 72);
1446
1447        // After migration, the legacy dir should be gone from the top level,
1448        // and the entry should now live under the default-session hash dir.
1449        let default_session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
1450        assert!(default_session_dir.exists());
1451        assert!(default_session_dir.join(legacy_hash).exists());
1452        assert!(!backups.join(legacy_hash).exists());
1453
1454        // The upgraded meta.json should now include session_id + schema_version.
1455        let meta_content =
1456            fs::read_to_string(default_session_dir.join(legacy_hash).join("meta.json")).unwrap();
1457        let meta: serde_json::Value = serde_json::from_str(&meta_content).unwrap();
1458        assert_eq!(meta["session_id"], DEFAULT_SESSION_ID);
1459        assert_eq!(meta["schema_version"], SCHEMA_VERSION);
1460
1461        let _ = fs::remove_dir_all(&dir);
1462    }
1463
1464    #[test]
1465    fn set_storage_dir_removes_stale_backup_sessions() {
1466        let dir = std::env::temp_dir().join("aft_backup_gc_test");
1467        let _ = fs::remove_dir_all(&dir);
1468        let backups = dir.join("backups");
1469        fs::create_dir_all(&backups).unwrap();
1470
1471        let stale_session_dir = backups.join("stale-session");
1472        fs::create_dir_all(&stale_session_dir).unwrap();
1473        let stale_marker = serde_json::json!({
1474            "schema_version": SCHEMA_VERSION,
1475            "session_id": "stale",
1476            "last_accessed": 1,
1477        });
1478        fs::write(
1479            stale_session_dir.join("session.json"),
1480            serde_json::to_string_pretty(&stale_marker).unwrap(),
1481        )
1482        .unwrap();
1483
1484        let mut store = BackupStore::new();
1485        store.set_storage_dir(dir.clone(), 1);
1486
1487        assert!(!stale_session_dir.exists());
1488        let _ = fs::remove_dir_all(&dir);
1489    }
1490
1491    #[test]
1492    fn markerless_session_dir_is_skipped_not_mapped_to_default() {
1493        let dir = std::env::temp_dir().join("aft_backup_markerless_skip_test");
1494        let _ = fs::remove_dir_all(&dir);
1495        let file_path = temp_file("markerless.txt", "original");
1496        let key = canonicalize_key(&file_path);
1497        let path_dir = dir
1498            .join("backups")
1499            .join("corrupt-session")
1500            .join("path-entry");
1501        fs::create_dir_all(&path_dir).unwrap();
1502        fs::write(path_dir.join("0.bak"), "original").unwrap();
1503        fs::write(
1504            path_dir.join("meta.json"),
1505            serde_json::to_string_pretty(&serde_json::json!({
1506                "schema_version": SCHEMA_VERSION,
1507                "session_id": "lost-session",
1508                "path": key.display().to_string(),
1509                "count": 1,
1510                "entries": [{
1511                    "backup_id": "disk-0",
1512                    "timestamp": 0,
1513                    "description": "corrupt marker test",
1514                    "op_id": null,
1515                    "kind": "content",
1516                }]
1517            }))
1518            .unwrap(),
1519        )
1520        .unwrap();
1521
1522        let mut store = BackupStore::new();
1523        store.set_storage_dir(dir.clone(), 72);
1524
1525        assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 0);
1526        assert!(store.sessions_with_backups().is_empty());
1527        let _ = fs::remove_dir_all(&dir);
1528    }
1529
1530    #[test]
1531    fn set_storage_dir_reconfiguration_drops_previous_disk_index() {
1532        let dir_a = std::env::temp_dir().join("aft_backup_storage_a_test");
1533        let dir_b = std::env::temp_dir().join("aft_backup_storage_b_test");
1534        let _ = fs::remove_dir_all(&dir_a);
1535        let _ = fs::remove_dir_all(&dir_b);
1536        fs::create_dir_all(&dir_a).unwrap();
1537        fs::create_dir_all(&dir_b).unwrap();
1538        let file_path = temp_file("storage_reconfigure.txt", "original");
1539
1540        let mut store = BackupStore::new();
1541        store.set_storage_dir(dir_a.clone(), 72);
1542        store
1543            .snapshot(DEFAULT_SESSION_ID, &file_path, "stored in a")
1544            .unwrap();
1545        assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 1);
1546
1547        store.set_storage_dir(dir_b.clone(), 72);
1548
1549        assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 0);
1550        assert!(store.tracked_files(DEFAULT_SESSION_ID).is_empty());
1551        let _ = fs::remove_dir_all(&dir_a);
1552        let _ = fs::remove_dir_all(&dir_b);
1553    }
1554
1555    #[test]
1556    fn restore_last_operation_restores_all_top_entries_for_same_op() {
1557        let path_a = temp_file("op_restore_a.txt", "a1");
1558        let path_b = temp_file("op_restore_b.txt", "b1");
1559        let mut store = BackupStore::new();
1560        let op_id = "op-test-00000001";
1561
1562        store
1563            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
1564            .unwrap();
1565        store
1566            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
1567            .unwrap();
1568        fs::write(&path_a, "a2").unwrap();
1569        fs::write(&path_b, "b2").unwrap();
1570
1571        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
1572        assert_eq!(restored.op_id, op_id);
1573        assert_eq!(restored.restored.len(), 2);
1574        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a1");
1575        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
1576    }
1577
1578    #[test]
1579    fn restore_last_operation_deletes_tombstone_destination() {
1580        let dir = std::env::temp_dir().join("aft_backup_tombstone_delete_test");
1581        let _ = fs::remove_dir_all(&dir);
1582        fs::create_dir_all(&dir).unwrap();
1583        let source = dir.join("source.txt");
1584        let destination = dir.join("destination.txt");
1585        fs::write(&source, "original").unwrap();
1586
1587        let mut store = BackupStore::new();
1588        let op_id = "op-tombstone-delete";
1589        store
1590            .snapshot_with_op(DEFAULT_SESSION_ID, &source, "move source", Some(op_id))
1591            .unwrap();
1592        fs::rename(&source, &destination).unwrap();
1593        store
1594            .snapshot_op_tombstone(DEFAULT_SESSION_ID, op_id, &destination, "created dest")
1595            .unwrap();
1596
1597        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
1598        assert_eq!(restored.op_id, op_id);
1599        assert_eq!(restored.restored.len(), 1);
1600        assert_eq!(fs::read_to_string(&source).unwrap(), "original");
1601        assert!(!destination.exists());
1602        let _ = fs::remove_dir_all(&dir);
1603    }
1604
1605    #[test]
1606    fn restore_last_operation_rolls_back_source_when_tombstone_delete_fails() {
1607        let dir = std::env::temp_dir().join("aft_backup_tombstone_atomic_test");
1608        let _ = fs::remove_dir_all(&dir);
1609        fs::create_dir_all(&dir).unwrap();
1610        let source = dir.join("source.txt");
1611        let destination = dir.join("destination.txt");
1612        fs::write(&source, "original").unwrap();
1613
1614        let mut store = BackupStore::new();
1615        let op_id = "op-tombstone-atomic";
1616        store
1617            .snapshot_with_op(DEFAULT_SESSION_ID, &source, "move source", Some(op_id))
1618            .unwrap();
1619        fs::rename(&source, &destination).unwrap();
1620        store
1621            .snapshot_op_tombstone(DEFAULT_SESSION_ID, op_id, &destination, "created dest")
1622            .unwrap();
1623
1624        fs::remove_file(&destination).unwrap();
1625        fs::create_dir(&destination).unwrap();
1626        let result = store.restore_last_operation(DEFAULT_SESSION_ID);
1627
1628        assert!(result.is_err(), "directory tombstone target should fail");
1629        assert!(
1630            !source.exists(),
1631            "source restore must roll back when destination deletion fails"
1632        );
1633        assert!(
1634            destination.is_dir(),
1635            "failed tombstone target should remain"
1636        );
1637        let _ = fs::remove_dir_all(&dir);
1638    }
1639
1640    // Uses Unix-specific PermissionsExt::set_mode to make a target file
1641    // read-only and force the Phase 1 write to fail. The atomicity logic
1642    // it exercises is platform-independent — Windows has different
1643    // mechanisms for forcing write failures, covered separately.
1644    #[cfg(unix)]
1645    #[test]
1646    fn restore_last_operation_is_atomic_when_a_write_fails() {
1647        let dir = std::env::temp_dir().join("aft_backup_tests_atomic_restore");
1648        let _ = fs::remove_dir_all(&dir);
1649        fs::create_dir_all(&dir).unwrap();
1650        let path_a = dir.join("a.txt");
1651        let path_b = dir.join("b.txt");
1652        let path_c = dir.join("c.txt");
1653        fs::write(&path_a, "a-original").unwrap();
1654        fs::write(&path_b, "b-original").unwrap();
1655        fs::write(&path_c, "c-original").unwrap();
1656
1657        let mut store = BackupStore::new();
1658        let op_id = "op-atomic-restore-01";
1659        let id_a = store
1660            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
1661            .unwrap();
1662        let id_b = store
1663            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
1664            .unwrap();
1665        let id_c = store
1666            .snapshot_with_op(DEFAULT_SESSION_ID, &path_c, "c", Some(op_id))
1667            .unwrap();
1668        fs::write(&path_a, "a-modified").unwrap();
1669        fs::write(&path_b, "b-modified").unwrap();
1670        fs::write(&path_c, "c-modified").unwrap();
1671
1672        let original_permissions = fs::metadata(&path_b).unwrap().permissions();
1673        let mut readonly_permissions = original_permissions.clone();
1674        readonly_permissions.set_mode(0o444);
1675        fs::set_permissions(&path_b, readonly_permissions).unwrap();
1676
1677        let result = store.restore_last_operation(DEFAULT_SESSION_ID);
1678        fs::set_permissions(&path_b, original_permissions).unwrap();
1679
1680        assert!(result.is_err());
1681        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
1682        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
1683        assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-modified");
1684
1685        let history_a = store.history(DEFAULT_SESSION_ID, &path_a);
1686        let history_b = store.history(DEFAULT_SESSION_ID, &path_b);
1687        let history_c = store.history(DEFAULT_SESSION_ID, &path_c);
1688        assert_eq!(history_a.len(), 1);
1689        assert_eq!(history_b.len(), 1);
1690        assert_eq!(history_c.len(), 1);
1691        assert_eq!(history_a[0].backup_id, id_a);
1692        assert_eq!(history_b[0].backup_id, id_b);
1693        assert_eq!(history_c[0].backup_id, id_c);
1694        assert_eq!(history_a[0].op_id.as_deref(), Some(op_id));
1695        assert_eq!(history_b[0].op_id.as_deref(), Some(op_id));
1696        assert_eq!(history_c[0].op_id.as_deref(), Some(op_id));
1697
1698        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
1699        assert_eq!(restored.op_id, op_id);
1700        assert_eq!(restored.restored.len(), 3);
1701        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
1702        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
1703        assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-original");
1704
1705        let _ = fs::remove_dir_all(&dir);
1706    }
1707
1708    #[test]
1709    fn restore_last_operation_restores_only_most_recent_op() {
1710        let path_a = temp_file("op_recent_a.txt", "a1");
1711        let path_b = temp_file("op_recent_b.txt", "b1");
1712        let mut store = BackupStore::new();
1713
1714        store
1715            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "older", Some("op-older"))
1716            .unwrap();
1717        store
1718            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "newer", Some("op-newer"))
1719            .unwrap();
1720        fs::write(&path_a, "a2").unwrap();
1721        fs::write(&path_b, "b2").unwrap();
1722
1723        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
1724        assert_eq!(restored.op_id, "op-newer");
1725        assert_eq!(restored.restored.len(), 1);
1726        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a2");
1727        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
1728    }
1729
1730    #[test]
1731    fn restore_recreates_missing_parent_directories() {
1732        // Simulate aft_delete files: [dir/] with recursive: true:
1733        // the parent directories are gone by the time we restore.
1734        let dir = std::env::temp_dir().join("aft_backup_tests_recreate_parents");
1735        let _ = fs::remove_dir_all(&dir);
1736        let nested = dir.join("nested");
1737        fs::create_dir_all(&nested).unwrap();
1738        let path = nested.join("inner.txt");
1739        fs::write(&path, "original").unwrap();
1740
1741        let mut store = BackupStore::new();
1742        let op_id = "op-recreate-parents-01";
1743        store
1744            .snapshot_with_op(DEFAULT_SESSION_ID, &path, "original", Some(op_id))
1745            .unwrap();
1746
1747        // Real-world delete sequence: tree is wiped before undo runs.
1748        fs::remove_dir_all(&dir).unwrap();
1749        assert!(!path.exists());
1750        assert!(!nested.exists());
1751        assert!(!dir.exists());
1752
1753        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
1754        assert_eq!(restored.op_id, op_id);
1755        assert_eq!(restored.restored.len(), 1);
1756        assert!(
1757            path.exists(),
1758            "file should be restored even though both nested/ and dir/ were missing"
1759        );
1760        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
1761
1762        let _ = fs::remove_dir_all(&dir);
1763    }
1764
1765    #[test]
1766    fn restore_last_operation_ignores_legacy_entries_without_op_id() {
1767        let path = temp_file("op_legacy_none.txt", "v1");
1768        let mut store = BackupStore::new();
1769
1770        store.snapshot(DEFAULT_SESSION_ID, &path, "legacy").unwrap();
1771        fs::write(&path, "v2").unwrap();
1772
1773        let err = store.restore_last_operation(DEFAULT_SESSION_ID);
1774        assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
1775        assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
1776    }
1777
1778    #[test]
1779    fn schema_v2_meta_loads_with_none_op_id_and_persists_as_v3() {
1780        let dir = std::env::temp_dir().join("aft_backup_v2_to_v3_test");
1781        let _ = fs::remove_dir_all(&dir);
1782        fs::create_dir_all(&dir).unwrap();
1783        let file_path = temp_file("v2_to_v3.txt", "original");
1784        let key = canonicalize_key(&file_path);
1785        let session_dir = dir
1786            .join("backups")
1787            .join(BackupStore::session_hash(DEFAULT_SESSION_ID));
1788        let path_dir = session_dir.join(BackupStore::path_hash(&key));
1789        fs::create_dir_all(&path_dir).unwrap();
1790        fs::write(path_dir.join("0.bak"), "original").unwrap();
1791        fs::write(
1792            session_dir.join("session.json"),
1793            serde_json::to_string_pretty(&serde_json::json!({
1794                "schema_version": 2,
1795                "session_id": DEFAULT_SESSION_ID,
1796                "last_accessed": current_timestamp(),
1797            }))
1798            .unwrap(),
1799        )
1800        .unwrap();
1801        fs::write(
1802            path_dir.join("meta.json"),
1803            serde_json::to_string_pretty(&serde_json::json!({
1804                "schema_version": 2,
1805                "session_id": DEFAULT_SESSION_ID,
1806                "path": key.display().to_string(),
1807                "count": 1,
1808            }))
1809            .unwrap(),
1810        )
1811        .unwrap();
1812
1813        let mut store = BackupStore::new();
1814        store.set_storage_dir(dir.clone(), 72);
1815        assert!(store.load_from_disk_if_needed(DEFAULT_SESSION_ID, &key));
1816        let history = store.history(DEFAULT_SESSION_ID, &file_path);
1817        assert_eq!(history.len(), 1);
1818        assert_eq!(history[0].op_id, None);
1819
1820        fs::write(&file_path, "second").unwrap();
1821        store
1822            .snapshot_with_op(DEFAULT_SESSION_ID, &file_path, "second", Some("op-v3"))
1823            .unwrap();
1824        let written: serde_json::Value =
1825            serde_json::from_str(&fs::read_to_string(path_dir.join("meta.json")).unwrap()).unwrap();
1826        assert_eq!(written["schema_version"], SCHEMA_VERSION);
1827        assert_eq!(written["entries"][0]["op_id"], serde_json::Value::Null);
1828        assert_eq!(written["entries"][1]["op_id"], "op-v3");
1829        let _ = fs::remove_dir_all(&dir);
1830    }
1831
1832    #[test]
1833    fn per_file_restore_latest_still_works_with_op_ids() {
1834        let path = temp_file("op_per_file.txt", "v1");
1835        let mut store = BackupStore::new();
1836
1837        store
1838            .snapshot_with_op(DEFAULT_SESSION_ID, &path, "op", Some("op-file"))
1839            .unwrap();
1840        fs::write(&path, "v2").unwrap();
1841
1842        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
1843        assert_eq!(entry.op_id.as_deref(), Some("op-file"));
1844        assert_eq!(fs::read_to_string(&path).unwrap(), "v1");
1845    }
1846}