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(|e| AftError::IoError {
89 path: path.display().to_string(),
90 message: e.to_string(),
91 })?;
92
93 Ok(entry)
94 }
95
96 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 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
113fn 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 fs::write(&path, "modified").unwrap();
151 assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
152
153 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 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}