Skip to main content

oxirs_stream/patch/
conflict.rs

1//! Conflict resolution for patches
2
3use crate::{PatchOperation, RdfPatch};
4use anyhow::Result;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use tracing::info;
8
9pub struct ConflictResolver {
10    strategy: ConflictStrategy,
11    priority_rules: Vec<PriorityRule>,
12    merge_policies: HashMap<String, MergePolicy>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub enum ConflictStrategy {
17    FirstWins,
18    LastWins,
19    Merge,
20    Manual,
21    Priority,
22    Temporal,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct PriorityRule {
27    pub operation_type: String,
28    pub priority: i32,
29    pub source_pattern: Option<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub enum MergePolicy {
34    Union,
35    Intersection,
36    CustomLogic(String),
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ConflictReport {
41    pub conflicts_found: usize,
42    pub conflicts_resolved: usize,
43    pub resolution_strategy: ConflictStrategy,
44    pub detailed_conflicts: Vec<DetailedConflict>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct DetailedConflict {
49    pub conflict_type: String,
50    pub operation1: PatchOperation,
51    pub operation2: PatchOperation,
52    pub resolution: ConflictResolution,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub enum ConflictResolution {
57    KeepFirst,
58    KeepSecond,
59    KeepBoth,
60    Merged(PatchOperation),
61    RequiresManualReview,
62}
63
64impl ConflictResolver {
65    pub fn new(strategy: ConflictStrategy) -> Self {
66        Self {
67            strategy,
68            priority_rules: Vec::new(),
69            merge_policies: HashMap::new(),
70        }
71    }
72
73    pub fn with_priority_rule(mut self, rule: PriorityRule) -> Self {
74        self.priority_rules.push(rule);
75        self
76    }
77
78    pub fn with_merge_policy(mut self, operation_type: String, policy: MergePolicy) -> Self {
79        self.merge_policies.insert(operation_type, policy);
80        self
81    }
82
83    /// Resolve conflicts between two patches
84    pub fn resolve_conflicts(
85        &self,
86        patch1: &RdfPatch,
87        patch2: &RdfPatch,
88    ) -> Result<(RdfPatch, ConflictReport)> {
89        let mut merged_patch = RdfPatch::new();
90        merged_patch.id = format!("merged-{}-{}", patch1.id, patch2.id);
91
92        let mut conflicts = Vec::new();
93
94        // Start from patch1's operations. patch2's non-conflicting operations are
95        // appended; conflicting ones are resolved according to the strategy.
96        let mut merged_ops: Vec<PatchOperation> = patch1.operations.clone();
97        // Track which patch1 operations have already been resolved so the same
98        // op isn't matched twice.
99        let mut resolved_indices: std::collections::HashSet<usize> =
100            std::collections::HashSet::new();
101
102        for op2 in &patch2.operations {
103            // Find the first not-yet-resolved conflicting operation in patch1.
104            let conflicting = patch1.operations.iter().enumerate().find(|(idx, op1)| {
105                !resolved_indices.contains(idx) && self.operations_conflict(op1, op2)
106            });
107
108            if let Some((idx, op1)) = conflicting {
109                resolved_indices.insert(idx);
110                let resolution = self.resolve_operation_conflict(op1, op2)?;
111
112                // Apply the resolution against `merged_ops`.
113                match &resolution {
114                    ConflictResolution::KeepFirst => {
115                        // op1 already present; drop op2.
116                    }
117                    ConflictResolution::KeepSecond => {
118                        // Replace op1 with op2 in place.
119                        if let Some(slot) = merged_ops.get_mut(idx) {
120                            *slot = op2.clone();
121                        }
122                    }
123                    ConflictResolution::KeepBoth => {
124                        merged_ops.push(op2.clone());
125                    }
126                    ConflictResolution::Merged(merged_op) => {
127                        if let Some(slot) = merged_ops.get_mut(idx) {
128                            *slot = merged_op.clone();
129                        }
130                    }
131                    ConflictResolution::RequiresManualReview => {
132                        merged_ops.push(PatchOperation::Header {
133                            key: "conflict".to_string(),
134                            value: format!("Manual review required for {:?} vs {:?}", op1, op2),
135                        });
136                    }
137                }
138
139                conflicts.push(DetailedConflict {
140                    conflict_type: "operation_overlap".to_string(),
141                    operation1: op1.clone(),
142                    operation2: op2.clone(),
143                    resolution,
144                });
145            } else if !merged_ops.contains(op2) {
146                // No conflict and not a duplicate: keep it.
147                merged_ops.push(op2.clone());
148            }
149        }
150
151        for operation in merged_ops {
152            merged_patch.add_operation(operation);
153        }
154
155        let report = ConflictReport {
156            conflicts_found: conflicts.len(),
157            conflicts_resolved: conflicts
158                .iter()
159                .filter(|c| !matches!(c.resolution, ConflictResolution::RequiresManualReview))
160                .count(),
161            resolution_strategy: self.strategy.clone(),
162            detailed_conflicts: conflicts,
163        };
164
165        info!(
166            "Conflict resolution completed: {}/{} conflicts resolved",
167            report.conflicts_resolved, report.conflicts_found
168        );
169        Ok((merged_patch, report))
170    }
171
172    /// Determine whether two operations conflict.
173    ///
174    /// Two triple operations conflict when they target the same subject and
175    /// predicate but either assert a different object or have opposing Add/Delete
176    /// semantics (e.g. one adds a triple another deletes). Two graph operations
177    /// conflict when they target the same graph URI with opposing Add/Delete
178    /// semantics.
179    fn operations_conflict(&self, op1: &PatchOperation, op2: &PatchOperation) -> bool {
180        if let (Some((add1, s1, p1, o1)), Some((add2, s2, p2, o2))) =
181            (Self::triple_parts(op1), Self::triple_parts(op2))
182        {
183            if s1 == s2 && p1 == p2 {
184                // Same subject+predicate: conflict if the object differs or the
185                // Add/Delete direction differs. Identical operations (same
186                // object, same direction) are duplicates, not conflicts.
187                return o1 != o2 || add1 != add2;
188            }
189            return false;
190        }
191
192        if let (Some((add1, g1)), Some((add2, g2))) =
193            (Self::graph_parts(op1), Self::graph_parts(op2))
194        {
195            // Opposing graph operations on the same graph conflict.
196            return g1 == g2 && add1 != add2;
197        }
198
199        false
200    }
201
202    /// Return `(is_add, subject, predicate, object)` for triple operations.
203    fn triple_parts(operation: &PatchOperation) -> Option<(bool, &str, &str, &str)> {
204        match operation {
205            PatchOperation::Add {
206                subject,
207                predicate,
208                object,
209            } => Some((true, subject, predicate, object)),
210            PatchOperation::Delete {
211                subject,
212                predicate,
213                object,
214            } => Some((false, subject, predicate, object)),
215            _ => None,
216        }
217    }
218
219    /// Return `(is_add, graph)` for graph operations.
220    fn graph_parts(operation: &PatchOperation) -> Option<(bool, &str)> {
221        match operation {
222            PatchOperation::AddGraph { graph } => Some((true, graph)),
223            PatchOperation::DeleteGraph { graph } => Some((false, graph)),
224            _ => None,
225        }
226    }
227
228    fn resolve_operation_conflict(
229        &self,
230        op1: &PatchOperation,
231        op2: &PatchOperation,
232    ) -> Result<ConflictResolution> {
233        match self.strategy {
234            ConflictStrategy::FirstWins => Ok(ConflictResolution::KeepFirst),
235            ConflictStrategy::LastWins => Ok(ConflictResolution::KeepSecond),
236            ConflictStrategy::Merge => {
237                // Attempt to merge operations
238                self.attempt_merge(op1, op2)
239            }
240            ConflictStrategy::Priority => {
241                // Use priority rules
242                self.resolve_by_priority(op1, op2)
243            }
244            ConflictStrategy::Temporal => {
245                // Use timestamps if available
246                Ok(ConflictResolution::KeepSecond) // Default to later operation
247            }
248            ConflictStrategy::Manual => Ok(ConflictResolution::RequiresManualReview),
249        }
250    }
251
252    fn attempt_merge(
253        &self,
254        op1: &PatchOperation,
255        op2: &PatchOperation,
256    ) -> Result<ConflictResolution> {
257        match (op1, op2) {
258            (
259                PatchOperation::Add {
260                    subject: s1,
261                    predicate: p1,
262                    object: o1,
263                },
264                PatchOperation::Add {
265                    subject: s2,
266                    predicate: p2,
267                    object: o2,
268                },
269            ) => {
270                if s1 == s2 && p1 == p2 && o1 == o2 {
271                    // Identical add on both sides: keep a single copy.
272                    Ok(ConflictResolution::KeepFirst)
273                } else {
274                    // Same subject/predicate but different object (multi-valued
275                    // property): retain both assertions.
276                    Ok(ConflictResolution::KeepBoth)
277                }
278            }
279            // An add opposing a delete of the same triple cannot be merged
280            // automatically without a policy: keep the addition.
281            (PatchOperation::Add { .. }, PatchOperation::Delete { .. })
282            | (PatchOperation::Delete { .. }, PatchOperation::Add { .. }) => {
283                Ok(ConflictResolution::KeepFirst)
284            }
285            _ => Ok(ConflictResolution::RequiresManualReview),
286        }
287    }
288
289    fn resolve_by_priority(
290        &self,
291        op1: &PatchOperation,
292        op2: &PatchOperation,
293    ) -> Result<ConflictResolution> {
294        let priority1 = self.get_operation_priority(op1);
295        let priority2 = self.get_operation_priority(op2);
296
297        if priority1 > priority2 {
298            Ok(ConflictResolution::KeepFirst)
299        } else if priority2 > priority1 {
300            Ok(ConflictResolution::KeepSecond)
301        } else {
302            Ok(ConflictResolution::RequiresManualReview)
303        }
304    }
305
306    fn get_operation_priority(&self, operation: &PatchOperation) -> i32 {
307        let op_type = match operation {
308            PatchOperation::Add { .. } => "add",
309            PatchOperation::Delete { .. } => "delete",
310            PatchOperation::AddGraph { .. } => "add_graph",
311            PatchOperation::DeleteGraph { .. } => "delete_graph",
312            _ => "other",
313        };
314
315        for rule in &self.priority_rules {
316            if rule.operation_type == op_type {
317                return rule.priority;
318            }
319        }
320
321        0 // Default priority
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn add(subject: &str, object: &str) -> PatchOperation {
330        PatchOperation::Add {
331            subject: subject.to_string(),
332            predicate: "http://example.org/p".to_string(),
333            object: object.to_string(),
334        }
335    }
336
337    fn delete(subject: &str, object: &str) -> PatchOperation {
338        PatchOperation::Delete {
339            subject: subject.to_string(),
340            predicate: "http://example.org/p".to_string(),
341            object: object.to_string(),
342        }
343    }
344
345    #[test]
346    fn regression_add_vs_delete_conflict_detected() {
347        let mut p1 = RdfPatch::new();
348        p1.add_operation(add("http://example.org/s", "http://example.org/o"));
349
350        let mut p2 = RdfPatch::new();
351        p2.add_operation(delete("http://example.org/s", "http://example.org/o"));
352
353        let resolver = ConflictResolver::new(ConflictStrategy::LastWins);
354        let (merged, report) = resolver.resolve_conflicts(&p1, &p2).unwrap();
355
356        // The opposing add/delete of the same triple MUST be detected as a
357        // conflict (previously `operations_conflict` was hardcoded to false).
358        assert_eq!(report.conflicts_found, 1);
359        assert_eq!(report.conflicts_resolved, 1);
360
361        // LastWins => the delete replaces the add.
362        assert_eq!(merged.operations.len(), 1);
363        assert!(matches!(
364            merged.operations[0],
365            PatchOperation::Delete { .. }
366        ));
367    }
368
369    #[test]
370    fn regression_non_conflicting_ops_concatenate() {
371        let mut p1 = RdfPatch::new();
372        p1.add_operation(add("http://example.org/s1", "http://example.org/o"));
373
374        let mut p2 = RdfPatch::new();
375        p2.add_operation(add("http://example.org/s2", "http://example.org/o"));
376
377        let resolver = ConflictResolver::new(ConflictStrategy::LastWins);
378        let (merged, report) = resolver.resolve_conflicts(&p1, &p2).unwrap();
379
380        assert_eq!(report.conflicts_found, 0);
381        assert_eq!(merged.operations.len(), 2);
382    }
383}