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    /// Return the file paths stored for a checkpoint.
109    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    /// List all checkpoints with metadata.
121    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    /// Remove checkpoints older than `ttl_hours`.
133    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        // Modify files
176        fs::write(&path1, "changed1").unwrap();
177        fs::write(&path2, "changed2").unwrap();
178
179        // Restore
180        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        // Restore should give v2 (the overwritten checkpoint)
201        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        // Manually insert an expired checkpoint
237        store.checkpoints.insert(
238            "old".to_string(),
239            Checkpoint {
240                name: "old".to_string(),
241                file_contents: HashMap::new(),
242                created_at: 1000, // far in the past
243            },
244        );
245
246        assert_eq!(store.list().len(), 2);
247        store.cleanup(24); // 24 hours
248        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        // Modify and restore
277        fs::write(&path, "modified").unwrap();
278        store.restore("from_tracked").unwrap();
279        assert_eq!(fs::read_to_string(&path).unwrap(), "tracked_content");
280    }
281}