Skip to main content

aft/
backup.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::{Arc, Mutex, RwLock};
5
6use rusqlite::Connection;
7
8use crate::db::backups::BackupRow;
9use crate::error::AftError;
10use sha2::{Digest, Sha256};
11
12const MAX_UNDO_DEPTH: usize = 20;
13
14/// Current on-disk backup metadata schema version.
15///
16/// Bump this when the `meta.json` shape changes. Readers check the field and
17/// refuse or migrate older versions instead of misinterpreting them.
18const SCHEMA_VERSION: u32 = 4;
19
20/// A single backup entry for a file.
21#[derive(Debug, Clone)]
22pub struct BackupEntry {
23    pub backup_id: String,
24    /// UTF-8 view of the captured regular-file bytes, kept for API/tests that
25    /// inspect text backups. Restore uses `content_bytes` so binary files round-trip.
26    pub content: String,
27    pub content_bytes: Vec<u8>,
28    pub timestamp: u64,
29    pub order: u128,
30    pub description: String,
31    pub op_id: Option<String>,
32    pub kind: BackupEntryKind,
33    pub mode: Option<u32>,
34    pub link_target: Option<PathBuf>,
35    pub created_dirs: Vec<PathBuf>,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum BackupEntryKind {
40    Content,
41    Symlink,
42    Tombstone,
43}
44
45#[derive(Debug, Clone)]
46struct BackupEntryHead {
47    order: u128,
48    op_id: Option<String>,
49}
50
51impl BackupEntryHead {
52    fn from_entry(entry: &BackupEntry) -> Self {
53        Self {
54            order: entry.order,
55            op_id: entry.op_id.clone(),
56        }
57    }
58
59    fn from_row(row: &BackupRow) -> Self {
60        Self {
61            order: row.order,
62            op_id: row.op_id.clone(),
63        }
64    }
65}
66
67impl BackupEntry {
68    fn to_backup_row(
69        &self,
70        harness: &str,
71        session_id: &str,
72        project_key: &str,
73        file_path: &str,
74        path_hash: &str,
75        backup_path: Option<&str>,
76    ) -> BackupRow {
77        BackupRow {
78            backup_id: self.backup_id.clone(),
79            harness: harness.to_string(),
80            session_id: session_id.to_string(),
81            project_key: project_key.to_string(),
82            op_id: self.op_id.clone(),
83            order: self.order,
84            file_path: file_path.to_string(),
85            path_hash: path_hash.to_string(),
86            backup_path: backup_path.map(str::to_string),
87            kind: match self.kind {
88                BackupEntryKind::Content => "content".to_string(),
89                BackupEntryKind::Symlink => "symlink".to_string(),
90                BackupEntryKind::Tombstone => "tombstone".to_string(),
91            },
92            description: self.description.clone(),
93            created_at: i64::try_from(self.timestamp).unwrap_or(i64::MAX),
94            is_tombstone: matches!(self.kind, BackupEntryKind::Tombstone),
95        }
96    }
97}
98
99impl TryFrom<BackupRow> for BackupEntry {
100    type Error = std::io::Error;
101
102    fn try_from(row: BackupRow) -> Result<Self, Self::Error> {
103        let kind = if row.is_tombstone || row.kind == "tombstone" {
104            BackupEntryKind::Tombstone
105        } else if row.kind == "symlink" {
106            BackupEntryKind::Symlink
107        } else {
108            BackupEntryKind::Content
109        };
110        let backup_path = row.backup_path.clone();
111        let disk_metadata = backup_path
112            .as_deref()
113            .and_then(|path| read_entry_disk_metadata(Path::new(path), &row.backup_id));
114        let content_bytes = match kind {
115            BackupEntryKind::Content | BackupEntryKind::Symlink => {
116                let backup_path = backup_path.ok_or_else(|| {
117                    std::io::Error::new(
118                        std::io::ErrorKind::NotFound,
119                        format!("backup DB row {} has no backup_path", row.backup_id),
120                    )
121                })?;
122                std::fs::read(backup_path)?
123            }
124            BackupEntryKind::Tombstone => Vec::new(),
125        };
126        let link_target = if kind == BackupEntryKind::Symlink {
127            disk_metadata
128                .as_ref()
129                .and_then(|metadata| metadata.link_target.clone())
130                .or_else(|| {
131                    Some(PathBuf::from(
132                        String::from_utf8_lossy(&content_bytes).into_owned(),
133                    ))
134                })
135        } else {
136            None
137        };
138        let content = match kind {
139            BackupEntryKind::Content => String::from_utf8_lossy(&content_bytes).into_owned(),
140            BackupEntryKind::Symlink => link_target
141                .as_ref()
142                .map(|target| target.display().to_string())
143                .unwrap_or_default(),
144            BackupEntryKind::Tombstone => String::new(),
145        };
146
147        Ok(BackupEntry {
148            backup_id: row.backup_id,
149            content,
150            content_bytes,
151            timestamp: u64::try_from(row.created_at).unwrap_or_default(),
152            order: row.order,
153            description: row.description,
154            op_id: row.op_id,
155            kind,
156            mode: disk_metadata.as_ref().and_then(|metadata| metadata.mode),
157            link_target,
158            created_dirs: disk_metadata
159                .map(|metadata| metadata.created_dirs)
160                .unwrap_or_default(),
161        })
162    }
163}
164
165#[derive(Debug, Clone)]
166pub struct RestoredOperation {
167    pub op_id: String,
168    pub restored: Vec<RestoredFile>,
169    pub warnings: Vec<String>,
170}
171
172#[derive(Debug, Clone)]
173pub struct RestoredFile {
174    pub path: PathBuf,
175    pub backup_id: String,
176}
177
178/// Per-(session, file) undo store with optional disk persistence.
179///
180/// Introduced alongside project-shared bridges (issue #14): one bridge can now
181/// serve many OpenCode sessions in the same project, so undo history must be
182/// partitioned by session to keep session A's edits invisible to session B.
183///
184/// The 20-entry cap is enforced **per (session, file)** deliberately — a global
185/// per-file LRU would re-couple sessions and let one busy session evict
186/// another's history.
187///
188/// Disk layout (schema v2):
189///   `<storage_dir>/backups/<session_hash>/session.json` — session metadata
190///   `<storage_dir>/backups/<session_hash>/<path_hash>/meta.json` — file path + count + session
191///   `<storage_dir>/backups/<session_hash>/<path_hash>/0.bak` … `19.bak` — snapshots
192///
193/// Legacy layouts from before sessionization (flat `<path_hash>/` directly under
194/// `backups/`) are migrated on first `set_storage_dir` call into the default
195/// session namespace.
196#[derive(Debug)]
197pub struct BackupStore {
198    /// session -> path -> entry stack
199    entries: HashMap<String, HashMap<PathBuf, Vec<BackupEntry>>>,
200    /// session -> path -> disk metadata
201    disk_index: HashMap<String, HashMap<PathBuf, DiskMeta>>,
202    /// session -> metadata
203    session_meta: HashMap<String, SessionMeta>,
204    counter: AtomicU64,
205    storage_dir: Option<PathBuf>,
206    storage_harness: Option<String>,
207    db_pool: RwLock<Option<Arc<Mutex<Connection>>>>,
208    db_harness: RwLock<Option<String>>,
209    db_project_key: RwLock<Option<String>>,
210}
211
212#[derive(Debug, Clone)]
213struct DiskMeta {
214    dir: PathBuf,
215    count: usize,
216}
217
218#[derive(Debug, Clone, Default)]
219struct SessionMeta {
220    /// Unix timestamp of last read/write activity in this session namespace.
221    /// Maintained in-memory now, reserved for future inactivity-TTL cleanup.
222    last_accessed: u64,
223}
224
225impl BackupStore {
226    pub fn new() -> Self {
227        BackupStore {
228            entries: HashMap::new(),
229            disk_index: HashMap::new(),
230            session_meta: HashMap::new(),
231            counter: AtomicU64::new(0),
232            storage_dir: None,
233            storage_harness: None,
234            db_pool: RwLock::new(None),
235            db_harness: RwLock::new(None),
236            db_project_key: RwLock::new(None),
237        }
238    }
239
240    pub fn set_db_pool(&self, conn: Arc<Mutex<Connection>>) {
241        if let Ok(mut slot) = self.db_pool.write() {
242            *slot = Some(conn);
243        }
244    }
245
246    pub fn clear_db_pool(&self) {
247        if let Ok(mut slot) = self.db_pool.write() {
248            *slot = None;
249        }
250    }
251
252    pub fn set_db_harness(&self, harness: crate::harness::Harness) {
253        if let Ok(mut slot) = self.db_harness.write() {
254            *slot = Some(harness.as_str().to_string());
255        }
256    }
257
258    pub fn set_db_project_key(&self, project_key: String) {
259        if let Ok(mut slot) = self.db_project_key.write() {
260            *slot = Some(project_key);
261        }
262    }
263
264    /// Set storage directory for disk persistence (called during configure).
265    ///
266    /// Loads the disk index for all session namespaces, removes stale session
267    /// directories, and migrates any legacy pre-session (flat) layout into the
268    /// default namespace.
269    pub fn set_storage_dir(&mut self, dir: PathBuf, ttl_hours: u32) {
270        self.set_storage_dir_inner(dir, None, ttl_hours);
271    }
272
273    pub fn set_storage_dir_for_harness(
274        &mut self,
275        dir: PathBuf,
276        harness: crate::harness::Harness,
277        ttl_hours: u32,
278    ) {
279        self.set_storage_dir_inner(dir, Some(harness.as_str().to_string()), ttl_hours);
280    }
281
282    fn set_storage_dir_inner(&mut self, dir: PathBuf, harness: Option<String>, ttl_hours: u32) {
283        self.storage_dir = Some(dir);
284        self.storage_harness = harness;
285        self.entries.clear();
286        self.disk_index.clear();
287        self.session_meta.clear();
288        self.repair_root_backups_if_needed();
289        self.gc_stale_sessions(ttl_hours);
290        self.migrate_legacy_layout_if_needed();
291        self.load_disk_index();
292    }
293
294    /// Snapshot the current contents of `path` under the given session namespace.
295    pub fn snapshot(
296        &mut self,
297        session: &str,
298        path: &Path,
299        description: &str,
300    ) -> Result<String, AftError> {
301        self.snapshot_with_op(session, path, description, None)
302    }
303
304    /// Snapshot the current contents of `path` under the given session namespace,
305    /// optionally tagging it with an operation id shared by all files touched by
306    /// one mutating tool call.
307    pub fn snapshot_with_op(
308        &mut self,
309        session: &str,
310        path: &Path,
311        description: &str,
312        op_id: Option<&str>,
313    ) -> Result<String, AftError> {
314        let key = canonicalize_key(path);
315        // Hydrate any prior on-disk history before appending, so a snapshot
316        // taken on a fresh store (post-restart) extends the existing stack and
317        // advances the id counter instead of overwriting history with a single
318        // entry and reusing backup-0.
319        self.ensure_stack_hydrated(session, &key);
320        let (id, order) = self.next_id_and_order();
321        let entry = backup_entry_from_path(path, id.clone(), order, description, op_id)?;
322
323        let session_entries = self.entries.entry(session.to_string()).or_default();
324        let stack = session_entries.entry(key.clone()).or_default();
325        if stack.len() >= MAX_UNDO_DEPTH {
326            stack.remove(0);
327        }
328        stack.push(entry);
329
330        // Persist to disk
331        let stack_clone = stack.clone();
332        self.write_snapshot_to_disk(session, &key, &stack_clone);
333        self.touch_session(session);
334
335        Ok(id)
336    }
337
338    /// Record that `path` was created by the operation and should be removed
339    /// if that operation is undone. No file content is captured.
340    pub fn snapshot_op_tombstone(
341        &mut self,
342        session: &str,
343        op_id: &str,
344        path: &Path,
345        description: &str,
346    ) -> Result<String, AftError> {
347        let key = canonicalize_key(path);
348        self.ensure_stack_hydrated(session, &key);
349        let created_dirs = path.parent().map(missing_parent_dirs).unwrap_or_default();
350        let (id, order) = self.next_id_and_order();
351        let entry = BackupEntry {
352            backup_id: id.clone(),
353            content: String::new(),
354            content_bytes: Vec::new(),
355            timestamp: current_timestamp(),
356            order,
357            description: description.to_string(),
358            op_id: Some(op_id.to_string()),
359            kind: BackupEntryKind::Tombstone,
360            mode: None,
361            link_target: None,
362            created_dirs,
363        };
364
365        let session_entries = self.entries.entry(session.to_string()).or_default();
366        let stack = session_entries.entry(key.clone()).or_default();
367        if stack.len() >= MAX_UNDO_DEPTH {
368            stack.remove(0);
369        }
370        stack.push(entry);
371
372        let stack_clone = stack.clone();
373        self.write_snapshot_to_disk(session, &key, &stack_clone);
374        self.touch_session(session);
375
376        Ok(id)
377    }
378
379    /// Restore every top-of-stack backup entry belonging to the most recent
380    /// operation in this session.
381    pub fn restore_last_operation(&mut self, session: &str) -> Result<RestoredOperation, AftError> {
382        match self.load_latest_operation_from_db(session) {
383            Some(Ok(true)) => {}
384            Some(Ok(false)) => {
385                crate::slog_info!(
386                    "backup latest operation DB miss for session {}; falling back to disk",
387                    session
388                );
389                self.load_all_disk_backups(session);
390            }
391            Some(Err(error)) => {
392                crate::slog_warn!(
393                    "backup latest operation DB lookup failed for session {}; falling back to disk: {}",
394                    session,
395                    error
396                );
397                self.load_all_disk_backups(session);
398            }
399            None => {
400                crate::slog_info!(
401                    "backup latest operation DB unavailable for session {}; falling back to disk",
402                    session
403                );
404                self.load_all_disk_backups(session);
405            }
406        }
407
408        let mut latest: Option<(u128, String)> = None;
409        if let Some(files) = self.entries.get(session) {
410            for stack in files.values() {
411                if let Some(entry) = stack.last() {
412                    if let Some(op_id) = &entry.op_id {
413                        let order = entry.order;
414                        if latest
415                            .as_ref()
416                            .map_or(true, |(latest_order, _)| order > *latest_order)
417                        {
418                            latest = Some((order, op_id.clone()));
419                        }
420                    }
421                }
422            }
423        }
424
425        let Some((_, op_id)) = latest else {
426            return Err(AftError::NoUndoHistory {
427                path: "operation".to_string(),
428            });
429        };
430
431        let mut keys_to_restore: Vec<PathBuf> = self
432            .entries
433            .get(session)
434            .map(|files| {
435                files
436                    .iter()
437                    .filter_map(|(key, stack)| {
438                        stack.last().and_then(|entry| {
439                            (entry.op_id.as_deref() == Some(op_id.as_str())).then(|| key.clone())
440                        })
441                    })
442                    .collect()
443            })
444            .unwrap_or_default();
445        keys_to_restore.sort();
446
447        if keys_to_restore.is_empty() {
448            return Err(AftError::NoUndoHistory {
449                path: "operation".to_string(),
450            });
451        }
452
453        let mut content_targets = Vec::new();
454        let mut tombstone_targets = Vec::new();
455        for key in &keys_to_restore {
456            let entry = self
457                .entries
458                .get(session)
459                .and_then(|files| files.get(key))
460                .and_then(|stack| stack.last())
461                .cloned()
462                .ok_or_else(|| AftError::NoUndoHistory {
463                    path: key.display().to_string(),
464                })?;
465            match entry.kind {
466                BackupEntryKind::Content | BackupEntryKind::Symlink => {
467                    let existing_state = capture_path_state(key)?;
468                    let warning = self.check_external_modification(session, key, key);
469                    content_targets.push((key.clone(), entry, warning, existing_state));
470                }
471                BackupEntryKind::Tombstone => {
472                    let existing_state = capture_path_state(key)?;
473                    tombstone_targets.push((key.clone(), entry, existing_state));
474                }
475            }
476        }
477
478        let mut created_dirs = Vec::new();
479        for (key, _, _, _) in &content_targets {
480            if let Some(parent) = key.parent() {
481                if !parent.as_os_str().is_empty() {
482                    let missing_dirs = missing_parent_dirs(parent);
483                    if let Err(e) = std::fs::create_dir_all(parent) {
484                        let mut dirs_to_remove = created_dirs;
485                        dirs_to_remove.extend(missing_dirs);
486                        let rollback_ok = rollback_created_dirs(&dirs_to_remove);
487                        return Err(AftError::IoError {
488                            path: parent.display().to_string(),
489                            message: format!(
490                                "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
491                                e,
492                                !rollback_ok,
493                                rollback_ok
494                            ),
495                        });
496                    }
497                    created_dirs.extend(missing_dirs);
498                }
499            }
500        }
501
502        let mut written = Vec::new();
503        for (key, entry, _, existing_state) in &content_targets {
504            if let Err(e) = restore_entry_to_path(key, entry) {
505                let files_rollback_ok =
506                    rollback_transactional_restore(&written, Some((key, existing_state)));
507                let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
508                let rollback_ok = files_rollback_ok && dirs_rollback_ok;
509                return Err(AftError::IoError {
510                    path: key.display().to_string(),
511                    message: format!(
512                        "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
513                        e,
514                        !rollback_ok,
515                        rollback_ok
516                    ),
517                });
518            }
519            written.push((key.clone(), existing_state.clone()));
520        }
521
522        let mut deleted_tombstones = Vec::new();
523        for (key, _, existing_state) in &tombstone_targets {
524            match remove_tombstone_path(key) {
525                Ok(()) => deleted_tombstones.push((key.clone(), existing_state.clone())),
526                Err(e) => {
527                    let files_rollback_ok = rollback_transactional_restore(&written, None);
528                    let tombstone_rollback_ok = rollback_deleted_tombstones(&deleted_tombstones);
529                    let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
530                    let rollback_ok =
531                        files_rollback_ok && tombstone_rollback_ok && dirs_rollback_ok;
532                    return Err(AftError::IoError {
533                        path: key.display().to_string(),
534                        message: format!(
535                            "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
536                            e,
537                            !rollback_ok,
538                            rollback_ok
539                        ),
540                    });
541                }
542            }
543        }
544        let tombstone_created_dirs = tombstone_targets
545            .iter()
546            .flat_map(|(_, entry, _)| entry.created_dirs.iter().cloned())
547            .collect::<Vec<_>>();
548        remove_created_dirs_best_effort(&tombstone_created_dirs);
549
550        let mut restored = Vec::new();
551        let mut warnings = Vec::new();
552        for (key, entry, warning, _) in content_targets {
553            self.commit_restored_backup(session, &key);
554            if let Some(warning) = warning {
555                warnings.push(format!("{}: {}", key.display(), warning));
556            }
557            restored.push(RestoredFile {
558                path: key,
559                backup_id: entry.backup_id,
560            });
561        }
562        for (key, _, _) in tombstone_targets {
563            self.commit_restored_backup(session, &key);
564        }
565        self.touch_session(session);
566
567        Ok(RestoredOperation {
568            op_id,
569            restored,
570            warnings,
571        })
572    }
573
574    /// Pop the most recent backup for `(session, path)` and restore the file.
575    /// Returns `(entry, optional_warning)`.
576    pub fn restore_latest(
577        &mut self,
578        session: &str,
579        path: &Path,
580    ) -> Result<(BackupEntry, Option<String>), AftError> {
581        let key = canonicalize_key(path);
582
583        match self.load_from_db_if_present(session, &key) {
584            Some(Ok(true)) => {
585                let warning = self.check_external_modification(session, &key, path);
586                let result = self
587                    .do_restore(session, &key, path)
588                    .map(|(entry, _)| (entry, warning));
589                if result.is_ok() {
590                    self.touch_session(session);
591                }
592                return result;
593            }
594            Some(Ok(false)) => {
595                crate::slog_info!(
596                    "backup DB miss for session {} path {}; falling back to disk",
597                    session,
598                    key.display()
599                );
600            }
601            Some(Err(error)) => {
602                crate::slog_warn!(
603                    "backup DB lookup failed for session {} path {}; falling back to disk: {}",
604                    session,
605                    key.display(),
606                    error
607                );
608            }
609            None => {
610                crate::slog_info!(
611                    "backup DB unavailable for session {} path {}; falling back to disk",
612                    session,
613                    key.display()
614                );
615            }
616        }
617
618        // Try memory first
619        let in_memory = self
620            .entries
621            .get(session)
622            .and_then(|s| s.get(&key))
623            .map_or(false, |s| !s.is_empty());
624        if in_memory {
625            let warning = self.check_external_modification(session, &key, path);
626            let result = self
627                .do_restore(session, &key, path)
628                .map(|(entry, _)| (entry, warning));
629            if result.is_ok() {
630                self.touch_session(session);
631            }
632            return result;
633        }
634
635        // Try disk fallback
636        if self.load_from_disk_if_needed(session, &key) {
637            // Check for external modification
638            let warning = self.check_external_modification(session, &key, path);
639            let (entry, _) = self.do_restore(session, &key, path)?;
640            self.touch_session(session);
641            return Ok((entry, warning));
642        }
643
644        Err(AftError::NoUndoHistory {
645            path: path.display().to_string(),
646        })
647    }
648
649    /// Return the backup history for `(session, path)` (oldest first).
650    pub fn history(&self, session: &str, path: &Path) -> Vec<BackupEntry> {
651        let key = canonicalize_key(path);
652        match self.read_stack_from_db(session, &key) {
653            Some(Ok(stack)) if !stack.is_empty() => return stack,
654            Some(Ok(_)) => {
655                crate::slog_info!(
656                    "backup history DB miss for session {} path {}; falling back to disk",
657                    session,
658                    key.display()
659                );
660            }
661            Some(Err(error)) => {
662                crate::slog_warn!(
663                    "backup history DB lookup failed for session {} path {}; falling back to disk: {}",
664                    session,
665                    key.display(),
666                    error
667                );
668            }
669            None => {
670                crate::slog_info!(
671                    "backup history DB unavailable for session {} path {}; falling back to disk",
672                    session,
673                    key.display()
674                );
675            }
676        }
677
678        self.entries
679            .get(session)
680            .and_then(|s| s.get(&key))
681            .cloned()
682            .or_else(|| self.read_stack_from_disk(session, &key))
683            .unwrap_or_default()
684    }
685
686    /// Return the number of on-disk backup entries for `(session, file)`.
687    pub fn disk_history_count(&self, session: &str, path: &Path) -> usize {
688        let key = canonicalize_key(path);
689        self.disk_index
690            .get(session)
691            .and_then(|s| s.get(&key))
692            .map(|m| m.count)
693            .unwrap_or(0)
694    }
695
696    /// Return all files that have at least one backup entry in this session
697    /// (memory + disk). Other sessions' files are not visible.
698    pub fn tracked_files(&self, session: &str) -> Vec<PathBuf> {
699        let mut files: std::collections::HashSet<PathBuf> = self
700            .entries
701            .get(session)
702            .map(|s| s.keys().cloned().collect())
703            .unwrap_or_default();
704        if let Some(disk) = self.disk_index.get(session) {
705            for key in disk.keys() {
706                files.insert(key.clone());
707            }
708        }
709        files.into_iter().collect()
710    }
711
712    /// Preview the file path that `restore_latest` would write for `(session, path)`.
713    ///
714    /// This is intentionally read-only: it inspects DB/disk/in-memory backup metadata
715    /// without popping the undo stack or writing restored file contents.
716    pub fn preview_latest_path(&self, session: &str, path: &Path) -> Result<PathBuf, AftError> {
717        let key = canonicalize_key(path);
718        if self.latest_head_for_key(session, &key).is_some() {
719            Ok(key)
720        } else {
721            Err(AftError::NoUndoHistory {
722                path: path.display().to_string(),
723            })
724        }
725    }
726
727    /// Preview the paths that `restore_last_operation` would touch for `session`.
728    ///
729    /// This mirrors the operation selection logic used by restore, but only reads
730    /// backup metadata. It includes tombstone targets because undoing a create
731    /// operation deletes those paths and therefore still requires write permission.
732    pub fn preview_last_operation_paths(&self, session: &str) -> Result<Vec<PathBuf>, AftError> {
733        let mut heads_by_path: HashMap<PathBuf, BackupEntryHead> = self
734            .entries
735            .get(session)
736            .map(|files| {
737                files
738                    .iter()
739                    .filter_map(|(key, stack)| {
740                        stack
741                            .last()
742                            .map(|entry| (key.clone(), BackupEntryHead::from_entry(entry)))
743                    })
744                    .collect()
745            })
746            .unwrap_or_default();
747
748        match self.read_latest_operation_heads_from_db(session) {
749            Some(Ok(db_heads)) if !db_heads.is_empty() => {
750                for (key, head) in db_heads {
751                    heads_by_path.insert(key, head);
752                }
753            }
754            Some(Ok(_)) => {
755                crate::slog_info!(
756                    "backup latest operation preview DB miss for session {}; falling back to disk",
757                    session
758                );
759                self.merge_disk_stack_heads(session, &mut heads_by_path);
760            }
761            Some(Err(error)) => {
762                crate::slog_warn!(
763                    "backup latest operation preview DB lookup failed for session {}; falling back to disk: {}",
764                    session,
765                    error
766                );
767                self.merge_disk_stack_heads(session, &mut heads_by_path);
768            }
769            None => {
770                crate::slog_info!(
771                    "backup latest operation preview DB unavailable for session {}; falling back to disk",
772                    session
773                );
774                self.merge_disk_stack_heads(session, &mut heads_by_path);
775            }
776        }
777
778        let mut latest: Option<(u128, String)> = None;
779        for head in heads_by_path.values() {
780            if let Some(op_id) = &head.op_id {
781                if latest
782                    .as_ref()
783                    .map_or(true, |(latest_order, _)| head.order > *latest_order)
784                {
785                    latest = Some((head.order, op_id.clone()));
786                }
787            }
788        }
789
790        let Some((_, op_id)) = latest else {
791            return Err(AftError::NoUndoHistory {
792                path: "operation".to_string(),
793            });
794        };
795
796        let mut paths: Vec<PathBuf> = heads_by_path
797            .into_iter()
798            .filter_map(|(key, head)| {
799                (head.op_id.as_deref() == Some(op_id.as_str())).then_some(key)
800            })
801            .collect();
802        paths.sort();
803
804        if paths.is_empty() {
805            Err(AftError::NoUndoHistory {
806                path: "operation".to_string(),
807            })
808        } else {
809            Ok(paths)
810        }
811    }
812
813    /// Return all session namespaces that currently have any backup state
814    /// (memory or disk). Exposed for `/aft-status` aggregate reporting.
815    pub fn sessions_with_backups(&self) -> Vec<String> {
816        let mut sessions: std::collections::HashSet<String> =
817            self.entries.keys().cloned().collect();
818        for s in self.disk_index.keys() {
819            sessions.insert(s.clone());
820        }
821        sessions.into_iter().collect()
822    }
823
824    /// Total on-disk bytes across all sessions (best-effort, reads metadata only).
825    /// Used by `/aft-status` to surface storage footprint.
826    pub fn total_disk_bytes(&self) -> u64 {
827        let mut total = 0u64;
828        for session_dirs in self.disk_index.values() {
829            for meta in session_dirs.values() {
830                if let Ok(read_dir) = std::fs::read_dir(&meta.dir) {
831                    for entry in read_dir.flatten() {
832                        if let Ok(m) = entry.metadata() {
833                            if m.is_file() {
834                                total += m.len();
835                            }
836                        }
837                    }
838                }
839            }
840        }
841        total
842    }
843
844    fn next_id_and_order(&self) -> (String, u128) {
845        let n = self.counter.fetch_add(1, Ordering::Relaxed);
846        let order = ((current_timestamp_nanos() as u128) << 32) | u128::from(n);
847        (format!("backup-{}", n), order)
848    }
849
850    fn db_pool_and_harness(&self) -> Option<(Arc<Mutex<Connection>>, String)> {
851        let pool = self.db_pool.read().ok().and_then(|slot| slot.clone())?;
852        let harness = self.db_harness.read().ok().and_then(|slot| slot.clone())?;
853        Some((pool, harness))
854    }
855
856    fn latest_head_for_key(&self, session: &str, key: &Path) -> Option<BackupEntryHead> {
857        match self.read_stack_heads_from_db(session, key) {
858            Some(Ok(stack)) if !stack.is_empty() => return stack.last().cloned(),
859            Some(Ok(_)) => {
860                crate::slog_info!(
861                    "backup preview DB miss for session {} path {}; falling back to disk",
862                    session,
863                    key.display()
864                );
865            }
866            Some(Err(error)) => {
867                crate::slog_warn!(
868                    "backup preview DB lookup failed for session {} path {}; falling back to disk: {}",
869                    session,
870                    key.display(),
871                    error
872                );
873            }
874            None => {
875                crate::slog_info!(
876                    "backup preview DB unavailable for session {} path {}; falling back to disk",
877                    session,
878                    key.display()
879                );
880            }
881        }
882
883        self.entries
884            .get(session)
885            .and_then(|files| files.get(key))
886            .and_then(|stack| stack.last())
887            .map(BackupEntryHead::from_entry)
888            .or_else(|| {
889                self.read_stack_heads_from_disk(session, key)
890                    .and_then(|stack| stack.last().cloned())
891            })
892    }
893
894    fn merge_disk_stack_heads(
895        &self,
896        session: &str,
897        heads_by_path: &mut HashMap<PathBuf, BackupEntryHead>,
898    ) {
899        let disk_keys: Vec<PathBuf> = self
900            .disk_index
901            .get(session)
902            .map(|files| files.keys().cloned().collect())
903            .unwrap_or_default();
904        for key in disk_keys {
905            if let Some(head) = self
906                .read_stack_heads_from_disk(session, &key)
907                .and_then(|stack| stack.last().cloned())
908            {
909                heads_by_path.insert(key, head);
910            }
911        }
912    }
913
914    fn read_stack_heads_from_db(
915        &self,
916        session: &str,
917        key: &Path,
918    ) -> Option<Result<Vec<BackupEntryHead>, String>> {
919        let (pool, harness) = self.db_pool_and_harness()?;
920        let conn = match pool.lock() {
921            Ok(conn) => conn,
922            Err(_) => return Some(Err("db mutex poisoned".to_string())),
923        };
924        let path_hash = Self::path_hash(key);
925        Some(
926            crate::db::backups::list_backups(&conn, &harness, session, &path_hash)
927                .map_err(|error| error.to_string())
928                .map(|rows| {
929                    rows.iter()
930                        .map(BackupEntryHead::from_row)
931                        .collect::<Vec<_>>()
932                }),
933        )
934    }
935
936    fn read_latest_operation_heads_from_db(
937        &self,
938        session: &str,
939    ) -> Option<Result<HashMap<PathBuf, BackupEntryHead>, String>> {
940        let (pool, harness) = self.db_pool_and_harness()?;
941        let conn = match pool.lock() {
942            Ok(conn) => conn,
943            Err(_) => return Some(Err("db mutex poisoned".to_string())),
944        };
945        let latest = match crate::db::backups::get_latest_operation_backup(&conn, &harness, session)
946        {
947            Ok(Some(row)) => row,
948            Ok(None) => return Some(Ok(HashMap::new())),
949            Err(error) => return Some(Err(error.to_string())),
950        };
951        let Some(op_id) = latest.op_id else {
952            return Some(Ok(HashMap::new()));
953        };
954        let rows = match crate::db::backups::list_backups_by_op(&conn, &harness, session, &op_id) {
955            Ok(rows) => rows,
956            Err(error) => return Some(Err(error.to_string())),
957        };
958        if rows.is_empty() {
959            return Some(Ok(HashMap::new()));
960        }
961        let path_hashes: std::collections::HashSet<String> =
962            rows.into_iter().map(|row| row.path_hash).collect();
963        drop(conn);
964
965        let mut heads = HashMap::new();
966        for path_hash in path_hashes {
967            let conn = match pool.lock() {
968                Ok(conn) => conn,
969                Err(_) => return Some(Err("db mutex poisoned".to_string())),
970            };
971            let rows = match crate::db::backups::list_backups(&conn, &harness, session, &path_hash)
972            {
973                Ok(rows) => rows,
974                Err(error) => return Some(Err(error.to_string())),
975            };
976            drop(conn);
977
978            let Some(file_path) = rows.first().map(|row| row.file_path.clone()) else {
979                continue;
980            };
981            let Some(head) = rows.last().map(BackupEntryHead::from_row) else {
982                continue;
983            };
984            heads.insert(PathBuf::from(file_path), head);
985        }
986
987        Some(Ok(heads))
988    }
989
990    fn read_stack_from_db(
991        &self,
992        session: &str,
993        key: &Path,
994    ) -> Option<Result<Vec<BackupEntry>, String>> {
995        let (pool, harness) = self.db_pool_and_harness()?;
996        let conn = match pool.lock() {
997            Ok(conn) => conn,
998            Err(_) => return Some(Err("db mutex poisoned".to_string())),
999        };
1000        let path_hash = Self::path_hash(key);
1001        Some(
1002            crate::db::backups::list_backups(&conn, &harness, session, &path_hash)
1003                .map_err(|error| error.to_string())
1004                .and_then(|rows| {
1005                    rows.into_iter()
1006                        .map(BackupEntry::try_from)
1007                        .collect::<Result<Vec<_>, _>>()
1008                        .map_err(|error| error.to_string())
1009                }),
1010        )
1011    }
1012
1013    fn load_from_db_if_present(
1014        &mut self,
1015        session: &str,
1016        key: &Path,
1017    ) -> Option<Result<bool, String>> {
1018        match self.read_stack_from_db(session, key) {
1019            Some(Ok(stack)) if !stack.is_empty() => {
1020                self.update_counter_from_entries(&stack);
1021                self.entries
1022                    .entry(session.to_string())
1023                    .or_default()
1024                    .insert(key.to_path_buf(), stack);
1025                Some(Ok(true))
1026            }
1027            Some(Ok(_)) => Some(Ok(false)),
1028            Some(Err(error)) => Some(Err(error)),
1029            None => None,
1030        }
1031    }
1032
1033    fn load_latest_operation_from_db(&mut self, session: &str) -> Option<Result<bool, String>> {
1034        let (pool, harness) = self.db_pool_and_harness()?;
1035        let conn = match pool.lock() {
1036            Ok(conn) => conn,
1037            Err(_) => return Some(Err("db mutex poisoned".to_string())),
1038        };
1039        let latest = match crate::db::backups::get_latest_operation_backup(&conn, &harness, session)
1040        {
1041            Ok(Some(row)) => row,
1042            Ok(None) => return Some(Ok(false)),
1043            Err(error) => return Some(Err(error.to_string())),
1044        };
1045        let Some(op_id) = latest.op_id else {
1046            return Some(Ok(false));
1047        };
1048        let rows = match crate::db::backups::list_backups_by_op(&conn, &harness, session, &op_id) {
1049            Ok(rows) => rows,
1050            Err(error) => return Some(Err(error.to_string())),
1051        };
1052        if rows.is_empty() {
1053            return Some(Ok(false));
1054        }
1055        let path_hashes: std::collections::HashSet<String> =
1056            rows.into_iter().map(|row| row.path_hash).collect();
1057        drop(conn);
1058
1059        let mut loaded_any = false;
1060        for path_hash in path_hashes {
1061            let conn = match pool.lock() {
1062                Ok(conn) => conn,
1063                Err(_) => return Some(Err("db mutex poisoned".to_string())),
1064            };
1065            let loaded =
1066                match crate::db::backups::list_backups(&conn, &harness, session, &path_hash) {
1067                    Ok(rows) => {
1068                        let file_path = rows.first().map(|row| row.file_path.clone());
1069                        rows.into_iter()
1070                            .map(BackupEntry::try_from)
1071                            .collect::<Result<Vec<_>, _>>()
1072                            .map(|stack| (file_path, stack))
1073                            .map_err(|error| error.to_string())
1074                    }
1075                    Err(error) => Err(error.to_string()),
1076                };
1077            drop(conn);
1078            let (file_path, stack) = match loaded {
1079                Ok((file_path, stack)) if !stack.is_empty() => (file_path, stack),
1080                Ok(_) => continue,
1081                Err(error) => return Some(Err(error)),
1082            };
1083            let Some(file_path) = file_path else {
1084                return Some(Err(format!(
1085                    "backup DB rows for path hash {path_hash} have no file path"
1086                )));
1087            };
1088            let key = PathBuf::from(file_path);
1089            self.update_counter_from_entries(&stack);
1090            self.entries
1091                .entry(session.to_string())
1092                .or_default()
1093                .insert(key, stack);
1094            loaded_any = true;
1095        }
1096
1097        Some(Ok(loaded_any))
1098    }
1099
1100    fn update_counter_from_entries(&self, entries: &[BackupEntry]) {
1101        if let Some(next_counter) = entries
1102            .iter()
1103            .filter_map(|entry| backup_sequence(&entry.backup_id))
1104            .max()
1105            .and_then(|max| max.checked_add(1))
1106        {
1107            let _ = self
1108                .counter
1109                .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
1110                    (current < next_counter).then_some(next_counter)
1111                });
1112        }
1113    }
1114
1115    pub fn discard_operation_entries(&mut self, session: &str, op_id: &str) {
1116        let keys: Vec<PathBuf> = self
1117            .entries
1118            .get(session)
1119            .map(|files| files.keys().cloned().collect())
1120            .unwrap_or_default();
1121
1122        for key in keys {
1123            let mut remove_key = false;
1124            let mut remaining_stack = None;
1125            if let Some(session_entries) = self.entries.get_mut(session) {
1126                if let Some(stack) = session_entries.get_mut(&key) {
1127                    while stack
1128                        .last()
1129                        .is_some_and(|entry| entry.op_id.as_deref() == Some(op_id))
1130                    {
1131                        stack.pop();
1132                    }
1133                    if stack.is_empty() {
1134                        remove_key = true;
1135                    } else {
1136                        remaining_stack = Some(stack.clone());
1137                    }
1138                }
1139                if remove_key {
1140                    session_entries.remove(&key);
1141                }
1142            }
1143
1144            if remove_key {
1145                self.remove_disk_backups(session, &key);
1146            } else if let Some(stack) = remaining_stack {
1147                self.write_snapshot_to_disk(session, &key, &stack);
1148            }
1149        }
1150
1151        if self
1152            .entries
1153            .get(session)
1154            .is_some_and(|session_entries| session_entries.is_empty())
1155        {
1156            self.entries.remove(session);
1157        }
1158    }
1159
1160    fn touch_session(&mut self, session: &str) {
1161        let now = current_timestamp();
1162        self.session_meta
1163            .entry(session.to_string())
1164            .or_default()
1165            .last_accessed = now;
1166        self.write_session_marker(session, now);
1167    }
1168
1169    // ---- Internal helpers ----
1170
1171    fn do_restore(
1172        &mut self,
1173        session: &str,
1174        key: &Path,
1175        path: &Path,
1176    ) -> Result<(BackupEntry, Option<String>), AftError> {
1177        let session_entries =
1178            self.entries
1179                .get_mut(session)
1180                .ok_or_else(|| AftError::NoUndoHistory {
1181                    path: path.display().to_string(),
1182                })?;
1183        let stack = session_entries
1184            .get_mut(key)
1185            .ok_or_else(|| AftError::NoUndoHistory {
1186                path: path.display().to_string(),
1187            })?;
1188
1189        let entry = stack
1190            .last()
1191            .cloned()
1192            .ok_or_else(|| AftError::NoUndoHistory {
1193                path: path.display().to_string(),
1194            })?;
1195
1196        match entry.kind {
1197            BackupEntryKind::Content | BackupEntryKind::Symlink => {
1198                restore_entry_to_path(path, &entry).map_err(|e| AftError::IoError {
1199                    path: path.display().to_string(),
1200                    message: e.to_string(),
1201                })?;
1202            }
1203            BackupEntryKind::Tombstone => {
1204                remove_tombstone_path(path).map_err(|e| AftError::IoError {
1205                    path: path.display().to_string(),
1206                    message: e.to_string(),
1207                })?;
1208                remove_created_dirs_best_effort(&entry.created_dirs);
1209            }
1210        }
1211
1212        stack.pop();
1213        if stack.is_empty() {
1214            session_entries.remove(key);
1215            // Also prune the session map when its last file is gone.
1216            if session_entries.is_empty() {
1217                self.entries.remove(session);
1218            }
1219            self.remove_disk_backups(session, key);
1220        } else {
1221            let stack_clone = self
1222                .entries
1223                .get(session)
1224                .and_then(|s| s.get(key))
1225                .cloned()
1226                .unwrap_or_default();
1227            self.write_snapshot_to_disk(session, key, &stack_clone);
1228        }
1229
1230        Ok((entry, None))
1231    }
1232
1233    fn commit_restored_backup(&mut self, session: &str, key: &Path) {
1234        let mut remove_key = false;
1235        let mut remove_session = false;
1236        let mut remaining_stack = None;
1237
1238        if let Some(session_entries) = self.entries.get_mut(session) {
1239            if let Some(stack) = session_entries.get_mut(key) {
1240                stack.pop();
1241                if stack.is_empty() {
1242                    remove_key = true;
1243                } else {
1244                    remaining_stack = Some(stack.clone());
1245                }
1246            }
1247
1248            if remove_key {
1249                session_entries.remove(key);
1250                remove_session = session_entries.is_empty();
1251            }
1252        }
1253
1254        if remove_session {
1255            self.entries.remove(session);
1256        }
1257
1258        if remove_key {
1259            self.remove_disk_backups(session, key);
1260        } else if let Some(stack) = remaining_stack {
1261            self.write_snapshot_to_disk(session, key, &stack);
1262        }
1263    }
1264
1265    fn check_external_modification(
1266        &self,
1267        session: &str,
1268        key: &Path,
1269        path: &Path,
1270    ) -> Option<String> {
1271        let stack = self.entries.get(session).and_then(|s| s.get(key))?;
1272        let latest = stack.last()?;
1273        let modified = match latest.kind {
1274            BackupEntryKind::Content => std::fs::read(path)
1275                .map(|current| current != latest.content_bytes)
1276                .unwrap_or(true),
1277            BackupEntryKind::Symlink => std::fs::read_link(path)
1278                .map(|target| latest.link_target.as_ref() != Some(&target))
1279                .unwrap_or(true),
1280            BackupEntryKind::Tombstone => false,
1281        };
1282        modified.then(|| "file was modified externally since last backup".to_string())
1283    }
1284
1285    // ---- Disk persistence ----
1286
1287    fn backups_dir(&self) -> Option<PathBuf> {
1288        self.storage_dir
1289            .as_ref()
1290            .map(|dir| match &self.storage_harness {
1291                Some(harness) => dir.join(harness).join("backups"),
1292                None => dir.join("backups"),
1293            })
1294    }
1295
1296    fn session_dir(&self, session: &str) -> Option<PathBuf> {
1297        self.backups_dir()
1298            .map(|d| d.join(Self::session_hash(session)))
1299    }
1300
1301    fn session_hash(session: &str) -> String {
1302        hash_session(session)
1303    }
1304
1305    fn path_hash(key: &Path) -> String {
1306        // v0.16.0 intentionally switched from DefaultHasher to SHA-256 for
1307        // stable on-disk names. Existing DefaultHasher backup directories are
1308        // not migrated: backups are short-lived/session-scoped, so one-time
1309        // loss of pre-upgrade undo history is acceptable.
1310        stable_hash_16(key.to_string_lossy().as_bytes())
1311    }
1312
1313    fn write_session_marker(&self, session: &str, last_accessed: u64) {
1314        let Some(session_dir) = self.session_dir(session) else {
1315            return;
1316        };
1317        if let Err(e) = std::fs::create_dir_all(&session_dir) {
1318            crate::slog_warn!("failed to create session dir: {}", e);
1319            return;
1320        }
1321        let marker = session_dir.join("session.json");
1322        let json = serde_json::json!({
1323            "schema_version": SCHEMA_VERSION,
1324            "session_id": session,
1325            "last_accessed": last_accessed,
1326        });
1327        if let Ok(s) = serde_json::to_string_pretty(&json) {
1328            let tmp = session_dir.join("session.json.tmp");
1329            if std::fs::write(&tmp, s).is_ok() {
1330                let _ = std::fs::rename(&tmp, marker);
1331            }
1332        }
1333    }
1334
1335    fn repair_root_backups_if_needed(&self) {
1336        let (Some(storage_dir), Some(harness)) = (&self.storage_dir, &self.storage_harness) else {
1337            return;
1338        };
1339        let root_backups = storage_dir.join("backups");
1340        if !dir_has_entries(&root_backups) {
1341            return;
1342        }
1343        let harness_backups = storage_dir.join(harness).join("backups");
1344        if dir_has_entries(&harness_backups) {
1345            return;
1346        }
1347        if let Some(parent) = harness_backups.parent() {
1348            if let Err(error) = std::fs::create_dir_all(parent) {
1349                crate::slog_warn!(
1350                    "failed to create harness backup dir {}: {}",
1351                    parent.display(),
1352                    error
1353                );
1354                return;
1355            }
1356        }
1357        if harness_backups.exists() {
1358            let _ = std::fs::remove_dir(&harness_backups);
1359        }
1360        match std::fs::rename(&root_backups, &harness_backups) {
1361            Ok(()) => {
1362                crate::slog_info!(
1363                    "moved legacy root backups into harness namespace: {}",
1364                    harness_backups.display()
1365                );
1366            }
1367            Err(error) => {
1368                crate::slog_warn!(
1369                    "failed to move legacy root backups into {}: {}; trying child merge",
1370                    harness_backups.display(),
1371                    error
1372                );
1373                if std::fs::create_dir_all(&harness_backups).is_err() {
1374                    return;
1375                }
1376                if let Ok(entries) = std::fs::read_dir(&root_backups) {
1377                    for entry in entries.flatten() {
1378                        let source = entry.path();
1379                        let target = harness_backups.join(entry.file_name());
1380                        if !target.exists() {
1381                            let _ = std::fs::rename(source, target);
1382                        }
1383                    }
1384                }
1385                let _ = std::fs::remove_dir(&root_backups);
1386            }
1387        }
1388    }
1389
1390    fn gc_stale_sessions(&mut self, ttl_hours: u32) {
1391        let backups_dir = match self.backups_dir() {
1392            Some(d) if d.exists() => d,
1393            _ => return,
1394        };
1395        let ttl_secs = u64::from(if ttl_hours == 0 { 72 } else { ttl_hours }) * 60 * 60;
1396        let cutoff = current_timestamp().saturating_sub(ttl_secs);
1397        let entries = match std::fs::read_dir(&backups_dir) {
1398            Ok(entries) => entries,
1399            Err(_) => return,
1400        };
1401
1402        for entry in entries.flatten() {
1403            let session_dir = entry.path();
1404            if !session_dir.is_dir() || session_dir.join("meta.json").exists() {
1405                continue;
1406            }
1407            let Some(last_accessed) = Self::read_session_last_accessed(&session_dir) else {
1408                continue;
1409            };
1410            if last_accessed >= cutoff {
1411                continue;
1412            }
1413            if let Err(e) = std::fs::remove_dir_all(&session_dir) {
1414                crate::slog_warn!(
1415                    "failed to remove stale backup session {}: {}",
1416                    session_dir.display(),
1417                    e
1418                );
1419            } else {
1420                crate::slog_warn!(
1421                    "removed stale backup session {} (last_accessed={})",
1422                    session_dir.display(),
1423                    last_accessed
1424                );
1425            }
1426        }
1427    }
1428
1429    /// One-time migration: move pre-session flat layout into the default
1430    /// session namespace. Called from `set_storage_dir` so existing backups
1431    /// survive the upgrade.
1432    ///
1433    /// Detection: any directory directly under `backups/` that contains a
1434    /// `meta.json` (as opposed to a `session.json` marker or subdirectories)
1435    /// is treated as a legacy entry.
1436    fn migrate_legacy_layout_if_needed(&mut self) {
1437        let backups_dir = match self.backups_dir() {
1438            Some(d) if d.exists() => d,
1439            _ => return,
1440        };
1441        let default_session_dir =
1442            backups_dir.join(Self::session_hash(crate::protocol::DEFAULT_SESSION_ID));
1443
1444        let entries = match std::fs::read_dir(&backups_dir) {
1445            Ok(e) => e,
1446            Err(_) => return,
1447        };
1448        let mut migrated = 0usize;
1449        for entry in entries.flatten() {
1450            let entry_path = entry.path();
1451            // Skip non-directories and already-sessionized layouts.
1452            if !entry_path.is_dir() {
1453                continue;
1454            }
1455            if entry_path == default_session_dir {
1456                continue;
1457            }
1458            let meta_path = entry_path.join("meta.json");
1459            if !meta_path.exists() {
1460                continue; // Already a session-hash dir (contains per-path subdirs), skip
1461            }
1462            // This is a legacy flat-layout path-hash directory. Move it under
1463            // the default session namespace.
1464            if let Err(e) = std::fs::create_dir_all(&default_session_dir) {
1465                crate::slog_warn!("failed to create default session dir: {}", e);
1466                return;
1467            }
1468            let leaf = match entry_path.file_name() {
1469                Some(n) => n,
1470                None => continue,
1471            };
1472            let target = default_session_dir.join(leaf);
1473            if target.exists() {
1474                // Already migrated on a prior run that was interrupted —
1475                // leave both and let the regular load pick up the target.
1476                continue;
1477            }
1478            match std::fs::rename(&entry_path, &target) {
1479                Ok(()) => {
1480                    // Bump meta.json to include session_id + schema_version.
1481                    Self::upgrade_meta_file(
1482                        &target.join("meta.json"),
1483                        crate::protocol::DEFAULT_SESSION_ID,
1484                    );
1485                    migrated += 1;
1486                }
1487                Err(e) => {
1488                    crate::slog_warn!(
1489                        "failed to migrate legacy backup {}: {}",
1490                        entry_path.display(),
1491                        e
1492                    );
1493                }
1494            }
1495        }
1496        if migrated > 0 {
1497            crate::slog_info!(
1498                "migrated {} legacy backup entries into default session namespace",
1499                migrated
1500            );
1501            // Write a session.json marker so future scans don't re-migrate.
1502            let marker = default_session_dir.join("session.json");
1503            let json = serde_json::json!({
1504                "schema_version": SCHEMA_VERSION,
1505                "session_id": crate::protocol::DEFAULT_SESSION_ID,
1506                "last_accessed": current_timestamp(),
1507            });
1508            if let Ok(s) = serde_json::to_string_pretty(&json) {
1509                let _ = std::fs::write(&marker, s);
1510            }
1511        }
1512    }
1513
1514    fn upgrade_meta_file(meta_path: &Path, session_id: &str) {
1515        let content = match std::fs::read_to_string(meta_path) {
1516            Ok(c) => c,
1517            Err(_) => return,
1518        };
1519        let mut parsed: serde_json::Value = match serde_json::from_str(&content) {
1520            Ok(v) => v,
1521            Err(_) => return,
1522        };
1523        if let Some(obj) = parsed.as_object_mut() {
1524            let count = obj.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
1525            obj.insert(
1526                "schema_version".to_string(),
1527                serde_json::json!(SCHEMA_VERSION),
1528            );
1529            obj.insert("session_id".to_string(), serde_json::json!(session_id));
1530            obj.entry("entries").or_insert_with(|| {
1531                serde_json::Value::Array(
1532                    (0..count)
1533                        .map(|i| {
1534                            serde_json::json!({
1535                                "backup_id": format!("disk-{}", i),
1536                                "timestamp": 0,
1537                                "description": "restored from disk",
1538                                "op_id": null,
1539                            })
1540                        })
1541                        .collect(),
1542                )
1543            });
1544        }
1545        if let Ok(s) = serde_json::to_string_pretty(&parsed) {
1546            let tmp = meta_path.with_extension("json.tmp");
1547            if std::fs::write(&tmp, &s).is_ok() {
1548                let _ = std::fs::rename(&tmp, meta_path);
1549            }
1550        }
1551    }
1552
1553    fn load_disk_index(&mut self) {
1554        let backups_dir = match self.backups_dir() {
1555            Some(d) if d.exists() => d,
1556            _ => return,
1557        };
1558        let session_dirs = match std::fs::read_dir(&backups_dir) {
1559            Ok(e) => e,
1560            Err(_) => return,
1561        };
1562        let mut total_entries = 0usize;
1563        let mut skipped_legacy = 0usize;
1564        for session_entry in session_dirs.flatten() {
1565            let session_dir = session_entry.path();
1566            if !session_dir.is_dir() {
1567                continue;
1568            }
1569            // Recover the session_id from session.json if present, otherwise skip
1570            // (can't invert the hash to recover the original).
1571            let session_id = match Self::read_session_marker(&session_dir) {
1572                Some(session_id) => session_id,
1573                None => {
1574                    crate::slog_warn!(
1575                        "skipping backup session dir without readable session marker: {}",
1576                        session_dir.display()
1577                    );
1578                    continue;
1579                }
1580            };
1581
1582            let path_dirs = match std::fs::read_dir(&session_dir) {
1583                Ok(e) => e,
1584                Err(_) => continue,
1585            };
1586            let per_session = self.disk_index.entry(session_id.clone()).or_default();
1587            for path_entry in path_dirs.flatten() {
1588                let path_dir = path_entry.path();
1589                if !path_dir.is_dir() {
1590                    continue;
1591                }
1592                let meta_path = path_dir.join("meta.json");
1593                if let Ok(content) = std::fs::read_to_string(&meta_path) {
1594                    if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
1595                        if let (Some(path_str), Some(count)) = (
1596                            meta.get("path").and_then(|v| v.as_str()),
1597                            meta.get("count").and_then(|v| v.as_u64()),
1598                        ) {
1599                            let key = PathBuf::from(path_str);
1600                            if !is_loadable_backup_path(&key, &path_dir) {
1601                                // Legacy/relocated backup dirs whose folder name came
1602                                // from an older path-hash scheme can never be loaded by
1603                                // the current hasher. They are harmless dead husks
1604                                // (active undo is DB-backed), so skip quietly and
1605                                // summarize once at debug instead of warning per entry.
1606                                skipped_legacy += 1;
1607                                crate::slog_debug!(
1608                                    "skipping backup entry with invalid path metadata: {}",
1609                                    meta_path.display()
1610                                );
1611                                continue;
1612                            }
1613                            per_session.insert(
1614                                key,
1615                                DiskMeta {
1616                                    dir: path_dir.clone(),
1617                                    count: count as usize,
1618                                },
1619                            );
1620                            total_entries += 1;
1621                        }
1622                    }
1623                }
1624            }
1625            if per_session.is_empty() {
1626                self.disk_index.remove(&session_id);
1627            }
1628        }
1629        if skipped_legacy > 0 {
1630            crate::slog_debug!(
1631                "skipped {} legacy backup entries with mismatched path-hash directories",
1632                skipped_legacy
1633            );
1634        }
1635        if total_entries > 0 {
1636            crate::slog_info!(
1637                "loaded {} backup entries across {} session(s) from disk",
1638                total_entries,
1639                self.disk_index.len()
1640            );
1641        }
1642    }
1643
1644    fn read_session_marker(session_dir: &Path) -> Option<String> {
1645        let marker = session_dir.join("session.json");
1646        let content = std::fs::read_to_string(&marker).ok()?;
1647        let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
1648        parsed
1649            .get("session_id")
1650            .and_then(|v| v.as_str())
1651            .map(|s| s.to_string())
1652    }
1653
1654    fn read_session_last_accessed(session_dir: &Path) -> Option<u64> {
1655        let marker = session_dir.join("session.json");
1656        let content = std::fs::read_to_string(&marker).ok()?;
1657        let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
1658        parsed.get("last_accessed").and_then(|v| v.as_u64())
1659    }
1660
1661    fn load_from_disk_if_needed(&mut self, session: &str, key: &Path) -> bool {
1662        let Some(entries) = self.read_stack_from_disk(session, key) else {
1663            return false;
1664        };
1665
1666        self.update_counter_from_entries(&entries);
1667
1668        self.entries
1669            .entry(session.to_string())
1670            .or_default()
1671            .insert(key.to_path_buf(), entries);
1672        true
1673    }
1674
1675    /// Ensure the in-memory undo stack for `(session, key)` reflects any prior
1676    /// on-disk history before a new snapshot is appended.
1677    ///
1678    /// Without this, a fresh `BackupStore` (e.g. after a bridge/process
1679    /// restart, which clears `self.entries` and resets `counter` to 0) would
1680    /// append a new snapshot onto an EMPTY in-memory stack and then
1681    /// `write_snapshot_to_disk` would overwrite the file's `meta.json`/`.bak`
1682    /// set with that single entry — silently discarding all undo history
1683    /// captured before the restart, and reusing `backup-0` because the counter
1684    /// was never advanced past the persisted entries. Hydrating here preserves
1685    /// the prior stack AND advances the counter via
1686    /// `update_counter_from_entries`. Only loads when nothing is in memory yet,
1687    /// so it never clobbers a stack already mutated in this run and adds at
1688    /// most one disk read per file per session.
1689    fn ensure_stack_hydrated(&mut self, session: &str, key: &Path) {
1690        let already_in_memory = self
1691            .entries
1692            .get(session)
1693            .and_then(|files| files.get(key))
1694            .is_some_and(|stack| !stack.is_empty());
1695        if !already_in_memory {
1696            self.load_from_disk_if_needed(session, key);
1697        }
1698    }
1699
1700    fn load_all_disk_backups(&mut self, session: &str) {
1701        let disk_keys: Vec<PathBuf> = self
1702            .disk_index
1703            .get(session)
1704            .map(|files| files.keys().cloned().collect())
1705            .unwrap_or_default();
1706        for key in disk_keys {
1707            self.load_from_disk_if_needed(session, &key);
1708        }
1709    }
1710
1711    fn read_stack_heads_from_disk(
1712        &self,
1713        session: &str,
1714        key: &Path,
1715    ) -> Option<Vec<BackupEntryHead>> {
1716        let disk_meta = match self
1717            .disk_index
1718            .get(session)
1719            .and_then(|s| s.get(key))
1720            .cloned()
1721        {
1722            Some(m) if m.count > 0 => m,
1723            _ => return None,
1724        };
1725
1726        let entry_meta = std::fs::read_to_string(disk_meta.dir.join("meta.json"))
1727            .ok()
1728            .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
1729            .and_then(|meta| meta.get("entries").and_then(|v| v.as_array()).cloned())
1730            .unwrap_or_default();
1731
1732        let mut heads = Vec::new();
1733        for i in 0..disk_meta.count {
1734            let meta = entry_meta.get(i);
1735            let backup_id = meta
1736                .and_then(|m| m.get("backup_id"))
1737                .and_then(|v| v.as_str())
1738                .map(str::to_string)
1739                .unwrap_or_else(|| format!("disk-{}", i));
1740            let timestamp = meta
1741                .and_then(|m| m.get("timestamp"))
1742                .and_then(|v| v.as_u64())
1743                .unwrap_or(0);
1744            let order = meta
1745                .and_then(|m| m.get("order"))
1746                .and_then(parse_order_value)
1747                .unwrap_or_else(|| legacy_entry_order(timestamp, &backup_id));
1748            heads.push(BackupEntryHead {
1749                order,
1750                op_id: meta
1751                    .and_then(|m| m.get("op_id"))
1752                    .and_then(|v| v.as_str())
1753                    .map(str::to_string),
1754            });
1755        }
1756
1757        if heads.is_empty() {
1758            return None;
1759        }
1760        Some(heads)
1761    }
1762
1763    fn read_stack_from_disk(&self, session: &str, key: &Path) -> Option<Vec<BackupEntry>> {
1764        let disk_meta = match self
1765            .disk_index
1766            .get(session)
1767            .and_then(|s| s.get(key))
1768            .cloned()
1769        {
1770            Some(m) if m.count > 0 => m,
1771            _ => return None,
1772        };
1773
1774        let mut entries = Vec::new();
1775        let entry_meta = std::fs::read_to_string(disk_meta.dir.join("meta.json"))
1776            .ok()
1777            .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
1778            .and_then(|meta| meta.get("entries").and_then(|v| v.as_array()).cloned())
1779            .unwrap_or_default();
1780
1781        for i in 0..disk_meta.count {
1782            let meta = entry_meta.get(i);
1783            let kind = match meta.and_then(|m| m.get("kind")).and_then(|v| v.as_str()) {
1784                Some("tombstone") => BackupEntryKind::Tombstone,
1785                Some("symlink") => BackupEntryKind::Symlink,
1786                _ => BackupEntryKind::Content,
1787            };
1788            let content_bytes = match kind {
1789                BackupEntryKind::Content | BackupEntryKind::Symlink => {
1790                    let bak_path = disk_meta.dir.join(format!("{}.bak", i));
1791                    match std::fs::read(&bak_path) {
1792                        Ok(content) => content,
1793                        Err(_) => continue,
1794                    }
1795                }
1796                BackupEntryKind::Tombstone => Vec::new(),
1797            };
1798            let link_target = if kind == BackupEntryKind::Symlink {
1799                meta.and_then(|m| m.get("link_target"))
1800                    .and_then(|v| v.as_str())
1801                    .map(PathBuf::from)
1802                    .or_else(|| {
1803                        Some(PathBuf::from(
1804                            String::from_utf8_lossy(&content_bytes).into_owned(),
1805                        ))
1806                    })
1807            } else {
1808                None
1809            };
1810            let content = match kind {
1811                BackupEntryKind::Content => String::from_utf8_lossy(&content_bytes).into_owned(),
1812                BackupEntryKind::Symlink => link_target
1813                    .as_ref()
1814                    .map(|target| target.display().to_string())
1815                    .unwrap_or_default(),
1816                BackupEntryKind::Tombstone => String::new(),
1817            };
1818            let backup_id = meta
1819                .and_then(|m| m.get("backup_id"))
1820                .and_then(|v| v.as_str())
1821                .map(str::to_string)
1822                .unwrap_or_else(|| format!("disk-{}", i));
1823            let timestamp = meta
1824                .and_then(|m| m.get("timestamp"))
1825                .and_then(|v| v.as_u64())
1826                .unwrap_or(0);
1827            let order = meta
1828                .and_then(|m| m.get("order"))
1829                .and_then(parse_order_value)
1830                .unwrap_or_else(|| legacy_entry_order(timestamp, &backup_id));
1831            entries.push(BackupEntry {
1832                backup_id,
1833                content,
1834                content_bytes,
1835                timestamp,
1836                order,
1837                description: meta
1838                    .and_then(|m| m.get("description"))
1839                    .and_then(|v| v.as_str())
1840                    .unwrap_or("restored from disk")
1841                    .to_string(),
1842                op_id: meta
1843                    .and_then(|m| m.get("op_id"))
1844                    .and_then(|v| v.as_str())
1845                    .map(str::to_string),
1846                kind,
1847                mode: meta
1848                    .and_then(|m| m.get("mode"))
1849                    .and_then(|v| v.as_u64())
1850                    .and_then(|mode| u32::try_from(mode).ok()),
1851                link_target,
1852                created_dirs: meta
1853                    .and_then(|m| m.get("created_dirs"))
1854                    .and_then(|v| v.as_array())
1855                    .map(|dirs| {
1856                        dirs.iter()
1857                            .filter_map(|dir| dir.as_str())
1858                            .map(PathBuf::from)
1859                            .collect()
1860                    })
1861                    .unwrap_or_default(),
1862            });
1863        }
1864
1865        if entries.is_empty() {
1866            return None;
1867        }
1868        Some(entries)
1869    }
1870
1871    fn write_snapshot_to_disk(&mut self, session: &str, key: &Path, stack: &[BackupEntry]) {
1872        let session_dir = match self.session_dir(session) {
1873            Some(d) => d,
1874            None => return,
1875        };
1876
1877        // Ensure session dir + marker exist.
1878        if let Err(e) = std::fs::create_dir_all(&session_dir) {
1879            crate::slog_warn!("failed to create session dir: {}", e);
1880            return;
1881        }
1882        let marker = session_dir.join("session.json");
1883        if !marker.exists() {
1884            let json = serde_json::json!({
1885                "schema_version": SCHEMA_VERSION,
1886                "session_id": session,
1887                "last_accessed": current_timestamp(),
1888            });
1889            if let Ok(s) = serde_json::to_string_pretty(&json) {
1890                let _ = std::fs::write(&marker, s);
1891            }
1892        }
1893
1894        let hash = Self::path_hash(key);
1895        let dir = session_dir.join(&hash);
1896        if let Err(e) = std::fs::create_dir_all(&dir) {
1897            crate::slog_warn!("failed to create backup dir: {}", e);
1898            return;
1899        }
1900
1901        for (i, entry) in stack.iter().enumerate() {
1902            let bak_path = dir.join(format!("{}.bak", i));
1903            let tmp_path = dir.join(format!("{}.bak.tmp", i));
1904            match entry.kind {
1905                BackupEntryKind::Content => {
1906                    if std::fs::write(&tmp_path, &entry.content_bytes).is_ok() {
1907                        let _ = std::fs::rename(&tmp_path, &bak_path);
1908                    }
1909                }
1910                BackupEntryKind::Symlink => {
1911                    let target = entry
1912                        .link_target
1913                        .as_ref()
1914                        .map(|target| target.as_os_str().to_string_lossy().as_bytes().to_vec())
1915                        .unwrap_or_default();
1916                    if std::fs::write(&tmp_path, target).is_ok() {
1917                        let _ = std::fs::rename(&tmp_path, &bak_path);
1918                    }
1919                }
1920                BackupEntryKind::Tombstone => {
1921                    let _ = std::fs::remove_file(&bak_path);
1922                    let _ = std::fs::remove_file(&tmp_path);
1923                }
1924            }
1925        }
1926
1927        // Clean up extra .bak files if stack shrank.
1928        for i in stack.len()..MAX_UNDO_DEPTH {
1929            let old = dir.join(format!("{}.bak", i));
1930            if old.exists() {
1931                let _ = std::fs::remove_file(&old);
1932            }
1933        }
1934
1935        let entries: Vec<serde_json::Value> = stack
1936            .iter()
1937            .map(|entry| {
1938                serde_json::json!({
1939                    "backup_id": entry.backup_id,
1940                    "timestamp": entry.timestamp,
1941                    "order": entry.order.to_string(),
1942                    "description": entry.description,
1943                    "op_id": entry.op_id,
1944                    "kind": match entry.kind {
1945                        BackupEntryKind::Content => "content",
1946                        BackupEntryKind::Symlink => "symlink",
1947                        BackupEntryKind::Tombstone => "tombstone",
1948                    },
1949                    "mode": entry.mode,
1950                    "link_target": entry.link_target.as_ref().map(|target| target.display().to_string()),
1951                    "created_dirs": entry
1952                        .created_dirs
1953                        .iter()
1954                        .map(|dir| dir.display().to_string())
1955                        .collect::<Vec<_>>(),
1956                })
1957            })
1958            .collect();
1959        let meta = serde_json::json!({
1960            "schema_version": SCHEMA_VERSION,
1961            "session_id": session,
1962            "path": key.display().to_string(),
1963            "count": stack.len(),
1964            "entries": entries,
1965        });
1966        let meta_path = dir.join("meta.json");
1967        let meta_tmp = dir.join("meta.json.tmp");
1968        if let Ok(content) = serde_json::to_string_pretty(&meta) {
1969            if std::fs::write(&meta_tmp, &content).is_ok() {
1970                let _ = std::fs::rename(&meta_tmp, &meta_path);
1971            }
1972        }
1973
1974        // Keep the in-memory disk_index in sync so tracked_files() and
1975        // disk_history_count() immediately reflect what we just wrote.
1976        self.disk_index
1977            .entry(session.to_string())
1978            .or_default()
1979            .insert(
1980                key.to_path_buf(),
1981                DiskMeta {
1982                    dir: dir.clone(),
1983                    count: stack.len(),
1984                },
1985            );
1986        self.dual_write_stack_to_db(session, key, &dir, stack);
1987    }
1988
1989    fn dual_write_stack_to_db(&self, session: &str, key: &Path, dir: &Path, stack: &[BackupEntry]) {
1990        let pool = self.db_pool.read().ok().and_then(|slot| slot.clone());
1991        let Some(pool) = pool else {
1992            return;
1993        };
1994        let harness = self.db_harness.read().ok().and_then(|slot| slot.clone());
1995        let Some(harness) = harness else {
1996            crate::slog_warn!(
1997                "dual-write backup to DB skipped for {}: harness not configured",
1998                key.display()
1999            );
2000            return;
2001        };
2002        let project_key = self
2003            .db_project_key
2004            .read()
2005            .ok()
2006            .and_then(|slot| slot.clone());
2007        let Some(project_key) = project_key else {
2008            crate::slog_warn!(
2009                "dual-write backup to DB skipped for {}: project key not configured",
2010                key.display()
2011            );
2012            return;
2013        };
2014
2015        let conn = match pool.lock() {
2016            Ok(conn) => conn,
2017            Err(_) => {
2018                crate::slog_warn!(
2019                    "dual-write backup to DB failed for {}: db mutex poisoned",
2020                    key.display()
2021                );
2022                return;
2023            }
2024        };
2025        let path_hash = Self::path_hash(key);
2026        let file_path = key.display().to_string();
2027
2028        // Replace the path's stack ATOMICALLY: delete old rows + insert the full
2029        // new stack inside one transaction. The previous version deleted, then
2030        // inserted row-by-row outside any transaction and merely warned-and-
2031        // continued on an insert error — so a crash or SQLITE_BUSY mid-loop left
2032        // a PARTIAL stack in the DB, which restore/history then preferred over
2033        // the (consistent) disk stack. On any error here the transaction rolls
2034        // back, leaving the prior consistent stack untouched.
2035        let write_result = (|| -> rusqlite::Result<()> {
2036            let tx = conn.unchecked_transaction()?;
2037            crate::db::backups::delete_backups_for_path(&tx, &harness, session, &path_hash)?;
2038            for (index, entry) in stack.iter().enumerate() {
2039                let backup_path = match entry.kind {
2040                    BackupEntryKind::Content | BackupEntryKind::Symlink => {
2041                        Some(dir.join(format!("{}.bak", index)).display().to_string())
2042                    }
2043                    BackupEntryKind::Tombstone => Some(dir.join("meta.json").display().to_string()),
2044                };
2045                let row = entry.to_backup_row(
2046                    &harness,
2047                    session,
2048                    &project_key,
2049                    &file_path,
2050                    &path_hash,
2051                    backup_path.as_deref(),
2052                );
2053                crate::db::backups::upsert_backup(&tx, &row)?;
2054            }
2055            tx.commit()
2056        })();
2057        if let Err(error) = write_result {
2058            crate::slog_warn!(
2059                "dual-write backup stack to DB failed for {} (rolled back, prior stack kept): {}",
2060                key.display(),
2061                error
2062            );
2063        }
2064    }
2065
2066    fn remove_disk_backups(&mut self, session: &str, key: &Path) {
2067        self.remove_db_backups(session, key);
2068        let removed = self.disk_index.get_mut(session).and_then(|s| s.remove(key));
2069        if let Some(meta) = removed {
2070            let _ = std::fs::remove_dir_all(&meta.dir);
2071        } else if let Some(session_dir) = self.session_dir(session) {
2072            let hash = Self::path_hash(key);
2073            let dir = session_dir.join(&hash);
2074            if dir.exists() {
2075                let _ = std::fs::remove_dir_all(&dir);
2076            }
2077        }
2078
2079        // If this session has no more disk entries, drop the map slot (session
2080        // dir itself is kept so the marker survives future sessions).
2081        let empty = self
2082            .disk_index
2083            .get(session)
2084            .map(|s| s.is_empty())
2085            .unwrap_or(false);
2086        if empty {
2087            self.disk_index.remove(session);
2088        }
2089    }
2090
2091    fn remove_db_backups(&self, session: &str, key: &Path) {
2092        let Some((pool, harness)) = self.db_pool_and_harness() else {
2093            return;
2094        };
2095        let conn = match pool.lock() {
2096            Ok(conn) => conn,
2097            Err(_) => {
2098                crate::slog_warn!(
2099                    "delete backup DB rows failed for {}: db mutex poisoned",
2100                    key.display()
2101                );
2102                return;
2103            }
2104        };
2105        let path_hash = Self::path_hash(key);
2106        if let Err(error) =
2107            crate::db::backups::delete_backups_for_path(&conn, &harness, session, &path_hash)
2108        {
2109            crate::slog_warn!(
2110                "delete backup DB rows failed for {}: {}",
2111                key.display(),
2112                error
2113            );
2114        }
2115    }
2116}
2117
2118pub fn hash_session(session: &str) -> String {
2119    stable_hash_16(session.as_bytes())
2120}
2121
2122pub fn new_op_id() -> String {
2123    let mut bytes = [0u8; 4];
2124    if getrandom::fill(&mut bytes).is_err() {
2125        bytes = current_timestamp().to_le_bytes()[..4]
2126            .try_into()
2127            .unwrap_or([0; 4]);
2128    }
2129    let rand = u32::from_le_bytes(bytes);
2130    format!("op-{}-{:08x}", current_timestamp() * 1000, rand)
2131}
2132
2133#[derive(Debug, Clone)]
2134struct BackupEntryDiskMetadata {
2135    mode: Option<u32>,
2136    link_target: Option<PathBuf>,
2137    created_dirs: Vec<PathBuf>,
2138}
2139
2140#[derive(Debug, Clone)]
2141enum RestorePathState {
2142    Missing,
2143    Regular {
2144        content_bytes: Vec<u8>,
2145        mode: Option<u32>,
2146    },
2147    Symlink {
2148        target: PathBuf,
2149    },
2150    Directory,
2151}
2152
2153fn backup_entry_from_path(
2154    path: &Path,
2155    backup_id: String,
2156    order: u128,
2157    description: &str,
2158    op_id: Option<&str>,
2159) -> Result<BackupEntry, AftError> {
2160    let metadata = std::fs::symlink_metadata(path).map_err(|error| match error.kind() {
2161        std::io::ErrorKind::NotFound => AftError::FileNotFound {
2162            path: path.display().to_string(),
2163        },
2164        _ => AftError::IoError {
2165            path: path.display().to_string(),
2166            message: error.to_string(),
2167        },
2168    })?;
2169    let mode = file_mode(&metadata);
2170
2171    let (kind, content, content_bytes, link_target) = if metadata.file_type().is_symlink() {
2172        let target = std::fs::read_link(path).map_err(|error| AftError::IoError {
2173            path: path.display().to_string(),
2174            message: error.to_string(),
2175        })?;
2176        (
2177            BackupEntryKind::Symlink,
2178            target.display().to_string(),
2179            Vec::new(),
2180            Some(target),
2181        )
2182    } else if metadata.is_file() {
2183        let bytes = std::fs::read(path).map_err(|error| AftError::IoError {
2184            path: path.display().to_string(),
2185            message: error.to_string(),
2186        })?;
2187        (
2188            BackupEntryKind::Content,
2189            String::from_utf8_lossy(&bytes).into_owned(),
2190            bytes,
2191            None,
2192        )
2193    } else {
2194        return Err(AftError::InvalidRequest {
2195            message: format!(
2196                "backup: '{}' is not a regular file or symlink",
2197                path.display()
2198            ),
2199        });
2200    };
2201
2202    Ok(BackupEntry {
2203        backup_id,
2204        content,
2205        content_bytes,
2206        timestamp: current_timestamp(),
2207        order,
2208        description: description.to_string(),
2209        op_id: op_id.map(str::to_string),
2210        kind,
2211        mode,
2212        link_target,
2213        created_dirs: Vec::new(),
2214    })
2215}
2216
2217fn canonicalize_key(path: &Path) -> PathBuf {
2218    let absolute = if path.is_absolute() {
2219        path.to_path_buf()
2220    } else {
2221        std::env::current_dir()
2222            .unwrap_or_else(|_| PathBuf::from("."))
2223            .join(path)
2224    };
2225
2226    match std::fs::symlink_metadata(&absolute) {
2227        Ok(metadata) if metadata.file_type().is_symlink() => {
2228            canonicalize_parent_join_leaf(&absolute)
2229        }
2230        Ok(_) => std::fs::canonicalize(&absolute)
2231            .map(|path| normalize_absolute_key(&path))
2232            .unwrap_or_else(|_| canonicalize_existing_ancestor(&absolute)),
2233        Err(_) => canonicalize_existing_ancestor(&absolute),
2234    }
2235}
2236
2237fn canonicalize_parent_join_leaf(path: &Path) -> PathBuf {
2238    let Some(parent) = path.parent() else {
2239        return normalize_absolute_key(path);
2240    };
2241    let mut key = canonicalize_existing_ancestor(parent);
2242    if let Some(file_name) = path.file_name() {
2243        key.push(file_name);
2244    }
2245    key
2246}
2247
2248fn canonicalize_existing_ancestor(path: &Path) -> PathBuf {
2249    let mut suffix = Vec::new();
2250    let mut current = path;
2251
2252    loop {
2253        if let Ok(mut base) = std::fs::canonicalize(current) {
2254            for component in suffix.iter().rev() {
2255                base.push(Path::new(component));
2256            }
2257            return normalize_absolute_key(&base);
2258        }
2259        let Some(parent) = current.parent() else {
2260            return normalize_absolute_key(path);
2261        };
2262        if let Some(file_name) = current.file_name() {
2263            suffix.push(file_name.to_os_string());
2264        }
2265        current = parent;
2266    }
2267}
2268
2269fn normalize_absolute_key(path: &Path) -> PathBuf {
2270    let mut normalized = PathBuf::new();
2271
2272    for component in path.components() {
2273        match component {
2274            std::path::Component::CurDir => {}
2275            std::path::Component::ParentDir => {
2276                if !normalized.pop() {
2277                    normalized.push(component.as_os_str());
2278                }
2279            }
2280            other => normalized.push(other.as_os_str()),
2281        }
2282    }
2283
2284    normalized
2285}
2286
2287fn file_mode(metadata: &std::fs::Metadata) -> Option<u32> {
2288    #[cfg(unix)]
2289    {
2290        use std::os::unix::fs::PermissionsExt;
2291        Some(metadata.permissions().mode())
2292    }
2293    #[cfg(not(unix))]
2294    {
2295        let _ = metadata;
2296        None
2297    }
2298}
2299
2300fn set_file_mode(path: &Path, mode: Option<u32>) -> std::io::Result<()> {
2301    #[cfg(unix)]
2302    {
2303        use std::os::unix::fs::PermissionsExt;
2304        if let Some(mode) = mode {
2305            std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
2306        }
2307    }
2308    #[cfg(not(unix))]
2309    {
2310        let _ = (path, mode);
2311    }
2312    Ok(())
2313}
2314
2315fn capture_path_state(path: &Path) -> Result<RestorePathState, AftError> {
2316    let metadata = match std::fs::symlink_metadata(path) {
2317        Ok(metadata) => metadata,
2318        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2319            return Ok(RestorePathState::Missing);
2320        }
2321        Err(error) => {
2322            return Err(AftError::IoError {
2323                path: path.display().to_string(),
2324                message: error.to_string(),
2325            });
2326        }
2327    };
2328
2329    if metadata.file_type().is_symlink() {
2330        let target = std::fs::read_link(path).map_err(|error| AftError::IoError {
2331            path: path.display().to_string(),
2332            message: error.to_string(),
2333        })?;
2334        Ok(RestorePathState::Symlink { target })
2335    } else if metadata.is_file() {
2336        let content_bytes = std::fs::read(path).map_err(|error| AftError::IoError {
2337            path: path.display().to_string(),
2338            message: error.to_string(),
2339        })?;
2340        Ok(RestorePathState::Regular {
2341            content_bytes,
2342            mode: file_mode(&metadata),
2343        })
2344    } else {
2345        Ok(RestorePathState::Directory)
2346    }
2347}
2348
2349fn restore_entry_to_path(path: &Path, entry: &BackupEntry) -> std::io::Result<()> {
2350    match entry.kind {
2351        BackupEntryKind::Content => restore_regular_file(path, &entry.content_bytes, entry.mode),
2352        BackupEntryKind::Symlink => {
2353            let target = entry.link_target.as_ref().ok_or_else(|| {
2354                std::io::Error::new(
2355                    std::io::ErrorKind::InvalidData,
2356                    "symlink backup entry missing target",
2357                )
2358            })?;
2359            restore_symlink(path, target)
2360        }
2361        BackupEntryKind::Tombstone => remove_tombstone_path(path),
2362    }
2363}
2364
2365fn restore_path_state(path: &Path, state: &RestorePathState) -> bool {
2366    match state {
2367        RestorePathState::Missing => remove_file_or_symlink_if_present(path).is_ok(),
2368        RestorePathState::Regular {
2369            content_bytes,
2370            mode,
2371        } => restore_regular_file(path, content_bytes, *mode).is_ok(),
2372        RestorePathState::Symlink { target } => restore_symlink(path, target).is_ok(),
2373        RestorePathState::Directory => true,
2374    }
2375}
2376
2377fn restore_regular_file(
2378    path: &Path,
2379    content_bytes: &[u8],
2380    mode: Option<u32>,
2381) -> std::io::Result<()> {
2382    if let Some(parent) = path.parent() {
2383        if !parent.as_os_str().is_empty() {
2384            std::fs::create_dir_all(parent)?;
2385        }
2386    }
2387    if std::fs::symlink_metadata(path)
2388        .map(|metadata| metadata.file_type().is_symlink())
2389        .unwrap_or(false)
2390    {
2391        std::fs::remove_file(path)?;
2392    }
2393    std::fs::write(path, content_bytes)?;
2394    set_file_mode(path, mode)
2395}
2396
2397fn restore_symlink(path: &Path, target: &Path) -> std::io::Result<()> {
2398    if let Some(parent) = path.parent() {
2399        if !parent.as_os_str().is_empty() {
2400            std::fs::create_dir_all(parent)?;
2401        }
2402    }
2403    remove_file_or_symlink_if_present(path)?;
2404    create_symlink(target, path)
2405}
2406
2407#[cfg(unix)]
2408fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
2409    std::os::unix::fs::symlink(target, link)
2410}
2411
2412#[cfg(windows)]
2413fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
2414    if target.is_dir() {
2415        std::os::windows::fs::symlink_dir(target, link)
2416    } else {
2417        std::os::windows::fs::symlink_file(target, link)
2418    }
2419}
2420
2421fn remove_tombstone_path(path: &Path) -> std::io::Result<()> {
2422    match std::fs::symlink_metadata(path) {
2423        Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => {
2424            std::fs::remove_file(path)
2425        }
2426        Ok(_) => Err(std::io::Error::new(
2427            std::io::ErrorKind::IsADirectory,
2428            "tombstone target is a directory",
2429        )),
2430        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2431        Err(error) => Err(error),
2432    }
2433}
2434
2435fn remove_file_or_symlink_if_present(path: &Path) -> std::io::Result<()> {
2436    match std::fs::symlink_metadata(path) {
2437        Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => {
2438            std::fs::remove_file(path)
2439        }
2440        Ok(_) => Err(std::io::Error::new(
2441            std::io::ErrorKind::IsADirectory,
2442            "path is a directory",
2443        )),
2444        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2445        Err(error) => Err(error),
2446    }
2447}
2448
2449fn read_entry_disk_metadata(
2450    backup_path: &Path,
2451    backup_id: &str,
2452) -> Option<BackupEntryDiskMetadata> {
2453    let meta_path = if backup_path.file_name().and_then(|name| name.to_str()) == Some("meta.json") {
2454        backup_path.to_path_buf()
2455    } else {
2456        backup_path.parent()?.join("meta.json")
2457    };
2458    let content = std::fs::read_to_string(meta_path).ok()?;
2459    let meta: serde_json::Value = serde_json::from_str(&content).ok()?;
2460    let entries = meta.get("entries")?.as_array()?;
2461    let entry = entries
2462        .iter()
2463        .find(|entry| entry.get("backup_id").and_then(|value| value.as_str()) == Some(backup_id))?;
2464    Some(BackupEntryDiskMetadata {
2465        mode: entry
2466            .get("mode")
2467            .and_then(|value| value.as_u64())
2468            .and_then(|mode| u32::try_from(mode).ok()),
2469        link_target: entry
2470            .get("link_target")
2471            .and_then(|value| value.as_str())
2472            .map(PathBuf::from),
2473        created_dirs: entry
2474            .get("created_dirs")
2475            .and_then(|value| value.as_array())
2476            .map(|dirs| {
2477                dirs.iter()
2478                    .filter_map(|dir| dir.as_str())
2479                    .map(PathBuf::from)
2480                    .collect()
2481            })
2482            .unwrap_or_default(),
2483    })
2484}
2485
2486fn rollback_transactional_restore(
2487    written: &[(PathBuf, RestorePathState)],
2488    attempted: Option<(&PathBuf, &RestorePathState)>,
2489) -> bool {
2490    let mut ok = true;
2491
2492    if let Some((path, state)) = attempted {
2493        ok &= restore_path_state(path, state);
2494    }
2495
2496    for (path, state) in written.iter().rev() {
2497        ok &= restore_path_state(path, state);
2498    }
2499
2500    ok
2501}
2502
2503fn rollback_deleted_tombstones(deleted: &[(PathBuf, RestorePathState)]) -> bool {
2504    let mut ok = true;
2505    for (path, state) in deleted.iter().rev() {
2506        ok &= restore_path_state(path, state);
2507    }
2508    ok
2509}
2510
2511fn missing_parent_dirs(parent: &Path) -> Vec<PathBuf> {
2512    let mut dirs = Vec::new();
2513    let mut current = Some(parent);
2514
2515    while let Some(dir) = current {
2516        if dir.as_os_str().is_empty() || dir.exists() {
2517            break;
2518        }
2519        dirs.push(dir.to_path_buf());
2520        current = dir.parent();
2521    }
2522
2523    dirs
2524}
2525
2526fn rollback_created_dirs(dirs: &[PathBuf]) -> bool {
2527    let mut dirs = dirs.to_vec();
2528    dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
2529    dirs.dedup();
2530
2531    let mut ok = true;
2532    for dir in dirs {
2533        match std::fs::remove_dir(&dir) {
2534            Ok(()) => {}
2535            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
2536            Err(_) => ok = false,
2537        }
2538    }
2539
2540    ok
2541}
2542
2543fn remove_created_dirs_best_effort(dirs: &[PathBuf]) {
2544    let mut dirs = dirs.to_vec();
2545    dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
2546    dirs.dedup();
2547
2548    for dir in dirs {
2549        match std::fs::remove_dir(&dir) {
2550            Ok(()) => {}
2551            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2552            Err(_) => {}
2553        }
2554    }
2555}
2556
2557fn dir_has_entries(path: &Path) -> bool {
2558    std::fs::read_dir(path)
2559        .map(|mut entries| entries.next().is_some())
2560        .unwrap_or(false)
2561}
2562
2563fn current_timestamp() -> u64 {
2564    std::time::SystemTime::now()
2565        .duration_since(std::time::UNIX_EPOCH)
2566        .unwrap_or_default()
2567        .as_secs()
2568}
2569
2570fn current_timestamp_nanos() -> u64 {
2571    let nanos = std::time::SystemTime::now()
2572        .duration_since(std::time::UNIX_EPOCH)
2573        .unwrap_or_default()
2574        .as_nanos();
2575    nanos.min(u128::from(u64::MAX)) as u64
2576}
2577
2578fn legacy_entry_order(timestamp_secs: u64, backup_id: &str) -> u128 {
2579    let nanos = timestamp_secs.saturating_mul(1_000_000_000);
2580    ((nanos as u128) << 32) | u128::from(backup_sequence(backup_id).unwrap_or(0))
2581}
2582
2583fn parse_order_value(value: &serde_json::Value) -> Option<u128> {
2584    value
2585        .as_str()
2586        .and_then(|s| s.parse::<u128>().ok())
2587        .or_else(|| value.as_u64().map(u128::from))
2588}
2589
2590fn is_loadable_backup_path(key: &Path, path_dir: &Path) -> bool {
2591    if !key.is_absolute()
2592        || key
2593            .components()
2594            .any(|c| matches!(c, std::path::Component::ParentDir))
2595    {
2596        return false;
2597    }
2598    let Some(dir_name) = path_dir.file_name().and_then(|name| name.to_str()) else {
2599        return false;
2600    };
2601    BackupStore::path_hash(key) == dir_name
2602}
2603
2604fn stable_hash_16(bytes: &[u8]) -> String {
2605    let digest = Sha256::digest(bytes);
2606    digest[..8]
2607        .iter()
2608        .map(|byte| format!("{:02x}", byte))
2609        .collect()
2610}
2611
2612fn backup_sequence(backup_id: &str) -> Option<u64> {
2613    backup_id
2614        .strip_prefix("backup-")
2615        .or_else(|| backup_id.strip_prefix("disk-"))
2616        .and_then(|s| s.parse().ok())
2617}
2618
2619#[cfg(test)]
2620mod tests {
2621    use super::*;
2622    use crate::protocol::DEFAULT_SESSION_ID;
2623    use std::fs;
2624    #[cfg(unix)]
2625    use std::os::unix::fs::PermissionsExt;
2626
2627    fn temp_file(name: &str, content: &str) -> PathBuf {
2628        let dir = std::env::temp_dir().join("aft_backup_tests");
2629        fs::create_dir_all(&dir).unwrap();
2630        let path = dir.join(name);
2631        fs::write(&path, content).unwrap();
2632        path
2633    }
2634
2635    #[test]
2636    fn snapshot_and_restore_round_trip() {
2637        let path = temp_file("round_trip.txt", "original");
2638        let mut store = BackupStore::new();
2639
2640        let id = store
2641            .snapshot(DEFAULT_SESSION_ID, &path, "before edit")
2642            .unwrap();
2643        assert!(id.starts_with("backup-"));
2644
2645        fs::write(&path, "modified").unwrap();
2646        assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
2647
2648        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
2649        assert_eq!(entry.content, "original");
2650        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
2651    }
2652
2653    #[test]
2654    fn multiple_snapshots_preserve_order() {
2655        let path = temp_file("order.txt", "v1");
2656        let mut store = BackupStore::new();
2657
2658        store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
2659        fs::write(&path, "v2").unwrap();
2660        store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
2661        fs::write(&path, "v3").unwrap();
2662        store.snapshot(DEFAULT_SESSION_ID, &path, "third").unwrap();
2663
2664        let history = store.history(DEFAULT_SESSION_ID, &path);
2665        assert_eq!(history.len(), 3);
2666        assert_eq!(history[0].content, "v1");
2667        assert_eq!(history[1].content, "v2");
2668        assert_eq!(history[2].content, "v3");
2669    }
2670
2671    #[test]
2672    fn restore_pops_from_stack() {
2673        let path = temp_file("pop.txt", "v1");
2674        let mut store = BackupStore::new();
2675
2676        store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
2677        fs::write(&path, "v2").unwrap();
2678        store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
2679
2680        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
2681        assert_eq!(entry.description, "second");
2682        assert_eq!(entry.content, "v2");
2683
2684        let history = store.history(DEFAULT_SESSION_ID, &path);
2685        assert_eq!(history.len(), 1);
2686    }
2687
2688    #[test]
2689    fn empty_history_returns_empty_vec() {
2690        let store = BackupStore::new();
2691        let path = Path::new("/tmp/aft_backup_tests/nonexistent_history.txt");
2692        assert!(store.history(DEFAULT_SESSION_ID, path).is_empty());
2693    }
2694
2695    #[test]
2696    fn snapshot_nonexistent_file_returns_error() {
2697        let mut store = BackupStore::new();
2698        let path = Path::new("/tmp/aft_backup_tests/absolutely_does_not_exist.txt");
2699        assert!(store.snapshot(DEFAULT_SESSION_ID, path, "test").is_err());
2700    }
2701
2702    #[test]
2703    fn tracked_files_lists_snapshotted_paths() {
2704        let path1 = temp_file("tracked1.txt", "a");
2705        let path2 = temp_file("tracked2.txt", "b");
2706        let mut store = BackupStore::new();
2707
2708        store.snapshot(DEFAULT_SESSION_ID, &path1, "snap1").unwrap();
2709        store.snapshot(DEFAULT_SESSION_ID, &path2, "snap2").unwrap();
2710        assert_eq!(store.tracked_files(DEFAULT_SESSION_ID).len(), 2);
2711    }
2712
2713    #[test]
2714    fn sessions_are_isolated() {
2715        let path = temp_file("isolated.txt", "original");
2716        let mut store = BackupStore::new();
2717
2718        store.snapshot("session_a", &path, "a's snapshot").unwrap();
2719
2720        // Session B sees no history for this file.
2721        assert!(store.history("session_b", &path).is_empty());
2722        assert_eq!(store.tracked_files("session_b").len(), 0);
2723
2724        // Session B's restore_latest fails with NoUndoHistory.
2725        let err = store.restore_latest("session_b", &path);
2726        assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
2727
2728        // Session A still sees its own snapshot.
2729        assert_eq!(store.history("session_a", &path).len(), 1);
2730        assert_eq!(store.tracked_files("session_a").len(), 1);
2731    }
2732
2733    #[test]
2734    fn per_session_per_file_cap_is_independent() {
2735        // Two sessions fill up their own stacks independently; hitting the cap
2736        // in session A does not evict anything from session B.
2737        let path = temp_file("cap_indep.txt", "v0");
2738        let mut store = BackupStore::new();
2739
2740        for i in 0..(MAX_UNDO_DEPTH + 5) {
2741            fs::write(&path, format!("a{}", i)).unwrap();
2742            store.snapshot("session_a", &path, "a").unwrap();
2743        }
2744        fs::write(&path, "b_initial").unwrap();
2745        store.snapshot("session_b", &path, "b").unwrap();
2746
2747        // Session A should be capped at MAX_UNDO_DEPTH.
2748        assert_eq!(store.history("session_a", &path).len(), MAX_UNDO_DEPTH);
2749        // Session B should still have its single entry.
2750        assert_eq!(store.history("session_b", &path).len(), 1);
2751    }
2752
2753    #[test]
2754    fn sessions_with_backups_lists_all_namespaces() {
2755        let path_a = temp_file("sessions_list_a.txt", "a");
2756        let path_b = temp_file("sessions_list_b.txt", "b");
2757        let mut store = BackupStore::new();
2758
2759        store.snapshot("alice", &path_a, "from alice").unwrap();
2760        store.snapshot("bob", &path_b, "from bob").unwrap();
2761
2762        let sessions = store.sessions_with_backups();
2763        assert_eq!(sessions.len(), 2);
2764        assert!(sessions.iter().any(|s| s == "alice"));
2765        assert!(sessions.iter().any(|s| s == "bob"));
2766    }
2767
2768    #[test]
2769    fn disk_persistence_survives_reload() {
2770        let dir = std::env::temp_dir().join("aft_backup_disk_test");
2771        let _ = fs::remove_dir_all(&dir);
2772        fs::create_dir_all(&dir).unwrap();
2773
2774        let file_path = temp_file("disk_persist.txt", "original");
2775
2776        // Create store with storage, snapshot under default session, drop.
2777        {
2778            let mut store = BackupStore::new();
2779            store.set_storage_dir(dir.clone(), 72);
2780            store
2781                .snapshot(DEFAULT_SESSION_ID, &file_path, "before edit")
2782                .unwrap();
2783        }
2784
2785        // Modify the file externally.
2786        fs::write(&file_path, "externally modified").unwrap();
2787
2788        // Create new store, load from disk, restore.
2789        let mut store2 = BackupStore::new();
2790        store2.set_storage_dir(dir.clone(), 72);
2791
2792        let (entry, warning) = store2
2793            .restore_latest(DEFAULT_SESSION_ID, &file_path)
2794            .unwrap();
2795        assert_eq!(entry.content, "original");
2796        assert!(warning.is_some()); // modified externally
2797        assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
2798
2799        let _ = fs::remove_dir_all(&dir);
2800    }
2801
2802    #[test]
2803    fn snapshot_after_restart_preserves_history_and_unique_ids() {
2804        // Regression (bug #8): after a restart the BackupStore is fresh
2805        // (entries cleared, counter reset to 0). A new snapshot must EXTEND the
2806        // persisted undo stack — not overwrite it with a single entry — and must
2807        // not reuse backup-0. Two undo levels must remain available across the
2808        // restart boundary.
2809        let dir = std::env::temp_dir().join("aft_backup_restart_history_test");
2810        let _ = fs::remove_dir_all(&dir);
2811        fs::create_dir_all(&dir).unwrap();
2812        let file_path = temp_file("restart_history.txt", "v0");
2813
2814        // Run 1: edit v0 -> v1 (snapshot captures "v0"), then write v1.
2815        let first_id = {
2816            let mut store = BackupStore::new();
2817            store.set_storage_dir(dir.clone(), 72);
2818            let id = store
2819                .snapshot(DEFAULT_SESSION_ID, &file_path, "edit 1")
2820                .unwrap();
2821            fs::write(&file_path, "v1").unwrap();
2822            id
2823        };
2824
2825        // Restart: fresh store, same storage dir. Edit v1 -> v2 (snapshot
2826        // captures "v1"), then write v2.
2827        let second_id = {
2828            let mut store = BackupStore::new();
2829            store.set_storage_dir(dir.clone(), 72);
2830            let id = store
2831                .snapshot(DEFAULT_SESSION_ID, &file_path, "edit 2")
2832                .unwrap();
2833            fs::write(&file_path, "v2").unwrap();
2834            id
2835        };
2836
2837        // The post-restart snapshot must NOT reuse the first id (counter
2838        // advanced past persisted entries).
2839        assert_ne!(
2840            first_id, second_id,
2841            "post-restart snapshot reused backup id {first_id}"
2842        );
2843
2844        // Both undo levels survive: a fresh store sees 2 entries on disk, and
2845        // two sequential restores walk v1 then v0.
2846        let mut store = BackupStore::new();
2847        store.set_storage_dir(dir.clone(), 72);
2848        assert_eq!(
2849            store.history(DEFAULT_SESSION_ID, &file_path).len(),
2850            2,
2851            "prior history was overwritten by the post-restart snapshot"
2852        );
2853
2854        let (entry1, _) = store
2855            .restore_latest(DEFAULT_SESSION_ID, &file_path)
2856            .unwrap();
2857        assert_eq!(entry1.content, "v1", "first undo should restore v1");
2858        let (entry0, _) = store
2859            .restore_latest(DEFAULT_SESSION_ID, &file_path)
2860            .unwrap();
2861        assert_eq!(entry0.content, "v0", "second undo should restore v0");
2862
2863        let _ = fs::remove_dir_all(&dir);
2864    }
2865
2866    #[test]
2867    fn legacy_flat_layout_migrates_to_default_session() {
2868        // Simulate a pre-session on-disk layout (schema v1) and verify it's
2869        // moved under the default session namespace on set_storage_dir.
2870        let dir = std::env::temp_dir().join("aft_backup_migration_test");
2871        let _ = fs::remove_dir_all(&dir);
2872        fs::create_dir_all(&dir).unwrap();
2873        let backups = dir.join("backups");
2874        fs::create_dir_all(&backups).unwrap();
2875
2876        // Fake legacy entry for some path hash.
2877        let legacy_hash = "deadbeefcafebabe";
2878        let legacy_dir = backups.join(legacy_hash);
2879        fs::create_dir_all(&legacy_dir).unwrap();
2880        fs::write(legacy_dir.join("0.bak"), "original content").unwrap();
2881        let legacy_meta = serde_json::json!({
2882            "path": "/tmp/migrated_file.txt",
2883            "count": 1,
2884        });
2885        fs::write(
2886            legacy_dir.join("meta.json"),
2887            serde_json::to_string_pretty(&legacy_meta).unwrap(),
2888        )
2889        .unwrap();
2890
2891        // Run migration.
2892        let mut store = BackupStore::new();
2893        store.set_storage_dir(dir.clone(), 72);
2894
2895        // After migration, the legacy dir should be gone from the top level,
2896        // and the entry should now live under the default-session hash dir.
2897        let default_session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
2898        assert!(default_session_dir.exists());
2899        assert!(default_session_dir.join(legacy_hash).exists());
2900        assert!(!backups.join(legacy_hash).exists());
2901
2902        // The upgraded meta.json should now include session_id + schema_version.
2903        let meta_content =
2904            fs::read_to_string(default_session_dir.join(legacy_hash).join("meta.json")).unwrap();
2905        let meta: serde_json::Value = serde_json::from_str(&meta_content).unwrap();
2906        assert_eq!(meta["session_id"], DEFAULT_SESSION_ID);
2907        assert_eq!(meta["schema_version"], SCHEMA_VERSION);
2908
2909        let _ = fs::remove_dir_all(&dir);
2910    }
2911
2912    #[test]
2913    fn set_storage_dir_removes_stale_backup_sessions() {
2914        let dir = std::env::temp_dir().join("aft_backup_gc_test");
2915        let _ = fs::remove_dir_all(&dir);
2916        let backups = dir.join("backups");
2917        fs::create_dir_all(&backups).unwrap();
2918
2919        let stale_session_dir = backups.join("stale-session");
2920        fs::create_dir_all(&stale_session_dir).unwrap();
2921        let stale_marker = serde_json::json!({
2922            "schema_version": SCHEMA_VERSION,
2923            "session_id": "stale",
2924            "last_accessed": 1,
2925        });
2926        fs::write(
2927            stale_session_dir.join("session.json"),
2928            serde_json::to_string_pretty(&stale_marker).unwrap(),
2929        )
2930        .unwrap();
2931
2932        let mut store = BackupStore::new();
2933        store.set_storage_dir(dir.clone(), 1);
2934
2935        assert!(!stale_session_dir.exists());
2936        let _ = fs::remove_dir_all(&dir);
2937    }
2938
2939    #[test]
2940    fn markerless_session_dir_is_skipped_not_mapped_to_default() {
2941        let dir = std::env::temp_dir().join("aft_backup_markerless_skip_test");
2942        let _ = fs::remove_dir_all(&dir);
2943        let file_path = temp_file("markerless.txt", "original");
2944        let key = canonicalize_key(&file_path);
2945        let path_dir = dir
2946            .join("backups")
2947            .join("corrupt-session")
2948            .join("path-entry");
2949        fs::create_dir_all(&path_dir).unwrap();
2950        fs::write(path_dir.join("0.bak"), "original").unwrap();
2951        fs::write(
2952            path_dir.join("meta.json"),
2953            serde_json::to_string_pretty(&serde_json::json!({
2954                "schema_version": SCHEMA_VERSION,
2955                "session_id": "lost-session",
2956                "path": key.display().to_string(),
2957                "count": 1,
2958                "entries": [{
2959                    "backup_id": "disk-0",
2960                    "timestamp": 0,
2961                    "description": "corrupt marker test",
2962                    "op_id": null,
2963                    "kind": "content",
2964                }]
2965            }))
2966            .unwrap(),
2967        )
2968        .unwrap();
2969
2970        let mut store = BackupStore::new();
2971        store.set_storage_dir(dir.clone(), 72);
2972
2973        assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 0);
2974        assert!(store.sessions_with_backups().is_empty());
2975        let _ = fs::remove_dir_all(&dir);
2976    }
2977
2978    #[test]
2979    fn set_storage_dir_reconfiguration_drops_previous_disk_index() {
2980        let dir_a = std::env::temp_dir().join("aft_backup_storage_a_test");
2981        let dir_b = std::env::temp_dir().join("aft_backup_storage_b_test");
2982        let _ = fs::remove_dir_all(&dir_a);
2983        let _ = fs::remove_dir_all(&dir_b);
2984        fs::create_dir_all(&dir_a).unwrap();
2985        fs::create_dir_all(&dir_b).unwrap();
2986        let file_path = temp_file("storage_reconfigure.txt", "original");
2987
2988        let mut store = BackupStore::new();
2989        store.set_storage_dir(dir_a.clone(), 72);
2990        store
2991            .snapshot(DEFAULT_SESSION_ID, &file_path, "stored in a")
2992            .unwrap();
2993        assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 1);
2994
2995        store.set_storage_dir(dir_b.clone(), 72);
2996
2997        assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 0);
2998        assert!(store.tracked_files(DEFAULT_SESSION_ID).is_empty());
2999        let _ = fs::remove_dir_all(&dir_a);
3000        let _ = fs::remove_dir_all(&dir_b);
3001    }
3002
3003    #[test]
3004    fn restore_last_operation_restores_all_top_entries_for_same_op() {
3005        let path_a = temp_file("op_restore_a.txt", "a1");
3006        let path_b = temp_file("op_restore_b.txt", "b1");
3007        let mut store = BackupStore::new();
3008        let op_id = "op-test-00000001";
3009
3010        store
3011            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
3012            .unwrap();
3013        store
3014            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
3015            .unwrap();
3016        fs::write(&path_a, "a2").unwrap();
3017        fs::write(&path_b, "b2").unwrap();
3018
3019        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
3020        assert_eq!(restored.op_id, op_id);
3021        assert_eq!(restored.restored.len(), 2);
3022        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a1");
3023        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
3024    }
3025
3026    #[test]
3027    fn restore_last_operation_deletes_tombstone_destination() {
3028        let dir = std::env::temp_dir().join("aft_backup_tombstone_delete_test");
3029        let _ = fs::remove_dir_all(&dir);
3030        fs::create_dir_all(&dir).unwrap();
3031        let source = dir.join("source.txt");
3032        let destination = dir.join("destination.txt");
3033        fs::write(&source, "original").unwrap();
3034
3035        let mut store = BackupStore::new();
3036        let op_id = "op-tombstone-delete";
3037        store
3038            .snapshot_with_op(DEFAULT_SESSION_ID, &source, "move source", Some(op_id))
3039            .unwrap();
3040        fs::rename(&source, &destination).unwrap();
3041        store
3042            .snapshot_op_tombstone(DEFAULT_SESSION_ID, op_id, &destination, "created dest")
3043            .unwrap();
3044
3045        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
3046        assert_eq!(restored.op_id, op_id);
3047        assert_eq!(restored.restored.len(), 1);
3048        assert_eq!(fs::read_to_string(&source).unwrap(), "original");
3049        assert!(!destination.exists());
3050        let _ = fs::remove_dir_all(&dir);
3051    }
3052
3053    #[test]
3054    fn restore_last_operation_rolls_back_source_when_tombstone_delete_fails() {
3055        let dir = std::env::temp_dir().join("aft_backup_tombstone_atomic_test");
3056        let _ = fs::remove_dir_all(&dir);
3057        fs::create_dir_all(&dir).unwrap();
3058        let source = dir.join("source.txt");
3059        let destination = dir.join("destination.txt");
3060        fs::write(&source, "original").unwrap();
3061
3062        let mut store = BackupStore::new();
3063        let op_id = "op-tombstone-atomic";
3064        store
3065            .snapshot_with_op(DEFAULT_SESSION_ID, &source, "move source", Some(op_id))
3066            .unwrap();
3067        fs::rename(&source, &destination).unwrap();
3068        store
3069            .snapshot_op_tombstone(DEFAULT_SESSION_ID, op_id, &destination, "created dest")
3070            .unwrap();
3071
3072        fs::remove_file(&destination).unwrap();
3073        fs::create_dir(&destination).unwrap();
3074        let result = store.restore_last_operation(DEFAULT_SESSION_ID);
3075
3076        assert!(result.is_err(), "directory tombstone target should fail");
3077        assert!(
3078            !source.exists(),
3079            "source restore must roll back when destination deletion fails"
3080        );
3081        assert!(
3082            destination.is_dir(),
3083            "failed tombstone target should remain"
3084        );
3085        let _ = fs::remove_dir_all(&dir);
3086    }
3087
3088    // Uses Unix-specific PermissionsExt::set_mode to make a target file
3089    // read-only and force the Phase 1 write to fail. The atomicity logic
3090    // it exercises is platform-independent — Windows has different
3091    // mechanisms for forcing write failures, covered separately.
3092    #[cfg(unix)]
3093    #[test]
3094    fn restore_last_operation_is_atomic_when_a_write_fails() {
3095        let dir = std::env::temp_dir().join("aft_backup_tests_atomic_restore");
3096        let _ = fs::remove_dir_all(&dir);
3097        fs::create_dir_all(&dir).unwrap();
3098        let path_a = dir.join("a.txt");
3099        let path_b = dir.join("b.txt");
3100        let path_c = dir.join("c.txt");
3101        fs::write(&path_a, "a-original").unwrap();
3102        fs::write(&path_b, "b-original").unwrap();
3103        fs::write(&path_c, "c-original").unwrap();
3104
3105        let mut store = BackupStore::new();
3106        let op_id = "op-atomic-restore-01";
3107        let id_a = store
3108            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
3109            .unwrap();
3110        let id_b = store
3111            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
3112            .unwrap();
3113        let id_c = store
3114            .snapshot_with_op(DEFAULT_SESSION_ID, &path_c, "c", Some(op_id))
3115            .unwrap();
3116        fs::write(&path_a, "a-modified").unwrap();
3117        fs::write(&path_b, "b-modified").unwrap();
3118        fs::write(&path_c, "c-modified").unwrap();
3119
3120        let original_permissions = fs::metadata(&path_b).unwrap().permissions();
3121        let mut readonly_permissions = original_permissions.clone();
3122        readonly_permissions.set_mode(0o444);
3123        fs::set_permissions(&path_b, readonly_permissions).unwrap();
3124
3125        let result = store.restore_last_operation(DEFAULT_SESSION_ID);
3126        fs::set_permissions(&path_b, original_permissions).unwrap();
3127
3128        assert!(result.is_err());
3129        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
3130        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
3131        assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-modified");
3132
3133        let history_a = store.history(DEFAULT_SESSION_ID, &path_a);
3134        let history_b = store.history(DEFAULT_SESSION_ID, &path_b);
3135        let history_c = store.history(DEFAULT_SESSION_ID, &path_c);
3136        assert_eq!(history_a.len(), 1);
3137        assert_eq!(history_b.len(), 1);
3138        assert_eq!(history_c.len(), 1);
3139        assert_eq!(history_a[0].backup_id, id_a);
3140        assert_eq!(history_b[0].backup_id, id_b);
3141        assert_eq!(history_c[0].backup_id, id_c);
3142        assert_eq!(history_a[0].op_id.as_deref(), Some(op_id));
3143        assert_eq!(history_b[0].op_id.as_deref(), Some(op_id));
3144        assert_eq!(history_c[0].op_id.as_deref(), Some(op_id));
3145
3146        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
3147        assert_eq!(restored.op_id, op_id);
3148        assert_eq!(restored.restored.len(), 3);
3149        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
3150        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
3151        assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-original");
3152
3153        let _ = fs::remove_dir_all(&dir);
3154    }
3155
3156    #[test]
3157    fn restore_last_operation_restores_only_most_recent_op() {
3158        let path_a = temp_file("op_recent_a.txt", "a1");
3159        let path_b = temp_file("op_recent_b.txt", "b1");
3160        let mut store = BackupStore::new();
3161
3162        store
3163            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "older", Some("op-older"))
3164            .unwrap();
3165        store
3166            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "newer", Some("op-newer"))
3167            .unwrap();
3168        fs::write(&path_a, "a2").unwrap();
3169        fs::write(&path_b, "b2").unwrap();
3170
3171        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
3172        assert_eq!(restored.op_id, "op-newer");
3173        assert_eq!(restored.restored.len(), 1);
3174        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a2");
3175        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
3176    }
3177
3178    #[test]
3179    fn restore_recreates_missing_parent_directories() {
3180        // Simulate aft_delete files: [dir/] with recursive: true:
3181        // the parent directories are gone by the time we restore.
3182        let dir = std::env::temp_dir().join("aft_backup_tests_recreate_parents");
3183        let _ = fs::remove_dir_all(&dir);
3184        let nested = dir.join("nested");
3185        fs::create_dir_all(&nested).unwrap();
3186        let path = nested.join("inner.txt");
3187        fs::write(&path, "original").unwrap();
3188
3189        let mut store = BackupStore::new();
3190        let op_id = "op-recreate-parents-01";
3191        store
3192            .snapshot_with_op(DEFAULT_SESSION_ID, &path, "original", Some(op_id))
3193            .unwrap();
3194
3195        // Real-world delete sequence: tree is wiped before undo runs.
3196        fs::remove_dir_all(&dir).unwrap();
3197        assert!(!path.exists());
3198        assert!(!nested.exists());
3199        assert!(!dir.exists());
3200
3201        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
3202        assert_eq!(restored.op_id, op_id);
3203        assert_eq!(restored.restored.len(), 1);
3204        assert!(
3205            path.exists(),
3206            "file should be restored even though both nested/ and dir/ were missing"
3207        );
3208        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
3209
3210        let _ = fs::remove_dir_all(&dir);
3211    }
3212
3213    #[test]
3214    fn restore_last_operation_ignores_legacy_entries_without_op_id() {
3215        let path = temp_file("op_legacy_none.txt", "v1");
3216        let mut store = BackupStore::new();
3217
3218        store.snapshot(DEFAULT_SESSION_ID, &path, "legacy").unwrap();
3219        fs::write(&path, "v2").unwrap();
3220
3221        let err = store.restore_last_operation(DEFAULT_SESSION_ID);
3222        assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
3223        assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
3224    }
3225
3226    #[test]
3227    fn schema_v2_meta_loads_with_none_op_id_and_persists_as_v3() {
3228        let dir = std::env::temp_dir().join("aft_backup_v2_to_v3_test");
3229        let _ = fs::remove_dir_all(&dir);
3230        fs::create_dir_all(&dir).unwrap();
3231        let file_path = temp_file("v2_to_v3.txt", "original");
3232        let key = canonicalize_key(&file_path);
3233        let session_dir = dir
3234            .join("backups")
3235            .join(BackupStore::session_hash(DEFAULT_SESSION_ID));
3236        let path_dir = session_dir.join(BackupStore::path_hash(&key));
3237        fs::create_dir_all(&path_dir).unwrap();
3238        fs::write(path_dir.join("0.bak"), "original").unwrap();
3239        fs::write(
3240            session_dir.join("session.json"),
3241            serde_json::to_string_pretty(&serde_json::json!({
3242                "schema_version": 2,
3243                "session_id": DEFAULT_SESSION_ID,
3244                "last_accessed": current_timestamp(),
3245            }))
3246            .unwrap(),
3247        )
3248        .unwrap();
3249        fs::write(
3250            path_dir.join("meta.json"),
3251            serde_json::to_string_pretty(&serde_json::json!({
3252                "schema_version": 2,
3253                "session_id": DEFAULT_SESSION_ID,
3254                "path": key.display().to_string(),
3255                "count": 1,
3256            }))
3257            .unwrap(),
3258        )
3259        .unwrap();
3260
3261        let mut store = BackupStore::new();
3262        store.set_storage_dir(dir.clone(), 72);
3263        assert!(store.load_from_disk_if_needed(DEFAULT_SESSION_ID, &key));
3264        let history = store.history(DEFAULT_SESSION_ID, &file_path);
3265        assert_eq!(history.len(), 1);
3266        assert_eq!(history[0].op_id, None);
3267
3268        fs::write(&file_path, "second").unwrap();
3269        store
3270            .snapshot_with_op(DEFAULT_SESSION_ID, &file_path, "second", Some("op-v3"))
3271            .unwrap();
3272        let written: serde_json::Value =
3273            serde_json::from_str(&fs::read_to_string(path_dir.join("meta.json")).unwrap()).unwrap();
3274        assert_eq!(written["schema_version"], SCHEMA_VERSION);
3275        assert_eq!(written["entries"][0]["op_id"], serde_json::Value::Null);
3276        assert_eq!(written["entries"][1]["op_id"], "op-v3");
3277        let _ = fs::remove_dir_all(&dir);
3278    }
3279
3280    #[test]
3281    fn per_file_restore_latest_still_works_with_op_ids() {
3282        let path = temp_file("op_per_file.txt", "v1");
3283        let mut store = BackupStore::new();
3284
3285        store
3286            .snapshot_with_op(DEFAULT_SESSION_ID, &path, "op", Some("op-file"))
3287            .unwrap();
3288        fs::write(&path, "v2").unwrap();
3289
3290        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
3291        assert_eq!(entry.op_id.as_deref(), Some("op-file"));
3292        assert_eq!(fs::read_to_string(&path).unwrap(), "v1");
3293    }
3294
3295    #[test]
3296    fn per_file_restore_latest_deletes_tombstone() {
3297        let dir = std::env::temp_dir().join("aft_backup_per_file_tombstone_test");
3298        let _ = fs::remove_dir_all(&dir);
3299        fs::create_dir_all(&dir).unwrap();
3300        let path = dir.join("created.txt");
3301        fs::write(&path, "created").unwrap();
3302
3303        let mut store = BackupStore::new();
3304        let id = store
3305            .snapshot_op_tombstone(DEFAULT_SESSION_ID, "op-create", &path, "created")
3306            .unwrap();
3307
3308        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
3309        assert_eq!(entry.backup_id, id);
3310        assert!(!path.exists(), "tombstone undo should delete the file");
3311        let _ = fs::remove_dir_all(&dir);
3312    }
3313
3314    #[test]
3315    fn load_disk_index_skips_tampered_meta_path_hash_mismatch() {
3316        let dir = std::env::temp_dir().join("aft_backup_tampered_meta_skip_test");
3317        let _ = fs::remove_dir_all(&dir);
3318        let backups = dir.join("backups");
3319        let session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
3320        let path_dir = session_dir.join("not-the-path-hash");
3321        fs::create_dir_all(&path_dir).unwrap();
3322        fs::write(
3323            session_dir.join("session.json"),
3324            serde_json::to_string_pretty(&serde_json::json!({
3325                "schema_version": SCHEMA_VERSION,
3326                "session_id": DEFAULT_SESSION_ID,
3327                "last_accessed": current_timestamp(),
3328            }))
3329            .unwrap(),
3330        )
3331        .unwrap();
3332        fs::write(path_dir.join("0.bak"), "outside").unwrap();
3333        fs::write(
3334            path_dir.join("meta.json"),
3335            serde_json::to_string_pretty(&serde_json::json!({
3336                "schema_version": SCHEMA_VERSION,
3337                "session_id": DEFAULT_SESSION_ID,
3338                "path": "/tmp/aft-malicious-overwrite-target.txt",
3339                "count": 1,
3340                "entries": [{
3341                    "backup_id": "backup-0",
3342                    "timestamp": current_timestamp(),
3343                    "order": "1",
3344                    "description": "tampered",
3345                    "op_id": "op-tampered",
3346                    "kind": "content",
3347                }]
3348            }))
3349            .unwrap(),
3350        )
3351        .unwrap();
3352
3353        let mut store = BackupStore::new();
3354        store.set_storage_dir(dir.clone(), 72);
3355
3356        assert!(store.sessions_with_backups().is_empty());
3357        let _ = fs::remove_dir_all(&dir);
3358    }
3359
3360    #[test]
3361    fn restore_last_operation_uses_only_top_entries_and_persisted_order() {
3362        let path_a = temp_file("op_order_a.txt", "a1");
3363        let path_b = temp_file("op_order_b.txt", "b1");
3364        let mut store = BackupStore::new();
3365
3366        store
3367            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "buried", Some("op-buried"))
3368            .unwrap();
3369        store
3370            .snapshot(DEFAULT_SESSION_ID, &path_a, "top without op")
3371            .unwrap();
3372        store
3373            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "top", Some("op-top"))
3374            .unwrap();
3375
3376        let key_a = canonicalize_key(&path_a);
3377        let key_b = canonicalize_key(&path_b);
3378        let files = store.entries.get_mut(DEFAULT_SESSION_ID).unwrap();
3379        files.get_mut(&key_a).unwrap()[0].order = u128::MAX;
3380        files.get_mut(&key_a).unwrap()[1].order = 1;
3381        files.get_mut(&key_b).unwrap()[0].order = 2;
3382
3383        fs::write(&path_a, "a2").unwrap();
3384        fs::write(&path_b, "b2").unwrap();
3385
3386        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
3387        assert_eq!(restored.op_id, "op-top");
3388        assert_eq!(restored.restored.len(), 1);
3389        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a2");
3390        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
3391    }
3392}