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