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 = 3;
19
20/// A single backup entry for a file.
21#[derive(Debug, Clone)]
22pub struct BackupEntry {
23    pub backup_id: String,
24    pub content: String,
25    pub timestamp: u64,
26    pub order: u128,
27    pub description: String,
28    pub op_id: Option<String>,
29    pub kind: BackupEntryKind,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum BackupEntryKind {
34    Content,
35    Tombstone,
36}
37
38impl BackupEntry {
39    fn to_backup_row(
40        &self,
41        harness: &str,
42        session_id: &str,
43        project_key: &str,
44        file_path: &str,
45        path_hash: &str,
46        backup_path: Option<&str>,
47    ) -> BackupRow {
48        BackupRow {
49            backup_id: self.backup_id.clone(),
50            harness: harness.to_string(),
51            session_id: session_id.to_string(),
52            project_key: project_key.to_string(),
53            op_id: self.op_id.clone(),
54            order: self.order,
55            file_path: file_path.to_string(),
56            path_hash: path_hash.to_string(),
57            backup_path: backup_path.map(str::to_string),
58            kind: match self.kind {
59                BackupEntryKind::Content => "content".to_string(),
60                BackupEntryKind::Tombstone => "tombstone".to_string(),
61            },
62            description: self.description.clone(),
63            created_at: i64::try_from(self.timestamp).unwrap_or(i64::MAX),
64            is_tombstone: matches!(self.kind, BackupEntryKind::Tombstone),
65        }
66    }
67}
68
69impl TryFrom<BackupRow> for BackupEntry {
70    type Error = std::io::Error;
71
72    fn try_from(row: BackupRow) -> Result<Self, Self::Error> {
73        let kind = if row.is_tombstone || row.kind == "tombstone" {
74            BackupEntryKind::Tombstone
75        } else {
76            BackupEntryKind::Content
77        };
78        let content = match kind {
79            BackupEntryKind::Content => {
80                let backup_path = row.backup_path.ok_or_else(|| {
81                    std::io::Error::new(
82                        std::io::ErrorKind::NotFound,
83                        format!("backup DB row {} has no backup_path", row.backup_id),
84                    )
85                })?;
86                std::fs::read_to_string(backup_path)?
87            }
88            BackupEntryKind::Tombstone => String::new(),
89        };
90
91        Ok(BackupEntry {
92            backup_id: row.backup_id,
93            content,
94            timestamp: u64::try_from(row.created_at).unwrap_or_default(),
95            order: row.order,
96            description: row.description,
97            op_id: row.op_id,
98            kind,
99        })
100    }
101}
102
103#[derive(Debug, Clone)]
104pub struct RestoredOperation {
105    pub op_id: String,
106    pub restored: Vec<RestoredFile>,
107    pub warnings: Vec<String>,
108}
109
110#[derive(Debug, Clone)]
111pub struct RestoredFile {
112    pub path: PathBuf,
113    pub backup_id: String,
114}
115
116/// Per-(session, file) undo store with optional disk persistence.
117///
118/// Introduced alongside project-shared bridges (issue #14): one bridge can now
119/// serve many OpenCode sessions in the same project, so undo history must be
120/// partitioned by session to keep session A's edits invisible to session B.
121///
122/// The 20-entry cap is enforced **per (session, file)** deliberately — a global
123/// per-file LRU would re-couple sessions and let one busy session evict
124/// another's history.
125///
126/// Disk layout (schema v2):
127///   `<storage_dir>/backups/<session_hash>/session.json` — session metadata
128///   `<storage_dir>/backups/<session_hash>/<path_hash>/meta.json` — file path + count + session
129///   `<storage_dir>/backups/<session_hash>/<path_hash>/0.bak` … `19.bak` — snapshots
130///
131/// Legacy layouts from before sessionization (flat `<path_hash>/` directly under
132/// `backups/`) are migrated on first `set_storage_dir` call into the default
133/// session namespace.
134#[derive(Debug)]
135pub struct BackupStore {
136    /// session -> path -> entry stack
137    entries: HashMap<String, HashMap<PathBuf, Vec<BackupEntry>>>,
138    /// session -> path -> disk metadata
139    disk_index: HashMap<String, HashMap<PathBuf, DiskMeta>>,
140    /// session -> metadata
141    session_meta: HashMap<String, SessionMeta>,
142    counter: AtomicU64,
143    storage_dir: Option<PathBuf>,
144    db_pool: RwLock<Option<Arc<Mutex<Connection>>>>,
145    db_harness: RwLock<Option<String>>,
146    db_project_key: RwLock<Option<String>>,
147}
148
149#[derive(Debug, Clone)]
150struct DiskMeta {
151    dir: PathBuf,
152    count: usize,
153}
154
155#[derive(Debug, Clone, Default)]
156struct SessionMeta {
157    /// Unix timestamp of last read/write activity in this session namespace.
158    /// Maintained in-memory now, reserved for future inactivity-TTL cleanup.
159    last_accessed: u64,
160}
161
162impl BackupStore {
163    pub fn new() -> Self {
164        BackupStore {
165            entries: HashMap::new(),
166            disk_index: HashMap::new(),
167            session_meta: HashMap::new(),
168            counter: AtomicU64::new(0),
169            storage_dir: None,
170            db_pool: RwLock::new(None),
171            db_harness: RwLock::new(None),
172            db_project_key: RwLock::new(None),
173        }
174    }
175
176    pub fn set_db_pool(&self, conn: Arc<Mutex<Connection>>) {
177        if let Ok(mut slot) = self.db_pool.write() {
178            *slot = Some(conn);
179        }
180    }
181
182    pub fn clear_db_pool(&self) {
183        if let Ok(mut slot) = self.db_pool.write() {
184            *slot = None;
185        }
186    }
187
188    pub fn set_db_harness(&self, harness: crate::harness::Harness) {
189        if let Ok(mut slot) = self.db_harness.write() {
190            *slot = Some(harness.as_str().to_string());
191        }
192    }
193
194    pub fn set_db_project_key(&self, project_key: String) {
195        if let Ok(mut slot) = self.db_project_key.write() {
196            *slot = Some(project_key);
197        }
198    }
199
200    /// Set storage directory for disk persistence (called during configure).
201    ///
202    /// Loads the disk index for all session namespaces, removes stale session
203    /// directories, and migrates any legacy pre-session (flat) layout into the
204    /// default namespace.
205    pub fn set_storage_dir(&mut self, dir: PathBuf, ttl_hours: u32) {
206        self.storage_dir = Some(dir);
207        self.entries.clear();
208        self.disk_index.clear();
209        self.session_meta.clear();
210        self.gc_stale_sessions(ttl_hours);
211        self.migrate_legacy_layout_if_needed();
212        self.load_disk_index();
213    }
214
215    /// Snapshot the current contents of `path` under the given session namespace.
216    pub fn snapshot(
217        &mut self,
218        session: &str,
219        path: &Path,
220        description: &str,
221    ) -> Result<String, AftError> {
222        self.snapshot_with_op(session, path, description, None)
223    }
224
225    /// Snapshot the current contents of `path` under the given session namespace,
226    /// optionally tagging it with an operation id shared by all files touched by
227    /// one mutating tool call.
228    pub fn snapshot_with_op(
229        &mut self,
230        session: &str,
231        path: &Path,
232        description: &str,
233        op_id: Option<&str>,
234    ) -> Result<String, AftError> {
235        let content = std::fs::read_to_string(path).map_err(|_| AftError::FileNotFound {
236            path: path.display().to_string(),
237        })?;
238
239        let key = canonicalize_key(path);
240        let (id, order) = self.next_id_and_order();
241        let entry = BackupEntry {
242            backup_id: id.clone(),
243            content,
244            timestamp: current_timestamp(),
245            order,
246            description: description.to_string(),
247            op_id: op_id.map(str::to_string),
248            kind: BackupEntryKind::Content,
249        };
250
251        let session_entries = self.entries.entry(session.to_string()).or_default();
252        let stack = session_entries.entry(key.clone()).or_default();
253        if stack.len() >= MAX_UNDO_DEPTH {
254            stack.remove(0);
255        }
256        stack.push(entry);
257
258        // Persist to disk
259        let stack_clone = stack.clone();
260        self.write_snapshot_to_disk(session, &key, &stack_clone);
261        self.touch_session(session);
262
263        Ok(id)
264    }
265
266    /// Record that `path` was created by the operation and should be removed
267    /// if that operation is undone. No file content is captured.
268    pub fn snapshot_op_tombstone(
269        &mut self,
270        session: &str,
271        op_id: &str,
272        path: &Path,
273        description: &str,
274    ) -> Result<String, AftError> {
275        let key = canonicalize_key(path);
276        let (id, order) = self.next_id_and_order();
277        let entry = BackupEntry {
278            backup_id: id.clone(),
279            content: String::new(),
280            timestamp: current_timestamp(),
281            order,
282            description: description.to_string(),
283            op_id: Some(op_id.to_string()),
284            kind: BackupEntryKind::Tombstone,
285        };
286
287        let session_entries = self.entries.entry(session.to_string()).or_default();
288        let stack = session_entries.entry(key.clone()).or_default();
289        if stack.len() >= MAX_UNDO_DEPTH {
290            stack.remove(0);
291        }
292        stack.push(entry);
293
294        let stack_clone = stack.clone();
295        self.write_snapshot_to_disk(session, &key, &stack_clone);
296        self.touch_session(session);
297
298        Ok(id)
299    }
300
301    /// Restore every top-of-stack backup entry belonging to the most recent
302    /// operation in this session.
303    pub fn restore_last_operation(&mut self, session: &str) -> Result<RestoredOperation, AftError> {
304        match self.load_latest_operation_from_db(session) {
305            Some(Ok(true)) => {}
306            Some(Ok(false)) => {
307                crate::slog_info!(
308                    "backup latest operation DB miss for session {}; falling back to disk",
309                    session
310                );
311                self.load_all_disk_backups(session);
312            }
313            Some(Err(error)) => {
314                crate::slog_warn!(
315                    "backup latest operation DB lookup failed for session {}; falling back to disk: {}",
316                    session,
317                    error
318                );
319                self.load_all_disk_backups(session);
320            }
321            None => {
322                crate::slog_info!(
323                    "backup latest operation DB unavailable for session {}; falling back to disk",
324                    session
325                );
326                self.load_all_disk_backups(session);
327            }
328        }
329
330        let mut latest: Option<(u128, String)> = None;
331        if let Some(files) = self.entries.get(session) {
332            for stack in files.values() {
333                if let Some(entry) = stack.last() {
334                    if let Some(op_id) = &entry.op_id {
335                        let order = entry.order;
336                        if latest
337                            .as_ref()
338                            .map_or(true, |(latest_order, _)| order > *latest_order)
339                        {
340                            latest = Some((order, op_id.clone()));
341                        }
342                    }
343                }
344            }
345        }
346
347        let Some((_, op_id)) = latest else {
348            return Err(AftError::NoUndoHistory {
349                path: "operation".to_string(),
350            });
351        };
352
353        let mut keys_to_restore: Vec<PathBuf> = self
354            .entries
355            .get(session)
356            .map(|files| {
357                files
358                    .iter()
359                    .filter_map(|(key, stack)| {
360                        stack.last().and_then(|entry| {
361                            (entry.op_id.as_deref() == Some(op_id.as_str())).then(|| key.clone())
362                        })
363                    })
364                    .collect()
365            })
366            .unwrap_or_default();
367        keys_to_restore.sort();
368
369        if keys_to_restore.is_empty() {
370            return Err(AftError::NoUndoHistory {
371                path: "operation".to_string(),
372            });
373        }
374
375        let mut content_targets = Vec::new();
376        let mut tombstone_targets = Vec::new();
377        for key in &keys_to_restore {
378            let entry = self
379                .entries
380                .get(session)
381                .and_then(|files| files.get(key))
382                .and_then(|stack| stack.last())
383                .cloned()
384                .ok_or_else(|| AftError::NoUndoHistory {
385                    path: key.display().to_string(),
386                })?;
387            match entry.kind {
388                BackupEntryKind::Content => {
389                    let existing_content = match std::fs::read(key) {
390                        Ok(content) => Some(content),
391                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
392                        Err(e) => {
393                            return Err(AftError::IoError {
394                                path: key.display().to_string(),
395                                message: e.to_string(),
396                            });
397                        }
398                    };
399                    let warning = self.check_external_modification(session, key, key);
400                    content_targets.push((key.clone(), entry, warning, existing_content));
401                }
402                BackupEntryKind::Tombstone => {
403                    let existing_content = if key.is_file() {
404                        Some(std::fs::read(key).map_err(|e| AftError::IoError {
405                            path: key.display().to_string(),
406                            message: e.to_string(),
407                        })?)
408                    } else {
409                        None
410                    };
411                    tombstone_targets.push((key.clone(), entry, existing_content));
412                }
413            }
414        }
415
416        let mut created_dirs = Vec::new();
417        for (key, _, _, _) in &content_targets {
418            if let Some(parent) = key.parent() {
419                if !parent.as_os_str().is_empty() {
420                    let missing_dirs = missing_parent_dirs(parent);
421                    if let Err(e) = std::fs::create_dir_all(parent) {
422                        let mut dirs_to_remove = created_dirs;
423                        dirs_to_remove.extend(missing_dirs);
424                        let rollback_ok = rollback_created_dirs(&dirs_to_remove);
425                        return Err(AftError::IoError {
426                            path: parent.display().to_string(),
427                            message: format!(
428                                "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
429                                e,
430                                !rollback_ok,
431                                rollback_ok
432                            ),
433                        });
434                    }
435                    created_dirs.extend(missing_dirs);
436                }
437            }
438        }
439
440        let mut written = Vec::new();
441        for (key, entry, _, existing_content) in &content_targets {
442            if let Err(e) = std::fs::write(key, &entry.content) {
443                let files_rollback_ok =
444                    rollback_transactional_restore(&written, Some((key, existing_content)));
445                let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
446                let rollback_ok = files_rollback_ok && dirs_rollback_ok;
447                return Err(AftError::IoError {
448                    path: key.display().to_string(),
449                    message: format!(
450                        "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
451                        e,
452                        !rollback_ok,
453                        rollback_ok
454                    ),
455                });
456            }
457            written.push((key.clone(), existing_content.clone()));
458        }
459
460        let mut deleted_tombstones = Vec::new();
461        for (key, _, existing_content) in &tombstone_targets {
462            match std::fs::remove_file(key) {
463                Ok(()) => deleted_tombstones.push((key.clone(), existing_content.clone())),
464                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
465                    deleted_tombstones.push((key.clone(), None));
466                }
467                Err(e) => {
468                    let files_rollback_ok = rollback_transactional_restore(&written, None);
469                    let tombstone_rollback_ok = rollback_deleted_tombstones(&deleted_tombstones);
470                    let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
471                    let rollback_ok =
472                        files_rollback_ok && tombstone_rollback_ok && dirs_rollback_ok;
473                    return Err(AftError::IoError {
474                        path: key.display().to_string(),
475                        message: format!(
476                            "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
477                            e,
478                            !rollback_ok,
479                            rollback_ok
480                        ),
481                    });
482                }
483            }
484        }
485
486        let mut restored = Vec::new();
487        let mut warnings = Vec::new();
488        for (key, entry, warning, _) in content_targets {
489            self.commit_restored_backup(session, &key);
490            if let Some(warning) = warning {
491                warnings.push(format!("{}: {}", key.display(), warning));
492            }
493            restored.push(RestoredFile {
494                path: key,
495                backup_id: entry.backup_id,
496            });
497        }
498        for (key, _, _) in tombstone_targets {
499            self.commit_restored_backup(session, &key);
500        }
501        self.touch_session(session);
502
503        Ok(RestoredOperation {
504            op_id,
505            restored,
506            warnings,
507        })
508    }
509
510    /// Pop the most recent backup for `(session, path)` and restore the file.
511    /// Returns `(entry, optional_warning)`.
512    pub fn restore_latest(
513        &mut self,
514        session: &str,
515        path: &Path,
516    ) -> Result<(BackupEntry, Option<String>), AftError> {
517        let key = canonicalize_key(path);
518
519        match self.load_from_db_if_present(session, &key) {
520            Some(Ok(true)) => {
521                let warning = self.check_external_modification(session, &key, path);
522                let result = self
523                    .do_restore(session, &key, path)
524                    .map(|(entry, _)| (entry, warning));
525                if result.is_ok() {
526                    self.touch_session(session);
527                }
528                return result;
529            }
530            Some(Ok(false)) => {
531                crate::slog_info!(
532                    "backup DB miss for session {} path {}; falling back to disk",
533                    session,
534                    key.display()
535                );
536            }
537            Some(Err(error)) => {
538                crate::slog_warn!(
539                    "backup DB lookup failed for session {} path {}; falling back to disk: {}",
540                    session,
541                    key.display(),
542                    error
543                );
544            }
545            None => {
546                crate::slog_info!(
547                    "backup DB unavailable for session {} path {}; falling back to disk",
548                    session,
549                    key.display()
550                );
551            }
552        }
553
554        // Try memory first
555        let in_memory = self
556            .entries
557            .get(session)
558            .and_then(|s| s.get(&key))
559            .map_or(false, |s| !s.is_empty());
560        if in_memory {
561            let warning = self.check_external_modification(session, &key, path);
562            let result = self
563                .do_restore(session, &key, path)
564                .map(|(entry, _)| (entry, warning));
565            if result.is_ok() {
566                self.touch_session(session);
567            }
568            return result;
569        }
570
571        // Try disk fallback
572        if self.load_from_disk_if_needed(session, &key) {
573            // Check for external modification
574            let warning = self.check_external_modification(session, &key, path);
575            let (entry, _) = self.do_restore(session, &key, path)?;
576            self.touch_session(session);
577            return Ok((entry, warning));
578        }
579
580        Err(AftError::NoUndoHistory {
581            path: path.display().to_string(),
582        })
583    }
584
585    /// Return the backup history for `(session, path)` (oldest first).
586    pub fn history(&self, session: &str, path: &Path) -> Vec<BackupEntry> {
587        let key = canonicalize_key(path);
588        match self.read_stack_from_db(session, &key) {
589            Some(Ok(stack)) if !stack.is_empty() => return stack,
590            Some(Ok(_)) => {
591                crate::slog_info!(
592                    "backup history DB miss for session {} path {}; falling back to disk",
593                    session,
594                    key.display()
595                );
596            }
597            Some(Err(error)) => {
598                crate::slog_warn!(
599                    "backup history DB lookup failed for session {} path {}; falling back to disk: {}",
600                    session,
601                    key.display(),
602                    error
603                );
604            }
605            None => {
606                crate::slog_info!(
607                    "backup history DB unavailable for session {} path {}; falling back to disk",
608                    session,
609                    key.display()
610                );
611            }
612        }
613
614        self.entries
615            .get(session)
616            .and_then(|s| s.get(&key))
617            .cloned()
618            .or_else(|| self.read_stack_from_disk(session, &key))
619            .unwrap_or_default()
620    }
621
622    /// Return the number of on-disk backup entries for `(session, file)`.
623    pub fn disk_history_count(&self, session: &str, path: &Path) -> usize {
624        let key = canonicalize_key(path);
625        self.disk_index
626            .get(session)
627            .and_then(|s| s.get(&key))
628            .map(|m| m.count)
629            .unwrap_or(0)
630    }
631
632    /// Return all files that have at least one backup entry in this session
633    /// (memory + disk). Other sessions' files are not visible.
634    pub fn tracked_files(&self, session: &str) -> Vec<PathBuf> {
635        let mut files: std::collections::HashSet<PathBuf> = self
636            .entries
637            .get(session)
638            .map(|s| s.keys().cloned().collect())
639            .unwrap_or_default();
640        if let Some(disk) = self.disk_index.get(session) {
641            for key in disk.keys() {
642                files.insert(key.clone());
643            }
644        }
645        files.into_iter().collect()
646    }
647
648    /// Return all session namespaces that currently have any backup state
649    /// (memory or disk). Exposed for `/aft-status` aggregate reporting.
650    pub fn sessions_with_backups(&self) -> Vec<String> {
651        let mut sessions: std::collections::HashSet<String> =
652            self.entries.keys().cloned().collect();
653        for s in self.disk_index.keys() {
654            sessions.insert(s.clone());
655        }
656        sessions.into_iter().collect()
657    }
658
659    /// Total on-disk bytes across all sessions (best-effort, reads metadata only).
660    /// Used by `/aft-status` to surface storage footprint.
661    pub fn total_disk_bytes(&self) -> u64 {
662        let mut total = 0u64;
663        for session_dirs in self.disk_index.values() {
664            for meta in session_dirs.values() {
665                if let Ok(read_dir) = std::fs::read_dir(&meta.dir) {
666                    for entry in read_dir.flatten() {
667                        if let Ok(m) = entry.metadata() {
668                            if m.is_file() {
669                                total += m.len();
670                            }
671                        }
672                    }
673                }
674            }
675        }
676        total
677    }
678
679    fn next_id_and_order(&self) -> (String, u128) {
680        let n = self.counter.fetch_add(1, Ordering::Relaxed);
681        let order = ((current_timestamp_nanos() as u128) << 32) | u128::from(n);
682        (format!("backup-{}", n), order)
683    }
684
685    fn db_pool_and_harness(&self) -> Option<(Arc<Mutex<Connection>>, String)> {
686        let pool = self.db_pool.read().ok().and_then(|slot| slot.clone())?;
687        let harness = self.db_harness.read().ok().and_then(|slot| slot.clone())?;
688        Some((pool, harness))
689    }
690
691    fn read_stack_from_db(
692        &self,
693        session: &str,
694        key: &Path,
695    ) -> Option<Result<Vec<BackupEntry>, String>> {
696        let (pool, harness) = self.db_pool_and_harness()?;
697        let conn = match pool.lock() {
698            Ok(conn) => conn,
699            Err(_) => return Some(Err("db mutex poisoned".to_string())),
700        };
701        let path_hash = Self::path_hash(key);
702        Some(
703            crate::db::backups::list_backups(&conn, &harness, session, &path_hash)
704                .map_err(|error| error.to_string())
705                .and_then(|rows| {
706                    rows.into_iter()
707                        .map(BackupEntry::try_from)
708                        .collect::<Result<Vec<_>, _>>()
709                        .map_err(|error| error.to_string())
710                }),
711        )
712    }
713
714    fn load_from_db_if_present(
715        &mut self,
716        session: &str,
717        key: &Path,
718    ) -> Option<Result<bool, String>> {
719        match self.read_stack_from_db(session, key) {
720            Some(Ok(stack)) if !stack.is_empty() => {
721                self.update_counter_from_entries(&stack);
722                self.entries
723                    .entry(session.to_string())
724                    .or_default()
725                    .insert(key.to_path_buf(), stack);
726                Some(Ok(true))
727            }
728            Some(Ok(_)) => Some(Ok(false)),
729            Some(Err(error)) => Some(Err(error)),
730            None => None,
731        }
732    }
733
734    fn load_latest_operation_from_db(&mut self, session: &str) -> Option<Result<bool, String>> {
735        let (pool, harness) = self.db_pool_and_harness()?;
736        let conn = match pool.lock() {
737            Ok(conn) => conn,
738            Err(_) => return Some(Err("db mutex poisoned".to_string())),
739        };
740        let latest = match crate::db::backups::get_latest_operation_backup(&conn, &harness, session)
741        {
742            Ok(Some(row)) => row,
743            Ok(None) => return Some(Ok(false)),
744            Err(error) => return Some(Err(error.to_string())),
745        };
746        let Some(op_id) = latest.op_id else {
747            return Some(Ok(false));
748        };
749        let rows = match crate::db::backups::list_backups_by_op(&conn, &harness, session, &op_id) {
750            Ok(rows) => rows,
751            Err(error) => return Some(Err(error.to_string())),
752        };
753        if rows.is_empty() {
754            return Some(Ok(false));
755        }
756        let path_hashes: std::collections::HashSet<String> =
757            rows.into_iter().map(|row| row.path_hash).collect();
758        drop(conn);
759
760        let mut loaded_any = false;
761        for path_hash in path_hashes {
762            let conn = match pool.lock() {
763                Ok(conn) => conn,
764                Err(_) => return Some(Err("db mutex poisoned".to_string())),
765            };
766            let loaded =
767                match crate::db::backups::list_backups(&conn, &harness, session, &path_hash) {
768                    Ok(rows) => {
769                        let file_path = rows.first().map(|row| row.file_path.clone());
770                        rows.into_iter()
771                            .map(BackupEntry::try_from)
772                            .collect::<Result<Vec<_>, _>>()
773                            .map(|stack| (file_path, stack))
774                            .map_err(|error| error.to_string())
775                    }
776                    Err(error) => Err(error.to_string()),
777                };
778            drop(conn);
779            let (file_path, stack) = match loaded {
780                Ok((file_path, stack)) if !stack.is_empty() => (file_path, stack),
781                Ok(_) => continue,
782                Err(error) => return Some(Err(error)),
783            };
784            let Some(file_path) = file_path else {
785                return Some(Err(format!(
786                    "backup DB rows for path hash {path_hash} have no file path"
787                )));
788            };
789            let key = PathBuf::from(file_path);
790            self.update_counter_from_entries(&stack);
791            self.entries
792                .entry(session.to_string())
793                .or_default()
794                .insert(key, stack);
795            loaded_any = true;
796        }
797
798        Some(Ok(loaded_any))
799    }
800
801    fn update_counter_from_entries(&self, entries: &[BackupEntry]) {
802        if let Some(next_counter) = entries
803            .iter()
804            .filter_map(|entry| backup_sequence(&entry.backup_id))
805            .max()
806            .and_then(|max| max.checked_add(1))
807        {
808            let _ = self
809                .counter
810                .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
811                    (current < next_counter).then_some(next_counter)
812                });
813        }
814    }
815
816    pub fn discard_operation_entries(&mut self, session: &str, op_id: &str) {
817        let keys: Vec<PathBuf> = self
818            .entries
819            .get(session)
820            .map(|files| files.keys().cloned().collect())
821            .unwrap_or_default();
822
823        for key in keys {
824            let mut remove_key = false;
825            let mut remaining_stack = None;
826            if let Some(session_entries) = self.entries.get_mut(session) {
827                if let Some(stack) = session_entries.get_mut(&key) {
828                    while stack
829                        .last()
830                        .is_some_and(|entry| entry.op_id.as_deref() == Some(op_id))
831                    {
832                        stack.pop();
833                    }
834                    if stack.is_empty() {
835                        remove_key = true;
836                    } else {
837                        remaining_stack = Some(stack.clone());
838                    }
839                }
840                if remove_key {
841                    session_entries.remove(&key);
842                }
843            }
844
845            if remove_key {
846                self.remove_disk_backups(session, &key);
847            } else if let Some(stack) = remaining_stack {
848                self.write_snapshot_to_disk(session, &key, &stack);
849            }
850        }
851
852        if self
853            .entries
854            .get(session)
855            .is_some_and(|session_entries| session_entries.is_empty())
856        {
857            self.entries.remove(session);
858        }
859    }
860
861    fn touch_session(&mut self, session: &str) {
862        let now = current_timestamp();
863        self.session_meta
864            .entry(session.to_string())
865            .or_default()
866            .last_accessed = now;
867        self.write_session_marker(session, now);
868    }
869
870    // ---- Internal helpers ----
871
872    fn do_restore(
873        &mut self,
874        session: &str,
875        key: &Path,
876        path: &Path,
877    ) -> Result<(BackupEntry, Option<String>), AftError> {
878        let session_entries =
879            self.entries
880                .get_mut(session)
881                .ok_or_else(|| AftError::NoUndoHistory {
882                    path: path.display().to_string(),
883                })?;
884        let stack = session_entries
885            .get_mut(key)
886            .ok_or_else(|| AftError::NoUndoHistory {
887                path: path.display().to_string(),
888            })?;
889
890        let entry = stack
891            .last()
892            .cloned()
893            .ok_or_else(|| AftError::NoUndoHistory {
894                path: path.display().to_string(),
895            })?;
896
897        match entry.kind {
898            BackupEntryKind::Content => {
899                // Ensure parent directory exists. This matters when restoring an
900                // operation that deleted a directory tree — the parent directories
901                // are gone by the time we try to write the file content back.
902                if let Some(parent) = path.parent() {
903                    if !parent.as_os_str().is_empty() {
904                        std::fs::create_dir_all(parent).map_err(|e| AftError::IoError {
905                            path: parent.display().to_string(),
906                            message: e.to_string(),
907                        })?;
908                    }
909                }
910                std::fs::write(path, &entry.content).map_err(|e| AftError::IoError {
911                    path: path.display().to_string(),
912                    message: e.to_string(),
913                })?;
914            }
915            BackupEntryKind::Tombstone => match std::fs::remove_file(path) {
916                Ok(()) => {}
917                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
918                Err(e) => {
919                    return Err(AftError::IoError {
920                        path: path.display().to_string(),
921                        message: e.to_string(),
922                    });
923                }
924            },
925        }
926
927        stack.pop();
928        if stack.is_empty() {
929            session_entries.remove(key);
930            // Also prune the session map when its last file is gone.
931            if session_entries.is_empty() {
932                self.entries.remove(session);
933            }
934            self.remove_disk_backups(session, key);
935        } else {
936            let stack_clone = self
937                .entries
938                .get(session)
939                .and_then(|s| s.get(key))
940                .cloned()
941                .unwrap_or_default();
942            self.write_snapshot_to_disk(session, key, &stack_clone);
943        }
944
945        Ok((entry, None))
946    }
947
948    fn commit_restored_backup(&mut self, session: &str, key: &Path) {
949        let mut remove_key = false;
950        let mut remove_session = false;
951        let mut remaining_stack = None;
952
953        if let Some(session_entries) = self.entries.get_mut(session) {
954            if let Some(stack) = session_entries.get_mut(key) {
955                stack.pop();
956                if stack.is_empty() {
957                    remove_key = true;
958                } else {
959                    remaining_stack = Some(stack.clone());
960                }
961            }
962
963            if remove_key {
964                session_entries.remove(key);
965                remove_session = session_entries.is_empty();
966            }
967        }
968
969        if remove_session {
970            self.entries.remove(session);
971        }
972
973        if remove_key {
974            self.remove_disk_backups(session, key);
975        } else if let Some(stack) = remaining_stack {
976            self.write_snapshot_to_disk(session, key, &stack);
977        }
978    }
979
980    fn check_external_modification(
981        &self,
982        session: &str,
983        key: &Path,
984        path: &Path,
985    ) -> Option<String> {
986        if let (Some(stack), Ok(current)) = (
987            self.entries.get(session).and_then(|s| s.get(key)),
988            std::fs::read_to_string(path),
989        ) {
990            if let Some(latest) = stack.last() {
991                if latest.content != current {
992                    return Some("file was modified externally since last backup".to_string());
993                }
994            }
995        }
996        None
997    }
998
999    // ---- Disk persistence ----
1000
1001    fn backups_dir(&self) -> Option<PathBuf> {
1002        self.storage_dir.as_ref().map(|d| d.join("backups"))
1003    }
1004
1005    fn session_dir(&self, session: &str) -> Option<PathBuf> {
1006        self.backups_dir()
1007            .map(|d| d.join(Self::session_hash(session)))
1008    }
1009
1010    fn session_hash(session: &str) -> String {
1011        hash_session(session)
1012    }
1013
1014    fn path_hash(key: &Path) -> String {
1015        // v0.16.0 intentionally switched from DefaultHasher to SHA-256 for
1016        // stable on-disk names. Existing DefaultHasher backup directories are
1017        // not migrated: backups are short-lived/session-scoped, so one-time
1018        // loss of pre-upgrade undo history is acceptable.
1019        stable_hash_16(key.to_string_lossy().as_bytes())
1020    }
1021
1022    fn write_session_marker(&self, session: &str, last_accessed: u64) {
1023        let Some(session_dir) = self.session_dir(session) else {
1024            return;
1025        };
1026        if let Err(e) = std::fs::create_dir_all(&session_dir) {
1027            crate::slog_warn!("failed to create session dir: {}", e);
1028            return;
1029        }
1030        let marker = session_dir.join("session.json");
1031        let json = serde_json::json!({
1032            "schema_version": SCHEMA_VERSION,
1033            "session_id": session,
1034            "last_accessed": last_accessed,
1035        });
1036        if let Ok(s) = serde_json::to_string_pretty(&json) {
1037            let tmp = session_dir.join("session.json.tmp");
1038            if std::fs::write(&tmp, s).is_ok() {
1039                let _ = std::fs::rename(&tmp, marker);
1040            }
1041        }
1042    }
1043
1044    fn gc_stale_sessions(&mut self, ttl_hours: u32) {
1045        let backups_dir = match self.backups_dir() {
1046            Some(d) if d.exists() => d,
1047            _ => return,
1048        };
1049        let ttl_secs = u64::from(if ttl_hours == 0 { 72 } else { ttl_hours }) * 60 * 60;
1050        let cutoff = current_timestamp().saturating_sub(ttl_secs);
1051        let entries = match std::fs::read_dir(&backups_dir) {
1052            Ok(entries) => entries,
1053            Err(_) => return,
1054        };
1055
1056        for entry in entries.flatten() {
1057            let session_dir = entry.path();
1058            if !session_dir.is_dir() || session_dir.join("meta.json").exists() {
1059                continue;
1060            }
1061            let Some(last_accessed) = Self::read_session_last_accessed(&session_dir) else {
1062                continue;
1063            };
1064            if last_accessed >= cutoff {
1065                continue;
1066            }
1067            if let Err(e) = std::fs::remove_dir_all(&session_dir) {
1068                crate::slog_warn!(
1069                    "failed to remove stale backup session {}: {}",
1070                    session_dir.display(),
1071                    e
1072                );
1073            } else {
1074                crate::slog_warn!(
1075                    "removed stale backup session {} (last_accessed={})",
1076                    session_dir.display(),
1077                    last_accessed
1078                );
1079            }
1080        }
1081    }
1082
1083    /// One-time migration: move pre-session flat layout into the default
1084    /// session namespace. Called from `set_storage_dir` so existing backups
1085    /// survive the upgrade.
1086    ///
1087    /// Detection: any directory directly under `backups/` that contains a
1088    /// `meta.json` (as opposed to a `session.json` marker or subdirectories)
1089    /// is treated as a legacy entry.
1090    fn migrate_legacy_layout_if_needed(&mut self) {
1091        let backups_dir = match self.backups_dir() {
1092            Some(d) if d.exists() => d,
1093            _ => return,
1094        };
1095        let default_session_dir =
1096            backups_dir.join(Self::session_hash(crate::protocol::DEFAULT_SESSION_ID));
1097
1098        let entries = match std::fs::read_dir(&backups_dir) {
1099            Ok(e) => e,
1100            Err(_) => return,
1101        };
1102        let mut migrated = 0usize;
1103        for entry in entries.flatten() {
1104            let entry_path = entry.path();
1105            // Skip non-directories and already-sessionized layouts.
1106            if !entry_path.is_dir() {
1107                continue;
1108            }
1109            if entry_path == default_session_dir {
1110                continue;
1111            }
1112            let meta_path = entry_path.join("meta.json");
1113            if !meta_path.exists() {
1114                continue; // Already a session-hash dir (contains per-path subdirs), skip
1115            }
1116            // This is a legacy flat-layout path-hash directory. Move it under
1117            // the default session namespace.
1118            if let Err(e) = std::fs::create_dir_all(&default_session_dir) {
1119                crate::slog_warn!("failed to create default session dir: {}", e);
1120                return;
1121            }
1122            let leaf = match entry_path.file_name() {
1123                Some(n) => n,
1124                None => continue,
1125            };
1126            let target = default_session_dir.join(leaf);
1127            if target.exists() {
1128                // Already migrated on a prior run that was interrupted —
1129                // leave both and let the regular load pick up the target.
1130                continue;
1131            }
1132            match std::fs::rename(&entry_path, &target) {
1133                Ok(()) => {
1134                    // Bump meta.json to include session_id + schema_version.
1135                    Self::upgrade_meta_file(
1136                        &target.join("meta.json"),
1137                        crate::protocol::DEFAULT_SESSION_ID,
1138                    );
1139                    migrated += 1;
1140                }
1141                Err(e) => {
1142                    crate::slog_warn!(
1143                        "failed to migrate legacy backup {}: {}",
1144                        entry_path.display(),
1145                        e
1146                    );
1147                }
1148            }
1149        }
1150        if migrated > 0 {
1151            crate::slog_info!(
1152                "migrated {} legacy backup entries into default session namespace",
1153                migrated
1154            );
1155            // Write a session.json marker so future scans don't re-migrate.
1156            let marker = default_session_dir.join("session.json");
1157            let json = serde_json::json!({
1158                "schema_version": SCHEMA_VERSION,
1159                "session_id": crate::protocol::DEFAULT_SESSION_ID,
1160                "last_accessed": current_timestamp(),
1161            });
1162            if let Ok(s) = serde_json::to_string_pretty(&json) {
1163                let _ = std::fs::write(&marker, s);
1164            }
1165        }
1166    }
1167
1168    fn upgrade_meta_file(meta_path: &Path, session_id: &str) {
1169        let content = match std::fs::read_to_string(meta_path) {
1170            Ok(c) => c,
1171            Err(_) => return,
1172        };
1173        let mut parsed: serde_json::Value = match serde_json::from_str(&content) {
1174            Ok(v) => v,
1175            Err(_) => return,
1176        };
1177        if let Some(obj) = parsed.as_object_mut() {
1178            let count = obj.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
1179            obj.insert(
1180                "schema_version".to_string(),
1181                serde_json::json!(SCHEMA_VERSION),
1182            );
1183            obj.insert("session_id".to_string(), serde_json::json!(session_id));
1184            obj.entry("entries").or_insert_with(|| {
1185                serde_json::Value::Array(
1186                    (0..count)
1187                        .map(|i| {
1188                            serde_json::json!({
1189                                "backup_id": format!("disk-{}", i),
1190                                "timestamp": 0,
1191                                "description": "restored from disk",
1192                                "op_id": null,
1193                            })
1194                        })
1195                        .collect(),
1196                )
1197            });
1198        }
1199        if let Ok(s) = serde_json::to_string_pretty(&parsed) {
1200            let tmp = meta_path.with_extension("json.tmp");
1201            if std::fs::write(&tmp, &s).is_ok() {
1202                let _ = std::fs::rename(&tmp, meta_path);
1203            }
1204        }
1205    }
1206
1207    fn load_disk_index(&mut self) {
1208        let backups_dir = match self.backups_dir() {
1209            Some(d) if d.exists() => d,
1210            _ => return,
1211        };
1212        let session_dirs = match std::fs::read_dir(&backups_dir) {
1213            Ok(e) => e,
1214            Err(_) => return,
1215        };
1216        let mut total_entries = 0usize;
1217        for session_entry in session_dirs.flatten() {
1218            let session_dir = session_entry.path();
1219            if !session_dir.is_dir() {
1220                continue;
1221            }
1222            // Recover the session_id from session.json if present, otherwise skip
1223            // (can't invert the hash to recover the original).
1224            let session_id = match Self::read_session_marker(&session_dir) {
1225                Some(session_id) => session_id,
1226                None => {
1227                    crate::slog_warn!(
1228                        "skipping backup session dir without readable session marker: {}",
1229                        session_dir.display()
1230                    );
1231                    continue;
1232                }
1233            };
1234
1235            let path_dirs = match std::fs::read_dir(&session_dir) {
1236                Ok(e) => e,
1237                Err(_) => continue,
1238            };
1239            let per_session = self.disk_index.entry(session_id.clone()).or_default();
1240            for path_entry in path_dirs.flatten() {
1241                let path_dir = path_entry.path();
1242                if !path_dir.is_dir() {
1243                    continue;
1244                }
1245                let meta_path = path_dir.join("meta.json");
1246                if let Ok(content) = std::fs::read_to_string(&meta_path) {
1247                    if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
1248                        if let (Some(path_str), Some(count)) = (
1249                            meta.get("path").and_then(|v| v.as_str()),
1250                            meta.get("count").and_then(|v| v.as_u64()),
1251                        ) {
1252                            let key = PathBuf::from(path_str);
1253                            if !is_loadable_backup_path(&key, &path_dir) {
1254                                crate::slog_warn!(
1255                                    "skipping backup entry with invalid path metadata: {}",
1256                                    meta_path.display()
1257                                );
1258                                continue;
1259                            }
1260                            per_session.insert(
1261                                key,
1262                                DiskMeta {
1263                                    dir: path_dir.clone(),
1264                                    count: count as usize,
1265                                },
1266                            );
1267                            total_entries += 1;
1268                        }
1269                    }
1270                }
1271            }
1272            if per_session.is_empty() {
1273                self.disk_index.remove(&session_id);
1274            }
1275        }
1276        if total_entries > 0 {
1277            crate::slog_info!(
1278                "loaded {} backup entries across {} session(s) from disk",
1279                total_entries,
1280                self.disk_index.len()
1281            );
1282        }
1283    }
1284
1285    fn read_session_marker(session_dir: &Path) -> Option<String> {
1286        let marker = session_dir.join("session.json");
1287        let content = std::fs::read_to_string(&marker).ok()?;
1288        let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
1289        parsed
1290            .get("session_id")
1291            .and_then(|v| v.as_str())
1292            .map(|s| s.to_string())
1293    }
1294
1295    fn read_session_last_accessed(session_dir: &Path) -> Option<u64> {
1296        let marker = session_dir.join("session.json");
1297        let content = std::fs::read_to_string(&marker).ok()?;
1298        let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
1299        parsed.get("last_accessed").and_then(|v| v.as_u64())
1300    }
1301
1302    fn load_from_disk_if_needed(&mut self, session: &str, key: &Path) -> bool {
1303        let Some(entries) = self.read_stack_from_disk(session, key) else {
1304            return false;
1305        };
1306
1307        self.update_counter_from_entries(&entries);
1308
1309        self.entries
1310            .entry(session.to_string())
1311            .or_default()
1312            .insert(key.to_path_buf(), entries);
1313        true
1314    }
1315
1316    fn load_all_disk_backups(&mut self, session: &str) {
1317        let disk_keys: Vec<PathBuf> = self
1318            .disk_index
1319            .get(session)
1320            .map(|files| files.keys().cloned().collect())
1321            .unwrap_or_default();
1322        for key in disk_keys {
1323            self.load_from_disk_if_needed(session, &key);
1324        }
1325    }
1326
1327    fn read_stack_from_disk(&self, session: &str, key: &Path) -> Option<Vec<BackupEntry>> {
1328        let disk_meta = match self
1329            .disk_index
1330            .get(session)
1331            .and_then(|s| s.get(key))
1332            .cloned()
1333        {
1334            Some(m) if m.count > 0 => m,
1335            _ => return None,
1336        };
1337
1338        let mut entries = Vec::new();
1339        let entry_meta = std::fs::read_to_string(disk_meta.dir.join("meta.json"))
1340            .ok()
1341            .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
1342            .and_then(|meta| meta.get("entries").and_then(|v| v.as_array()).cloned())
1343            .unwrap_or_default();
1344
1345        for i in 0..disk_meta.count {
1346            let meta = entry_meta.get(i);
1347            let kind = match meta.and_then(|m| m.get("kind")).and_then(|v| v.as_str()) {
1348                Some("tombstone") => BackupEntryKind::Tombstone,
1349                _ => BackupEntryKind::Content,
1350            };
1351            let content = match kind {
1352                BackupEntryKind::Content => {
1353                    let bak_path = disk_meta.dir.join(format!("{}.bak", i));
1354                    match std::fs::read_to_string(&bak_path) {
1355                        Ok(content) => content,
1356                        Err(_) => continue,
1357                    }
1358                }
1359                BackupEntryKind::Tombstone => String::new(),
1360            };
1361            let backup_id = meta
1362                .and_then(|m| m.get("backup_id"))
1363                .and_then(|v| v.as_str())
1364                .map(str::to_string)
1365                .unwrap_or_else(|| format!("disk-{}", i));
1366            let timestamp = meta
1367                .and_then(|m| m.get("timestamp"))
1368                .and_then(|v| v.as_u64())
1369                .unwrap_or(0);
1370            let order = meta
1371                .and_then(|m| m.get("order"))
1372                .and_then(parse_order_value)
1373                .unwrap_or_else(|| legacy_entry_order(timestamp, &backup_id));
1374            entries.push(BackupEntry {
1375                backup_id,
1376                content,
1377                timestamp,
1378                order,
1379                description: meta
1380                    .and_then(|m| m.get("description"))
1381                    .and_then(|v| v.as_str())
1382                    .unwrap_or("restored from disk")
1383                    .to_string(),
1384                op_id: meta
1385                    .and_then(|m| m.get("op_id"))
1386                    .and_then(|v| v.as_str())
1387                    .map(str::to_string),
1388                kind,
1389            });
1390        }
1391
1392        if entries.is_empty() {
1393            return None;
1394        }
1395        Some(entries)
1396    }
1397
1398    fn write_snapshot_to_disk(&mut self, session: &str, key: &Path, stack: &[BackupEntry]) {
1399        let session_dir = match self.session_dir(session) {
1400            Some(d) => d,
1401            None => return,
1402        };
1403
1404        // Ensure session dir + marker exist.
1405        if let Err(e) = std::fs::create_dir_all(&session_dir) {
1406            crate::slog_warn!("failed to create session dir: {}", e);
1407            return;
1408        }
1409        let marker = session_dir.join("session.json");
1410        if !marker.exists() {
1411            let json = serde_json::json!({
1412                "schema_version": SCHEMA_VERSION,
1413                "session_id": session,
1414                "last_accessed": current_timestamp(),
1415            });
1416            if let Ok(s) = serde_json::to_string_pretty(&json) {
1417                let _ = std::fs::write(&marker, s);
1418            }
1419        }
1420
1421        let hash = Self::path_hash(key);
1422        let dir = session_dir.join(&hash);
1423        if let Err(e) = std::fs::create_dir_all(&dir) {
1424            crate::slog_warn!("failed to create backup dir: {}", e);
1425            return;
1426        }
1427
1428        for (i, entry) in stack.iter().enumerate() {
1429            let bak_path = dir.join(format!("{}.bak", i));
1430            let tmp_path = dir.join(format!("{}.bak.tmp", i));
1431            match entry.kind {
1432                BackupEntryKind::Content => {
1433                    if std::fs::write(&tmp_path, &entry.content).is_ok() {
1434                        let _ = std::fs::rename(&tmp_path, &bak_path);
1435                    }
1436                }
1437                BackupEntryKind::Tombstone => {
1438                    let _ = std::fs::remove_file(&bak_path);
1439                    let _ = std::fs::remove_file(&tmp_path);
1440                }
1441            }
1442        }
1443
1444        // Clean up extra .bak files if stack shrank.
1445        for i in stack.len()..MAX_UNDO_DEPTH {
1446            let old = dir.join(format!("{}.bak", i));
1447            if old.exists() {
1448                let _ = std::fs::remove_file(&old);
1449            }
1450        }
1451
1452        let entries: Vec<serde_json::Value> = stack
1453            .iter()
1454            .map(|entry| {
1455                serde_json::json!({
1456                    "backup_id": entry.backup_id,
1457                    "timestamp": entry.timestamp,
1458                    "order": entry.order.to_string(),
1459                    "description": entry.description,
1460                    "op_id": entry.op_id,
1461                    "kind": match entry.kind {
1462                        BackupEntryKind::Content => "content",
1463                        BackupEntryKind::Tombstone => "tombstone",
1464                    },
1465                })
1466            })
1467            .collect();
1468        let meta = serde_json::json!({
1469            "schema_version": SCHEMA_VERSION,
1470            "session_id": session,
1471            "path": key.display().to_string(),
1472            "count": stack.len(),
1473            "entries": entries,
1474        });
1475        let meta_path = dir.join("meta.json");
1476        let meta_tmp = dir.join("meta.json.tmp");
1477        if let Ok(content) = serde_json::to_string_pretty(&meta) {
1478            if std::fs::write(&meta_tmp, &content).is_ok() {
1479                let _ = std::fs::rename(&meta_tmp, &meta_path);
1480            }
1481        }
1482
1483        // Keep the in-memory disk_index in sync so tracked_files() and
1484        // disk_history_count() immediately reflect what we just wrote.
1485        self.disk_index
1486            .entry(session.to_string())
1487            .or_default()
1488            .insert(
1489                key.to_path_buf(),
1490                DiskMeta {
1491                    dir: dir.clone(),
1492                    count: stack.len(),
1493                },
1494            );
1495        self.dual_write_stack_to_db(session, key, &dir, stack);
1496    }
1497
1498    fn dual_write_stack_to_db(&self, session: &str, key: &Path, dir: &Path, stack: &[BackupEntry]) {
1499        let pool = self.db_pool.read().ok().and_then(|slot| slot.clone());
1500        let Some(pool) = pool else {
1501            return;
1502        };
1503        let harness = self.db_harness.read().ok().and_then(|slot| slot.clone());
1504        let Some(harness) = harness else {
1505            crate::slog_warn!(
1506                "dual-write backup to DB skipped for {}: harness not configured",
1507                key.display()
1508            );
1509            return;
1510        };
1511        let project_key = self
1512            .db_project_key
1513            .read()
1514            .ok()
1515            .and_then(|slot| slot.clone());
1516        let Some(project_key) = project_key else {
1517            crate::slog_warn!(
1518                "dual-write backup to DB skipped for {}: project key not configured",
1519                key.display()
1520            );
1521            return;
1522        };
1523
1524        let conn = match pool.lock() {
1525            Ok(conn) => conn,
1526            Err(_) => {
1527                crate::slog_warn!(
1528                    "dual-write backup to DB failed for {}: db mutex poisoned",
1529                    key.display()
1530                );
1531                return;
1532            }
1533        };
1534        let path_hash = Self::path_hash(key);
1535        let file_path = key.display().to_string();
1536        if let Err(error) =
1537            crate::db::backups::delete_backups_for_path(&conn, &harness, session, &path_hash)
1538        {
1539            crate::slog_warn!(
1540                "delete old backup DB rows failed for {}: {}",
1541                key.display(),
1542                error
1543            );
1544            return;
1545        }
1546        for (index, entry) in stack.iter().enumerate() {
1547            let backup_path = match entry.kind {
1548                BackupEntryKind::Content => {
1549                    Some(dir.join(format!("{}.bak", index)).display().to_string())
1550                }
1551                BackupEntryKind::Tombstone => None,
1552            };
1553            let row = entry.to_backup_row(
1554                &harness,
1555                session,
1556                &project_key,
1557                &file_path,
1558                &path_hash,
1559                backup_path.as_deref(),
1560            );
1561            if let Err(error) = crate::db::backups::upsert_backup(&conn, &row) {
1562                crate::slog_warn!(
1563                    "dual-write backup to DB failed for {}: {}",
1564                    entry.backup_id,
1565                    error
1566                );
1567            }
1568        }
1569    }
1570
1571    fn remove_disk_backups(&mut self, session: &str, key: &Path) {
1572        self.remove_db_backups(session, key);
1573        let removed = self.disk_index.get_mut(session).and_then(|s| s.remove(key));
1574        if let Some(meta) = removed {
1575            let _ = std::fs::remove_dir_all(&meta.dir);
1576        } else if let Some(session_dir) = self.session_dir(session) {
1577            let hash = Self::path_hash(key);
1578            let dir = session_dir.join(&hash);
1579            if dir.exists() {
1580                let _ = std::fs::remove_dir_all(&dir);
1581            }
1582        }
1583
1584        // If this session has no more disk entries, drop the map slot (session
1585        // dir itself is kept so the marker survives future sessions).
1586        let empty = self
1587            .disk_index
1588            .get(session)
1589            .map(|s| s.is_empty())
1590            .unwrap_or(false);
1591        if empty {
1592            self.disk_index.remove(session);
1593        }
1594    }
1595
1596    fn remove_db_backups(&self, session: &str, key: &Path) {
1597        let Some((pool, harness)) = self.db_pool_and_harness() else {
1598            return;
1599        };
1600        let conn = match pool.lock() {
1601            Ok(conn) => conn,
1602            Err(_) => {
1603                crate::slog_warn!(
1604                    "delete backup DB rows failed for {}: db mutex poisoned",
1605                    key.display()
1606                );
1607                return;
1608            }
1609        };
1610        let path_hash = Self::path_hash(key);
1611        if let Err(error) =
1612            crate::db::backups::delete_backups_for_path(&conn, &harness, session, &path_hash)
1613        {
1614            crate::slog_warn!(
1615                "delete backup DB rows failed for {}: {}",
1616                key.display(),
1617                error
1618            );
1619        }
1620    }
1621}
1622
1623pub fn hash_session(session: &str) -> String {
1624    stable_hash_16(session.as_bytes())
1625}
1626
1627pub fn new_op_id() -> String {
1628    let mut bytes = [0u8; 4];
1629    if getrandom::fill(&mut bytes).is_err() {
1630        bytes = current_timestamp().to_le_bytes()[..4]
1631            .try_into()
1632            .unwrap_or([0; 4]);
1633    }
1634    let rand = u32::from_le_bytes(bytes);
1635    format!("op-{}-{:08x}", current_timestamp() * 1000, rand)
1636}
1637
1638fn canonicalize_key(path: &Path) -> PathBuf {
1639    std::fs::canonicalize(path).unwrap_or_else(|err| {
1640        log::debug!(
1641            "backup canonicalize_key fallback for {}: {}",
1642            path.display(),
1643            err
1644        );
1645        path.to_path_buf()
1646    })
1647}
1648
1649fn rollback_transactional_restore(
1650    written: &[(PathBuf, Option<Vec<u8>>)],
1651    attempted: Option<(&PathBuf, &Option<Vec<u8>>)>,
1652) -> bool {
1653    let mut ok = true;
1654
1655    if let Some((path, content)) = attempted {
1656        ok &= rollback_one_restore_write(path, content);
1657    }
1658
1659    for (path, content) in written.iter().rev() {
1660        ok &= rollback_one_restore_write(path, content);
1661    }
1662
1663    ok
1664}
1665
1666fn rollback_one_restore_write(path: &Path, content: &Option<Vec<u8>>) -> bool {
1667    match content {
1668        Some(content) => std::fs::write(path, content).is_ok(),
1669        None => match std::fs::remove_file(path) {
1670            Ok(()) => true,
1671            Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
1672            Err(_) => false,
1673        },
1674    }
1675}
1676
1677fn rollback_deleted_tombstones(deleted: &[(PathBuf, Option<Vec<u8>>)]) -> bool {
1678    let mut ok = true;
1679    for (path, content) in deleted.iter().rev() {
1680        if let Some(content) = content {
1681            if let Some(parent) = path.parent() {
1682                if !parent.as_os_str().is_empty() && std::fs::create_dir_all(parent).is_err() {
1683                    ok = false;
1684                    continue;
1685                }
1686            }
1687            if std::fs::write(path, content).is_err() {
1688                ok = false;
1689            }
1690        }
1691    }
1692    ok
1693}
1694
1695fn missing_parent_dirs(parent: &Path) -> Vec<PathBuf> {
1696    let mut dirs = Vec::new();
1697    let mut current = Some(parent);
1698
1699    while let Some(dir) = current {
1700        if dir.as_os_str().is_empty() || dir.exists() {
1701            break;
1702        }
1703        dirs.push(dir.to_path_buf());
1704        current = dir.parent();
1705    }
1706
1707    dirs
1708}
1709
1710fn rollback_created_dirs(dirs: &[PathBuf]) -> bool {
1711    let mut dirs = dirs.to_vec();
1712    dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
1713    dirs.dedup();
1714
1715    let mut ok = true;
1716    for dir in dirs {
1717        match std::fs::remove_dir(&dir) {
1718            Ok(()) => {}
1719            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1720            Err(_) => ok = false,
1721        }
1722    }
1723
1724    ok
1725}
1726
1727fn current_timestamp() -> u64 {
1728    std::time::SystemTime::now()
1729        .duration_since(std::time::UNIX_EPOCH)
1730        .unwrap_or_default()
1731        .as_secs()
1732}
1733
1734fn current_timestamp_nanos() -> u64 {
1735    let nanos = std::time::SystemTime::now()
1736        .duration_since(std::time::UNIX_EPOCH)
1737        .unwrap_or_default()
1738        .as_nanos();
1739    nanos.min(u128::from(u64::MAX)) as u64
1740}
1741
1742fn legacy_entry_order(timestamp_secs: u64, backup_id: &str) -> u128 {
1743    let nanos = timestamp_secs.saturating_mul(1_000_000_000);
1744    ((nanos as u128) << 32) | u128::from(backup_sequence(backup_id).unwrap_or(0))
1745}
1746
1747fn parse_order_value(value: &serde_json::Value) -> Option<u128> {
1748    value
1749        .as_str()
1750        .and_then(|s| s.parse::<u128>().ok())
1751        .or_else(|| value.as_u64().map(u128::from))
1752}
1753
1754fn is_loadable_backup_path(key: &Path, path_dir: &Path) -> bool {
1755    if !key.is_absolute()
1756        || key
1757            .components()
1758            .any(|c| matches!(c, std::path::Component::ParentDir))
1759    {
1760        return false;
1761    }
1762    let Some(dir_name) = path_dir.file_name().and_then(|name| name.to_str()) else {
1763        return false;
1764    };
1765    BackupStore::path_hash(key) == dir_name
1766}
1767
1768fn stable_hash_16(bytes: &[u8]) -> String {
1769    let digest = Sha256::digest(bytes);
1770    digest[..8]
1771        .iter()
1772        .map(|byte| format!("{:02x}", byte))
1773        .collect()
1774}
1775
1776fn backup_sequence(backup_id: &str) -> Option<u64> {
1777    backup_id
1778        .strip_prefix("backup-")
1779        .or_else(|| backup_id.strip_prefix("disk-"))
1780        .and_then(|s| s.parse().ok())
1781}
1782
1783#[cfg(test)]
1784mod tests {
1785    use super::*;
1786    use crate::protocol::DEFAULT_SESSION_ID;
1787    use std::fs;
1788    #[cfg(unix)]
1789    use std::os::unix::fs::PermissionsExt;
1790
1791    fn temp_file(name: &str, content: &str) -> PathBuf {
1792        let dir = std::env::temp_dir().join("aft_backup_tests");
1793        fs::create_dir_all(&dir).unwrap();
1794        let path = dir.join(name);
1795        fs::write(&path, content).unwrap();
1796        path
1797    }
1798
1799    #[test]
1800    fn snapshot_and_restore_round_trip() {
1801        let path = temp_file("round_trip.txt", "original");
1802        let mut store = BackupStore::new();
1803
1804        let id = store
1805            .snapshot(DEFAULT_SESSION_ID, &path, "before edit")
1806            .unwrap();
1807        assert!(id.starts_with("backup-"));
1808
1809        fs::write(&path, "modified").unwrap();
1810        assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
1811
1812        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
1813        assert_eq!(entry.content, "original");
1814        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
1815    }
1816
1817    #[test]
1818    fn multiple_snapshots_preserve_order() {
1819        let path = temp_file("order.txt", "v1");
1820        let mut store = BackupStore::new();
1821
1822        store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
1823        fs::write(&path, "v2").unwrap();
1824        store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
1825        fs::write(&path, "v3").unwrap();
1826        store.snapshot(DEFAULT_SESSION_ID, &path, "third").unwrap();
1827
1828        let history = store.history(DEFAULT_SESSION_ID, &path);
1829        assert_eq!(history.len(), 3);
1830        assert_eq!(history[0].content, "v1");
1831        assert_eq!(history[1].content, "v2");
1832        assert_eq!(history[2].content, "v3");
1833    }
1834
1835    #[test]
1836    fn restore_pops_from_stack() {
1837        let path = temp_file("pop.txt", "v1");
1838        let mut store = BackupStore::new();
1839
1840        store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
1841        fs::write(&path, "v2").unwrap();
1842        store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
1843
1844        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
1845        assert_eq!(entry.description, "second");
1846        assert_eq!(entry.content, "v2");
1847
1848        let history = store.history(DEFAULT_SESSION_ID, &path);
1849        assert_eq!(history.len(), 1);
1850    }
1851
1852    #[test]
1853    fn empty_history_returns_empty_vec() {
1854        let store = BackupStore::new();
1855        let path = Path::new("/tmp/aft_backup_tests/nonexistent_history.txt");
1856        assert!(store.history(DEFAULT_SESSION_ID, path).is_empty());
1857    }
1858
1859    #[test]
1860    fn snapshot_nonexistent_file_returns_error() {
1861        let mut store = BackupStore::new();
1862        let path = Path::new("/tmp/aft_backup_tests/absolutely_does_not_exist.txt");
1863        assert!(store.snapshot(DEFAULT_SESSION_ID, path, "test").is_err());
1864    }
1865
1866    #[test]
1867    fn tracked_files_lists_snapshotted_paths() {
1868        let path1 = temp_file("tracked1.txt", "a");
1869        let path2 = temp_file("tracked2.txt", "b");
1870        let mut store = BackupStore::new();
1871
1872        store.snapshot(DEFAULT_SESSION_ID, &path1, "snap1").unwrap();
1873        store.snapshot(DEFAULT_SESSION_ID, &path2, "snap2").unwrap();
1874        assert_eq!(store.tracked_files(DEFAULT_SESSION_ID).len(), 2);
1875    }
1876
1877    #[test]
1878    fn sessions_are_isolated() {
1879        let path = temp_file("isolated.txt", "original");
1880        let mut store = BackupStore::new();
1881
1882        store.snapshot("session_a", &path, "a's snapshot").unwrap();
1883
1884        // Session B sees no history for this file.
1885        assert!(store.history("session_b", &path).is_empty());
1886        assert_eq!(store.tracked_files("session_b").len(), 0);
1887
1888        // Session B's restore_latest fails with NoUndoHistory.
1889        let err = store.restore_latest("session_b", &path);
1890        assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
1891
1892        // Session A still sees its own snapshot.
1893        assert_eq!(store.history("session_a", &path).len(), 1);
1894        assert_eq!(store.tracked_files("session_a").len(), 1);
1895    }
1896
1897    #[test]
1898    fn per_session_per_file_cap_is_independent() {
1899        // Two sessions fill up their own stacks independently; hitting the cap
1900        // in session A does not evict anything from session B.
1901        let path = temp_file("cap_indep.txt", "v0");
1902        let mut store = BackupStore::new();
1903
1904        for i in 0..(MAX_UNDO_DEPTH + 5) {
1905            fs::write(&path, format!("a{}", i)).unwrap();
1906            store.snapshot("session_a", &path, "a").unwrap();
1907        }
1908        fs::write(&path, "b_initial").unwrap();
1909        store.snapshot("session_b", &path, "b").unwrap();
1910
1911        // Session A should be capped at MAX_UNDO_DEPTH.
1912        assert_eq!(store.history("session_a", &path).len(), MAX_UNDO_DEPTH);
1913        // Session B should still have its single entry.
1914        assert_eq!(store.history("session_b", &path).len(), 1);
1915    }
1916
1917    #[test]
1918    fn sessions_with_backups_lists_all_namespaces() {
1919        let path_a = temp_file("sessions_list_a.txt", "a");
1920        let path_b = temp_file("sessions_list_b.txt", "b");
1921        let mut store = BackupStore::new();
1922
1923        store.snapshot("alice", &path_a, "from alice").unwrap();
1924        store.snapshot("bob", &path_b, "from bob").unwrap();
1925
1926        let sessions = store.sessions_with_backups();
1927        assert_eq!(sessions.len(), 2);
1928        assert!(sessions.iter().any(|s| s == "alice"));
1929        assert!(sessions.iter().any(|s| s == "bob"));
1930    }
1931
1932    #[test]
1933    fn disk_persistence_survives_reload() {
1934        let dir = std::env::temp_dir().join("aft_backup_disk_test");
1935        let _ = fs::remove_dir_all(&dir);
1936        fs::create_dir_all(&dir).unwrap();
1937
1938        let file_path = temp_file("disk_persist.txt", "original");
1939
1940        // Create store with storage, snapshot under default session, drop.
1941        {
1942            let mut store = BackupStore::new();
1943            store.set_storage_dir(dir.clone(), 72);
1944            store
1945                .snapshot(DEFAULT_SESSION_ID, &file_path, "before edit")
1946                .unwrap();
1947        }
1948
1949        // Modify the file externally.
1950        fs::write(&file_path, "externally modified").unwrap();
1951
1952        // Create new store, load from disk, restore.
1953        let mut store2 = BackupStore::new();
1954        store2.set_storage_dir(dir.clone(), 72);
1955
1956        let (entry, warning) = store2
1957            .restore_latest(DEFAULT_SESSION_ID, &file_path)
1958            .unwrap();
1959        assert_eq!(entry.content, "original");
1960        assert!(warning.is_some()); // modified externally
1961        assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
1962
1963        let _ = fs::remove_dir_all(&dir);
1964    }
1965
1966    #[test]
1967    fn legacy_flat_layout_migrates_to_default_session() {
1968        // Simulate a pre-session on-disk layout (schema v1) and verify it's
1969        // moved under the default session namespace on set_storage_dir.
1970        let dir = std::env::temp_dir().join("aft_backup_migration_test");
1971        let _ = fs::remove_dir_all(&dir);
1972        fs::create_dir_all(&dir).unwrap();
1973        let backups = dir.join("backups");
1974        fs::create_dir_all(&backups).unwrap();
1975
1976        // Fake legacy entry for some path hash.
1977        let legacy_hash = "deadbeefcafebabe";
1978        let legacy_dir = backups.join(legacy_hash);
1979        fs::create_dir_all(&legacy_dir).unwrap();
1980        fs::write(legacy_dir.join("0.bak"), "original content").unwrap();
1981        let legacy_meta = serde_json::json!({
1982            "path": "/tmp/migrated_file.txt",
1983            "count": 1,
1984        });
1985        fs::write(
1986            legacy_dir.join("meta.json"),
1987            serde_json::to_string_pretty(&legacy_meta).unwrap(),
1988        )
1989        .unwrap();
1990
1991        // Run migration.
1992        let mut store = BackupStore::new();
1993        store.set_storage_dir(dir.clone(), 72);
1994
1995        // After migration, the legacy dir should be gone from the top level,
1996        // and the entry should now live under the default-session hash dir.
1997        let default_session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
1998        assert!(default_session_dir.exists());
1999        assert!(default_session_dir.join(legacy_hash).exists());
2000        assert!(!backups.join(legacy_hash).exists());
2001
2002        // The upgraded meta.json should now include session_id + schema_version.
2003        let meta_content =
2004            fs::read_to_string(default_session_dir.join(legacy_hash).join("meta.json")).unwrap();
2005        let meta: serde_json::Value = serde_json::from_str(&meta_content).unwrap();
2006        assert_eq!(meta["session_id"], DEFAULT_SESSION_ID);
2007        assert_eq!(meta["schema_version"], SCHEMA_VERSION);
2008
2009        let _ = fs::remove_dir_all(&dir);
2010    }
2011
2012    #[test]
2013    fn set_storage_dir_removes_stale_backup_sessions() {
2014        let dir = std::env::temp_dir().join("aft_backup_gc_test");
2015        let _ = fs::remove_dir_all(&dir);
2016        let backups = dir.join("backups");
2017        fs::create_dir_all(&backups).unwrap();
2018
2019        let stale_session_dir = backups.join("stale-session");
2020        fs::create_dir_all(&stale_session_dir).unwrap();
2021        let stale_marker = serde_json::json!({
2022            "schema_version": SCHEMA_VERSION,
2023            "session_id": "stale",
2024            "last_accessed": 1,
2025        });
2026        fs::write(
2027            stale_session_dir.join("session.json"),
2028            serde_json::to_string_pretty(&stale_marker).unwrap(),
2029        )
2030        .unwrap();
2031
2032        let mut store = BackupStore::new();
2033        store.set_storage_dir(dir.clone(), 1);
2034
2035        assert!(!stale_session_dir.exists());
2036        let _ = fs::remove_dir_all(&dir);
2037    }
2038
2039    #[test]
2040    fn markerless_session_dir_is_skipped_not_mapped_to_default() {
2041        let dir = std::env::temp_dir().join("aft_backup_markerless_skip_test");
2042        let _ = fs::remove_dir_all(&dir);
2043        let file_path = temp_file("markerless.txt", "original");
2044        let key = canonicalize_key(&file_path);
2045        let path_dir = dir
2046            .join("backups")
2047            .join("corrupt-session")
2048            .join("path-entry");
2049        fs::create_dir_all(&path_dir).unwrap();
2050        fs::write(path_dir.join("0.bak"), "original").unwrap();
2051        fs::write(
2052            path_dir.join("meta.json"),
2053            serde_json::to_string_pretty(&serde_json::json!({
2054                "schema_version": SCHEMA_VERSION,
2055                "session_id": "lost-session",
2056                "path": key.display().to_string(),
2057                "count": 1,
2058                "entries": [{
2059                    "backup_id": "disk-0",
2060                    "timestamp": 0,
2061                    "description": "corrupt marker test",
2062                    "op_id": null,
2063                    "kind": "content",
2064                }]
2065            }))
2066            .unwrap(),
2067        )
2068        .unwrap();
2069
2070        let mut store = BackupStore::new();
2071        store.set_storage_dir(dir.clone(), 72);
2072
2073        assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 0);
2074        assert!(store.sessions_with_backups().is_empty());
2075        let _ = fs::remove_dir_all(&dir);
2076    }
2077
2078    #[test]
2079    fn set_storage_dir_reconfiguration_drops_previous_disk_index() {
2080        let dir_a = std::env::temp_dir().join("aft_backup_storage_a_test");
2081        let dir_b = std::env::temp_dir().join("aft_backup_storage_b_test");
2082        let _ = fs::remove_dir_all(&dir_a);
2083        let _ = fs::remove_dir_all(&dir_b);
2084        fs::create_dir_all(&dir_a).unwrap();
2085        fs::create_dir_all(&dir_b).unwrap();
2086        let file_path = temp_file("storage_reconfigure.txt", "original");
2087
2088        let mut store = BackupStore::new();
2089        store.set_storage_dir(dir_a.clone(), 72);
2090        store
2091            .snapshot(DEFAULT_SESSION_ID, &file_path, "stored in a")
2092            .unwrap();
2093        assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 1);
2094
2095        store.set_storage_dir(dir_b.clone(), 72);
2096
2097        assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 0);
2098        assert!(store.tracked_files(DEFAULT_SESSION_ID).is_empty());
2099        let _ = fs::remove_dir_all(&dir_a);
2100        let _ = fs::remove_dir_all(&dir_b);
2101    }
2102
2103    #[test]
2104    fn restore_last_operation_restores_all_top_entries_for_same_op() {
2105        let path_a = temp_file("op_restore_a.txt", "a1");
2106        let path_b = temp_file("op_restore_b.txt", "b1");
2107        let mut store = BackupStore::new();
2108        let op_id = "op-test-00000001";
2109
2110        store
2111            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
2112            .unwrap();
2113        store
2114            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
2115            .unwrap();
2116        fs::write(&path_a, "a2").unwrap();
2117        fs::write(&path_b, "b2").unwrap();
2118
2119        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2120        assert_eq!(restored.op_id, op_id);
2121        assert_eq!(restored.restored.len(), 2);
2122        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a1");
2123        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
2124    }
2125
2126    #[test]
2127    fn restore_last_operation_deletes_tombstone_destination() {
2128        let dir = std::env::temp_dir().join("aft_backup_tombstone_delete_test");
2129        let _ = fs::remove_dir_all(&dir);
2130        fs::create_dir_all(&dir).unwrap();
2131        let source = dir.join("source.txt");
2132        let destination = dir.join("destination.txt");
2133        fs::write(&source, "original").unwrap();
2134
2135        let mut store = BackupStore::new();
2136        let op_id = "op-tombstone-delete";
2137        store
2138            .snapshot_with_op(DEFAULT_SESSION_ID, &source, "move source", Some(op_id))
2139            .unwrap();
2140        fs::rename(&source, &destination).unwrap();
2141        store
2142            .snapshot_op_tombstone(DEFAULT_SESSION_ID, op_id, &destination, "created dest")
2143            .unwrap();
2144
2145        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2146        assert_eq!(restored.op_id, op_id);
2147        assert_eq!(restored.restored.len(), 1);
2148        assert_eq!(fs::read_to_string(&source).unwrap(), "original");
2149        assert!(!destination.exists());
2150        let _ = fs::remove_dir_all(&dir);
2151    }
2152
2153    #[test]
2154    fn restore_last_operation_rolls_back_source_when_tombstone_delete_fails() {
2155        let dir = std::env::temp_dir().join("aft_backup_tombstone_atomic_test");
2156        let _ = fs::remove_dir_all(&dir);
2157        fs::create_dir_all(&dir).unwrap();
2158        let source = dir.join("source.txt");
2159        let destination = dir.join("destination.txt");
2160        fs::write(&source, "original").unwrap();
2161
2162        let mut store = BackupStore::new();
2163        let op_id = "op-tombstone-atomic";
2164        store
2165            .snapshot_with_op(DEFAULT_SESSION_ID, &source, "move source", Some(op_id))
2166            .unwrap();
2167        fs::rename(&source, &destination).unwrap();
2168        store
2169            .snapshot_op_tombstone(DEFAULT_SESSION_ID, op_id, &destination, "created dest")
2170            .unwrap();
2171
2172        fs::remove_file(&destination).unwrap();
2173        fs::create_dir(&destination).unwrap();
2174        let result = store.restore_last_operation(DEFAULT_SESSION_ID);
2175
2176        assert!(result.is_err(), "directory tombstone target should fail");
2177        assert!(
2178            !source.exists(),
2179            "source restore must roll back when destination deletion fails"
2180        );
2181        assert!(
2182            destination.is_dir(),
2183            "failed tombstone target should remain"
2184        );
2185        let _ = fs::remove_dir_all(&dir);
2186    }
2187
2188    // Uses Unix-specific PermissionsExt::set_mode to make a target file
2189    // read-only and force the Phase 1 write to fail. The atomicity logic
2190    // it exercises is platform-independent — Windows has different
2191    // mechanisms for forcing write failures, covered separately.
2192    #[cfg(unix)]
2193    #[test]
2194    fn restore_last_operation_is_atomic_when_a_write_fails() {
2195        let dir = std::env::temp_dir().join("aft_backup_tests_atomic_restore");
2196        let _ = fs::remove_dir_all(&dir);
2197        fs::create_dir_all(&dir).unwrap();
2198        let path_a = dir.join("a.txt");
2199        let path_b = dir.join("b.txt");
2200        let path_c = dir.join("c.txt");
2201        fs::write(&path_a, "a-original").unwrap();
2202        fs::write(&path_b, "b-original").unwrap();
2203        fs::write(&path_c, "c-original").unwrap();
2204
2205        let mut store = BackupStore::new();
2206        let op_id = "op-atomic-restore-01";
2207        let id_a = store
2208            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
2209            .unwrap();
2210        let id_b = store
2211            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
2212            .unwrap();
2213        let id_c = store
2214            .snapshot_with_op(DEFAULT_SESSION_ID, &path_c, "c", Some(op_id))
2215            .unwrap();
2216        fs::write(&path_a, "a-modified").unwrap();
2217        fs::write(&path_b, "b-modified").unwrap();
2218        fs::write(&path_c, "c-modified").unwrap();
2219
2220        let original_permissions = fs::metadata(&path_b).unwrap().permissions();
2221        let mut readonly_permissions = original_permissions.clone();
2222        readonly_permissions.set_mode(0o444);
2223        fs::set_permissions(&path_b, readonly_permissions).unwrap();
2224
2225        let result = store.restore_last_operation(DEFAULT_SESSION_ID);
2226        fs::set_permissions(&path_b, original_permissions).unwrap();
2227
2228        assert!(result.is_err());
2229        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
2230        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
2231        assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-modified");
2232
2233        let history_a = store.history(DEFAULT_SESSION_ID, &path_a);
2234        let history_b = store.history(DEFAULT_SESSION_ID, &path_b);
2235        let history_c = store.history(DEFAULT_SESSION_ID, &path_c);
2236        assert_eq!(history_a.len(), 1);
2237        assert_eq!(history_b.len(), 1);
2238        assert_eq!(history_c.len(), 1);
2239        assert_eq!(history_a[0].backup_id, id_a);
2240        assert_eq!(history_b[0].backup_id, id_b);
2241        assert_eq!(history_c[0].backup_id, id_c);
2242        assert_eq!(history_a[0].op_id.as_deref(), Some(op_id));
2243        assert_eq!(history_b[0].op_id.as_deref(), Some(op_id));
2244        assert_eq!(history_c[0].op_id.as_deref(), Some(op_id));
2245
2246        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2247        assert_eq!(restored.op_id, op_id);
2248        assert_eq!(restored.restored.len(), 3);
2249        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
2250        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
2251        assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-original");
2252
2253        let _ = fs::remove_dir_all(&dir);
2254    }
2255
2256    #[test]
2257    fn restore_last_operation_restores_only_most_recent_op() {
2258        let path_a = temp_file("op_recent_a.txt", "a1");
2259        let path_b = temp_file("op_recent_b.txt", "b1");
2260        let mut store = BackupStore::new();
2261
2262        store
2263            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "older", Some("op-older"))
2264            .unwrap();
2265        store
2266            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "newer", Some("op-newer"))
2267            .unwrap();
2268        fs::write(&path_a, "a2").unwrap();
2269        fs::write(&path_b, "b2").unwrap();
2270
2271        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2272        assert_eq!(restored.op_id, "op-newer");
2273        assert_eq!(restored.restored.len(), 1);
2274        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a2");
2275        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
2276    }
2277
2278    #[test]
2279    fn restore_recreates_missing_parent_directories() {
2280        // Simulate aft_delete files: [dir/] with recursive: true:
2281        // the parent directories are gone by the time we restore.
2282        let dir = std::env::temp_dir().join("aft_backup_tests_recreate_parents");
2283        let _ = fs::remove_dir_all(&dir);
2284        let nested = dir.join("nested");
2285        fs::create_dir_all(&nested).unwrap();
2286        let path = nested.join("inner.txt");
2287        fs::write(&path, "original").unwrap();
2288
2289        let mut store = BackupStore::new();
2290        let op_id = "op-recreate-parents-01";
2291        store
2292            .snapshot_with_op(DEFAULT_SESSION_ID, &path, "original", Some(op_id))
2293            .unwrap();
2294
2295        // Real-world delete sequence: tree is wiped before undo runs.
2296        fs::remove_dir_all(&dir).unwrap();
2297        assert!(!path.exists());
2298        assert!(!nested.exists());
2299        assert!(!dir.exists());
2300
2301        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2302        assert_eq!(restored.op_id, op_id);
2303        assert_eq!(restored.restored.len(), 1);
2304        assert!(
2305            path.exists(),
2306            "file should be restored even though both nested/ and dir/ were missing"
2307        );
2308        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
2309
2310        let _ = fs::remove_dir_all(&dir);
2311    }
2312
2313    #[test]
2314    fn restore_last_operation_ignores_legacy_entries_without_op_id() {
2315        let path = temp_file("op_legacy_none.txt", "v1");
2316        let mut store = BackupStore::new();
2317
2318        store.snapshot(DEFAULT_SESSION_ID, &path, "legacy").unwrap();
2319        fs::write(&path, "v2").unwrap();
2320
2321        let err = store.restore_last_operation(DEFAULT_SESSION_ID);
2322        assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
2323        assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
2324    }
2325
2326    #[test]
2327    fn schema_v2_meta_loads_with_none_op_id_and_persists_as_v3() {
2328        let dir = std::env::temp_dir().join("aft_backup_v2_to_v3_test");
2329        let _ = fs::remove_dir_all(&dir);
2330        fs::create_dir_all(&dir).unwrap();
2331        let file_path = temp_file("v2_to_v3.txt", "original");
2332        let key = canonicalize_key(&file_path);
2333        let session_dir = dir
2334            .join("backups")
2335            .join(BackupStore::session_hash(DEFAULT_SESSION_ID));
2336        let path_dir = session_dir.join(BackupStore::path_hash(&key));
2337        fs::create_dir_all(&path_dir).unwrap();
2338        fs::write(path_dir.join("0.bak"), "original").unwrap();
2339        fs::write(
2340            session_dir.join("session.json"),
2341            serde_json::to_string_pretty(&serde_json::json!({
2342                "schema_version": 2,
2343                "session_id": DEFAULT_SESSION_ID,
2344                "last_accessed": current_timestamp(),
2345            }))
2346            .unwrap(),
2347        )
2348        .unwrap();
2349        fs::write(
2350            path_dir.join("meta.json"),
2351            serde_json::to_string_pretty(&serde_json::json!({
2352                "schema_version": 2,
2353                "session_id": DEFAULT_SESSION_ID,
2354                "path": key.display().to_string(),
2355                "count": 1,
2356            }))
2357            .unwrap(),
2358        )
2359        .unwrap();
2360
2361        let mut store = BackupStore::new();
2362        store.set_storage_dir(dir.clone(), 72);
2363        assert!(store.load_from_disk_if_needed(DEFAULT_SESSION_ID, &key));
2364        let history = store.history(DEFAULT_SESSION_ID, &file_path);
2365        assert_eq!(history.len(), 1);
2366        assert_eq!(history[0].op_id, None);
2367
2368        fs::write(&file_path, "second").unwrap();
2369        store
2370            .snapshot_with_op(DEFAULT_SESSION_ID, &file_path, "second", Some("op-v3"))
2371            .unwrap();
2372        let written: serde_json::Value =
2373            serde_json::from_str(&fs::read_to_string(path_dir.join("meta.json")).unwrap()).unwrap();
2374        assert_eq!(written["schema_version"], SCHEMA_VERSION);
2375        assert_eq!(written["entries"][0]["op_id"], serde_json::Value::Null);
2376        assert_eq!(written["entries"][1]["op_id"], "op-v3");
2377        let _ = fs::remove_dir_all(&dir);
2378    }
2379
2380    #[test]
2381    fn per_file_restore_latest_still_works_with_op_ids() {
2382        let path = temp_file("op_per_file.txt", "v1");
2383        let mut store = BackupStore::new();
2384
2385        store
2386            .snapshot_with_op(DEFAULT_SESSION_ID, &path, "op", Some("op-file"))
2387            .unwrap();
2388        fs::write(&path, "v2").unwrap();
2389
2390        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
2391        assert_eq!(entry.op_id.as_deref(), Some("op-file"));
2392        assert_eq!(fs::read_to_string(&path).unwrap(), "v1");
2393    }
2394
2395    #[test]
2396    fn per_file_restore_latest_deletes_tombstone() {
2397        let dir = std::env::temp_dir().join("aft_backup_per_file_tombstone_test");
2398        let _ = fs::remove_dir_all(&dir);
2399        fs::create_dir_all(&dir).unwrap();
2400        let path = dir.join("created.txt");
2401        fs::write(&path, "created").unwrap();
2402
2403        let mut store = BackupStore::new();
2404        let id = store
2405            .snapshot_op_tombstone(DEFAULT_SESSION_ID, "op-create", &path, "created")
2406            .unwrap();
2407
2408        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
2409        assert_eq!(entry.backup_id, id);
2410        assert!(!path.exists(), "tombstone undo should delete the file");
2411        let _ = fs::remove_dir_all(&dir);
2412    }
2413
2414    #[test]
2415    fn load_disk_index_skips_tampered_meta_path_hash_mismatch() {
2416        let dir = std::env::temp_dir().join("aft_backup_tampered_meta_skip_test");
2417        let _ = fs::remove_dir_all(&dir);
2418        let backups = dir.join("backups");
2419        let session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
2420        let path_dir = session_dir.join("not-the-path-hash");
2421        fs::create_dir_all(&path_dir).unwrap();
2422        fs::write(
2423            session_dir.join("session.json"),
2424            serde_json::to_string_pretty(&serde_json::json!({
2425                "schema_version": SCHEMA_VERSION,
2426                "session_id": DEFAULT_SESSION_ID,
2427                "last_accessed": current_timestamp(),
2428            }))
2429            .unwrap(),
2430        )
2431        .unwrap();
2432        fs::write(path_dir.join("0.bak"), "outside").unwrap();
2433        fs::write(
2434            path_dir.join("meta.json"),
2435            serde_json::to_string_pretty(&serde_json::json!({
2436                "schema_version": SCHEMA_VERSION,
2437                "session_id": DEFAULT_SESSION_ID,
2438                "path": "/tmp/aft-malicious-overwrite-target.txt",
2439                "count": 1,
2440                "entries": [{
2441                    "backup_id": "backup-0",
2442                    "timestamp": current_timestamp(),
2443                    "order": "1",
2444                    "description": "tampered",
2445                    "op_id": "op-tampered",
2446                    "kind": "content",
2447                }]
2448            }))
2449            .unwrap(),
2450        )
2451        .unwrap();
2452
2453        let mut store = BackupStore::new();
2454        store.set_storage_dir(dir.clone(), 72);
2455
2456        assert!(store.sessions_with_backups().is_empty());
2457        let _ = fs::remove_dir_all(&dir);
2458    }
2459
2460    #[test]
2461    fn restore_last_operation_uses_only_top_entries_and_persisted_order() {
2462        let path_a = temp_file("op_order_a.txt", "a1");
2463        let path_b = temp_file("op_order_b.txt", "b1");
2464        let mut store = BackupStore::new();
2465
2466        store
2467            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "buried", Some("op-buried"))
2468            .unwrap();
2469        store
2470            .snapshot(DEFAULT_SESSION_ID, &path_a, "top without op")
2471            .unwrap();
2472        store
2473            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "top", Some("op-top"))
2474            .unwrap();
2475
2476        let key_a = canonicalize_key(&path_a);
2477        let key_b = canonicalize_key(&path_b);
2478        let files = store.entries.get_mut(DEFAULT_SESSION_ID).unwrap();
2479        files.get_mut(&key_a).unwrap()[0].order = u128::MAX;
2480        files.get_mut(&key_a).unwrap()[1].order = 1;
2481        files.get_mut(&key_b).unwrap()[0].order = 2;
2482
2483        fs::write(&path_a, "a2").unwrap();
2484        fs::write(&path_b, "b2").unwrap();
2485
2486        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2487        assert_eq!(restored.op_id, "op-top");
2488        assert_eq!(restored.restored.len(), 1);
2489        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a2");
2490        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
2491    }
2492}