Skip to main content

aft/
checkpoint.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use crate::backup::BackupStore;
5use crate::error::AftError;
6
7/// Metadata about a checkpoint, returned by list/create/restore.
8#[derive(Debug, Clone)]
9pub struct CheckpointInfo {
10    pub name: String,
11    pub file_count: usize,
12    pub created_at: u64,
13}
14
15/// A stored checkpoint: a snapshot of multiple file contents.
16#[derive(Debug, Clone)]
17struct Checkpoint {
18    name: String,
19    file_contents: HashMap<PathBuf, String>,
20    created_at: u64,
21}
22
23/// Workspace-wide checkpoint store.
24///
25/// Stores named snapshots of file contents. On `create`, reads the listed files
26/// (or all tracked files from a BackupStore if the list is empty). On `restore`,
27/// overwrites files with stored content. Checkpoints can be cleaned up by TTL.
28#[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    /// Create a checkpoint by reading the given files.
41    ///
42    /// If `files` is empty, snapshots all tracked files from the BackupStore.
43    /// Overwrites any existing checkpoint with the same name.
44    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    /// Restore a checkpoint by overwriting files with stored content.
85    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    /// Restore a checkpoint using a caller-validated path list.
109    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    /// Return the file paths stored for a checkpoint.
144    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    /// List all checkpoints with metadata.
156    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    /// Remove checkpoints older than `ttl_hours`.
168    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        // Modify files
211        fs::write(&path1, "changed1").unwrap();
212        fs::write(&path2, "changed2").unwrap();
213
214        // Restore
215        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        // Restore should give v2 (the overwritten checkpoint)
236        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        // Manually insert an expired checkpoint
272        store.checkpoints.insert(
273            "old".to_string(),
274            Checkpoint {
275                name: "old".to_string(),
276                file_contents: HashMap::new(),
277                created_at: 1000, // far in the past
278            },
279        );
280
281        assert_eq!(store.list().len(), 2);
282        store.cleanup(24); // 24 hours
283        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        // Modify and restore
312        fs::write(&path, "modified").unwrap();
313        store.restore("from_tracked").unwrap();
314        assert_eq!(fs::read_to_string(&path).unwrap(), "tracked_content");
315    }
316}