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