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