1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use crate::backup::BackupStore;
5use crate::error::AftError;
6
7#[derive(Debug, Clone)]
9pub struct CheckpointInfo {
10 pub name: String,
11 pub file_count: usize,
12 pub created_at: u64,
13}
14
15#[derive(Debug, Clone)]
17struct Checkpoint {
18 name: String,
19 file_contents: HashMap<PathBuf, String>,
20 created_at: u64,
21}
22
23#[derive(Debug)]
29pub struct CheckpointStore {
30 checkpoints: HashMap<String, Checkpoint>,
31}
32
33impl CheckpointStore {
34 pub fn new() -> Self {
35 CheckpointStore {
36 checkpoints: HashMap::new(),
37 }
38 }
39
40 pub fn create(
45 &mut self,
46 name: &str,
47 files: Vec<PathBuf>,
48 backup_store: &BackupStore,
49 ) -> Result<CheckpointInfo, AftError> {
50 let file_list = if files.is_empty() {
51 backup_store.tracked_files()
52 } else {
53 files
54 };
55
56 let mut file_contents = HashMap::new();
57 for path in &file_list {
58 let content = std::fs::read_to_string(path).map_err(|_| AftError::FileNotFound {
59 path: path.display().to_string(),
60 })?;
61 file_contents.insert(path.clone(), content);
62 }
63
64 let created_at = current_timestamp();
65 let file_count = file_contents.len();
66
67 let checkpoint = Checkpoint {
68 name: name.to_string(),
69 file_contents,
70 created_at,
71 };
72
73 self.checkpoints.insert(name.to_string(), checkpoint);
74
75 log::info!("checkpoint created: {} ({} files)", name, file_count);
76
77 Ok(CheckpointInfo {
78 name: name.to_string(),
79 file_count,
80 created_at,
81 })
82 }
83
84 pub fn restore(&self, name: &str) -> Result<CheckpointInfo, AftError> {
86 let checkpoint =
87 self.checkpoints
88 .get(name)
89 .ok_or_else(|| AftError::CheckpointNotFound {
90 name: name.to_string(),
91 })?;
92
93 for (path, content) in &checkpoint.file_contents {
94 std::fs::write(path, content).map_err(|_| AftError::FileNotFound {
95 path: path.display().to_string(),
96 })?;
97 }
98
99 log::info!("checkpoint restored: {}", name);
100
101 Ok(CheckpointInfo {
102 name: checkpoint.name.clone(),
103 file_count: checkpoint.file_contents.len(),
104 created_at: checkpoint.created_at,
105 })
106 }
107
108 pub fn restore_validated(
110 &self,
111 name: &str,
112 validated_paths: &[PathBuf],
113 ) -> Result<CheckpointInfo, AftError> {
114 let checkpoint =
115 self.checkpoints
116 .get(name)
117 .ok_or_else(|| AftError::CheckpointNotFound {
118 name: name.to_string(),
119 })?;
120
121 for path in validated_paths {
122 let content =
123 checkpoint
124 .file_contents
125 .get(path)
126 .ok_or_else(|| AftError::FileNotFound {
127 path: path.display().to_string(),
128 })?;
129 std::fs::write(path, content).map_err(|_| AftError::FileNotFound {
130 path: path.display().to_string(),
131 })?;
132 }
133
134 log::info!("checkpoint restored: {}", name);
135
136 Ok(CheckpointInfo {
137 name: checkpoint.name.clone(),
138 file_count: checkpoint.file_contents.len(),
139 created_at: checkpoint.created_at,
140 })
141 }
142
143 pub fn file_paths(&self, name: &str) -> Result<Vec<PathBuf>, AftError> {
145 let checkpoint =
146 self.checkpoints
147 .get(name)
148 .ok_or_else(|| AftError::CheckpointNotFound {
149 name: name.to_string(),
150 })?;
151
152 Ok(checkpoint.file_contents.keys().cloned().collect())
153 }
154
155 pub fn list(&self) -> Vec<CheckpointInfo> {
157 self.checkpoints
158 .values()
159 .map(|cp| CheckpointInfo {
160 name: cp.name.clone(),
161 file_count: cp.file_contents.len(),
162 created_at: cp.created_at,
163 })
164 .collect()
165 }
166
167 pub fn cleanup(&mut self, ttl_hours: u32) {
169 let now = current_timestamp();
170 let ttl_secs = ttl_hours as u64 * 3600;
171 self.checkpoints
172 .retain(|_, cp| now.saturating_sub(cp.created_at) < ttl_secs);
173 }
174}
175
176fn current_timestamp() -> u64 {
177 std::time::SystemTime::now()
178 .duration_since(std::time::UNIX_EPOCH)
179 .unwrap_or_default()
180 .as_secs()
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use std::fs;
187
188 fn temp_file(name: &str, content: &str) -> PathBuf {
189 let dir = std::env::temp_dir().join("aft_checkpoint_tests");
190 fs::create_dir_all(&dir).unwrap();
191 let path = dir.join(name);
192 fs::write(&path, content).unwrap();
193 path
194 }
195
196 #[test]
197 fn create_and_restore_round_trip() {
198 let path1 = temp_file("cp_rt1.txt", "hello");
199 let path2 = temp_file("cp_rt2.txt", "world");
200
201 let backup_store = BackupStore::new();
202 let mut store = CheckpointStore::new();
203
204 let info = store
205 .create("snap1", vec![path1.clone(), path2.clone()], &backup_store)
206 .unwrap();
207 assert_eq!(info.name, "snap1");
208 assert_eq!(info.file_count, 2);
209
210 fs::write(&path1, "changed1").unwrap();
212 fs::write(&path2, "changed2").unwrap();
213
214 let info = store.restore("snap1").unwrap();
216 assert_eq!(info.file_count, 2);
217 assert_eq!(fs::read_to_string(&path1).unwrap(), "hello");
218 assert_eq!(fs::read_to_string(&path2).unwrap(), "world");
219 }
220
221 #[test]
222 fn overwrite_existing_name() {
223 let path = temp_file("cp_overwrite.txt", "v1");
224 let backup_store = BackupStore::new();
225 let mut store = CheckpointStore::new();
226
227 store
228 .create("dup", vec![path.clone()], &backup_store)
229 .unwrap();
230 fs::write(&path, "v2").unwrap();
231 store
232 .create("dup", vec![path.clone()], &backup_store)
233 .unwrap();
234
235 fs::write(&path, "v3").unwrap();
237 store.restore("dup").unwrap();
238 assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
239 }
240
241 #[test]
242 fn list_returns_metadata() {
243 let path = temp_file("cp_list.txt", "data");
244 let backup_store = BackupStore::new();
245 let mut store = CheckpointStore::new();
246
247 store
248 .create("a", vec![path.clone()], &backup_store)
249 .unwrap();
250 store
251 .create("b", vec![path.clone()], &backup_store)
252 .unwrap();
253
254 let list = store.list();
255 assert_eq!(list.len(), 2);
256 let names: Vec<&str> = list.iter().map(|i| i.name.as_str()).collect();
257 assert!(names.contains(&"a"));
258 assert!(names.contains(&"b"));
259 }
260
261 #[test]
262 fn cleanup_removes_expired() {
263 let path = temp_file("cp_cleanup.txt", "data");
264 let backup_store = BackupStore::new();
265 let mut store = CheckpointStore::new();
266
267 store
268 .create("recent", vec![path.clone()], &backup_store)
269 .unwrap();
270
271 store.checkpoints.insert(
273 "old".to_string(),
274 Checkpoint {
275 name: "old".to_string(),
276 file_contents: HashMap::new(),
277 created_at: 1000, },
279 );
280
281 assert_eq!(store.list().len(), 2);
282 store.cleanup(24); let remaining = store.list();
284 assert_eq!(remaining.len(), 1);
285 assert_eq!(remaining[0].name, "recent");
286 }
287
288 #[test]
289 fn restore_nonexistent_returns_error() {
290 let store = CheckpointStore::new();
291 let result = store.restore("nope");
292 assert!(result.is_err());
293 match result.unwrap_err() {
294 AftError::CheckpointNotFound { name } => {
295 assert_eq!(name, "nope");
296 }
297 other => panic!("expected CheckpointNotFound, got: {:?}", other),
298 }
299 }
300
301 #[test]
302 fn create_with_empty_files_uses_backup_tracked() {
303 let path = temp_file("cp_tracked.txt", "tracked_content");
304 let mut backup_store = BackupStore::new();
305 backup_store.snapshot(&path, "auto").unwrap();
306
307 let mut store = CheckpointStore::new();
308 let info = store.create("from_tracked", vec![], &backup_store).unwrap();
309 assert!(info.file_count >= 1);
310
311 fs::write(&path, "modified").unwrap();
313 store.restore("from_tracked").unwrap();
314 assert_eq!(fs::read_to_string(&path).unwrap(), "tracked_content");
315 }
316}