Skip to main content

mockforge_collab/
conflict.rs

1//! Conflict resolution for concurrent edits
2
3use crate::error::{CollabError, Result};
4use serde::{Deserialize, Serialize};
5use similar::{ChangeTag, TextDiff};
6
7/// Merge strategy for conflict resolution
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum MergeStrategy {
11    /// Keep local changes (ours)
12    Ours,
13    /// Keep remote changes (theirs)
14    Theirs,
15    /// Attempt automatic merge
16    Auto,
17    /// Manual resolution required
18    Manual,
19}
20
21/// Conflict resolution result
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ConflictResolution {
24    /// Whether conflicts were detected
25    pub has_conflicts: bool,
26    /// Resolved content
27    pub resolved: serde_json::Value,
28    /// List of conflicts (if manual resolution needed)
29    pub conflicts: Vec<Conflict>,
30}
31
32/// A conflict between two versions
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Conflict {
35    /// Path to the conflicting field
36    pub path: String,
37    /// Local value
38    pub ours: serde_json::Value,
39    /// Remote value
40    pub theirs: serde_json::Value,
41    /// Common ancestor value (if available)
42    pub base: Option<serde_json::Value>,
43}
44
45/// Conflict resolver
46pub struct ConflictResolver {
47    /// Default merge strategy
48    default_strategy: MergeStrategy,
49}
50
51impl ConflictResolver {
52    /// Create a new conflict resolver
53    #[must_use]
54    pub const fn new(default_strategy: MergeStrategy) -> Self {
55        Self { default_strategy }
56    }
57
58    /// Resolve conflicts between two versions
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if auto-merge encounters unresolvable conflicts.
63    pub fn resolve(
64        &self,
65        base: Option<&serde_json::Value>,
66        ours: &serde_json::Value,
67        theirs: &serde_json::Value,
68        strategy: Option<MergeStrategy>,
69    ) -> Result<ConflictResolution> {
70        let strategy = strategy.unwrap_or(self.default_strategy);
71
72        // If values are identical, no conflict
73        if ours == theirs {
74            return Ok(ConflictResolution {
75                has_conflicts: false,
76                resolved: ours.clone(),
77                conflicts: Vec::new(),
78            });
79        }
80
81        match strategy {
82            MergeStrategy::Ours => Ok(ConflictResolution {
83                has_conflicts: false,
84                resolved: ours.clone(),
85                conflicts: Vec::new(),
86            }),
87            MergeStrategy::Theirs => Ok(ConflictResolution {
88                has_conflicts: false,
89                resolved: theirs.clone(),
90                conflicts: Vec::new(),
91            }),
92            MergeStrategy::Auto => self.auto_merge(base, ours, theirs),
93            MergeStrategy::Manual => {
94                // Detect all conflicts and return for manual resolution
95                let conflicts = self.detect_conflicts("", base, ours, theirs);
96                Ok(ConflictResolution {
97                    has_conflicts: !conflicts.is_empty(),
98                    resolved: ours.clone(), // Default to ours
99                    conflicts,
100                })
101            }
102        }
103    }
104
105    /// Attempt automatic merge
106    #[allow(clippy::unnecessary_wraps, clippy::unused_self)]
107    fn auto_merge(
108        &self,
109        base: Option<&serde_json::Value>,
110        ours: &serde_json::Value,
111        theirs: &serde_json::Value,
112    ) -> Result<ConflictResolution> {
113        // For JSON objects, try field-by-field merge
114        match (ours, theirs) {
115            (serde_json::Value::Object(ours_obj), serde_json::Value::Object(theirs_obj)) => {
116                let mut resolved = serde_json::Map::new();
117                let mut conflicts = Vec::new();
118
119                let base_obj = base.and_then(|b| b.as_object());
120
121                // Merge all keys
122                let all_keys: std::collections::HashSet<_> =
123                    ours_obj.keys().chain(theirs_obj.keys()).collect();
124
125                for key in all_keys {
126                    let ours_val = ours_obj.get(key);
127                    let theirs_val = theirs_obj.get(key);
128                    let base_val = base_obj.and_then(|b| b.get(key));
129
130                    match (ours_val, theirs_val) {
131                        (Some(o), Some(t)) if o == t => {
132                            // No conflict, values are the same
133                            resolved.insert(key.clone(), o.clone());
134                        }
135                        (Some(o), Some(t)) => {
136                            // Check if only one side changed from base
137                            if let Some(base_val) = base_val {
138                                if o == base_val {
139                                    // Only theirs changed
140                                    resolved.insert(key.clone(), t.clone());
141                                } else if t == base_val {
142                                    // Only ours changed
143                                    resolved.insert(key.clone(), o.clone());
144                                } else {
145                                    // Both changed - conflict
146                                    conflicts.push(Conflict {
147                                        path: key.clone(),
148                                        ours: o.clone(),
149                                        theirs: t.clone(),
150                                        base: Some(base_val.clone()),
151                                    });
152                                    resolved.insert(key.clone(), o.clone()); // Default to ours
153                                }
154                            } else {
155                                // No base - conflict
156                                conflicts.push(Conflict {
157                                    path: key.clone(),
158                                    ours: o.clone(),
159                                    theirs: t.clone(),
160                                    base: None,
161                                });
162                                resolved.insert(key.clone(), o.clone());
163                            }
164                        }
165                        (Some(o), None) => {
166                            // Only in ours
167                            resolved.insert(key.clone(), o.clone());
168                        }
169                        (None, Some(t)) => {
170                            // Only in theirs
171                            resolved.insert(key.clone(), t.clone());
172                        }
173                        (None, None) => {} // Key in all_keys but absent from both - skip
174                    }
175                }
176
177                Ok(ConflictResolution {
178                    has_conflicts: !conflicts.is_empty(),
179                    resolved: serde_json::Value::Object(resolved),
180                    conflicts,
181                })
182            }
183            _ => {
184                // For non-objects, treat as conflict
185                Ok(ConflictResolution {
186                    has_conflicts: true,
187                    resolved: ours.clone(),
188                    conflicts: vec![Conflict {
189                        path: String::new(),
190                        ours: ours.clone(),
191                        theirs: theirs.clone(),
192                        base: base.cloned(),
193                    }],
194                })
195            }
196        }
197    }
198
199    /// Detect all conflicts recursively
200    #[allow(clippy::only_used_in_recursion)]
201    fn detect_conflicts(
202        &self,
203        path: &str,
204        base: Option<&serde_json::Value>,
205        ours: &serde_json::Value,
206        theirs: &serde_json::Value,
207    ) -> Vec<Conflict> {
208        let mut conflicts = Vec::new();
209
210        if ours == theirs {
211            return conflicts;
212        }
213
214        match (ours, theirs) {
215            (serde_json::Value::Object(ours_obj), serde_json::Value::Object(theirs_obj)) => {
216                let base_obj = base.and_then(|b| b.as_object());
217                let all_keys: std::collections::HashSet<_> =
218                    ours_obj.keys().chain(theirs_obj.keys()).collect();
219
220                for key in all_keys {
221                    let new_path = if path.is_empty() {
222                        key.clone()
223                    } else {
224                        format!("{path}.{key}")
225                    };
226
227                    let ours_val = ours_obj.get(key);
228                    let theirs_val = theirs_obj.get(key);
229                    let base_val = base_obj.and_then(|b| b.get(key));
230
231                    if let (Some(o), Some(t)) = (ours_val, theirs_val) {
232                        conflicts.extend(self.detect_conflicts(&new_path, base_val, o, t));
233                    } else if ours_val != theirs_val {
234                        conflicts.push(Conflict {
235                            path: new_path,
236                            ours: ours_val.cloned().unwrap_or(serde_json::Value::Null),
237                            theirs: theirs_val.cloned().unwrap_or(serde_json::Value::Null),
238                            base: base_val.cloned(),
239                        });
240                    }
241                }
242            }
243            _ => {
244                conflicts.push(Conflict {
245                    path: path.to_string(),
246                    ours: ours.clone(),
247                    theirs: theirs.clone(),
248                    base: base.cloned(),
249                });
250            }
251        }
252
253        conflicts
254    }
255
256    /// Merge text with three-way merge algorithm
257    ///
258    /// # Errors
259    ///
260    /// Returns an error if the text has conflicting changes on the same lines.
261    ///
262    /// Uses line-based diffing against a common base to detect whether both
263    /// sides modified the same lines. When only one side changes a line, that
264    /// change is applied. When both sides change the same line differently, a
265    /// conflict is raised.
266    #[allow(clippy::similar_names)]
267    pub fn merge_text(&self, base: &str, ours: &str, theirs: &str) -> Result<String> {
268        if ours == theirs {
269            return Ok(ours.to_string());
270        }
271
272        // Build sets of lines changed by each side relative to the base.
273        let base_lines: Vec<&str> = base.lines().collect();
274        let ours_lines: Vec<&str> = ours.lines().collect();
275        let theirs_lines: Vec<&str> = theirs.lines().collect();
276
277        // Collect per-line changes from each diff
278        let diff_ours = TextDiff::from_lines(base, ours);
279        let diff_theirs = TextDiff::from_lines(base, theirs);
280
281        // Build maps of base-line-index → replacement lines for each side
282        let ours_changes = Self::collect_line_changes(&diff_ours);
283        let theirs_changes = Self::collect_line_changes(&diff_theirs);
284
285        let mut result = String::new();
286        let mut has_conflict = false;
287
288        // Walk through base lines, applying non-conflicting changes
289        for (i, base_line) in base_lines.iter().enumerate() {
290            let ours_changed = ours_changes.get(&i);
291            let theirs_changed = theirs_changes.get(&i);
292
293            match (ours_changed, theirs_changed) {
294                (None, None) => {
295                    // Neither side changed this line — keep base
296                    result.push_str(base_line);
297                    result.push('\n');
298                }
299                (Some(ours_replacement), None) => {
300                    // Only ours changed — apply ours
301                    for line in ours_replacement {
302                        result.push_str(line);
303                        result.push('\n');
304                    }
305                }
306                (None, Some(theirs_replacement)) => {
307                    // Only theirs changed — apply theirs
308                    for line in theirs_replacement {
309                        result.push_str(line);
310                        result.push('\n');
311                    }
312                }
313                (Some(ours_replacement), Some(theirs_replacement)) => {
314                    if ours_replacement == theirs_replacement {
315                        // Both sides made the same change — apply once
316                        for line in ours_replacement {
317                            result.push_str(line);
318                            result.push('\n');
319                        }
320                    } else {
321                        // True conflict: both sides changed the same line differently
322                        has_conflict = true;
323                    }
324                }
325            }
326        }
327
328        // Append any lines added beyond the base by ours
329        for line in ours_lines.iter().skip(base_lines.len()) {
330            result.push_str(line);
331            result.push('\n');
332        }
333        // Append any lines added beyond the base by theirs
334        for line in theirs_lines.iter().skip(base_lines.len()) {
335            result.push_str(line);
336            result.push('\n');
337        }
338
339        if has_conflict {
340            Err(CollabError::ConflictDetected("Text merge conflict".to_string()))
341        } else {
342            Ok(result)
343        }
344    }
345
346    /// Collect line changes from a diff as a map of base-line-index to replacement lines.
347    ///
348    /// A deleted line maps to an empty vec (removed). An inserted line is attached
349    /// to the most recent base-line index. An equal line is skipped (no change).
350    fn collect_line_changes<'a>(
351        diff: &TextDiff<'a, 'a, 'a, str>,
352    ) -> std::collections::HashMap<usize, Vec<&'a str>> {
353        let mut changes: std::collections::HashMap<usize, Vec<&str>> =
354            std::collections::HashMap::new();
355        let mut base_idx: usize = 0;
356
357        for change in diff.iter_all_changes() {
358            match change.tag() {
359                ChangeTag::Equal => {
360                    base_idx += 1;
361                }
362                ChangeTag::Delete => {
363                    // This base line was removed (or replaced by a subsequent Insert)
364                    changes.entry(base_idx).or_default();
365                    base_idx += 1;
366                }
367                ChangeTag::Insert => {
368                    // Inserted line — attach to previous base line's change set
369                    let idx = if base_idx > 0 { base_idx - 1 } else { 0 };
370                    changes.entry(idx).or_default().push(change.value().trim_end_matches('\n'));
371                }
372            }
373        }
374
375        changes
376    }
377}
378
379impl Default for ConflictResolver {
380    fn default() -> Self {
381        Self::new(MergeStrategy::Auto)
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use serde_json::json;
389
390    #[test]
391    fn test_no_conflict() {
392        let resolver = ConflictResolver::default();
393        let value = json!({"key": "value"});
394
395        let result = resolver.resolve(None, &value, &value, None).unwrap();
396
397        assert!(!result.has_conflicts);
398        assert_eq!(result.resolved, value);
399        assert!(result.conflicts.is_empty());
400    }
401
402    #[test]
403    fn test_strategy_ours() {
404        let resolver = ConflictResolver::default();
405        let ours = json!({"key": "ours"});
406        let theirs = json!({"key": "theirs"});
407
408        let result = resolver.resolve(None, &ours, &theirs, Some(MergeStrategy::Ours)).unwrap();
409
410        assert!(!result.has_conflicts);
411        assert_eq!(result.resolved, ours);
412    }
413
414    #[test]
415    fn test_strategy_theirs() {
416        let resolver = ConflictResolver::default();
417        let ours = json!({"key": "ours"});
418        let theirs = json!({"key": "theirs"});
419
420        let result = resolver.resolve(None, &ours, &theirs, Some(MergeStrategy::Theirs)).unwrap();
421
422        assert!(!result.has_conflicts);
423        assert_eq!(result.resolved, theirs);
424    }
425
426    #[test]
427    fn test_auto_merge_no_base() {
428        let resolver = ConflictResolver::default();
429        let ours = json!({"key1": "value1"});
430        let theirs = json!({"key2": "value2"});
431
432        let result = resolver.resolve(None, &ours, &theirs, Some(MergeStrategy::Auto)).unwrap();
433
434        // Should merge both keys
435        assert!(!result.has_conflicts);
436        assert_eq!(result.resolved["key1"], "value1");
437        assert_eq!(result.resolved["key2"], "value2");
438    }
439
440    #[test]
441    fn test_auto_merge_with_base() {
442        let resolver = ConflictResolver::default();
443        let base = json!({"key": "base"});
444        let ours = json!({"key": "ours"});
445        let theirs = json!({"key": "base"}); // Only ours changed
446
447        let result = resolver
448            .resolve(Some(&base), &ours, &theirs, Some(MergeStrategy::Auto))
449            .unwrap();
450
451        assert!(!result.has_conflicts);
452        assert_eq!(result.resolved["key"], "ours");
453    }
454
455    #[test]
456    fn test_conflict_detection() {
457        let resolver = ConflictResolver::default();
458        let base = json!({"key": "base"});
459        let ours = json!({"key": "ours"});
460        let theirs = json!({"key": "theirs"});
461
462        let result = resolver
463            .resolve(Some(&base), &ours, &theirs, Some(MergeStrategy::Auto))
464            .unwrap();
465
466        assert!(result.has_conflicts);
467        assert_eq!(result.conflicts.len(), 1);
468        assert_eq!(result.conflicts[0].path, "key");
469    }
470}