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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum DeltaType {
14 Added,
16 Modified,
18 Deleted,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct DeltaEntry {
25 pub path: String,
27 pub delta_type: DeltaType,
29 pub hash: Option<String>,
31 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66pub struct Delta {
67 pub entries: Vec<DeltaEntry>,
69 pub base_checkpoint: Option<String>,
71 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
122pub 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
130pub 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 if current_dir.exists() {
142 walk_directory(current_dir, current_dir, &mut current_files)?;
143 }
144
145 for (path, hash) in ¤t_files {
147 match base_files.get(path) {
148 None => {
149 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 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 }
163 }
164 }
165
166 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
176fn 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
203pub 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 }
225 }
226 }
227
228 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
237pub 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 fs::write(dir.join("new.txt"), "new content")?;
298 fs::write(dir.join("modified.txt"), "modified content")?;
299
300 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}