1use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19pub const MAX_CHECKPOINTS: usize = 100;
21pub const MAX_SNAPSHOT_BYTES: u64 = 16 * 1024 * 1024;
23
24#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct CheckpointFile {
28 pub path: String,
30 pub existed: bool,
32}
33
34#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub struct Checkpoint {
38 pub entry_id: String,
40 pub created_at: i64,
42 pub files: Vec<CheckpointFile>,
44}
45
46#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct RewindResult {
50 pub restored: Vec<String>,
52 pub deleted: Vec<String>,
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub failed: Vec<RewindFailure>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub text: Option<String>,
60 pub cancelled: bool,
62}
63
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
66pub struct RewindFailure {
67 pub path: String,
69 pub error: String,
71}
72
73#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "camelCase", tag = "kind")]
76enum Blob {
77 Absent,
79 File {
81 name: String,
83 },
84 #[serde(skip)]
86 Memory(Vec<u8>),
87}
88
89#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91struct Stored {
92 entry_id: String,
93 created_at: i64,
94 files: Vec<(String, Blob)>,
95}
96
97#[derive(Debug, Default)]
99pub(crate) struct CheckpointStore {
100 dir: Option<PathBuf>,
101 loaded: bool,
102 list: Vec<Stored>,
103 next_blob: u64,
104}
105
106fn now_ms() -> i64 {
107 std::time::SystemTime::now()
108 .duration_since(std::time::UNIX_EPOCH)
109 .ok()
110 .and_then(|d| i64::try_from(d.as_millis()).ok())
111 .unwrap_or_default()
112}
113
114impl CheckpointStore {
115 pub(crate) fn bind(&mut self, dir: Option<PathBuf>) {
117 if self.loaded && self.dir == dir {
118 return;
119 }
120 self.dir = dir;
121 self.loaded = true;
122 self.list.clear();
123 let Some(dir) = &self.dir else {
124 return;
125 };
126 let index = std::fs::read(dir.join("index.json"))
127 .ok()
128 .and_then(|bytes| serde_json::from_slice::<Vec<Stored>>(&bytes).ok());
129 if let Some(index) = index {
130 self.next_blob = index
131 .iter()
132 .flat_map(|c| &c.files)
133 .filter_map(|(_, blob)| match blob {
134 Blob::File { name } => name.parse::<u64>().ok(),
135 _ => None,
136 })
137 .max()
138 .map_or(0, |n| n + 1);
139 self.list = index;
140 }
141 }
142
143 fn persist(&self) {
144 let Some(dir) = &self.dir else {
145 return;
146 };
147 if let Ok(bytes) = serde_json::to_vec(&self.list) {
148 let _ = std::fs::create_dir_all(dir);
149 let tmp = dir.join("index.json.tmp");
150 if std::fs::write(&tmp, bytes).is_ok() {
151 let _ = std::fs::rename(&tmp, dir.join("index.json"));
152 }
153 }
154 }
155
156 pub(crate) fn record(&mut self, entry_id: &str, path: &Path) -> bool {
159 let key = path.display().to_string();
160 let index = if let Some(index) = self.list.iter().position(|c| c.entry_id == entry_id) {
161 index
162 } else {
163 self.list.push(Stored {
164 entry_id: entry_id.to_owned(),
165 created_at: now_ms(),
166 files: Vec::new(),
167 });
168 self.list.len() - 1
169 };
170 if self.list[index].files.iter().any(|(p, _)| *p == key) {
171 return false;
172 }
173 let blob = match std::fs::metadata(path) {
174 Err(_) => Blob::Absent,
175 Ok(meta) if !meta.is_file() || meta.len() > MAX_SNAPSHOT_BYTES => return false,
176 Ok(_) => match std::fs::read(path) {
177 Err(_) => return false,
178 Ok(bytes) => self.store_blob(bytes),
179 },
180 };
181 self.list[index].files.push((key, blob));
182 self.evict();
183 self.persist();
184 true
185 }
186
187 fn store_blob(&mut self, bytes: Vec<u8>) -> Blob {
188 let Some(dir) = &self.dir else {
189 return Blob::Memory(bytes);
190 };
191 let name = self.next_blob.to_string();
192 self.next_blob += 1;
193 let blobs = dir.join("blobs");
194 let _ = std::fs::create_dir_all(&blobs);
195 match std::fs::write(blobs.join(&name), &bytes) {
196 Ok(()) => Blob::File { name },
197 Err(_) => Blob::Memory(bytes),
198 }
199 }
200
201 fn evict(&mut self) {
202 while self.list.len() > MAX_CHECKPOINTS {
203 let old = self.list.remove(0);
204 self.remove_blobs(&old);
205 }
206 }
207
208 fn remove_blobs(&self, stored: &Stored) {
209 let Some(dir) = &self.dir else {
210 return;
211 };
212 for (_, blob) in &stored.files {
213 if let Blob::File { name } = blob {
214 let _ = std::fs::remove_file(dir.join("blobs").join(name));
215 }
216 }
217 }
218
219 pub(crate) fn list(&self) -> Vec<Checkpoint> {
221 self.list
222 .iter()
223 .map(|c| Checkpoint {
224 entry_id: c.entry_id.clone(),
225 created_at: c.created_at,
226 files: c
227 .files
228 .iter()
229 .map(|(path, blob)| CheckpointFile {
230 path: path.clone(),
231 existed: *blob != Blob::Absent,
232 })
233 .collect(),
234 })
235 .collect()
236 }
237
238 pub(crate) fn contains(&self, entry_id: &str) -> bool {
240 self.list.iter().any(|c| c.entry_id == entry_id)
241 }
242
243 pub(crate) fn rewind(&mut self, entry_id: &str) -> Option<RewindResult> {
246 let start = self.list.iter().position(|c| c.entry_id == entry_id)?;
247 let mut earliest: BTreeMap<String, Blob> = BTreeMap::new();
249 for stored in &self.list[start..] {
250 for (path, blob) in &stored.files {
251 earliest.entry(path.clone()).or_insert_with(|| blob.clone());
252 }
253 }
254 let mut result = RewindResult::default();
255 for (path, blob) in &earliest {
256 let outcome = match blob {
257 Blob::Absent => match std::fs::remove_file(path) {
258 Ok(()) => {
259 result.deleted.push(path.clone());
260 Ok(())
261 }
262 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
263 Err(e) => Err(e.to_string()),
264 },
265 Blob::Memory(bytes) => write_back(path, bytes).map(|()| {
266 result.restored.push(path.clone());
267 }),
268 Blob::File { name } => self
269 .dir
270 .as_ref()
271 .ok_or_else(|| "checkpoint blob missing".to_owned())
272 .and_then(|dir| {
273 std::fs::read(dir.join("blobs").join(name)).map_err(|e| e.to_string())
274 })
275 .and_then(|bytes| write_back(path, &bytes))
276 .map(|()| result.restored.push(path.clone())),
277 };
278 if let Err(error) = outcome {
279 result.failed.push(RewindFailure {
280 path: path.clone(),
281 error,
282 });
283 }
284 }
285 let dropped: Vec<Stored> = self.list.drain(start..).collect();
286 for stored in &dropped {
287 self.remove_blobs(stored);
288 }
289 self.persist();
290 Some(result)
291 }
292
293 pub(crate) fn copy_matching(&self, keep: impl Fn(&str) -> bool, target: &mut Self) {
296 for stored in self.list.iter().filter(|c| keep(&c.entry_id)) {
297 let mut copy = Stored {
298 entry_id: stored.entry_id.clone(),
299 created_at: stored.created_at,
300 files: Vec::new(),
301 };
302 for (path, blob) in &stored.files {
303 let blob = match blob {
304 Blob::Absent => Blob::Absent,
305 Blob::Memory(bytes) => target.store_blob(bytes.clone()),
306 Blob::File { name } => {
307 let bytes = self
308 .dir
309 .as_ref()
310 .and_then(|dir| std::fs::read(dir.join("blobs").join(name)).ok());
311 match bytes {
312 Some(bytes) => target.store_blob(bytes),
313 None => continue,
314 }
315 }
316 };
317 copy.files.push((path.clone(), blob));
318 }
319 target.list.push(copy);
320 }
321 target.persist();
322 }
323}
324
325fn write_back(path: &str, bytes: &[u8]) -> Result<(), String> {
326 let path = Path::new(path);
327 if let Some(parent) = path.parent() {
328 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
329 }
330 std::fs::write(path, bytes).map_err(|e| e.to_string())
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 fn roundtrip(persisted: bool) {
338 let dir = tempfile::tempdir().unwrap();
339 let work = dir.path().join("work");
340 std::fs::create_dir_all(&work).unwrap();
341 let a = work.join("a.txt");
342 let b = work.join("b.txt");
343 std::fs::write(&a, "a0").unwrap();
344 let mut store = CheckpointStore::default();
345 store.bind(persisted.then(|| dir.path().join("cp")));
346 assert!(store.record("u1", &a));
347 assert!(!store.record("u1", &a), "once per checkpoint");
348 std::fs::write(&a, "a1").unwrap();
349 assert!(store.record("u2", &a));
350 assert!(store.record("u2", &b));
351 std::fs::write(&a, "a2").unwrap();
352 std::fs::write(&b, "b2").unwrap();
353 assert_eq!(store.list().len(), 2);
354 assert!(!store.list()[1].files[1].existed);
355 if persisted {
356 let mut reloaded = CheckpointStore::default();
357 reloaded.bind(Some(dir.path().join("cp")));
358 assert_eq!(reloaded.list(), store.list());
359 store = reloaded;
360 }
361 let result = store.rewind("u2").unwrap();
362 assert_eq!(std::fs::read_to_string(&a).unwrap(), "a1");
363 assert!(!b.exists());
364 assert_eq!(result.restored.len(), 1);
365 assert_eq!(result.deleted.len(), 1);
366 assert_eq!(store.list().len(), 1);
367 let result = store.rewind("u1").unwrap();
368 assert_eq!(std::fs::read_to_string(&a).unwrap(), "a0");
369 assert_eq!(result.restored, vec![a.display().to_string()]);
370 assert!(store.rewind("u1").is_none());
371 }
372
373 #[test]
374 fn rewind_in_memory() {
375 roundtrip(false);
376 }
377
378 #[test]
379 fn rewind_persisted() {
380 roundtrip(true);
381 }
382
383 #[test]
384 fn copy_kept_entries() {
385 let dir = tempfile::tempdir().unwrap();
386 let a = dir.path().join("a");
387 std::fs::write(&a, "x").unwrap();
388 let mut store = CheckpointStore::default();
389 store.bind(None);
390 store.record("u1", &a);
391 store.record("u2", &a);
392 let mut target = CheckpointStore::default();
393 target.bind(None);
394 store.copy_matching(|id| id == "u1", &mut target);
395 assert_eq!(target.list().len(), 1);
396 assert!(target.contains("u1"));
397 }
398}