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