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    /// List all checkpoints with metadata.
109    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    /// Remove checkpoints older than `ttl_hours`.
121    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        // Modify files
164        fs::write(&path1, "changed1").unwrap();
165        fs::write(&path2, "changed2").unwrap();
166
167        // Restore
168        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        // Restore should give v2 (the overwritten checkpoint)
189        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        // Manually insert an expired checkpoint
225        store.checkpoints.insert(
226            "old".to_string(),
227            Checkpoint {
228                name: "old".to_string(),
229                file_contents: HashMap::new(),
230                created_at: 1000, // far in the past
231            },
232        );
233
234        assert_eq!(store.list().len(), 2);
235        store.cleanup(24); // 24 hours
236        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        // Modify and restore
265        fs::write(&path, "modified").unwrap();
266        store.restore("from_tracked").unwrap();
267        assert_eq!(fs::read_to_string(&path).unwrap(), "tracked_content");
268    }
269}