Skip to main content

suture_core/patch/
compose.rs

1//! Patch composition — collapsing a patch sequence into a single equivalent patch.
2//!
3//! # Theory
4//!
5//! DEF-COMPOSE-001: Given P₁ → P₂ (P₂ has P₁ as ancestor), the composition
6//! P₃ = P₁ ∘ P₂ satisfies:
7//!   apply(P₃, pre_P₁_state) = apply(P₂, apply(P₁, pre_P₁_state))
8//!
9//! THM-COMPOSE-001: If P₁ and P₂ commute, then P₁ ∘ P₂ = P₂ ∘ P₁.
10//!   (The composed patch is independent of ordering.)
11
12use crate::patch::types::{OperationType, Patch};
13
14/// Result of composing two patches.
15#[derive(Clone, Debug)]
16pub struct ComposeResult {
17    /// The composed patch.
18    pub patch: Patch,
19    /// Number of patches that were composed.
20    pub count: usize,
21}
22
23/// Compose two patches into a single equivalent patch.
24///
25/// The patches must form a chain: P₂ must have P₁ as its direct ancestor.
26///
27/// # Arguments
28/// * `p1` - The first (earlier) patch
29/// * `p2` - The second (later) patch, must have p1.id in parent_ids
30/// * `author` - Author of the composition operation
31/// * `message` - Description of the composition
32///
33/// # Returns
34///
35/// A ComposeResult with the composed patch, or an error if composition is not possible.
36pub fn compose(
37    p1: &Patch,
38    p2: &Patch,
39    author: &str,
40    message: &str,
41) -> Result<ComposeResult, ComposeError> {
42    // Verify p2 has p1 as parent
43    if !p2.parent_ids.contains(&p1.id) {
44        return Err(ComposeError::NotAncestor {
45            p1_id: p1.id.to_hex(),
46            p2_id: p2.id.to_hex(),
47        });
48    }
49
50    // Union of touch sets
51    let mut composed_touch = p1.touch_set.clone();
52    for addr in p2.touch_set.iter() {
53        composed_touch.insert(addr.clone());
54    }
55
56    // If both modify the same file, use the later payload
57    let composed_path = p2.target_path.clone().or(p1.target_path.clone());
58    let composed_payload = if !p2.payload.is_empty() {
59        p2.payload.clone()
60    } else {
61        p1.payload.clone()
62    };
63
64    // Determine operation type:
65    // - If p2 creates a file that p1 didn't touch → Create
66    // - If p2 deletes a file → Delete
67    // - If either modifies → Modify
68    // - If p2 moves → Move
69    let composed_op = match (&p1.operation_type, &p2.operation_type) {
70        (_, OperationType::Delete) => OperationType::Delete,
71        (_, OperationType::Move) => OperationType::Move,
72        (_, OperationType::Create) => {
73            if p1.operation_type == OperationType::Create {
74                OperationType::Create
75            } else {
76                OperationType::Modify
77            }
78        }
79        (OperationType::Create, OperationType::Modify) => OperationType::Create,
80        _ => OperationType::Modify,
81    };
82
83    let composed_patch = Patch::new(
84        composed_op,
85        composed_touch,
86        composed_path,
87        composed_payload,
88        p1.parent_ids.clone(), // Take p1's parents as the composed patch's parents
89        author.to_string(),
90        message.to_string(),
91    );
92
93    Ok(ComposeResult {
94        patch: composed_patch,
95        count: 2,
96    })
97}
98
99/// Compose a sequence of patches into a single equivalent patch.
100///
101/// Patches must form a linear chain: each patch's first parent is the previous patch.
102/// Unlike `compose`, this does not require strict ancestry verification between
103/// intermediate composed results and the next patch, since the intermediate
104/// composed patch has a new ID.
105pub fn compose_chain(
106    patches: &[Patch],
107    author: &str,
108    message: &str,
109) -> Result<ComposeResult, ComposeError> {
110    if patches.is_empty() {
111        return Err(ComposeError::EmptyChain);
112    }
113    if patches.len() == 1 {
114        return Ok(ComposeResult {
115            patch: patches[0].clone(),
116            count: 1,
117        });
118    }
119
120    let mut composed_touch = patches[0].touch_set.clone();
121    let mut composed_path = patches[0].target_path.clone();
122    let mut composed_payload = patches[0].payload.clone();
123    let mut composed_op = patches[0].operation_type.clone();
124
125    for p in &patches[1..] {
126        for addr in p.touch_set.iter() {
127            composed_touch.insert(addr.clone());
128        }
129        if !p.payload.is_empty() {
130            composed_payload = p.payload.clone();
131        }
132        if p.target_path.is_some() {
133            composed_path = p.target_path.clone();
134        }
135        composed_op = match (&composed_op, &p.operation_type) {
136            (_, OperationType::Delete) => OperationType::Delete,
137            (_, OperationType::Move) => OperationType::Move,
138            (_, OperationType::Create) => {
139                if composed_op == OperationType::Create {
140                    OperationType::Create
141                } else {
142                    OperationType::Modify
143                }
144            }
145            (OperationType::Create, OperationType::Modify) => OperationType::Create,
146            _ => OperationType::Modify,
147        };
148    }
149
150    let composed_patch = Patch::new(
151        composed_op,
152        composed_touch,
153        composed_path,
154        composed_payload,
155        patches[0].parent_ids.clone(),
156        author.to_string(),
157        message.to_string(),
158    );
159
160    Ok(ComposeResult {
161        patch: composed_patch,
162        count: patches.len(),
163    })
164}
165
166#[derive(Debug, thiserror::Error)]
167pub enum ComposeError {
168    #[error("patches do not form a chain: {p2_id} does not have {p1_id} as ancestor")]
169    NotAncestor { p1_id: String, p2_id: String },
170    #[error("cannot compose an empty patch chain")]
171    EmptyChain,
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::patch::types::{PatchId, TouchSet};
178    use suture_common::Hash;
179
180    fn make_patch(
181        op: OperationType,
182        touch: &[&str],
183        path: Option<&str>,
184        payload: &[u8],
185        parents: &[PatchId],
186        author: &str,
187        message: &str,
188    ) -> Patch {
189        Patch::new(
190            op,
191            TouchSet::from_addrs(touch.iter().copied()),
192            path.map(|s| s.to_string()),
193            payload.to_vec(),
194            parents.to_vec(),
195            author.to_string(),
196            message.to_string(),
197        )
198    }
199
200    #[test]
201    fn test_compose_linear_chain() {
202        let root = Hash::from_data(b"root");
203        let p1 = make_patch(
204            OperationType::Modify,
205            &["file_a"],
206            Some("file_a"),
207            b"content_a",
208            &[root],
209            "alice",
210            "edit file_a",
211        );
212        let p2 = make_patch(
213            OperationType::Modify,
214            &["file_b"],
215            Some("file_b"),
216            b"content_b",
217            &[p1.id],
218            "alice",
219            "edit file_b",
220        );
221
222        let result = compose(&p1, &p2, "alice", "composed").unwrap();
223        assert_eq!(result.count, 2);
224        assert_eq!(result.patch.parent_ids, vec![root]);
225        assert!(result.patch.touch_set.contains("file_a"));
226        assert!(result.patch.touch_set.contains("file_b"));
227    }
228
229    #[test]
230    fn test_compose_disjoint_touch_sets() {
231        let root = Hash::from_data(b"root");
232        let p1 = make_patch(
233            OperationType::Modify,
234            &["alpha"],
235            Some("alpha"),
236            b"aaa",
237            &[root],
238            "bob",
239            "change alpha",
240        );
241        let p2 = make_patch(
242            OperationType::Modify,
243            &["beta"],
244            Some("beta"),
245            b"bbb",
246            &[p1.id],
247            "bob",
248            "change beta",
249        );
250
251        let result = compose(&p1, &p2, "bob", "merge disjoint").unwrap();
252        assert_eq!(result.patch.touch_set.len(), 2);
253        assert!(result.patch.touch_set.contains("alpha"));
254        assert!(result.patch.touch_set.contains("beta"));
255    }
256
257    #[test]
258    fn test_compose_overlapping_touch_sets() {
259        let root = Hash::from_data(b"root");
260        let p1 = make_patch(
261            OperationType::Modify,
262            &["shared"],
263            Some("shared"),
264            b"v1",
265            &[root],
266            "carol",
267            "first edit",
268        );
269        let p2 = make_patch(
270            OperationType::Modify,
271            &["shared"],
272            Some("shared"),
273            b"v2",
274            &[p1.id],
275            "carol",
276            "second edit",
277        );
278
279        let result = compose(&p1, &p2, "carol", "merge overlap").unwrap();
280        // Touch set should have shared only (union of identical sets)
281        assert_eq!(result.patch.touch_set.len(), 1);
282        assert!(result.patch.touch_set.contains("shared"));
283        // Should take p2's payload (later)
284        assert_eq!(result.patch.payload, b"v2".to_vec());
285    }
286
287    #[test]
288    fn test_compose_not_ancestor_error() {
289        let root = Hash::from_data(b"root");
290        let p1 = make_patch(
291            OperationType::Modify,
292            &["a"],
293            Some("a"),
294            b"aaa",
295            &[root],
296            "dave",
297            "p1",
298        );
299        // p2 does NOT have p1 as ancestor — both share root
300        let p2 = make_patch(
301            OperationType::Modify,
302            &["b"],
303            Some("b"),
304            b"bbb",
305            &[root],
306            "dave",
307            "p2",
308        );
309
310        let err = compose(&p1, &p2, "dave", "fail").unwrap_err();
311        let msg = err.to_string();
312        assert!(msg.contains("does not have"), "unexpected error: {msg}");
313    }
314
315    #[test]
316    fn test_compose_empty_chain_error() {
317        let err = compose_chain(&[], "alice", "empty").unwrap_err();
318        assert!(matches!(err, ComposeError::EmptyChain));
319    }
320
321    #[test]
322    fn test_compose_single_patch() {
323        let root = Hash::from_data(b"root");
324        let p1 = make_patch(
325            OperationType::Modify,
326            &["solo"],
327            Some("solo"),
328            b"data",
329            &[root],
330            "eve",
331            "solo commit",
332        );
333
334        let result = compose_chain(&[p1.clone()], "eve", "noop").unwrap();
335        assert_eq!(result.count, 1);
336        assert_eq!(result.patch.id, p1.id);
337    }
338
339    #[test]
340    fn test_compose_chain_multiple() {
341        let root = Hash::from_data(b"root");
342        let p1 = make_patch(
343            OperationType::Modify,
344            &["x"],
345            Some("x"),
346            b"x1",
347            &[root],
348            "frank",
349            "first",
350        );
351        let p2 = make_patch(
352            OperationType::Modify,
353            &["y"],
354            Some("y"),
355            b"y1",
356            &[p1.id],
357            "frank",
358            "second",
359        );
360        let p3 = make_patch(
361            OperationType::Modify,
362            &["z"],
363            Some("z"),
364            b"z1",
365            &[p2.id],
366            "frank",
367            "third",
368        );
369
370        let result = compose_chain(&[p1, p2, p3], "frank", "all three").unwrap();
371        assert_eq!(result.count, 3);
372        assert_eq!(result.patch.parent_ids, vec![root]);
373        assert!(result.patch.touch_set.contains("x"));
374        assert!(result.patch.touch_set.contains("y"));
375        assert!(result.patch.touch_set.contains("z"));
376    }
377
378    #[test]
379    fn test_compose_preserves_union_touch_set() {
380        let root = Hash::from_data(b"root");
381        let p1 = make_patch(
382            OperationType::Modify,
383            &["a", "b", "c"],
384            Some("a"),
385            b"data",
386            &[root],
387            "grace",
388            "batch 1",
389        );
390        let p2 = make_patch(
391            OperationType::Modify,
392            &["c", "d", "e"],
393            Some("d"),
394            b"data2",
395            &[p1.id],
396            "grace",
397            "batch 2",
398        );
399
400        let result = compose(&p1, &p2, "grace", "union test").unwrap();
401
402        // Union should be {a, b, c, d, e}
403        assert!(result.patch.touch_set.contains("a"));
404        assert!(result.patch.touch_set.contains("b"));
405        assert!(result.patch.touch_set.contains("c"));
406        assert!(result.patch.touch_set.contains("d"));
407        assert!(result.patch.touch_set.contains("e"));
408        assert_eq!(result.patch.touch_set.len(), 5);
409    }
410}