1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use super::delta::{compute_delta, load_delta, save_delta_files, Delta};
8use crate::error::Result;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Checkpoint {
13 pub id: String,
15 pub operation_id: String,
17 pub created_at: DateTime<Utc>,
19 pub is_full: bool,
21 pub base_checkpoint: Option<String>,
23 pub file_hashes: HashMap<String, String>,
25 pub file_count: usize,
27 pub total_size: u64,
29}
30
31impl Checkpoint {
32 pub fn new(id: String, operation_id: String, is_full: bool) -> Self {
33 Self {
34 id,
35 operation_id,
36 created_at: Utc::now(),
37 is_full,
38 base_checkpoint: None,
39 file_hashes: HashMap::new(),
40 file_count: 0,
41 total_size: 0,
42 }
43 }
44
45 pub fn with_base(mut self, base_id: String) -> Self {
46 self.base_checkpoint = Some(base_id);
47 self
48 }
49
50 pub fn with_hashes(mut self, hashes: HashMap<String, String>) -> Self {
51 self.file_count = hashes.len();
52 self.file_hashes = hashes;
53 self
54 }
55
56 pub fn with_size(mut self, size: u64) -> Self {
57 self.total_size = size;
58 self
59 }
60}
61
62pub struct CheckpointManager {
64 base_dir: PathBuf,
66 manifest: CheckpointManifest,
68 full_snapshot_interval: usize,
70}
71
72#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74pub struct CheckpointManifest {
75 pub version: u32,
76 pub checkpoints: Vec<CheckpointEntry>,
77 pub latest_full: Option<String>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct CheckpointEntry {
82 pub id: String,
83 pub operation_id: String,
84 pub created_at: DateTime<Utc>,
85 pub is_full: bool,
86}
87
88impl CheckpointManager {
89 pub fn new(base_dir: PathBuf) -> Result<Self> {
90 fs::create_dir_all(&base_dir)?;
91
92 let manifest_path = base_dir.join("manifest.toml");
93 let manifest = if manifest_path.exists() {
94 let content = fs::read_to_string(&manifest_path)?;
95 toml::from_str(&content)
96 .map_err(|e| crate::error::DotAgentError::Internal(e.to_string()))?
97 } else {
98 CheckpointManifest {
99 version: 1,
100 checkpoints: Vec::new(),
101 latest_full: None,
102 }
103 };
104
105 Ok(Self {
106 base_dir,
107 manifest,
108 full_snapshot_interval: 10,
109 })
110 }
111
112 pub fn with_full_snapshot_interval(mut self, interval: usize) -> Self {
114 self.full_snapshot_interval = interval;
115 self
116 }
117
118 fn generate_checkpoint_id() -> String {
120 let now = Utc::now();
121 format!("cp-{}", now.format("%Y%m%d_%H%M%S_%3f"))
122 }
123
124 fn should_create_full(&self) -> bool {
126 if self.manifest.latest_full.is_none() {
127 return true;
128 }
129
130 let since_last_full = self
131 .manifest
132 .checkpoints
133 .iter()
134 .rev()
135 .take_while(|c| !c.is_full)
136 .count();
137
138 since_last_full >= self.full_snapshot_interval
139 }
140
141 pub fn create_checkpoint(
143 &mut self,
144 operation_id: &str,
145 source_dir: &Path,
146 ) -> Result<Checkpoint> {
147 let checkpoint_id = Self::generate_checkpoint_id();
148 let is_full = self.should_create_full();
149
150 let checkpoint_dir = self.base_dir.join(&checkpoint_id);
151 fs::create_dir_all(&checkpoint_dir)?;
152
153 let base_hashes = if is_full {
155 HashMap::new()
156 } else if let Some(ref latest_full_id) = self.manifest.latest_full {
157 self.load_checkpoint(latest_full_id)?
158 .map(|c| c.file_hashes)
159 .unwrap_or_default()
160 } else {
161 HashMap::new()
162 };
163
164 let delta = compute_delta(&base_hashes, source_dir)?;
166
167 save_delta_files(&delta, source_dir, &checkpoint_dir)?;
169
170 let mut current_hashes = base_hashes.clone();
172 let mut total_size = 0u64;
173
174 for entry in &delta.entries {
175 match entry.delta_type {
176 super::delta::DeltaType::Added | super::delta::DeltaType::Modified => {
177 if let Some(ref hash) = entry.hash {
178 current_hashes.insert(entry.path.clone(), hash.clone());
179 }
180 if let Some(size) = entry.size {
181 total_size = total_size.saturating_add(size);
182 }
183 }
184 super::delta::DeltaType::Deleted => {
185 current_hashes.remove(&entry.path);
186 }
187 }
188 }
189
190 let checkpoint = Checkpoint::new(checkpoint_id.clone(), operation_id.to_string(), is_full)
192 .with_hashes(current_hashes)
193 .with_size(total_size);
194
195 if !is_full {
196 let checkpoint = checkpoint.with_base(
197 self.manifest
198 .latest_full
199 .clone()
200 .unwrap_or_else(|| checkpoint_id.clone()),
201 );
202 self.save_checkpoint_meta(&checkpoint, &checkpoint_dir)?;
203
204 self.manifest.checkpoints.push(CheckpointEntry {
206 id: checkpoint_id,
207 operation_id: operation_id.to_string(),
208 created_at: checkpoint.created_at,
209 is_full,
210 });
211 self.save_manifest()?;
212
213 return Ok(checkpoint);
214 }
215
216 self.save_checkpoint_meta(&checkpoint, &checkpoint_dir)?;
218
219 self.manifest.checkpoints.push(CheckpointEntry {
221 id: checkpoint_id.clone(),
222 operation_id: operation_id.to_string(),
223 created_at: checkpoint.created_at,
224 is_full,
225 });
226
227 if is_full {
228 self.manifest.latest_full = Some(checkpoint_id);
229 }
230
231 self.save_manifest()?;
232
233 Ok(checkpoint)
234 }
235
236 fn save_checkpoint_meta(&self, checkpoint: &Checkpoint, checkpoint_dir: &Path) -> Result<()> {
238 let meta_path = checkpoint_dir.join("meta.toml");
239 let content = toml::to_string_pretty(checkpoint)
240 .map_err(|e| crate::error::DotAgentError::Internal(e.to_string()))?;
241 fs::write(meta_path, content)?;
242 Ok(())
243 }
244
245 fn save_manifest(&self) -> Result<()> {
247 let manifest_path = self.base_dir.join("manifest.toml");
248 let content = toml::to_string_pretty(&self.manifest)
249 .map_err(|e| crate::error::DotAgentError::Internal(e.to_string()))?;
250 fs::write(manifest_path, content)?;
251 Ok(())
252 }
253
254 pub fn load_checkpoint(&self, checkpoint_id: &str) -> Result<Option<Checkpoint>> {
256 let checkpoint_dir = self.base_dir.join(checkpoint_id);
257 let meta_path = checkpoint_dir.join("meta.toml");
258
259 if !meta_path.exists() {
260 return Ok(None);
261 }
262
263 let content = fs::read_to_string(meta_path)?;
264 let checkpoint: Checkpoint = toml::from_str(&content)
265 .map_err(|e| crate::error::DotAgentError::Internal(e.to_string()))?;
266
267 Ok(Some(checkpoint))
268 }
269
270 pub fn restore_checkpoint(&self, checkpoint_id: &str, target_dir: &Path) -> Result<()> {
272 let chain = self.build_checkpoint_chain(checkpoint_id)?;
274
275 self.clear_target_preserving_meta(target_dir)?;
277
278 for cp_id in chain {
280 let checkpoint_dir = self.base_dir.join(&cp_id);
281 let delta = load_delta(&checkpoint_dir)?;
282 self.apply_delta(&delta, &checkpoint_dir, target_dir)?;
283 }
284
285 Ok(())
286 }
287
288 fn build_checkpoint_chain(&self, checkpoint_id: &str) -> Result<Vec<String>> {
290 let mut chain = Vec::new();
291 let mut current_id = checkpoint_id.to_string();
292
293 loop {
294 chain.push(current_id.clone());
295
296 let checkpoint = self
297 .load_checkpoint(¤t_id)?
298 .ok_or_else(|| crate::error::DotAgentError::NotFound(current_id.clone()))?;
299
300 if checkpoint.is_full {
301 break;
302 }
303
304 match checkpoint.base_checkpoint {
305 Some(base_id) => current_id = base_id,
306 None => break,
307 }
308 }
309
310 chain.reverse();
311 Ok(chain)
312 }
313
314 fn clear_target_preserving_meta(&self, target_dir: &Path) -> Result<()> {
316 if !target_dir.exists() {
317 return Ok(());
318 }
319
320 for entry in fs::read_dir(target_dir)? {
321 let entry = entry?;
322 let path = entry.path();
323 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
324
325 if name == ".dot-agent-meta.toml" || name == ".dot-agent-history" {
327 continue;
328 }
329
330 if path.is_dir() {
331 fs::remove_dir_all(&path)?;
332 } else {
333 fs::remove_file(&path)?;
334 }
335 }
336
337 Ok(())
338 }
339
340 fn apply_delta(&self, delta: &Delta, checkpoint_dir: &Path, target_dir: &Path) -> Result<()> {
342 let delta_dir = checkpoint_dir.join("delta");
343
344 for entry in &delta.entries {
345 let target_path = target_dir.join(&entry.path);
346
347 match entry.delta_type {
348 super::delta::DeltaType::Added | super::delta::DeltaType::Modified => {
349 let source_path = delta_dir.join(&entry.path);
350 if source_path.exists() {
351 if let Some(parent) = target_path.parent() {
352 fs::create_dir_all(parent)?;
353 }
354 fs::copy(&source_path, &target_path)?;
355 }
356 }
357 super::delta::DeltaType::Deleted => {
358 if target_path.exists() {
359 fs::remove_file(&target_path)?;
360 }
361 }
362 }
363 }
364
365 Ok(())
366 }
367
368 pub fn list_checkpoints(&self) -> &[CheckpointEntry] {
370 &self.manifest.checkpoints
371 }
372
373 pub fn prune(&mut self, keep_count: usize) -> Result<usize> {
375 if self.manifest.checkpoints.len() <= keep_count {
376 return Ok(0);
377 }
378
379 let to_remove = self.manifest.checkpoints.len() - keep_count;
380 let mut removed = 0;
381
382 let mut new_checkpoints = Vec::new();
384 for (i, entry) in self.manifest.checkpoints.iter().enumerate() {
385 if i < to_remove && Some(&entry.id) != self.manifest.latest_full.as_ref() {
386 let checkpoint_dir = self.base_dir.join(&entry.id);
387 if checkpoint_dir.exists() {
388 fs::remove_dir_all(&checkpoint_dir)?;
389 removed += 1;
390 }
391 } else {
392 new_checkpoints.push(entry.clone());
393 }
394 }
395
396 self.manifest.checkpoints = new_checkpoints;
397 self.save_manifest()?;
398
399 Ok(removed)
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use tempfile::TempDir;
407
408 #[test]
409 fn checkpoint_creation() {
410 let cp = Checkpoint::new("cp-001".into(), "op-001".into(), true);
411 assert!(cp.is_full);
412 assert!(cp.base_checkpoint.is_none());
413 }
414
415 #[test]
416 fn checkpoint_manager_init() -> Result<()> {
417 let temp = TempDir::new()?;
418 let manager = CheckpointManager::new(temp.path().to_path_buf())?;
419
420 assert!(manager.manifest.checkpoints.is_empty());
421 assert!(manager.manifest.latest_full.is_none());
422
423 Ok(())
424 }
425
426 #[test]
427 fn create_full_checkpoint() -> Result<()> {
428 let temp_base = TempDir::new()?;
429 let temp_source = TempDir::new()?;
430
431 fs::write(temp_source.path().join("file1.txt"), "content1")?;
433 fs::write(temp_source.path().join("file2.txt"), "content2")?;
434
435 let mut manager = CheckpointManager::new(temp_base.path().to_path_buf())?;
436 let checkpoint = manager.create_checkpoint("op-001", temp_source.path())?;
437
438 assert!(checkpoint.is_full);
439 assert_eq!(checkpoint.file_count, 2);
440
441 Ok(())
442 }
443
444 #[test]
445 fn restore_checkpoint() -> Result<()> {
446 let temp_base = TempDir::new()?;
447 let temp_source = TempDir::new()?;
448 let temp_target = TempDir::new()?;
449
450 fs::write(temp_source.path().join("file1.txt"), "content1")?;
452 fs::write(temp_source.path().join("file2.txt"), "content2")?;
453
454 let mut manager = CheckpointManager::new(temp_base.path().to_path_buf())?;
455 let checkpoint = manager.create_checkpoint("op-001", temp_source.path())?;
456
457 manager.restore_checkpoint(&checkpoint.id, temp_target.path())?;
459
460 assert!(temp_target.path().join("file1.txt").exists());
461 assert!(temp_target.path().join("file2.txt").exists());
462
463 Ok(())
464 }
465}