1use crate::engine::tree::FileTree;
13use crate::patch::types::{OperationType, Patch, TouchSet};
14use thiserror::Error;
15
16#[derive(Error, Debug)]
18pub enum ApplyError {
19 #[error("patch not found in DAG: {0}")]
20 PatchNotFound(String),
21
22 #[error("file not found for delete: {0}")]
23 FileNotFound(String),
24
25 #[error("file already exists for create: {0}")]
26 FileAlreadyExists(String),
27
28 #[error("cannot apply patch: {0}")]
29 Custom(String),
30}
31
32pub fn apply_patch<F>(
54 tree: &FileTree,
55 patch: &Patch,
56 mut get_payload_blob: F,
57) -> Result<FileTree, ApplyError>
58where
59 F: FnMut(&Patch) -> Option<suture_common::Hash>,
60{
61 let mut new_tree = tree.clone();
62
63 if patch.operation_type == OperationType::Batch {
65 if let Some(changes) = patch.file_changes() {
66 for change in &changes {
67 new_tree = apply_single_op(
68 &new_tree,
69 &change.op,
70 &change.path,
71 &change.payload,
72 &mut get_payload_blob,
73 )?;
74 }
75 }
76 return Ok(new_tree);
77 }
78
79 if patch.is_identity()
81 || patch.operation_type == OperationType::Merge
82 || patch.target_path.is_none()
83 {
84 return Ok(new_tree);
85 }
86
87 let Some(target_path) = patch.target_path.as_deref() else {
89 return Ok(new_tree);
90 };
91
92 apply_single_op(
93 &new_tree,
94 &patch.operation_type,
95 target_path,
96 &patch.payload,
97 &mut get_payload_blob,
98 )
99}
100
101fn apply_single_op<F>(
102 tree: &FileTree,
103 op: &OperationType,
104 target_path: &str,
105 payload: &[u8],
106 mut get_payload_blob: F,
107) -> Result<FileTree, ApplyError>
108where
109 F: FnMut(&Patch) -> Option<suture_common::Hash>,
110{
111 let mut new_tree = tree.clone();
112
113 match op {
114 OperationType::Create => {
115 let tmp_patch = Patch::new(
116 OperationType::Create,
117 TouchSet::single(target_path),
118 Some(target_path.to_string()),
119 payload.to_vec(),
120 vec![],
121 String::new(),
122 String::new(),
123 );
124 if let Some(blob_hash) = get_payload_blob(&tmp_patch) {
125 new_tree.insert(target_path.to_string(), blob_hash);
126 }
127 }
128 OperationType::Modify => {
129 let tmp_patch = Patch::new(
130 OperationType::Modify,
131 TouchSet::single(target_path),
132 Some(target_path.to_string()),
133 payload.to_vec(),
134 vec![],
135 String::new(),
136 String::new(),
137 );
138 if let Some(blob_hash) = get_payload_blob(&tmp_patch) {
139 new_tree.insert(target_path.to_string(), blob_hash);
140 }
141 }
142 OperationType::Delete => {
143 new_tree.remove(target_path);
144 }
145 OperationType::Move => {
146 let new_path = String::from_utf8(payload.to_vec())
147 .map_err(|_| ApplyError::Custom("Move payload must be valid UTF-8 path".into()))?;
148 new_tree.rename(target_path, new_path);
149 }
150 OperationType::Metadata => {}
151 OperationType::Merge | OperationType::Identity | OperationType::Batch => {}
152 }
153
154 Ok(new_tree)
155}
156
157pub fn apply_patch_chain<F>(
168 patches: &[Patch],
169 mut get_payload_blob: F,
170) -> Result<FileTree, ApplyError>
171where
172 F: FnMut(&Patch) -> Option<suture_common::Hash>,
173{
174 let mut tree = FileTree::empty();
175
176 for patch in patches {
177 tree = apply_patch(&tree, patch, &mut get_payload_blob)?;
178 }
179
180 Ok(tree)
181}
182
183pub fn resolve_payload_to_hash(patch: &Patch) -> Option<suture_common::Hash> {
188 if patch.payload.is_empty() {
189 return None;
190 }
191 let hex = String::from_utf8(patch.payload.clone()).ok()?;
192 suture_common::Hash::from_hex(&hex).ok()
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use crate::patch::types::{FileChange, TouchSet};
199
200 fn make_patch(op: OperationType, path: &str, payload: &[u8]) -> Patch {
201 let op_name = format!("{:?}", op);
202 Patch::new(
203 op,
204 TouchSet::single(path),
205 Some(path.to_string()),
206 payload.to_vec(),
207 vec![],
208 "test".to_string(),
209 format!("{} {}", op_name, path),
210 )
211 }
212
213 fn blob_hash(data: &[u8]) -> Vec<u8> {
214 suture_common::Hash::from_data(data).to_hex().into_bytes()
215 }
216
217 #[test]
218 fn test_apply_create() {
219 let tree = FileTree::empty();
220 let data = b"hello world";
221 let patch = make_patch(OperationType::Create, "hello.txt", &blob_hash(data));
222 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
223 assert!(result.contains("hello.txt"));
224 }
225
226 #[test]
227 fn test_apply_modify() {
228 let mut tree = FileTree::empty();
229 let old_hash = suture_common::Hash::from_data(b"old content");
230 tree.insert("file.txt".to_string(), old_hash);
231
232 let new_data = b"new content";
233 let new_hash = suture_common::Hash::from_data(new_data);
234 let patch = make_patch(OperationType::Modify, "file.txt", &blob_hash(new_data));
235 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
236 assert_eq!(result.get("file.txt"), Some(&new_hash));
237 }
238
239 #[test]
240 fn test_apply_delete() {
241 let mut tree = FileTree::empty();
242 tree.insert(
243 "file.txt".to_string(),
244 suture_common::Hash::from_data(b"data"),
245 );
246
247 let patch = make_patch(OperationType::Delete, "file.txt", &[]);
248 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
249 assert!(!result.contains("file.txt"));
250 assert!(result.is_empty());
251 }
252
253 #[test]
254 fn test_apply_move() {
255 let mut tree = FileTree::empty();
256 let hash = suture_common::Hash::from_data(b"data");
257 tree.insert("old.txt".to_string(), hash);
258
259 let patch = make_patch(OperationType::Move, "old.txt", b"new.txt");
260 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
261 assert!(!result.contains("old.txt"));
262 assert!(result.contains("new.txt"));
263 assert_eq!(result.get("new.txt"), Some(&hash));
264 }
265
266 #[test]
267 fn test_apply_identity() {
268 let mut tree = FileTree::empty();
269 tree.insert(
270 "file.txt".to_string(),
271 suture_common::Hash::from_data(b"data"),
272 );
273
274 let parent = suture_common::Hash::ZERO;
275 let identity = Patch::identity(parent, "test".to_string());
276 let result = apply_patch(&tree, &identity, resolve_payload_to_hash).unwrap();
277 assert_eq!(result, tree);
278 }
279
280 #[test]
281 fn test_apply_chain() {
282 let p1 = make_patch(OperationType::Create, "a.txt", &blob_hash(b"content a"));
283 let p2 = make_patch(OperationType::Create, "b.txt", &blob_hash(b"content b"));
284 let p3 = make_patch(OperationType::Modify, "a.txt", &blob_hash(b"content a v2"));
285
286 let tree = apply_patch_chain(&[p1, p2, p3], resolve_payload_to_hash).unwrap();
287 assert_eq!(tree.len(), 2);
288 assert_eq!(
289 tree.get("a.txt"),
290 Some(&suture_common::Hash::from_data(b"content a v2"))
291 );
292 assert_eq!(
293 tree.get("b.txt"),
294 Some(&suture_common::Hash::from_data(b"content b"))
295 );
296 }
297
298 #[test]
299 fn test_apply_chain_with_delete() {
300 let p1 = make_patch(OperationType::Create, "a.txt", &blob_hash(b"data"));
301 let p2 = make_patch(OperationType::Delete, "a.txt", &[]);
302
303 let tree = apply_patch_chain(&[p1, p2], resolve_payload_to_hash).unwrap();
304 assert!(tree.is_empty());
305 }
306
307 #[test]
308 fn test_resolve_payload_to_hash() {
309 let hash = suture_common::Hash::from_data(b"test");
310 let patch = make_patch(
311 OperationType::Create,
312 "file.txt",
313 &hash.to_hex().into_bytes(),
314 );
315 let resolved = resolve_payload_to_hash(&patch).unwrap();
316 assert_eq!(resolved, hash);
317 }
318
319 #[test]
320 fn test_resolve_empty_payload() {
321 let patch = make_patch(OperationType::Delete, "file.txt", &[]);
322 assert!(resolve_payload_to_hash(&patch).is_none());
323 }
324
325 #[test]
326 fn test_apply_batch() {
327 let tree = FileTree::empty();
328 let file_changes = vec![
329 FileChange {
330 op: OperationType::Create,
331 path: "a.txt".to_string(),
332 payload: blob_hash(b"content a"),
333 },
334 FileChange {
335 op: OperationType::Create,
336 path: "b.txt".to_string(),
337 payload: blob_hash(b"content b"),
338 },
339 FileChange {
340 op: OperationType::Modify,
341 path: "a.txt".to_string(),
342 payload: blob_hash(b"content a v2"),
343 },
344 ];
345 let batch = Patch::new_batch(
346 file_changes,
347 vec![],
348 "test".to_string(),
349 "batch commit".to_string(),
350 );
351 let result = apply_patch(&tree, &batch, resolve_payload_to_hash).unwrap();
352 assert_eq!(result.len(), 2);
353 assert_eq!(
354 result.get("a.txt"),
355 Some(&suture_common::Hash::from_data(b"content a v2"))
356 );
357 assert_eq!(
358 result.get("b.txt"),
359 Some(&suture_common::Hash::from_data(b"content b"))
360 );
361 }
362
363 #[test]
364 fn test_apply_batch_with_delete() {
365 let mut tree = FileTree::empty();
366 tree.insert("a.txt".to_string(), suture_common::Hash::from_data(b"old"));
367 tree.insert("b.txt".to_string(), suture_common::Hash::from_data(b"keep"));
368
369 let file_changes = vec![
370 FileChange {
371 op: OperationType::Modify,
372 path: "a.txt".to_string(),
373 payload: blob_hash(b"new"),
374 },
375 FileChange {
376 op: OperationType::Delete,
377 path: "b.txt".to_string(),
378 payload: vec![],
379 },
380 ];
381 let batch = Patch::new_batch(
382 file_changes,
383 vec![],
384 "test".to_string(),
385 "batch with delete".to_string(),
386 );
387 let result = apply_patch(&tree, &batch, resolve_payload_to_hash).unwrap();
388 assert_eq!(result.len(), 1);
389 assert_eq!(
390 result.get("a.txt"),
391 Some(&suture_common::Hash::from_data(b"new"))
392 );
393 assert!(!result.contains("b.txt"));
394 }
395
396 mod proptests {
397 use super::*;
398 use proptest::prelude::*;
399 use suture_common::Hash;
400
401 fn valid_path() -> impl Strategy<Value = String> {
402 proptest::string::string_regex("[a-zA-Z0-9_/:-]{1,100}").unwrap()
403 }
404
405 fn hash_strategy() -> impl Strategy<Value = Hash> {
406 proptest::array::uniform32(proptest::num::u8::ANY).prop_map(Hash::from)
407 }
408
409 fn blob_hash_for(h: &Hash) -> Vec<u8> {
410 h.to_hex().into_bytes()
411 }
412
413 proptest! {
414 #[test]
415 fn apply_delete_removes_file(path in valid_path(), hash in hash_strategy()) {
416 let mut tree = FileTree::empty();
417 tree.insert(path.clone(), hash);
418 let patch = make_patch(OperationType::Delete, &path, &[]);
419 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
420 prop_assert!(!result.contains(&path));
421 }
422
423 #[test]
424 fn apply_create_adds_file(path in valid_path(), hash in hash_strategy()) {
425 let tree = FileTree::empty();
426 let patch = make_patch(OperationType::Create, &path, &blob_hash_for(&hash));
427 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
428 prop_assert!(result.contains(&path));
429 prop_assert_eq!(result.get(&path), Some(&hash));
430 }
431
432 #[test]
433 fn apply_modify_updates_hash(
434 path in valid_path(),
435 hash1 in hash_strategy(),
436 hash2 in hash_strategy()
437 ) {
438 prop_assume!(hash1 != hash2);
439 let mut tree = FileTree::empty();
440 tree.insert(path.clone(), hash1);
441 let patch = make_patch(OperationType::Modify, &path, &blob_hash_for(&hash2));
442 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
443 prop_assert_eq!(result.get(&path), Some(&hash2));
444 }
445
446 #[test]
447 fn apply_chain_order_matters(
448 path_a in valid_path(),
449 path_b in valid_path(),
450 hash1 in hash_strategy(),
451 hash2 in hash_strategy()
452 ) {
453 prop_assume!(path_a != path_b);
454 let p1 = make_patch(OperationType::Create, &path_a, &blob_hash_for(&hash1));
455 let p2 = make_patch(OperationType::Create, &path_b, &blob_hash_for(&hash2));
456
457 let tree_ab = apply_patch_chain(&[p1.clone(), p2.clone()], resolve_payload_to_hash).unwrap();
458 prop_assert!(tree_ab.contains(&path_a));
459 prop_assert!(tree_ab.contains(&path_b));
460
461 let tree_ba = apply_patch_chain(&[p2.clone(), p1.clone()], resolve_payload_to_hash).unwrap();
462 prop_assert!(tree_ba.contains(&path_a));
463 prop_assert!(tree_ba.contains(&path_b));
464 }
465 }
466 }
467}