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)]
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 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 const MAX_UNDO_DEPTH: usize = 20;
58 if stack.len() >= MAX_UNDO_DEPTH {
59 stack.remove(0); }
61 stack.push(entry);
62 Ok(id)
63 }
64
65 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 if stack.is_empty() {
84 self.entries.remove(&key);
85 }
86
87 std::fs::write(path, &entry.content).map_err(|_| AftError::FileNotFound {
89 path: path.display().to_string(),
90 })?;
91
92 Ok(entry)
93 }
94
95 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 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
112fn 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 fs::write(&path, "modified").unwrap();
150 assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
151
152 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 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}