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 if new_tree.contains(target_path) {
116 return Ok(new_tree);
117 }
118 let tmp_patch = Patch::new(
119 OperationType::Create,
120 TouchSet::single(target_path),
121 Some(target_path.to_string()),
122 payload.to_vec(),
123 vec![],
124 String::new(),
125 String::new(),
126 );
127 if let Some(blob_hash) = get_payload_blob(&tmp_patch) {
128 new_tree.insert(target_path.to_string(), blob_hash);
129 }
130 }
131 OperationType::Modify => {
132 if !new_tree.contains(target_path) {
133 return Ok(new_tree);
134 }
135 let tmp_patch = Patch::new(
136 OperationType::Modify,
137 TouchSet::single(target_path),
138 Some(target_path.to_string()),
139 payload.to_vec(),
140 vec![],
141 String::new(),
142 String::new(),
143 );
144 if let Some(blob_hash) = get_payload_blob(&tmp_patch) {
145 new_tree.insert(target_path.to_string(), blob_hash);
146 }
147 }
148 OperationType::Delete => {
149 new_tree.remove(target_path);
150 }
151 OperationType::Move => {
152 let new_path = String::from_utf8(payload.to_vec())
153 .map_err(|_| ApplyError::Custom("Move payload must be valid UTF-8 path".into()))?;
154 new_tree.rename(target_path, new_path);
155 }
156 OperationType::Metadata => {}
157 OperationType::Merge | OperationType::Identity | OperationType::Batch => {}
158 }
159
160 Ok(new_tree)
161}
162
163pub fn apply_patch_chain<F>(
174 patches: &[Patch],
175 mut get_payload_blob: F,
176) -> Result<FileTree, ApplyError>
177where
178 F: FnMut(&Patch) -> Option<suture_common::Hash>,
179{
180 let mut tree = FileTree::empty();
181
182 for patch in patches {
183 tree = apply_patch(&tree, patch, &mut get_payload_blob)?;
184 }
185
186 Ok(tree)
187}
188
189pub fn resolve_payload_to_hash(patch: &Patch) -> Option<suture_common::Hash> {
194 if patch.payload.is_empty() {
195 return None;
196 }
197 let hex = String::from_utf8(patch.payload.clone()).ok()?;
198 suture_common::Hash::from_hex(&hex).ok()
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use crate::patch::types::{FileChange, TouchSet};
205
206 fn make_patch(op: OperationType, path: &str, payload: &[u8]) -> Patch {
207 let op_name = format!("{:?}", op);
208 Patch::new(
209 op,
210 TouchSet::single(path),
211 Some(path.to_string()),
212 payload.to_vec(),
213 vec![],
214 "test".to_string(),
215 format!("{} {}", op_name, path),
216 )
217 }
218
219 fn blob_hash(data: &[u8]) -> Vec<u8> {
220 suture_common::Hash::from_data(data).to_hex().into_bytes()
221 }
222
223 #[test]
224 fn test_apply_create() {
225 let tree = FileTree::empty();
226 let data = b"hello world";
227 let patch = make_patch(OperationType::Create, "hello.txt", &blob_hash(data));
228 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
229 assert!(result.contains("hello.txt"));
230 }
231
232 #[test]
233 fn test_apply_modify() {
234 let mut tree = FileTree::empty();
235 let old_hash = suture_common::Hash::from_data(b"old content");
236 tree.insert("file.txt".to_string(), old_hash);
237
238 let new_data = b"new content";
239 let new_hash = suture_common::Hash::from_data(new_data);
240 let patch = make_patch(OperationType::Modify, "file.txt", &blob_hash(new_data));
241 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
242 assert_eq!(result.get("file.txt"), Some(&new_hash));
243 }
244
245 #[test]
246 fn test_apply_delete() {
247 let mut tree = FileTree::empty();
248 tree.insert(
249 "file.txt".to_string(),
250 suture_common::Hash::from_data(b"data"),
251 );
252
253 let patch = make_patch(OperationType::Delete, "file.txt", &[]);
254 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
255 assert!(!result.contains("file.txt"));
256 assert!(result.is_empty());
257 }
258
259 #[test]
260 fn test_apply_move() {
261 let mut tree = FileTree::empty();
262 let hash = suture_common::Hash::from_data(b"data");
263 tree.insert("old.txt".to_string(), hash);
264
265 let patch = make_patch(OperationType::Move, "old.txt", b"new.txt");
266 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
267 assert!(!result.contains("old.txt"));
268 assert!(result.contains("new.txt"));
269 assert_eq!(result.get("new.txt"), Some(&hash));
270 }
271
272 #[test]
273 fn test_apply_identity() {
274 let mut tree = FileTree::empty();
275 tree.insert(
276 "file.txt".to_string(),
277 suture_common::Hash::from_data(b"data"),
278 );
279
280 let parent = suture_common::Hash::ZERO;
281 let identity = Patch::identity(parent, "test".to_string());
282 let result = apply_patch(&tree, &identity, resolve_payload_to_hash).unwrap();
283 assert_eq!(result, tree);
284 }
285
286 #[test]
287 fn test_apply_chain() {
288 let p1 = make_patch(OperationType::Create, "a.txt", &blob_hash(b"content a"));
289 let p2 = make_patch(OperationType::Create, "b.txt", &blob_hash(b"content b"));
290 let p3 = make_patch(OperationType::Modify, "a.txt", &blob_hash(b"content a v2"));
291
292 let tree = apply_patch_chain(&[p1, p2, p3], resolve_payload_to_hash).unwrap();
293 assert_eq!(tree.len(), 2);
294 assert_eq!(
295 tree.get("a.txt"),
296 Some(&suture_common::Hash::from_data(b"content a v2"))
297 );
298 assert_eq!(
299 tree.get("b.txt"),
300 Some(&suture_common::Hash::from_data(b"content b"))
301 );
302 }
303
304 #[test]
305 fn test_apply_chain_with_delete() {
306 let p1 = make_patch(OperationType::Create, "a.txt", &blob_hash(b"data"));
307 let p2 = make_patch(OperationType::Delete, "a.txt", &[]);
308
309 let tree = apply_patch_chain(&[p1, p2], resolve_payload_to_hash).unwrap();
310 assert!(tree.is_empty());
311 }
312
313 #[test]
314 fn test_resolve_payload_to_hash() {
315 let hash = suture_common::Hash::from_data(b"test");
316 let patch = make_patch(
317 OperationType::Create,
318 "file.txt",
319 &hash.to_hex().into_bytes(),
320 );
321 let resolved = resolve_payload_to_hash(&patch).unwrap();
322 assert_eq!(resolved, hash);
323 }
324
325 #[test]
326 fn test_resolve_empty_payload() {
327 let patch = make_patch(OperationType::Delete, "file.txt", &[]);
328 assert!(resolve_payload_to_hash(&patch).is_none());
329 }
330
331 #[test]
332 fn test_apply_batch() {
333 let tree = FileTree::empty();
334 let file_changes = vec![
335 FileChange {
336 op: OperationType::Create,
337 path: "a.txt".to_string(),
338 payload: blob_hash(b"content a"),
339 },
340 FileChange {
341 op: OperationType::Create,
342 path: "b.txt".to_string(),
343 payload: blob_hash(b"content b"),
344 },
345 FileChange {
346 op: OperationType::Modify,
347 path: "a.txt".to_string(),
348 payload: blob_hash(b"content a v2"),
349 },
350 ];
351 let batch = Patch::new_batch(
352 file_changes,
353 vec![],
354 "test".to_string(),
355 "batch commit".to_string(),
356 );
357 let result = apply_patch(&tree, &batch, resolve_payload_to_hash).unwrap();
358 assert_eq!(result.len(), 2);
359 assert_eq!(
360 result.get("a.txt"),
361 Some(&suture_common::Hash::from_data(b"content a v2"))
362 );
363 assert_eq!(
364 result.get("b.txt"),
365 Some(&suture_common::Hash::from_data(b"content b"))
366 );
367 }
368
369 #[test]
370 fn test_apply_batch_with_delete() {
371 let mut tree = FileTree::empty();
372 tree.insert("a.txt".to_string(), suture_common::Hash::from_data(b"old"));
373 tree.insert("b.txt".to_string(), suture_common::Hash::from_data(b"keep"));
374
375 let file_changes = vec![
376 FileChange {
377 op: OperationType::Modify,
378 path: "a.txt".to_string(),
379 payload: blob_hash(b"new"),
380 },
381 FileChange {
382 op: OperationType::Delete,
383 path: "b.txt".to_string(),
384 payload: vec![],
385 },
386 ];
387 let batch = Patch::new_batch(
388 file_changes,
389 vec![],
390 "test".to_string(),
391 "batch with delete".to_string(),
392 );
393 let result = apply_patch(&tree, &batch, resolve_payload_to_hash).unwrap();
394 assert_eq!(result.len(), 1);
395 assert_eq!(
396 result.get("a.txt"),
397 Some(&suture_common::Hash::from_data(b"new"))
398 );
399 assert!(!result.contains("b.txt"));
400 }
401
402 #[test]
403 fn test_create_on_existing_path_with_same_hash() {
404 let mut tree = FileTree::empty();
405 let hash = suture_common::Hash::from_data(b"hello");
406 tree.insert("file.txt".to_string(), hash);
407
408 let patch = make_patch(OperationType::Create, "file.txt", &blob_hash(b"hello"));
409 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
410 assert_eq!(result, tree);
411 }
412
413 #[test]
414 fn test_create_on_existing_path_with_different_hash() {
415 let mut tree = FileTree::empty();
416 let original_hash = suture_common::Hash::from_data(b"original");
417 tree.insert("file.txt".to_string(), original_hash);
418
419 let patch = make_patch(OperationType::Create, "file.txt", &blob_hash(b"different"));
420 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
421 assert_eq!(result.get("file.txt"), Some(&original_hash));
422 }
423
424 #[test]
425 fn test_modify_on_nonexistent_path() {
426 let tree = FileTree::empty();
427 let patch = make_patch(
428 OperationType::Modify,
429 "ghost.txt",
430 &blob_hash(b"new content"),
431 );
432 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
433 assert!(result.is_empty());
434 }
435
436 #[test]
437 fn test_modify_on_existing_path() {
438 let mut tree = FileTree::empty();
439 tree.insert(
440 "file.txt".to_string(),
441 suture_common::Hash::from_data(b"old"),
442 );
443
444 let patch = make_patch(OperationType::Modify, "file.txt", &blob_hash(b"new"));
445 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
446 assert_eq!(
447 result.get("file.txt"),
448 Some(&suture_common::Hash::from_data(b"new"))
449 );
450 }
451
452 #[test]
453 fn test_delete_on_nonexistent_path() {
454 let tree = FileTree::empty();
455 let patch = make_patch(OperationType::Delete, "ghost.txt", &[]);
456 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
457 assert!(result.is_empty());
458 }
459
460 mod proptests {
461 use super::*;
462 use proptest::prelude::*;
463 use suture_common::Hash;
464
465 fn valid_path() -> impl Strategy<Value = String> {
466 proptest::string::string_regex("[a-zA-Z0-9_/:-]{1,100}").unwrap()
467 }
468
469 fn hash_strategy() -> impl Strategy<Value = Hash> {
470 proptest::array::uniform32(proptest::num::u8::ANY).prop_map(Hash::from)
471 }
472
473 fn blob_hash_for(h: &Hash) -> Vec<u8> {
474 h.to_hex().into_bytes()
475 }
476
477 proptest! {
478 #[test]
479 fn apply_delete_removes_file(path in valid_path(), hash in hash_strategy()) {
480 let mut tree = FileTree::empty();
481 tree.insert(path.clone(), hash);
482 let patch = make_patch(OperationType::Delete, &path, &[]);
483 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
484 prop_assert!(!result.contains(&path));
485 }
486
487 #[test]
488 fn apply_create_adds_file(path in valid_path(), hash in hash_strategy()) {
489 let tree = FileTree::empty();
490 let patch = make_patch(OperationType::Create, &path, &blob_hash_for(&hash));
491 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
492 prop_assert!(result.contains(&path));
493 prop_assert_eq!(result.get(&path), Some(&hash));
494 }
495
496 #[test]
497 fn apply_modify_updates_hash(
498 path in valid_path(),
499 hash1 in hash_strategy(),
500 hash2 in hash_strategy()
501 ) {
502 prop_assume!(hash1 != hash2);
503 let mut tree = FileTree::empty();
504 tree.insert(path.clone(), hash1);
505 let patch = make_patch(OperationType::Modify, &path, &blob_hash_for(&hash2));
506 let result = apply_patch(&tree, &patch, resolve_payload_to_hash).unwrap();
507 prop_assert_eq!(result.get(&path), Some(&hash2));
508 }
509
510 #[test]
511 fn apply_chain_order_matters(
512 path_a in valid_path(),
513 path_b in valid_path(),
514 hash1 in hash_strategy(),
515 hash2 in hash_strategy()
516 ) {
517 prop_assume!(path_a != path_b);
518 let p1 = make_patch(OperationType::Create, &path_a, &blob_hash_for(&hash1));
519 let p2 = make_patch(OperationType::Create, &path_b, &blob_hash_for(&hash2));
520
521 let tree_ab = apply_patch_chain(&[p1.clone(), p2.clone()], resolve_payload_to_hash).unwrap();
522 prop_assert!(tree_ab.contains(&path_a));
523 prop_assert!(tree_ab.contains(&path_b));
524
525 let tree_ba = apply_patch_chain(&[p2.clone(), p1.clone()], resolve_payload_to_hash).unwrap();
526 prop_assert!(tree_ba.contains(&path_a));
527 prop_assert!(tree_ba.contains(&path_b));
528 }
529 }
530 }
531}