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            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
157/// Apply a chain of patches (from oldest to newest) to produce a final FileTree.
158///
159/// The patches should be in application order (root first, tip last).
160/// This function applies each patch sequentially, threading the FileTree
161/// through each transformation.
162///
163/// # Arguments
164///
165/// * `patches` - Ordered list of patches to apply (oldest first)
166/// * `get_payload_blob` - Function to resolve patch payload to CAS hash
167pub 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
183/// Resolve a patch's payload to a CAS blob hash.
184///
185/// The payload in suture-core patches stores the hex-encoded BLAKE3 hash
186/// of the blob in the CAS. This function parses it back into a Hash.
187pub 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}