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