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 file_paths(&self, name: &str) -> Result<Vec<PathBuf>, AftError> {
110 let checkpoint =
111 self.checkpoints
112 .get(name)
113 .ok_or_else(|| AftError::CheckpointNotFound {
114 name: name.to_string(),
115 })?;
116
117 Ok(checkpoint.file_contents.keys().cloned().collect())
118 }
119
120 pub fn list(&self) -> Vec<CheckpointInfo> {
122 self.checkpoints
123 .values()
124 .map(|cp| CheckpointInfo {
125 name: cp.name.clone(),
126 file_count: cp.file_contents.len(),
127 created_at: cp.created_at,
128 })
129 .collect()
130 }
131
132 pub fn cleanup(&mut self, ttl_hours: u32) {
134 let now = current_timestamp();
135 let ttl_secs = ttl_hours as u64 * 3600;
136 self.checkpoints
137 .retain(|_, cp| now.saturating_sub(cp.created_at) < ttl_secs);
138 }
139}
140
141fn current_timestamp() -> u64 {
142 std::time::SystemTime::now()
143 .duration_since(std::time::UNIX_EPOCH)
144 .unwrap_or_default()
145 .as_secs()
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use std::fs;
152
153 fn temp_file(name: &str, content: &str) -> PathBuf {
154 let dir = std::env::temp_dir().join("aft_checkpoint_tests");
155 fs::create_dir_all(&dir).unwrap();
156 let path = dir.join(name);
157 fs::write(&path, content).unwrap();
158 path
159 }
160
161 #[test]
162 fn create_and_restore_round_trip() {
163 let path1 = temp_file("cp_rt1.txt", "hello");
164 let path2 = temp_file("cp_rt2.txt", "world");
165
166 let backup_store = BackupStore::new();
167 let mut store = CheckpointStore::new();
168
169 let info = store
170 .create("snap1", vec![path1.clone(), path2.clone()], &backup_store)
171 .unwrap();
172 assert_eq!(info.name, "snap1");
173 assert_eq!(info.file_count, 2);
174
175 fs::write(&path1, "changed1").unwrap();
177 fs::write(&path2, "changed2").unwrap();
178
179 let info = store.restore("snap1").unwrap();
181 assert_eq!(info.file_count, 2);
182 assert_eq!(fs::read_to_string(&path1).unwrap(), "hello");
183 assert_eq!(fs::read_to_string(&path2).unwrap(), "world");
184 }
185
186 #[test]
187 fn overwrite_existing_name() {
188 let path = temp_file("cp_overwrite.txt", "v1");
189 let backup_store = BackupStore::new();
190 let mut store = CheckpointStore::new();
191
192 store
193 .create("dup", vec![path.clone()], &backup_store)
194 .unwrap();
195 fs::write(&path, "v2").unwrap();
196 store
197 .create("dup", vec![path.clone()], &backup_store)
198 .unwrap();
199
200 fs::write(&path, "v3").unwrap();
202 store.restore("dup").unwrap();
203 assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
204 }
205
206 #[test]
207 fn list_returns_metadata() {
208 let path = temp_file("cp_list.txt", "data");
209 let backup_store = BackupStore::new();
210 let mut store = CheckpointStore::new();
211
212 store
213 .create("a", vec![path.clone()], &backup_store)
214 .unwrap();
215 store
216 .create("b", vec![path.clone()], &backup_store)
217 .unwrap();
218
219 let list = store.list();
220 assert_eq!(list.len(), 2);
221 let names: Vec<&str> = list.iter().map(|i| i.name.as_str()).collect();
222 assert!(names.contains(&"a"));
223 assert!(names.contains(&"b"));
224 }
225
226 #[test]
227 fn cleanup_removes_expired() {
228 let path = temp_file("cp_cleanup.txt", "data");
229 let backup_store = BackupStore::new();
230 let mut store = CheckpointStore::new();
231
232 store
233 .create("recent", vec![path.clone()], &backup_store)
234 .unwrap();
235
236 store.checkpoints.insert(
238 "old".to_string(),
239 Checkpoint {
240 name: "old".to_string(),
241 file_contents: HashMap::new(),
242 created_at: 1000, },
244 );
245
246 assert_eq!(store.list().len(), 2);
247 store.cleanup(24); let remaining = store.list();
249 assert_eq!(remaining.len(), 1);
250 assert_eq!(remaining[0].name, "recent");
251 }
252
253 #[test]
254 fn restore_nonexistent_returns_error() {
255 let store = CheckpointStore::new();
256 let result = store.restore("nope");
257 assert!(result.is_err());
258 match result.unwrap_err() {
259 AftError::CheckpointNotFound { name } => {
260 assert_eq!(name, "nope");
261 }
262 other => panic!("expected CheckpointNotFound, got: {:?}", other),
263 }
264 }
265
266 #[test]
267 fn create_with_empty_files_uses_backup_tracked() {
268 let path = temp_file("cp_tracked.txt", "tracked_content");
269 let mut backup_store = BackupStore::new();
270 backup_store.snapshot(&path, "auto").unwrap();
271
272 let mut store = CheckpointStore::new();
273 let info = store.create("from_tracked", vec![], &backup_store).unwrap();
274 assert!(info.file_count >= 1);
275
276 fs::write(&path, "modified").unwrap();
278 store.restore("from_tracked").unwrap();
279 assert_eq!(fs::read_to_string(&path).unwrap(), "tracked_content");
280 }
281}