Skip to main content

dot_agent_core/history/
delta.rs

1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3use std::collections::HashMap;
4use std::fs;
5use std::io;
6use std::path::Path;
7
8use crate::error::Result;
9
10/// Type of change in a delta entry
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum DeltaType {
14    /// File was added
15    Added,
16    /// File was modified
17    Modified,
18    /// File was deleted (tombstone)
19    Deleted,
20}
21
22/// A single file change in the delta
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct DeltaEntry {
25    /// Relative path from target root
26    pub path: String,
27    /// Type of change
28    pub delta_type: DeltaType,
29    /// SHA256 hash of the new content (None for deleted)
30    pub hash: Option<String>,
31    /// Size in bytes (None for deleted)
32    pub size: Option<u64>,
33}
34
35impl DeltaEntry {
36    pub fn added(path: impl Into<String>, hash: String, size: u64) -> Self {
37        Self {
38            path: path.into(),
39            delta_type: DeltaType::Added,
40            hash: Some(hash),
41            size: Some(size),
42        }
43    }
44
45    pub fn modified(path: impl Into<String>, hash: String, size: u64) -> Self {
46        Self {
47            path: path.into(),
48            delta_type: DeltaType::Modified,
49            hash: Some(hash),
50            size: Some(size),
51        }
52    }
53
54    pub fn deleted(path: impl Into<String>) -> Self {
55        Self {
56            path: path.into(),
57            delta_type: DeltaType::Deleted,
58            hash: None,
59            size: None,
60        }
61    }
62}
63
64/// Delta between two states (incremental snapshot)
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66pub struct Delta {
67    /// Entries in this delta
68    pub entries: Vec<DeltaEntry>,
69    /// Reference to base checkpoint (None for full snapshot)
70    pub base_checkpoint: Option<String>,
71    /// Whether this is a full snapshot
72    pub is_full: bool,
73}
74
75impl Delta {
76    pub fn new_incremental(base_checkpoint: String) -> Self {
77        Self {
78            entries: Vec::new(),
79            base_checkpoint: Some(base_checkpoint),
80            is_full: false,
81        }
82    }
83
84    pub fn new_full() -> Self {
85        Self {
86            entries: Vec::new(),
87            base_checkpoint: None,
88            is_full: true,
89        }
90    }
91
92    pub fn add_entry(&mut self, entry: DeltaEntry) {
93        self.entries.push(entry);
94    }
95
96    pub fn is_empty(&self) -> bool {
97        self.entries.is_empty()
98    }
99
100    pub fn added_count(&self) -> usize {
101        self.entries
102            .iter()
103            .filter(|e| e.delta_type == DeltaType::Added)
104            .count()
105    }
106
107    pub fn modified_count(&self) -> usize {
108        self.entries
109            .iter()
110            .filter(|e| e.delta_type == DeltaType::Modified)
111            .count()
112    }
113
114    pub fn deleted_count(&self) -> usize {
115        self.entries
116            .iter()
117            .filter(|e| e.delta_type == DeltaType::Deleted)
118            .count()
119    }
120}
121
122/// Compute SHA256 hash of a file
123pub fn compute_file_hash(path: &Path) -> io::Result<String> {
124    let content = fs::read(path)?;
125    let mut hasher = Sha256::new();
126    hasher.update(&content);
127    Ok(format!("{:x}", hasher.finalize()))
128}
129
130/// Compute delta between two directory states
131pub fn compute_delta(base_files: &HashMap<String, String>, current_dir: &Path) -> Result<Delta> {
132    let mut delta = if base_files.is_empty() {
133        Delta::new_full()
134    } else {
135        Delta::new_incremental(String::new())
136    };
137
138    let mut current_files = HashMap::new();
139
140    // Walk current directory
141    if current_dir.exists() {
142        walk_directory(current_dir, current_dir, &mut current_files)?;
143    }
144
145    // Find added and modified files
146    for (path, hash) in &current_files {
147        match base_files.get(path) {
148            None => {
149                // Added
150                let full_path = current_dir.join(path);
151                let size = fs::metadata(&full_path).map(|m| m.len()).unwrap_or(0);
152                delta.add_entry(DeltaEntry::added(path, hash.clone(), size));
153            }
154            Some(base_hash) if base_hash != hash => {
155                // Modified
156                let full_path = current_dir.join(path);
157                let size = fs::metadata(&full_path).map(|m| m.len()).unwrap_or(0);
158                delta.add_entry(DeltaEntry::modified(path, hash.clone(), size));
159            }
160            _ => {
161                // Unchanged
162            }
163        }
164    }
165
166    // Find deleted files
167    for path in base_files.keys() {
168        if !current_files.contains_key(path) {
169            delta.add_entry(DeltaEntry::deleted(path));
170        }
171    }
172
173    Ok(delta)
174}
175
176/// Walk directory and collect file hashes
177fn walk_directory(root: &Path, current: &Path, files: &mut HashMap<String, String>) -> Result<()> {
178    if !current.is_dir() {
179        return Ok(());
180    }
181
182    for entry in fs::read_dir(current)? {
183        let entry = entry?;
184        let path = entry.path();
185
186        if path.is_dir() {
187            walk_directory(root, &path, files)?;
188        } else if path.is_file() {
189            let relative = path
190                .strip_prefix(root)
191                .map_err(|e| crate::error::DotAgentError::Internal(e.to_string()))?;
192            let relative_str = relative.to_string_lossy().to_string();
193
194            if let Ok(hash) = compute_file_hash(&path) {
195                files.insert(relative_str, hash);
196            }
197        }
198    }
199
200    Ok(())
201}
202
203/// Save delta files to a checkpoint directory
204pub fn save_delta_files(delta: &Delta, source_dir: &Path, checkpoint_dir: &Path) -> Result<()> {
205    let delta_dir = checkpoint_dir.join("delta");
206    fs::create_dir_all(&delta_dir)?;
207
208    for entry in &delta.entries {
209        match entry.delta_type {
210            DeltaType::Added | DeltaType::Modified => {
211                let source_path = source_dir.join(&entry.path);
212                let dest_path = delta_dir.join(&entry.path);
213
214                if let Some(parent) = dest_path.parent() {
215                    fs::create_dir_all(parent)?;
216                }
217
218                if source_path.exists() {
219                    fs::copy(&source_path, &dest_path)?;
220                }
221            }
222            DeltaType::Deleted => {
223                // Just record in manifest, no file to copy
224            }
225        }
226    }
227
228    // Save delta manifest
229    let manifest_path = checkpoint_dir.join("delta.toml");
230    let manifest_content = toml::to_string_pretty(delta)
231        .map_err(|e| crate::error::DotAgentError::Internal(e.to_string()))?;
232    fs::write(manifest_path, manifest_content)?;
233
234    Ok(())
235}
236
237/// Load delta from checkpoint directory
238pub fn load_delta(checkpoint_dir: &Path) -> Result<Delta> {
239    let manifest_path = checkpoint_dir.join("delta.toml");
240    let content = fs::read_to_string(manifest_path)?;
241    let delta: Delta = toml::from_str(&content)
242        .map_err(|e| crate::error::DotAgentError::Internal(e.to_string()))?;
243    Ok(delta)
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use tempfile::TempDir;
250
251    #[test]
252    fn delta_entry_creation() {
253        let added = DeltaEntry::added("file.txt", "abc123".into(), 100);
254        assert_eq!(added.delta_type, DeltaType::Added);
255        assert!(added.hash.is_some());
256
257        let deleted = DeltaEntry::deleted("old.txt");
258        assert_eq!(deleted.delta_type, DeltaType::Deleted);
259        assert!(deleted.hash.is_none());
260    }
261
262    #[test]
263    fn delta_counts() {
264        let mut delta = Delta::new_incremental("cp-001".into());
265        delta.add_entry(DeltaEntry::added("a.txt", "hash1".into(), 10));
266        delta.add_entry(DeltaEntry::added("b.txt", "hash2".into(), 20));
267        delta.add_entry(DeltaEntry::modified("c.txt", "hash3".into(), 30));
268        delta.add_entry(DeltaEntry::deleted("d.txt"));
269
270        assert_eq!(delta.added_count(), 2);
271        assert_eq!(delta.modified_count(), 1);
272        assert_eq!(delta.deleted_count(), 1);
273    }
274
275    #[test]
276    fn compute_delta_empty_base() -> Result<()> {
277        let temp = TempDir::new()?;
278        let dir = temp.path();
279
280        fs::write(dir.join("file.txt"), "content")?;
281
282        let base_files = HashMap::new();
283        let delta = compute_delta(&base_files, dir)?;
284
285        assert!(delta.is_full);
286        assert_eq!(delta.added_count(), 1);
287
288        Ok(())
289    }
290
291    #[test]
292    fn compute_delta_with_changes() -> Result<()> {
293        let temp = TempDir::new()?;
294        let dir = temp.path();
295
296        // Current state
297        fs::write(dir.join("new.txt"), "new content")?;
298        fs::write(dir.join("modified.txt"), "modified content")?;
299
300        // Base state (simulated)
301        let mut base_files = HashMap::new();
302        base_files.insert("modified.txt".into(), "old_hash".into());
303        base_files.insert("deleted.txt".into(), "deleted_hash".into());
304
305        let delta = compute_delta(&base_files, dir)?;
306
307        assert_eq!(delta.added_count(), 1);
308        assert_eq!(delta.modified_count(), 1);
309        assert_eq!(delta.deleted_count(), 1);
310
311        Ok(())
312    }
313}