1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4
5use crate::error::AftError;
6
7#[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#[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 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 const MAX_UNDO_DEPTH: usize = 20;
62 if stack.len() >= MAX_UNDO_DEPTH {
63 stack.remove(0); }
65 stack.push(entry);
66 Ok(id)
67 }
68
69 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 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 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 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
121fn 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 fs::write(&path, "modified").unwrap();
159 assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
160
161 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 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}