Skip to main content

aft/
backup.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4
5use crate::error::AftError;
6
7const MAX_UNDO_DEPTH: usize = 20;
8
9/// A single backup entry for a file.
10#[derive(Debug, Clone)]
11pub struct BackupEntry {
12    pub backup_id: String,
13    pub content: String,
14    pub timestamp: u64,
15    pub description: String,
16}
17
18/// Per-file undo store with optional disk persistence.
19///
20/// When `storage_dir` is set, backups persist to disk so undo history
21/// survives bridge and OpenCode restarts. Disk layout:
22///   `<storage_dir>/backups/<path_hash>/meta.json` — file path + count
23///   `<storage_dir>/backups/<path_hash>/0.bak` ... `19.bak` — content snapshots
24#[derive(Debug)]
25pub struct BackupStore {
26    entries: HashMap<PathBuf, Vec<BackupEntry>>,
27    counter: AtomicU64,
28    storage_dir: Option<PathBuf>,
29    disk_index: HashMap<PathBuf, DiskMeta>,
30}
31
32#[derive(Debug, Clone)]
33struct DiskMeta {
34    dir: PathBuf,
35    count: usize,
36}
37
38impl BackupStore {
39    pub fn new() -> Self {
40        BackupStore {
41            entries: HashMap::new(),
42            counter: AtomicU64::new(0),
43            storage_dir: None,
44            disk_index: HashMap::new(),
45        }
46    }
47
48    /// Set storage directory for disk persistence (called during configure).
49    pub fn set_storage_dir(&mut self, dir: PathBuf) {
50        self.storage_dir = Some(dir);
51        self.load_disk_index();
52    }
53
54    /// Snapshot the current contents of `path` with a description.
55    pub fn snapshot(&mut self, path: &Path, description: &str) -> Result<String, AftError> {
56        let content = std::fs::read_to_string(path).map_err(|_| AftError::FileNotFound {
57            path: path.display().to_string(),
58        })?;
59
60        let key = canonicalize_key(path);
61        let id = self.next_id();
62        let entry = BackupEntry {
63            backup_id: id.clone(),
64            content,
65            timestamp: current_timestamp(),
66            description: description.to_string(),
67        };
68
69        let stack = self.entries.entry(key.clone()).or_default();
70        if stack.len() >= MAX_UNDO_DEPTH {
71            stack.remove(0);
72        }
73        stack.push(entry);
74
75        // Persist to disk
76        let stack_clone = stack.clone();
77        self.write_snapshot_to_disk(&key, &stack_clone);
78
79        Ok(id)
80    }
81
82    /// Pop the most recent backup for `path` and restore the file contents.
83    /// Returns `(entry, optional_warning)`.
84    pub fn restore_latest(
85        &mut self,
86        path: &Path,
87    ) -> Result<(BackupEntry, Option<String>), AftError> {
88        let key = canonicalize_key(path);
89
90        // Try memory first
91        if self.entries.get(&key).map_or(false, |s| !s.is_empty()) {
92            return self.do_restore(&key, path);
93        }
94
95        // Try disk fallback
96        if self.load_from_disk_if_needed(&key) {
97            // Check for external modification
98            let warning = self.check_external_modification(&key, path);
99            let (entry, _) = self.do_restore(&key, path)?;
100            return Ok((entry, warning));
101        }
102
103        Err(AftError::NoUndoHistory {
104            path: path.display().to_string(),
105        })
106    }
107
108    /// Return the backup history for a file (oldest first).
109    pub fn history(&self, path: &Path) -> Vec<BackupEntry> {
110        let key = canonicalize_key(path);
111        self.entries.get(&key).cloned().unwrap_or_default()
112    }
113
114    /// Return the number of on-disk backup entries for a file.
115    pub fn disk_history_count(&self, path: &Path) -> usize {
116        let key = canonicalize_key(path);
117        self.disk_index.get(&key).map(|m| m.count).unwrap_or(0)
118    }
119
120    /// Return all files that have at least one backup entry (memory + disk).
121    pub fn tracked_files(&self) -> Vec<PathBuf> {
122        let mut files: std::collections::HashSet<PathBuf> = self.entries.keys().cloned().collect();
123        for key in self.disk_index.keys() {
124            files.insert(key.clone());
125        }
126        files.into_iter().collect()
127    }
128
129    fn next_id(&self) -> String {
130        let n = self.counter.fetch_add(1, Ordering::Relaxed);
131        format!("backup-{}", n)
132    }
133
134    // ---- Internal helpers ----
135
136    fn do_restore(
137        &mut self,
138        key: &Path,
139        path: &Path,
140    ) -> Result<(BackupEntry, Option<String>), AftError> {
141        let stack = self
142            .entries
143            .get_mut(key)
144            .ok_or_else(|| AftError::NoUndoHistory {
145                path: path.display().to_string(),
146            })?;
147
148        let entry = stack
149            .last()
150            .cloned()
151            .ok_or_else(|| AftError::NoUndoHistory {
152                path: path.display().to_string(),
153            })?;
154
155        std::fs::write(path, &entry.content).map_err(|e| AftError::IoError {
156            path: path.display().to_string(),
157            message: e.to_string(),
158        })?;
159
160        stack.pop();
161        if stack.is_empty() {
162            self.entries.remove(key);
163            self.remove_disk_backups(key);
164        } else {
165            let stack_clone = self.entries.get(key).cloned().unwrap_or_default();
166            self.write_snapshot_to_disk(key, &stack_clone);
167        }
168
169        Ok((entry, None))
170    }
171
172    fn check_external_modification(&self, key: &Path, path: &Path) -> Option<String> {
173        if let (Some(stack), Ok(current)) = (self.entries.get(key), std::fs::read_to_string(path)) {
174            if let Some(latest) = stack.last() {
175                if latest.content != current {
176                    return Some("file was modified externally since last backup".to_string());
177                }
178            }
179        }
180        None
181    }
182
183    // ---- Disk persistence ----
184
185    fn backups_dir(&self) -> Option<PathBuf> {
186        self.storage_dir.as_ref().map(|d| d.join("backups"))
187    }
188
189    fn path_hash(key: &Path) -> String {
190        use std::hash::{Hash, Hasher};
191        let mut hasher = std::collections::hash_map::DefaultHasher::new();
192        key.hash(&mut hasher);
193        format!("{:016x}", hasher.finish())
194    }
195
196    fn load_disk_index(&mut self) {
197        let backups_dir = match self.backups_dir() {
198            Some(d) if d.exists() => d,
199            _ => return,
200        };
201        let entries = match std::fs::read_dir(&backups_dir) {
202            Ok(e) => e,
203            Err(_) => return,
204        };
205        for entry in entries.flatten() {
206            let meta_path = entry.path().join("meta.json");
207            if let Ok(content) = std::fs::read_to_string(&meta_path) {
208                if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
209                    if let (Some(path_str), Some(count)) = (
210                        meta.get("path").and_then(|v| v.as_str()),
211                        meta.get("count").and_then(|v| v.as_u64()),
212                    ) {
213                        self.disk_index.insert(
214                            PathBuf::from(path_str),
215                            DiskMeta {
216                                dir: entry.path(),
217                                count: count as usize,
218                            },
219                        );
220                    }
221                }
222            }
223        }
224        if !self.disk_index.is_empty() {
225            log::info!(
226                "[aft] loaded {} backup entries from disk",
227                self.disk_index.len()
228            );
229        }
230    }
231
232    fn load_from_disk_if_needed(&mut self, key: &Path) -> bool {
233        let meta = match self.disk_index.get(key) {
234            Some(m) if m.count > 0 => m.clone(),
235            _ => return false,
236        };
237
238        let mut entries = Vec::new();
239        for i in 0..meta.count {
240            let bak_path = meta.dir.join(format!("{}.bak", i));
241            if let Ok(content) = std::fs::read_to_string(&bak_path) {
242                entries.push(BackupEntry {
243                    backup_id: format!("disk-{}", i),
244                    content,
245                    timestamp: 0,
246                    description: "restored from disk".to_string(),
247                });
248            }
249        }
250
251        if entries.is_empty() {
252            return false;
253        }
254
255        self.entries.insert(key.to_path_buf(), entries);
256        true
257    }
258
259    fn write_snapshot_to_disk(&self, key: &Path, stack: &[BackupEntry]) {
260        let backups_dir = match self.backups_dir() {
261            Some(d) => d,
262            None => return,
263        };
264
265        let hash = Self::path_hash(key);
266        let dir = backups_dir.join(&hash);
267        if let Err(e) = std::fs::create_dir_all(&dir) {
268            log::warn!("[aft] failed to create backup dir: {}", e);
269            return;
270        }
271
272        for (i, entry) in stack.iter().enumerate() {
273            let bak_path = dir.join(format!("{}.bak", i));
274            let tmp_path = dir.join(format!("{}.bak.tmp", i));
275            if std::fs::write(&tmp_path, &entry.content).is_ok() {
276                let _ = std::fs::rename(&tmp_path, &bak_path);
277            }
278        }
279
280        // Clean up extra .bak files if stack shrank
281        for i in stack.len()..MAX_UNDO_DEPTH {
282            let old = dir.join(format!("{}.bak", i));
283            if old.exists() {
284                let _ = std::fs::remove_file(&old);
285            }
286        }
287
288        let meta = serde_json::json!({
289            "path": key.display().to_string(),
290            "count": stack.len(),
291        });
292        let meta_path = dir.join("meta.json");
293        let meta_tmp = dir.join("meta.json.tmp");
294        if let Ok(content) = serde_json::to_string_pretty(&meta) {
295            if std::fs::write(&meta_tmp, &content).is_ok() {
296                let _ = std::fs::rename(&meta_tmp, &meta_path);
297            }
298        }
299    }
300
301    fn remove_disk_backups(&mut self, key: &Path) {
302        if let Some(meta) = self.disk_index.remove(key) {
303            let _ = std::fs::remove_dir_all(&meta.dir);
304        } else if let Some(backups_dir) = self.backups_dir() {
305            let hash = Self::path_hash(key);
306            let dir = backups_dir.join(&hash);
307            if dir.exists() {
308                let _ = std::fs::remove_dir_all(&dir);
309            }
310        }
311    }
312}
313
314fn canonicalize_key(path: &Path) -> PathBuf {
315    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
316}
317
318fn current_timestamp() -> u64 {
319    std::time::SystemTime::now()
320        .duration_since(std::time::UNIX_EPOCH)
321        .unwrap_or_default()
322        .as_secs()
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use std::fs;
329
330    fn temp_file(name: &str, content: &str) -> PathBuf {
331        let dir = std::env::temp_dir().join("aft_backup_tests");
332        fs::create_dir_all(&dir).unwrap();
333        let path = dir.join(name);
334        fs::write(&path, content).unwrap();
335        path
336    }
337
338    #[test]
339    fn snapshot_and_restore_round_trip() {
340        let path = temp_file("round_trip.txt", "original");
341        let mut store = BackupStore::new();
342
343        let id = store.snapshot(&path, "before edit").unwrap();
344        assert!(id.starts_with("backup-"));
345
346        fs::write(&path, "modified").unwrap();
347        assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
348
349        let (entry, _) = store.restore_latest(&path).unwrap();
350        assert_eq!(entry.content, "original");
351        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
352    }
353
354    #[test]
355    fn multiple_snapshots_preserve_order() {
356        let path = temp_file("order.txt", "v1");
357        let mut store = BackupStore::new();
358
359        store.snapshot(&path, "first").unwrap();
360        fs::write(&path, "v2").unwrap();
361        store.snapshot(&path, "second").unwrap();
362        fs::write(&path, "v3").unwrap();
363        store.snapshot(&path, "third").unwrap();
364
365        let history = store.history(&path);
366        assert_eq!(history.len(), 3);
367        assert_eq!(history[0].content, "v1");
368        assert_eq!(history[1].content, "v2");
369        assert_eq!(history[2].content, "v3");
370    }
371
372    #[test]
373    fn restore_pops_from_stack() {
374        let path = temp_file("pop.txt", "v1");
375        let mut store = BackupStore::new();
376
377        store.snapshot(&path, "first").unwrap();
378        fs::write(&path, "v2").unwrap();
379        store.snapshot(&path, "second").unwrap();
380
381        let (entry, _) = store.restore_latest(&path).unwrap();
382        assert_eq!(entry.description, "second");
383        assert_eq!(entry.content, "v2");
384
385        let history = store.history(&path);
386        assert_eq!(history.len(), 1);
387    }
388
389    #[test]
390    fn empty_history_returns_empty_vec() {
391        let store = BackupStore::new();
392        let path = Path::new("/tmp/aft_backup_tests/nonexistent_history.txt");
393        assert!(store.history(path).is_empty());
394    }
395
396    #[test]
397    fn snapshot_nonexistent_file_returns_error() {
398        let mut store = BackupStore::new();
399        let path = Path::new("/tmp/aft_backup_tests/absolutely_does_not_exist.txt");
400        assert!(store.snapshot(path, "test").is_err());
401    }
402
403    #[test]
404    fn tracked_files_lists_snapshotted_paths() {
405        let path1 = temp_file("tracked1.txt", "a");
406        let path2 = temp_file("tracked2.txt", "b");
407        let mut store = BackupStore::new();
408
409        store.snapshot(&path1, "snap1").unwrap();
410        store.snapshot(&path2, "snap2").unwrap();
411        assert_eq!(store.tracked_files().len(), 2);
412    }
413
414    #[test]
415    fn disk_persistence_survives_reload() {
416        let dir = std::env::temp_dir().join("aft_backup_disk_test");
417        let _ = fs::remove_dir_all(&dir);
418        fs::create_dir_all(&dir).unwrap();
419
420        let file_path = temp_file("disk_persist.txt", "original");
421
422        // Create store with storage, snapshot, then drop
423        {
424            let mut store = BackupStore::new();
425            store.set_storage_dir(dir.clone());
426            store.snapshot(&file_path, "before edit").unwrap();
427        }
428
429        // Modify the file externally
430        fs::write(&file_path, "externally modified").unwrap();
431
432        // Create new store, load from disk, restore
433        let mut store2 = BackupStore::new();
434        store2.set_storage_dir(dir.clone());
435
436        let (entry, warning) = store2.restore_latest(&file_path).unwrap();
437        assert_eq!(entry.content, "original");
438        assert!(warning.is_some()); // file was modified externally
439        assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
440
441        let _ = fs::remove_dir_all(&dir);
442    }
443}