Skip to main content

suture_core/engine/
apply.rs

1//! Patch application — transform a FileTree by applying a patch.
2//!
3//! Each patch operation type has well-defined semantics:
4//! - **Create**: Add a new file to the tree
5//! - **Modify**: Update an existing file's blob hash
6//! - **Delete**: Remove a file from the tree
7//! - **Move**: Rename a file in the tree
8//! - **Metadata**: Update path permissions/timestamps (no tree change in v0.1)
9//! - **Merge**: Special commit combining two parents (apply both parents' chains)
10//! - **Identity**: No-op
11
12use crate::engine::tree::FileTree;
13use crate::patch::types::{OperationType, Patch, TouchSet};
14use thiserror::Error;
15
16/// Errors that can occur during patch application.
17#[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
32/// Apply a single patch to a FileTree, producing a new FileTree.
33///
34/// # Operation Semantics
35///
36/// | Operation | Precondition | Effect |
37/// |-----------|-------------|--------|
38/// | Create | Path must NOT exist | Insert path → blob hash |
39/// | Modify | Path must exist | Update blob hash |
40/// | Delete | Path must exist | Remove path |
41/// | Move | Old path must exist, new must NOT | Rename |
42/// | Metadata | Path must exist (if specified) | No tree change |
43/// | Merge | N/A | No tree change (merge commits carry no payload) |
44/// | Identity | N/A | No change |
45///
46/// # Arguments
47///
48/// * `tree` - The current file tree state
49/// * `patch` - The patch to apply
50/// * `get_payload_blob` - Function to resolve the patch payload to a CAS hash.
51///   For Modify/Create, the payload is a hex-encoded CAS hash; this function
52///   parses it and returns the actual hash.
53pub 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    // Handle Batch patches — iterate file changes and apply each
64    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    // Skip identity, merge, and root patches (no target_path)
80    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    // Safe to unwrap — we checked is_none above
88    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
163/// Apply a chain of patches (from oldest to newest) to produce a final FileTree.
164///
165/// The patches should be in application order (root first, tip last).
166/// This function applies each patch sequentially, threading the FileTree
167/// through each transformation.
168///
169/// # Arguments
170///
171/// * `patches` - Ordered list of patches to apply (oldest first)
172/// * `get_payload_blob` - Function to resolve patch payload to CAS hash
173pub 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
189/// Resolve a patch's payload to a CAS blob hash.
190///
191/// The payload in suture-core patches stores the hex-encoded BLAKE3 hash
192/// of the blob in the CAS. This function parses it back into a Hash.
193pub 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}