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(|e| AftError::IoError {
89            path: path.display().to_string(),
90            message: e.to_string(),
91        })?;
92
93        Ok(entry)
94    }
95
96    /// Return the backup history for a file (oldest first).
97    pub fn history(&self, path: &Path) -> Vec<BackupEntry> {
98        let key = canonicalize_key(path);
99        self.entries.get(&key).cloned().unwrap_or_default()
100    }
101
102    /// Return all files that have at least one backup entry.
103    pub fn tracked_files(&self) -> Vec<PathBuf> {
104        self.entries.keys().cloned().collect()
105    }
106
107    fn next_id(&self) -> String {
108        let n = self.counter.fetch_add(1, Ordering::Relaxed);
109        format!("backup-{}", n)
110    }
111}
112
113/// Canonicalize path for use as a HashMap key.
114///
115/// Uses `std::fs::canonicalize` when the file exists, falls back to
116/// the cleaned path for files that don't exist yet.
117fn canonicalize_key(path: &Path) -> PathBuf {
118    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
119}
120
121fn current_timestamp() -> u64 {
122    std::time::SystemTime::now()
123        .duration_since(std::time::UNIX_EPOCH)
124        .unwrap_or_default()
125        .as_secs()
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use std::fs;
132
133    fn temp_file(name: &str, content: &str) -> PathBuf {
134        let dir = std::env::temp_dir().join("aft_backup_tests");
135        fs::create_dir_all(&dir).unwrap();
136        let path = dir.join(name);
137        fs::write(&path, content).unwrap();
138        path
139    }
140
141    #[test]
142    fn snapshot_and_restore_round_trip() {
143        let path = temp_file("round_trip.txt", "original");
144        let mut store = BackupStore::new();
145
146        let id = store.snapshot(&path, "before edit").unwrap();
147        assert!(id.starts_with("backup-"));
148
149        // Modify file
150        fs::write(&path, "modified").unwrap();
151        assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
152
153        // Restore
154        let entry = store.restore_latest(&path).unwrap();
155        assert_eq!(entry.content, "original");
156        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
157    }
158
159    #[test]
160    fn multiple_snapshots_preserve_order() {
161        let path = temp_file("order.txt", "v1");
162        let mut store = BackupStore::new();
163
164        store.snapshot(&path, "first").unwrap();
165        fs::write(&path, "v2").unwrap();
166        store.snapshot(&path, "second").unwrap();
167        fs::write(&path, "v3").unwrap();
168        store.snapshot(&path, "third").unwrap();
169
170        let history = store.history(&path);
171        assert_eq!(history.len(), 3);
172        assert_eq!(history[0].description, "first");
173        assert_eq!(history[1].description, "second");
174        assert_eq!(history[2].description, "third");
175        assert_eq!(history[0].content, "v1");
176        assert_eq!(history[1].content, "v2");
177        assert_eq!(history[2].content, "v3");
178    }
179
180    #[test]
181    fn restore_pops_from_stack() {
182        let path = temp_file("pop.txt", "v1");
183        let mut store = BackupStore::new();
184
185        store.snapshot(&path, "first").unwrap();
186        fs::write(&path, "v2").unwrap();
187        store.snapshot(&path, "second").unwrap();
188
189        let entry = store.restore_latest(&path).unwrap();
190        assert_eq!(entry.description, "second");
191        assert_eq!(entry.content, "v2");
192
193        // One entry remains
194        let history = store.history(&path);
195        assert_eq!(history.len(), 1);
196        assert_eq!(history[0].description, "first");
197    }
198
199    #[test]
200    fn empty_history_returns_empty_vec() {
201        let store = BackupStore::new();
202        let path = Path::new("/tmp/aft_backup_tests/nonexistent_history.txt");
203        let history = store.history(path);
204        assert!(history.is_empty());
205    }
206
207    #[test]
208    fn snapshot_nonexistent_file_returns_error() {
209        let mut store = BackupStore::new();
210        let path = Path::new("/tmp/aft_backup_tests/absolutely_does_not_exist.txt");
211        let result = store.snapshot(path, "test");
212        assert!(result.is_err());
213        match result.unwrap_err() {
214            AftError::FileNotFound { path: p } => {
215                assert!(p.contains("absolutely_does_not_exist"));
216            }
217            other => panic!("expected FileNotFound, got: {:?}", other),
218        }
219    }
220
221    #[test]
222    fn tracked_files_lists_snapshotted_paths() {
223        let path1 = temp_file("tracked1.txt", "a");
224        let path2 = temp_file("tracked2.txt", "b");
225        let mut store = BackupStore::new();
226
227        assert!(store.tracked_files().is_empty());
228
229        store.snapshot(&path1, "snap1").unwrap();
230        store.snapshot(&path2, "snap2").unwrap();
231
232        let tracked = store.tracked_files();
233        assert_eq!(tracked.len(), 2);
234    }
235}