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
7/// A single backup entry for a file.
8#[derive(Debug, Clone)]
9pub struct BackupEntry {
10    pub backup_id: String,
11    pub content: String,
12    pub timestamp: u64,
13    pub description: String,
14}
15
16/// Per-file undo store backed by an in-memory stack.
17///
18/// Keys are canonical paths (resolved via `std::fs::canonicalize` with fallback
19/// to cleaned relative path). Each file maps to a stack of `BackupEntry` values
20/// ordered oldest-first so `restore_latest` pops from the end.
21///
22/// No global cap is enforced on total tracked files because session-scoped
23/// bridges bound memory growth to a single OpenCode session. The per-file cap
24/// (`MAX_UNDO_DEPTH = 20`) prevents unbounded growth for any individual path.
25#[derive(Debug)]
26pub struct BackupStore {
27    entries: HashMap<PathBuf, Vec<BackupEntry>>,
28    counter: AtomicU64,
29}
30
31impl BackupStore {
32    pub fn new() -> Self {
33        BackupStore {
34            entries: HashMap::new(),
35            counter: AtomicU64::new(0),
36        }
37    }
38
39    /// Snapshot the current contents of `path` with a description.
40    ///
41    /// Returns the generated backup ID. Fails with `FileNotFound` if the file
42    /// cannot be read.
43    pub fn snapshot(&mut self, path: &Path, description: &str) -> Result<String, AftError> {
44        let content = std::fs::read_to_string(path).map_err(|_| AftError::FileNotFound {
45            path: path.display().to_string(),
46        })?;
47
48        let key = canonicalize_key(path);
49        let id = self.next_id();
50        let entry = BackupEntry {
51            backup_id: id.clone(),
52            content,
53            timestamp: current_timestamp(),
54            description: description.to_string(),
55        };
56
57        let stack = self.entries.entry(key).or_default();
58        // Cap per-file undo depth to prevent unbounded memory growth.
59        // Glob edits can touch hundreds of files, and repeated edits to large
60        // files would otherwise accumulate full-content copies indefinitely.
61        const MAX_UNDO_DEPTH: usize = 20;
62        if stack.len() >= MAX_UNDO_DEPTH {
63            stack.remove(0); // evict oldest
64        }
65        stack.push(entry);
66        Ok(id)
67    }
68
69    /// Pop the most recent backup for `path` and restore the file contents.
70    ///
71    /// Returns the restored entry. Fails with `NoUndoHistory` if no backups
72    /// exist for the path.
73    pub fn restore_latest(&mut self, path: &Path) -> Result<BackupEntry, AftError> {
74        let key = canonicalize_key(path);
75        let stack = self
76            .entries
77            .get_mut(&key)
78            .ok_or_else(|| AftError::NoUndoHistory {
79                path: path.display().to_string(),
80            })?;
81
82        let entry = stack
83            .last()
84            .cloned()
85            .ok_or_else(|| AftError::NoUndoHistory {
86                path: path.display().to_string(),
87            })?;
88
89        // Write the restored content back to disk
90        std::fs::write(path, &entry.content).map_err(|e| AftError::IoError {
91            path: path.display().to_string(),
92            message: e.to_string(),
93        })?;
94
95        stack.pop();
96        let remove_key = stack.is_empty();
97        if remove_key {
98            self.entries.remove(&key);
99        }
100
101        Ok(entry)
102    }
103
104    /// Return the backup history for a file (oldest first).
105    pub fn history(&self, path: &Path) -> Vec<BackupEntry> {
106        let key = canonicalize_key(path);
107        self.entries.get(&key).cloned().unwrap_or_default()
108    }
109
110    /// Return all files that have at least one backup entry.
111    pub fn tracked_files(&self) -> Vec<PathBuf> {
112        self.entries.keys().cloned().collect()
113    }
114
115    fn next_id(&self) -> String {
116        let n = self.counter.fetch_add(1, Ordering::Relaxed);
117        format!("backup-{}", n)
118    }
119}
120
121/// Canonicalize path for use as a HashMap key.
122///
123/// Uses `std::fs::canonicalize` when the file exists, falls back to
124/// the cleaned path for files that don't exist yet.
125fn canonicalize_key(path: &Path) -> PathBuf {
126    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
127}
128
129fn current_timestamp() -> u64 {
130    std::time::SystemTime::now()
131        .duration_since(std::time::UNIX_EPOCH)
132        .unwrap_or_default()
133        .as_secs()
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use std::fs;
140
141    fn temp_file(name: &str, content: &str) -> PathBuf {
142        let dir = std::env::temp_dir().join("aft_backup_tests");
143        fs::create_dir_all(&dir).unwrap();
144        let path = dir.join(name);
145        fs::write(&path, content).unwrap();
146        path
147    }
148
149    #[test]
150    fn snapshot_and_restore_round_trip() {
151        let path = temp_file("round_trip.txt", "original");
152        let mut store = BackupStore::new();
153
154        let id = store.snapshot(&path, "before edit").unwrap();
155        assert!(id.starts_with("backup-"));
156
157        // Modify file
158        fs::write(&path, "modified").unwrap();
159        assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
160
161        // Restore
162        let entry = store.restore_latest(&path).unwrap();
163        assert_eq!(entry.content, "original");
164        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
165    }
166
167    #[test]
168    fn multiple_snapshots_preserve_order() {
169        let path = temp_file("order.txt", "v1");
170        let mut store = BackupStore::new();
171
172        store.snapshot(&path, "first").unwrap();
173        fs::write(&path, "v2").unwrap();
174        store.snapshot(&path, "second").unwrap();
175        fs::write(&path, "v3").unwrap();
176        store.snapshot(&path, "third").unwrap();
177
178        let history = store.history(&path);
179        assert_eq!(history.len(), 3);
180        assert_eq!(history[0].description, "first");
181        assert_eq!(history[1].description, "second");
182        assert_eq!(history[2].description, "third");
183        assert_eq!(history[0].content, "v1");
184        assert_eq!(history[1].content, "v2");
185        assert_eq!(history[2].content, "v3");
186    }
187
188    #[test]
189    fn restore_pops_from_stack() {
190        let path = temp_file("pop.txt", "v1");
191        let mut store = BackupStore::new();
192
193        store.snapshot(&path, "first").unwrap();
194        fs::write(&path, "v2").unwrap();
195        store.snapshot(&path, "second").unwrap();
196
197        let entry = store.restore_latest(&path).unwrap();
198        assert_eq!(entry.description, "second");
199        assert_eq!(entry.content, "v2");
200
201        // One entry remains
202        let history = store.history(&path);
203        assert_eq!(history.len(), 1);
204        assert_eq!(history[0].description, "first");
205    }
206
207    #[test]
208    fn empty_history_returns_empty_vec() {
209        let store = BackupStore::new();
210        let path = Path::new("/tmp/aft_backup_tests/nonexistent_history.txt");
211        let history = store.history(path);
212        assert!(history.is_empty());
213    }
214
215    #[test]
216    fn snapshot_nonexistent_file_returns_error() {
217        let mut store = BackupStore::new();
218        let path = Path::new("/tmp/aft_backup_tests/absolutely_does_not_exist.txt");
219        let result = store.snapshot(path, "test");
220        assert!(result.is_err());
221        match result.unwrap_err() {
222            AftError::FileNotFound { path: p } => {
223                assert!(p.contains("absolutely_does_not_exist"));
224            }
225            other => panic!("expected FileNotFound, got: {:?}", other),
226        }
227    }
228
229    #[test]
230    fn tracked_files_lists_snapshotted_paths() {
231        let path1 = temp_file("tracked1.txt", "a");
232        let path2 = temp_file("tracked2.txt", "b");
233        let mut store = BackupStore::new();
234
235        assert!(store.tracked_files().is_empty());
236
237        store.snapshot(&path1, "snap1").unwrap();
238        store.snapshot(&path2, "snap2").unwrap();
239
240        let tracked = store.tracked_files();
241        assert_eq!(tracked.len(), 2);
242    }
243}