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#[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#[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 pub fn set_storage_dir(&mut self, dir: PathBuf) {
50 self.storage_dir = Some(dir);
51 self.load_disk_index();
52 }
53
54 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 let stack_clone = stack.clone();
77 self.write_snapshot_to_disk(&key, &stack_clone);
78
79 Ok(id)
80 }
81
82 pub fn restore_latest(
85 &mut self,
86 path: &Path,
87 ) -> Result<(BackupEntry, Option<String>), AftError> {
88 let key = canonicalize_key(path);
89
90 if self.entries.get(&key).map_or(false, |s| !s.is_empty()) {
92 return self.do_restore(&key, path);
93 }
94
95 if self.load_from_disk_if_needed(&key) {
97 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 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 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 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 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 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 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 {
424 let mut store = BackupStore::new();
425 store.set_storage_dir(dir.clone());
426 store.snapshot(&file_path, "before edit").unwrap();
427 }
428
429 fs::write(&file_path, "externally modified").unwrap();
431
432 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()); assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
440
441 let _ = fs::remove_dir_all(&dir);
442 }
443}