Skip to main content

dot_agent_core/history/
pack.rs

1//! # History Pack Format
2//!
3//! **⚠️ EXPERIMENTAL: This API and data format are unstable.**
4//!
5//! Breaking changes may occur without notice until stabilization.
6//! Do not rely on this format for long-term storage yet.
7//!
8//! ## Overview
9//!
10//! Pack is a portable archive format for dot-agent operation history.
11//! It bundles the operation graph and checkpoint data into a single file
12//! for easy backup, transfer, and restore.
13//!
14//! ## Format Structure
15//!
16//! ```text
17//! ┌─────────────────────────────────────┐
18//! │ Header (magic + version)            │
19//! ├─────────────────────────────────────┤
20//! │ Graph (TOML, length-prefixed)       │
21//! ├─────────────────────────────────────┤
22//! │ Checkpoint Count (u32)              │
23//! ├─────────────────────────────────────┤
24//! │ Checkpoint 0                        │
25//! │   ├─ ID (length-prefixed string)    │
26//! │   ├─ Meta (TOML, length-prefixed)   │
27//! │   ├─ File Count (u32)               │
28//! │   └─ Files[]                        │
29//! │       ├─ Path (length-prefixed)     │
30//! │       └─ Content (length-prefixed)  │
31//! ├─────────────────────────────────────┤
32//! │ Checkpoint 1...N                    │
33//! └─────────────────────────────────────┘
34//! ```
35//!
36//! ## Usage
37//!
38//! ```rust,ignore
39//! use dot_agent_core::history::{PackWriter, PackReader};
40//!
41//! // Export
42//! let writer = PackWriter::new(&history_dir)?;
43//! writer.write_to_file("backup.dotpack")?;
44//!
45//! // Import
46//! let reader = PackReader::open("backup.dotpack")?;
47//! reader.unpack_to(&history_dir)?;
48//! ```
49
50use 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
61/// Pack format magic bytes
62const PACK_MAGIC: &[u8; 8] = b"DOTPACK\0";
63
64/// Current pack format version
65///
66/// **EXPERIMENTAL**: This version number will change with breaking format changes.
67const PACK_VERSION: u32 = 1;
68
69/// File extension for pack files
70pub const PACK_EXTENSION: &str = "dotpack";
71
72/// A packed checkpoint with embedded file contents
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct PackedCheckpoint {
75    /// Checkpoint ID
76    pub id: String,
77    /// Checkpoint metadata
78    pub meta: Checkpoint,
79    /// File contents: path -> raw bytes
80    #[serde(skip)]
81    pub files: HashMap<String, Vec<u8>>,
82}
83
84/// Complete pack structure
85///
86/// **⚠️ EXPERIMENTAL**: Format may change without notice.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct Pack {
89    /// Format version
90    pub version: u32,
91    /// Operation graph
92    pub graph: OperationGraph,
93    /// Packed checkpoints
94    pub checkpoints: Vec<PackedCheckpoint>,
95}
96
97impl Pack {
98    /// Create a new empty pack
99    pub fn new() -> Self {
100        Self {
101            version: PACK_VERSION,
102            graph: OperationGraph::new(),
103            checkpoints: Vec::new(),
104        }
105    }
106
107    /// Get total file count across all checkpoints
108    pub fn total_files(&self) -> usize {
109        self.checkpoints.iter().map(|c| c.files.len()).sum()
110    }
111
112    /// Get total size in bytes
113    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
128/// Writer for creating pack files
129///
130/// **⚠️ EXPERIMENTAL**: API may change without notice.
131pub struct PackWriter {
132    history_dir: PathBuf,
133}
134
135impl PackWriter {
136    /// Create a new pack writer
137    pub fn new(history_dir: impl Into<PathBuf>) -> Self {
138        Self {
139            history_dir: history_dir.into(),
140        }
141    }
142
143    /// Build pack from history directory
144    pub fn build_pack(&self) -> Result<Pack> {
145        let mut pack = Pack::new();
146
147        // Load graph
148        let graph_path = self.history_dir.join("graph.toml");
149        pack.graph = OperationGraph::load(&graph_path)?;
150
151        // Load checkpoints
152        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                    // Load delta files
167                    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    /// Collect files recursively
181    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    /// Write pack to file
209    ///
210    /// **⚠️ EXPERIMENTAL**: File format may change.
211    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        // Write header
217        writer.write_all(PACK_MAGIC)?;
218        writer.write_all(&PACK_VERSION.to_le_bytes())?;
219
220        // Write graph as length-prefixed TOML
221        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        // Write checkpoint count
226        writer.write_all(&(pack.checkpoints.len() as u32).to_le_bytes())?;
227
228        // Write each checkpoint
229        for checkpoint in &pack.checkpoints {
230            // ID
231            write_length_prefixed(&mut writer, checkpoint.id.as_bytes())?;
232
233            // Meta as TOML
234            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            // File count
239            writer.write_all(&(checkpoint.files.len() as u32).to_le_bytes())?;
240
241            // Files
242            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
259/// Reader for unpacking pack files
260///
261/// **⚠️ EXPERIMENTAL**: API may change without notice.
262pub struct PackReader {
263    pack: Pack,
264}
265
266impl PackReader {
267    /// Open a pack file
268    ///
269    /// **⚠️ EXPERIMENTAL**: File format may change.
270    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        // Read and verify header
275        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        // Read graph
293        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        // Read checkpoint count
300        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        // Read checkpoints
305        let mut checkpoints = Vec::with_capacity(checkpoint_count);
306        for _ in 0..checkpoint_count {
307            // ID
308            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            // Meta
313            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            // File count
320            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            // Files
325            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    /// Get pack info
347    pub fn info(&self) -> &Pack {
348        &self.pack
349    }
350
351    /// Get statistics
352    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    /// Unpack to history directory
362    ///
363    /// **⚠️ EXPERIMENTAL**: This will overwrite existing history.
364    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        // Write graph
369        let graph_path = history_dir.join("graph.toml");
370        self.pack.graph.save(&graph_path)?;
371
372        // Write checkpoints
373        let checkpoints_dir = history_dir.join("checkpoints");
374        fs::create_dir_all(&checkpoints_dir)?;
375
376        // Write manifest
377        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        // Write checkpoint data
404        for checkpoint in &self.pack.checkpoints {
405            let checkpoint_dir = checkpoints_dir.join(&checkpoint.id);
406            fs::create_dir_all(&checkpoint_dir)?;
407
408            // Meta
409            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            // Delta manifest (reconstruct from meta)
414            let delta = super::delta::Delta {
415                entries: Vec::new(), // Will be reconstructed from files
416                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            // Files
424            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    /// Merge into existing history (append non-duplicate operations)
438    ///
439    /// **⚠️ EXPERIMENTAL**: Merge logic may change.
440    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        // Load existing graph
445        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        // Merge operations
453        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        // Update roots and head
463        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        // Merge checkpoints
475        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            // Write checkpoint data (same as unpack)
488            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/// Statistics about a pack file
509#[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/// Statistics from a merge operation
518#[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
526// Re-use checkpoint types for manifest
527use super::checkpoint::{CheckpointEntry, CheckpointManifest};
528
529/// Write length-prefixed data
530fn 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
536/// Read length-prefixed data
537fn 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        // Create minimal history
558        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        // Pack
575        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        // Unpack to new location
583        let temp_restore = TempDir::new()?;
584        let reader = PackReader::open(&pack_path)?;
585        reader.unpack_to(temp_restore.path())?;
586
587        // Verify graph exists
588        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}