Skip to main content

suture_core/patch/
conflict.rs

1//! Conflict types for non-commutative patch pairs.
2
3use crate::patch::types::Patch;
4use crate::patch::types::PatchId;
5use crate::patch::types::TouchSet;
6use serde::{Deserialize, Serialize};
7
8/// Classification of a conflict's severity and resolvability.
9///
10/// Per THM-CONFCLASS-001 (YP-ALGEBRA-PATCH-001):
11/// Conflict classification determines the merge strategy:
12/// - Type I: Auto-resolve (identical changes)
13/// - Type II: Attempt driver merge
14/// - Type III: Human intervention required
15/// - Type IV: Structural restructuring (highest complexity)
16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
17pub enum ConflictClass {
18    /// Both sides made the identical change — auto-resolvable.
19    AutoResolvable,
20    /// Both changed different aspects of the same element — may be driver-resolvable.
21    DriverResolvable,
22    /// Both changed the same element differently — genuine conflict.
23    Genuine,
24    /// One side restructured, other modified — structural conflict.
25    Structural,
26}
27
28/// A conflict between two patches that cannot commute.
29///
30/// A conflict preserves full information from BOTH patches so that
31/// a human can resolve it later. Per THM-CONF-001, no data is lost.
32#[derive(Clone, Debug, Serialize, Deserialize)]
33pub struct Conflict {
34    /// The patch from branch A.
35    pub patch_a_id: PatchId,
36    /// The patch from branch B.
37    pub patch_b_id: PatchId,
38    /// The addresses where both patches touch (the conflict addresses).
39    pub conflict_addresses: Vec<String>,
40}
41
42/// A conflict node in the Patch-DAG.
43///
44/// Unlike a regular patch, a conflict node stores references to two
45/// conflicting patches and the base state. It can be resolved later
46/// by choosing one side or manually merging.
47#[derive(Clone, Debug, Serialize, Deserialize)]
48pub struct ConflictNode {
49    /// The patch from branch A.
50    pub patch_a_id: PatchId,
51    /// The patch from branch B.
52    pub patch_b_id: PatchId,
53    /// The base (common ancestor) patch ID.
54    pub base_patch_id: PatchId,
55    /// The addresses where the conflict occurs.
56    pub touch_set: TouchSet,
57    /// Human-readable description of the conflict.
58    pub description: String,
59    /// Resolution status.
60    pub status: ConflictStatus,
61}
62
63/// The resolution status of a conflict.
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65pub enum ConflictStatus {
66    /// Conflict is unresolved.
67    Unresolved,
68    /// Conflict was resolved by choosing side A.
69    ResolvedA,
70    /// Conflict was resolved by choosing side B.
71    ResolvedB,
72    /// Conflict was resolved with a manual merge.
73    ResolvedManual,
74}
75
76impl Conflict {
77    /// Create a new conflict between two patches.
78    pub fn new(patch_a_id: PatchId, patch_b_id: PatchId, conflict_addresses: Vec<String>) -> Self {
79        let mut sorted = conflict_addresses.clone();
80        sorted.sort();
81        Self {
82            patch_a_id,
83            patch_b_id,
84            conflict_addresses: sorted,
85        }
86    }
87
88    /// Classify this conflict based on patch payloads.
89    ///
90    /// Returns a ConflictClass indicating the severity and potential resolvability.
91    pub fn classify(&self, patch_a: Option<&Patch>, patch_b: Option<&Patch>) -> ConflictClass {
92        match (patch_a, patch_b) {
93            (Some(pa), Some(pb)) => {
94                if pa.payload == pb.payload && pa.operation_type == pb.operation_type {
95                    ConflictClass::AutoResolvable
96                } else if pa.operation_type == pb.operation_type {
97                    let a_sub: Vec<String> = self
98                        .conflict_addresses
99                        .iter()
100                        .filter(|a| pa.touch_set.contains(a))
101                        .cloned()
102                        .collect();
103                    let b_sub: Vec<String> = self
104                        .conflict_addresses
105                        .iter()
106                        .filter(|a| pb.touch_set.contains(a))
107                        .cloned()
108                        .collect();
109                    if a_sub != b_sub {
110                        ConflictClass::DriverResolvable
111                    } else {
112                        ConflictClass::Genuine
113                    }
114                } else {
115                    ConflictClass::Structural
116                }
117            }
118            _ => ConflictClass::Genuine,
119        }
120    }
121}
122
123impl ConflictNode {
124    /// Create a new unresolved conflict node.
125    pub fn new(
126        patch_a_id: PatchId,
127        patch_b_id: PatchId,
128        base_patch_id: PatchId,
129        touch_set: TouchSet,
130        description: String,
131    ) -> Self {
132        Self {
133            patch_a_id,
134            patch_b_id,
135            base_patch_id,
136            touch_set,
137            description,
138            status: ConflictStatus::Unresolved,
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use suture_common::Hash;
147
148    fn test_hash(s: &str) -> PatchId {
149        Hash::from_data(s.as_bytes())
150    }
151
152    #[test]
153    fn test_conflict_creation() {
154        let c = Conflict::new(
155            test_hash("patch_a"),
156            test_hash("patch_b"),
157            vec!["A1".to_string(), "B1".to_string()],
158        );
159        assert_eq!(c.conflict_addresses.len(), 2);
160    }
161
162    #[test]
163    fn test_conflict_node_creation() {
164        let node = ConflictNode::new(
165            test_hash("patch_a"),
166            test_hash("patch_b"),
167            test_hash("base"),
168            TouchSet::from_addrs(["A1", "B1"]),
169            "Both edited A1".to_string(),
170        );
171        assert_eq!(node.status, ConflictStatus::Unresolved);
172        assert_eq!(node.touch_set.len(), 2);
173    }
174
175    #[test]
176    fn test_classify_auto_resolvable() {
177        let root = Hash::from_data(b"root");
178        let pa = Patch::new(
179            crate::patch::types::OperationType::Modify,
180            TouchSet::from_addrs(["f1"]),
181            Some("f1".to_string()),
182            b"same content".to_vec(),
183            vec![root],
184            "alice".to_string(),
185            "edit".to_string(),
186        );
187        let pb = Patch::new(
188            crate::patch::types::OperationType::Modify,
189            TouchSet::from_addrs(["f1"]),
190            Some("f1".to_string()),
191            b"same content".to_vec(),
192            vec![root],
193            "bob".to_string(),
194            "edit".to_string(),
195        );
196
197        let conflict = Conflict::new(pa.id, pb.id, vec!["f1".to_string()]);
198        assert_eq!(
199            conflict.classify(Some(&pa), Some(&pb)),
200            ConflictClass::AutoResolvable
201        );
202    }
203
204    #[test]
205    fn test_classify_genuine() {
206        let root = Hash::from_data(b"root");
207        let pa = Patch::new(
208            crate::patch::types::OperationType::Modify,
209            TouchSet::from_addrs(["f1"]),
210            Some("f1".to_string()),
211            b"version A".to_vec(),
212            vec![root],
213            "alice".to_string(),
214            "edit A".to_string(),
215        );
216        let pb = Patch::new(
217            crate::patch::types::OperationType::Modify,
218            TouchSet::from_addrs(["f1"]),
219            Some("f1".to_string()),
220            b"version B".to_vec(),
221            vec![root],
222            "bob".to_string(),
223            "edit B".to_string(),
224        );
225
226        let conflict = Conflict::new(pa.id, pb.id, vec!["f1".to_string()]);
227        assert_eq!(
228            conflict.classify(Some(&pa), Some(&pb)),
229            ConflictClass::Genuine
230        );
231    }
232
233    #[test]
234    fn test_classify_structural() {
235        let root = Hash::from_data(b"root");
236        let pa = Patch::new(
237            crate::patch::types::OperationType::Modify,
238            TouchSet::from_addrs(["f1"]),
239            Some("f1".to_string()),
240            b"modified content".to_vec(),
241            vec![root],
242            "alice".to_string(),
243            "modify".to_string(),
244        );
245        let pb = Patch::new(
246            crate::patch::types::OperationType::Delete,
247            TouchSet::from_addrs(["f1"]),
248            Some("f1".to_string()),
249            vec![],
250            vec![root],
251            "bob".to_string(),
252            "delete".to_string(),
253        );
254
255        let conflict = Conflict::new(pa.id, pb.id, vec!["f1".to_string()]);
256        assert_eq!(
257            conflict.classify(Some(&pa), Some(&pb)),
258            ConflictClass::Structural
259        );
260    }
261
262    #[test]
263    fn test_classify_driver_resolvable() {
264        let root = Hash::from_data(b"root");
265        let pa = Patch::new(
266            crate::patch::types::OperationType::Modify,
267            TouchSet::from_addrs(["f1.key_a"]),
268            Some("f1".to_string()),
269            b"val_a".to_vec(),
270            vec![root],
271            "alice".to_string(),
272            "edit key_a".to_string(),
273        );
274        let pb = Patch::new(
275            crate::patch::types::OperationType::Modify,
276            TouchSet::from_addrs(["f1.key_b"]),
277            Some("f1".to_string()),
278            b"val_b".to_vec(),
279            vec![root],
280            "bob".to_string(),
281            "edit key_b".to_string(),
282        );
283
284        let conflict = Conflict::new(
285            pa.id,
286            pb.id,
287            vec!["f1.key_a".to_string(), "f1.key_b".to_string()],
288        );
289        assert_eq!(
290            conflict.classify(Some(&pa), Some(&pb)),
291            ConflictClass::DriverResolvable
292        );
293    }
294
295    #[test]
296    fn test_classify_missing_patches() {
297        let conflict = Conflict::new(test_hash("a"), test_hash("b"), vec!["f1".to_string()]);
298        assert_eq!(conflict.classify(None, None), ConflictClass::Genuine);
299        assert_eq!(conflict.classify(None, None), ConflictClass::Genuine);
300    }
301}