1use std::collections::HashMap;
51use std::fs::{self, File};
52use std::io::{self, BufReader, BufWriter, Read, Write};
53use std::path::{Path, PathBuf};
54
55use serde::{Deserialize, Serialize};
56
57use super::checkpoint::{Checkpoint, CheckpointManager};
58use super::graph::OperationGraph;
59use crate::error::{DotAgentError, Result};
60
61const PACK_MAGIC: &[u8; 8] = b"DOTPACK\0";
63
64const PACK_VERSION: u32 = 1;
68
69pub const PACK_EXTENSION: &str = "dotpack";
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct PackedCheckpoint {
75 pub id: String,
77 pub meta: Checkpoint,
79 #[serde(skip)]
81 pub files: HashMap<String, Vec<u8>>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct Pack {
89 pub version: u32,
91 pub graph: OperationGraph,
93 pub checkpoints: Vec<PackedCheckpoint>,
95}
96
97impl Pack {
98 pub fn new() -> Self {
100 Self {
101 version: PACK_VERSION,
102 graph: OperationGraph::new(),
103 checkpoints: Vec::new(),
104 }
105 }
106
107 pub fn total_files(&self) -> usize {
109 self.checkpoints.iter().map(|c| c.files.len()).sum()
110 }
111
112 pub fn total_size(&self) -> usize {
114 self.checkpoints
115 .iter()
116 .flat_map(|c| c.files.values())
117 .map(|v| v.len())
118 .sum()
119 }
120}
121
122impl Default for Pack {
123 fn default() -> Self {
124 Self::new()
125 }
126}
127
128pub struct PackWriter {
132 history_dir: PathBuf,
133}
134
135impl PackWriter {
136 pub fn new(history_dir: impl Into<PathBuf>) -> Self {
138 Self {
139 history_dir: history_dir.into(),
140 }
141 }
142
143 pub fn build_pack(&self) -> Result<Pack> {
145 let mut pack = Pack::new();
146
147 let graph_path = self.history_dir.join("graph.toml");
149 pack.graph = OperationGraph::load(&graph_path)?;
150
151 let checkpoints_dir = self.history_dir.join("checkpoints");
153 if checkpoints_dir.exists() {
154 let checkpoint_manager = CheckpointManager::new(checkpoints_dir.clone())?;
155
156 for entry in checkpoint_manager.list_checkpoints() {
157 let checkpoint_dir = checkpoints_dir.join(&entry.id);
158
159 if let Some(meta) = checkpoint_manager.load_checkpoint(&entry.id)? {
160 let mut packed = PackedCheckpoint {
161 id: entry.id.clone(),
162 meta,
163 files: HashMap::new(),
164 };
165
166 let delta_dir = checkpoint_dir.join("delta");
168 if delta_dir.exists() {
169 Self::collect_files(&delta_dir, &delta_dir, &mut packed.files)?;
170 }
171
172 pack.checkpoints.push(packed);
173 }
174 }
175 }
176
177 Ok(pack)
178 }
179
180 fn collect_files(
182 root: &Path,
183 current: &Path,
184 files: &mut HashMap<String, Vec<u8>>,
185 ) -> Result<()> {
186 if !current.is_dir() {
187 return Ok(());
188 }
189
190 for entry in fs::read_dir(current)? {
191 let entry = entry?;
192 let path = entry.path();
193
194 if path.is_dir() {
195 Self::collect_files(root, &path, files)?;
196 } else if path.is_file() {
197 let relative = path
198 .strip_prefix(root)
199 .map_err(|e| DotAgentError::Internal(e.to_string()))?;
200 let content = fs::read(&path)?;
201 files.insert(relative.to_string_lossy().to_string(), content);
202 }
203 }
204
205 Ok(())
206 }
207
208 pub fn write_to_file(&self, path: impl AsRef<Path>) -> Result<PackStats> {
212 let pack = self.build_pack()?;
213 let file = File::create(path.as_ref())?;
214 let mut writer = BufWriter::new(file);
215
216 writer.write_all(PACK_MAGIC)?;
218 writer.write_all(&PACK_VERSION.to_le_bytes())?;
219
220 let graph_toml = toml::to_string_pretty(&pack.graph)
222 .map_err(|e| DotAgentError::Internal(e.to_string()))?;
223 write_length_prefixed(&mut writer, graph_toml.as_bytes())?;
224
225 writer.write_all(&(pack.checkpoints.len() as u32).to_le_bytes())?;
227
228 for checkpoint in &pack.checkpoints {
230 write_length_prefixed(&mut writer, checkpoint.id.as_bytes())?;
232
233 let meta_toml = toml::to_string_pretty(&checkpoint.meta)
235 .map_err(|e| DotAgentError::Internal(e.to_string()))?;
236 write_length_prefixed(&mut writer, meta_toml.as_bytes())?;
237
238 writer.write_all(&(checkpoint.files.len() as u32).to_le_bytes())?;
240
241 for (path, content) in &checkpoint.files {
243 write_length_prefixed(&mut writer, path.as_bytes())?;
244 write_length_prefixed(&mut writer, content)?;
245 }
246 }
247
248 writer.flush()?;
249
250 Ok(PackStats {
251 operations: pack.graph.len(),
252 checkpoints: pack.checkpoints.len(),
253 files: pack.total_files(),
254 size_bytes: pack.total_size(),
255 })
256 }
257}
258
259pub struct PackReader {
263 pack: Pack,
264}
265
266impl PackReader {
267 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
271 let file = File::open(path.as_ref())?;
272 let mut reader = BufReader::new(file);
273
274 let mut magic = [0u8; 8];
276 reader.read_exact(&mut magic)?;
277 if &magic != PACK_MAGIC {
278 return Err(DotAgentError::Internal("Invalid pack file magic".into()));
279 }
280
281 let mut version_bytes = [0u8; 4];
282 reader.read_exact(&mut version_bytes)?;
283 let version = u32::from_le_bytes(version_bytes);
284
285 if version > PACK_VERSION {
286 return Err(DotAgentError::Internal(format!(
287 "Pack version {} is newer than supported version {}",
288 version, PACK_VERSION
289 )));
290 }
291
292 let graph_bytes = read_length_prefixed(&mut reader)?;
294 let graph_toml =
295 String::from_utf8(graph_bytes).map_err(|e| DotAgentError::Internal(e.to_string()))?;
296 let graph: OperationGraph =
297 toml::from_str(&graph_toml).map_err(|e| DotAgentError::Internal(e.to_string()))?;
298
299 let mut count_bytes = [0u8; 4];
301 reader.read_exact(&mut count_bytes)?;
302 let checkpoint_count = u32::from_le_bytes(count_bytes) as usize;
303
304 let mut checkpoints = Vec::with_capacity(checkpoint_count);
306 for _ in 0..checkpoint_count {
307 let id_bytes = read_length_prefixed(&mut reader)?;
309 let id =
310 String::from_utf8(id_bytes).map_err(|e| DotAgentError::Internal(e.to_string()))?;
311
312 let meta_bytes = read_length_prefixed(&mut reader)?;
314 let meta_toml = String::from_utf8(meta_bytes)
315 .map_err(|e| DotAgentError::Internal(e.to_string()))?;
316 let meta: Checkpoint =
317 toml::from_str(&meta_toml).map_err(|e| DotAgentError::Internal(e.to_string()))?;
318
319 let mut file_count_bytes = [0u8; 4];
321 reader.read_exact(&mut file_count_bytes)?;
322 let file_count = u32::from_le_bytes(file_count_bytes) as usize;
323
324 let mut files = HashMap::with_capacity(file_count);
326 for _ in 0..file_count {
327 let path_bytes = read_length_prefixed(&mut reader)?;
328 let path = String::from_utf8(path_bytes)
329 .map_err(|e| DotAgentError::Internal(e.to_string()))?;
330 let content = read_length_prefixed(&mut reader)?;
331 files.insert(path, content);
332 }
333
334 checkpoints.push(PackedCheckpoint { id, meta, files });
335 }
336
337 Ok(Self {
338 pack: Pack {
339 version,
340 graph,
341 checkpoints,
342 },
343 })
344 }
345
346 pub fn info(&self) -> &Pack {
348 &self.pack
349 }
350
351 pub fn stats(&self) -> PackStats {
353 PackStats {
354 operations: self.pack.graph.len(),
355 checkpoints: self.pack.checkpoints.len(),
356 files: self.pack.total_files(),
357 size_bytes: self.pack.total_size(),
358 }
359 }
360
361 pub fn unpack_to(&self, history_dir: impl AsRef<Path>) -> Result<()> {
365 let history_dir = history_dir.as_ref();
366 fs::create_dir_all(history_dir)?;
367
368 let graph_path = history_dir.join("graph.toml");
370 self.pack.graph.save(&graph_path)?;
371
372 let checkpoints_dir = history_dir.join("checkpoints");
374 fs::create_dir_all(&checkpoints_dir)?;
375
376 let manifest = CheckpointManifest {
378 version: 1,
379 checkpoints: self
380 .pack
381 .checkpoints
382 .iter()
383 .map(|c| CheckpointEntry {
384 id: c.id.clone(),
385 operation_id: c.meta.operation_id.clone(),
386 created_at: c.meta.created_at,
387 is_full: c.meta.is_full,
388 })
389 .collect(),
390 latest_full: self
391 .pack
392 .checkpoints
393 .iter()
394 .filter(|c| c.meta.is_full)
395 .next_back()
396 .map(|c| c.id.clone()),
397 };
398
399 let manifest_toml = toml::to_string_pretty(&manifest)
400 .map_err(|e| DotAgentError::Internal(e.to_string()))?;
401 fs::write(checkpoints_dir.join("manifest.toml"), manifest_toml)?;
402
403 for checkpoint in &self.pack.checkpoints {
405 let checkpoint_dir = checkpoints_dir.join(&checkpoint.id);
406 fs::create_dir_all(&checkpoint_dir)?;
407
408 let meta_toml = toml::to_string_pretty(&checkpoint.meta)
410 .map_err(|e| DotAgentError::Internal(e.to_string()))?;
411 fs::write(checkpoint_dir.join("meta.toml"), meta_toml)?;
412
413 let delta = super::delta::Delta {
415 entries: Vec::new(), base_checkpoint: checkpoint.meta.base_checkpoint.clone(),
417 is_full: checkpoint.meta.is_full,
418 };
419 let delta_toml = toml::to_string_pretty(&delta)
420 .map_err(|e| DotAgentError::Internal(e.to_string()))?;
421 fs::write(checkpoint_dir.join("delta.toml"), delta_toml)?;
422
423 let delta_dir = checkpoint_dir.join("delta");
425 for (path, content) in &checkpoint.files {
426 let file_path = delta_dir.join(path);
427 if let Some(parent) = file_path.parent() {
428 fs::create_dir_all(parent)?;
429 }
430 fs::write(file_path, content)?;
431 }
432 }
433
434 Ok(())
435 }
436
437 pub fn merge_into(&self, history_dir: impl AsRef<Path>) -> Result<MergeStats> {
441 let history_dir = history_dir.as_ref();
442 let mut stats = MergeStats::default();
443
444 let graph_path = history_dir.join("graph.toml");
446 let mut existing_graph = if graph_path.exists() {
447 OperationGraph::load(&graph_path)?
448 } else {
449 OperationGraph::new()
450 };
451
452 for (id, op) in &self.pack.graph.operations {
454 if !existing_graph.operations.contains_key(id) {
455 existing_graph.operations.insert(id.clone(), op.clone());
456 stats.operations_added += 1;
457 } else {
458 stats.operations_skipped += 1;
459 }
460 }
461
462 for root in &self.pack.graph.roots {
464 if !existing_graph.roots.contains(root) {
465 existing_graph.roots.push(root.clone());
466 }
467 }
468 if let Some(ref head) = self.pack.graph.head {
469 existing_graph.head = Some(head.clone());
470 }
471
472 existing_graph.save(&graph_path)?;
473
474 let checkpoints_dir = history_dir.join("checkpoints");
476 fs::create_dir_all(&checkpoints_dir)?;
477
478 for checkpoint in &self.pack.checkpoints {
479 let checkpoint_dir = checkpoints_dir.join(&checkpoint.id);
480 if checkpoint_dir.exists() {
481 stats.checkpoints_skipped += 1;
482 continue;
483 }
484
485 fs::create_dir_all(&checkpoint_dir)?;
486
487 let meta_toml = toml::to_string_pretty(&checkpoint.meta)
489 .map_err(|e| DotAgentError::Internal(e.to_string()))?;
490 fs::write(checkpoint_dir.join("meta.toml"), meta_toml)?;
491
492 let delta_dir = checkpoint_dir.join("delta");
493 for (path, content) in &checkpoint.files {
494 let file_path = delta_dir.join(path);
495 if let Some(parent) = file_path.parent() {
496 fs::create_dir_all(parent)?;
497 }
498 fs::write(file_path, content)?;
499 }
500
501 stats.checkpoints_added += 1;
502 }
503
504 Ok(stats)
505 }
506}
507
508#[derive(Debug, Clone, Default)]
510pub struct PackStats {
511 pub operations: usize,
512 pub checkpoints: usize,
513 pub files: usize,
514 pub size_bytes: usize,
515}
516
517#[derive(Debug, Clone, Default)]
519pub struct MergeStats {
520 pub operations_added: usize,
521 pub operations_skipped: usize,
522 pub checkpoints_added: usize,
523 pub checkpoints_skipped: usize,
524}
525
526use super::checkpoint::{CheckpointEntry, CheckpointManifest};
528
529fn write_length_prefixed<W: Write>(writer: &mut W, data: &[u8]) -> io::Result<()> {
531 writer.write_all(&(data.len() as u32).to_le_bytes())?;
532 writer.write_all(data)?;
533 Ok(())
534}
535
536fn read_length_prefixed<R: Read>(reader: &mut R) -> io::Result<Vec<u8>> {
538 let mut len_bytes = [0u8; 4];
539 reader.read_exact(&mut len_bytes)?;
540 let len = u32::from_le_bytes(len_bytes) as usize;
541
542 let mut data = vec![0u8; len];
543 reader.read_exact(&mut data)?;
544 Ok(data)
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550 use tempfile::TempDir;
551
552 #[test]
553 fn pack_roundtrip() -> Result<()> {
554 let temp_history = TempDir::new()?;
555 let temp_pack = TempDir::new()?;
556
557 let graph = OperationGraph::new();
559 let graph_path = temp_history.path().join("graph.toml");
560 graph.save(&graph_path)?;
561
562 fs::create_dir_all(temp_history.path().join("checkpoints"))?;
563 let manifest = CheckpointManifest {
564 version: 1,
565 checkpoints: Vec::new(),
566 latest_full: None,
567 };
568 let manifest_toml = toml::to_string_pretty(&manifest).unwrap();
569 fs::write(
570 temp_history.path().join("checkpoints/manifest.toml"),
571 manifest_toml,
572 )?;
573
574 let pack_path = temp_pack.path().join("test.dotpack");
576 let writer = PackWriter::new(temp_history.path());
577 let stats = writer.write_to_file(&pack_path)?;
578
579 assert_eq!(stats.operations, 0);
580 assert_eq!(stats.checkpoints, 0);
581
582 let temp_restore = TempDir::new()?;
584 let reader = PackReader::open(&pack_path)?;
585 reader.unpack_to(temp_restore.path())?;
586
587 assert!(temp_restore.path().join("graph.toml").exists());
589
590 Ok(())
591 }
592
593 #[test]
594 fn pack_stats() {
595 let pack = Pack::new();
596 assert_eq!(pack.total_files(), 0);
597 assert_eq!(pack.total_size(), 0);
598 }
599}