Skip to main content

suture_core/engine/
merge.rs

1//! Line-level three-way merge algorithm.
2//!
3//! Given a common base and two modified versions, produces a merged result
4//! with minimal conflict markers. Uses a longest common subsequence (LCS)
5//! approach to compute diffs and merge them intelligently.
6
7/// A change in a sequence of lines relative to a base.
8#[derive(Debug, Clone, PartialEq)]
9pub enum LineChange {
10    /// Lines that are unchanged between base and modified.
11    Unchanged(Vec<String>),
12    /// Lines present in base but deleted in modified.
13    Deleted(Vec<String>),
14    /// Lines inserted in modified (not in base).
15    Inserted(Vec<String>),
16}
17
18impl LineChange {
19    #[allow(dead_code)]
20    fn is_unchanged(&self) -> bool {
21        matches!(self, LineChange::Unchanged(_))
22    }
23}
24
25/// Compute the diff between two line sequences using LCS.
26///
27/// Returns a list of changes that transform `base` into `modified`.
28pub fn diff_lines(base: &[&str], modified: &[&str]) -> Vec<LineChange> {
29    if base.is_empty() && modified.is_empty() {
30        return Vec::new();
31    }
32    if base.is_empty() {
33        return vec![LineChange::Inserted(
34            modified.iter().map(|s| s.to_string()).collect(),
35        )];
36    }
37    if modified.is_empty() {
38        return vec![LineChange::Deleted(
39            base.iter().map(|s| s.to_string()).collect(),
40        )];
41    }
42
43    let m = base.len();
44    let n = modified.len();
45
46    // Build LCS DP table
47    let mut dp = vec![vec![0usize; n + 1]; m + 1];
48    for i in 1..=m {
49        for j in 1..=n {
50            if base[i - 1] == modified[j - 1] {
51                dp[i][j] = dp[i - 1][j - 1] + 1;
52            } else {
53                dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
54            }
55        }
56    }
57
58    // Backtrack to produce diff
59    let mut changes = Vec::new();
60    let mut i = m;
61    let mut j = n;
62
63    while i > 0 || j > 0 {
64        if i > 0 && j > 0 && base[i - 1] == modified[j - 1] {
65            changes.push(LineChange::Unchanged(vec![base[i - 1].to_string()]));
66            i -= 1;
67            j -= 1;
68        } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
69            changes.push(LineChange::Inserted(vec![modified[j - 1].to_string()]));
70            j -= 1;
71        } else {
72            changes.push(LineChange::Deleted(vec![base[i - 1].to_string()]));
73            i -= 1;
74        }
75    }
76
77    changes.reverse();
78    coalesce_changes(changes)
79}
80
81/// Try to extend the last change in `result` with `change` if they're the same variant.
82/// Returns true if merged, false if not (caller should push `change`).
83fn try_extend_last(result: &mut [LineChange], change: &LineChange) -> bool {
84    let can_merge = match result.last() {
85        Some(LineChange::Unchanged(_)) => matches!(change, LineChange::Unchanged(_)),
86        Some(LineChange::Deleted(_)) => matches!(change, LineChange::Deleted(_)),
87        Some(LineChange::Inserted(_)) => matches!(change, LineChange::Inserted(_)),
88        None => false,
89    };
90    if !can_merge {
91        return false;
92    }
93    let Some(last) = result.last_mut() else {
94        return false;
95    };
96    match (last, change) {
97        (LineChange::Unchanged(v), LineChange::Unchanged(new)) => {
98            v.extend(new.iter().cloned());
99            true
100        }
101        (LineChange::Deleted(v), LineChange::Deleted(new)) => {
102            v.extend(new.iter().cloned());
103            true
104        }
105        (LineChange::Inserted(v), LineChange::Inserted(new)) => {
106            v.extend(new.iter().cloned());
107            true
108        }
109        _ => false,
110    }
111}
112
113/// Merge consecutive changes of the same type.
114fn coalesce_changes(changes: Vec<LineChange>) -> Vec<LineChange> {
115    let mut result = Vec::new();
116    for change in changes {
117        if !try_extend_last(&mut result, &change) {
118            result.push(change);
119        }
120    }
121    result
122}
123
124/// Result of a line-level three-way merge.
125#[derive(Debug, Clone)]
126pub struct MergeOutput {
127    /// The merged content (with conflict markers if any).
128    pub lines: Vec<String>,
129    /// Whether the merge was clean (no conflicts).
130    pub is_clean: bool,
131    /// Number of auto-merged regions.
132    pub auto_merged: usize,
133    /// Number of conflicting regions.
134    pub conflicts: usize,
135}
136
137/// Perform a line-level three-way merge.
138///
139/// Given base, ours, and theirs content:
140/// 1. Compute diffs base→ours and base→theirs
141/// 2. Extract edit chunks from both
142/// 3. For each chunk: auto-merge if possible, otherwise conflict markers
143///
144/// Returns a `MergeOutput` with the merged lines and conflict status.
145pub fn three_way_merge_lines(
146    base: &[&str],
147    ours: &[&str],
148    theirs: &[&str],
149    ours_label: &str,
150    theirs_label: &str,
151) -> MergeOutput {
152    // Trivial cases
153    if ours == theirs {
154        return MergeOutput {
155            lines: ours.iter().map(|s| s.to_string()).collect(),
156            is_clean: true,
157            auto_merged: 0,
158            conflicts: 0,
159        };
160    }
161    if base == ours {
162        return MergeOutput {
163            lines: theirs.iter().map(|s| s.to_string()).collect(),
164            is_clean: true,
165            auto_merged: 0,
166            conflicts: 0,
167        };
168    }
169    if base == theirs {
170        return MergeOutput {
171            lines: ours.iter().map(|s| s.to_string()).collect(),
172            is_clean: true,
173            auto_merged: 0,
174            conflicts: 0,
175        };
176    }
177
178    let ours_diff = diff_lines(base, ours);
179    let theirs_diff = diff_lines(base, theirs);
180
181    let mut merged = Vec::new();
182    let mut is_clean = true;
183    let mut auto_merged = 0usize;
184    let mut conflicts = 0usize;
185
186    // Build a map of base line index → what each side did at that position
187    // Use `mut` so we can `remove` entries to avoid infinite loops on Insert actions.
188    let mut ours_map = build_change_map(base, &ours_diff);
189    let mut theirs_map = build_change_map(base, &theirs_diff);
190
191    let base_len = base.len();
192    let mut i = 0;
193
194    while i < base_len {
195        let ours_action = ours_map.remove(&i);
196        let theirs_action = theirs_map.remove(&i);
197
198        match (ours_action, theirs_action) {
199            (None, None) => {
200                // Both sides kept this line
201                merged.push(base[i].to_string());
202                i += 1;
203            }
204            (Some(a), None) => {
205                // Only ours changed
206                apply_action(&mut merged, &a, &mut i);
207            }
208            (None, Some(a)) => {
209                // Only theirs changed
210                apply_action(&mut merged, &a, &mut i);
211            }
212            (Some(a), Some(b)) => {
213                // Both sides changed at the same base position
214                // Check if they made the same change
215                if a.output_lines() == b.output_lines() {
216                    // Same change — take either
217                    merged.extend(a.output_lines());
218                    i += a.advance();
219                    auto_merged += 1;
220                } else {
221                    // Conflict!
222                    is_clean = false;
223                    conflicts += 1;
224                    merged.push(format!("<<<<<<< {}", ours_label));
225                    merged.extend(a.output_lines());
226                    merged.push("=======".to_string());
227                    merged.extend(b.output_lines());
228                    merged.push(format!(">>>>>>> {}", theirs_label));
229                    // Advance past the longer action
230                    i += a.advance().max(b.advance());
231                }
232            }
233        }
234    }
235
236    // Handle trailing insertions (after the last base line).
237    // Both sides may have appended content — check for conflicts.
238    let ours_trailing = ours_map.remove(&base_len);
239    let theirs_trailing = theirs_map.remove(&base_len);
240    match (ours_trailing, theirs_trailing) {
241        (None, None) => {}
242        (Some(a), None) | (None, Some(a)) => {
243            merged.extend(a.output_lines());
244        }
245        (Some(a), Some(b)) => {
246            if a.output_lines() == b.output_lines() {
247                merged.extend(a.output_lines());
248            } else {
249                is_clean = false;
250                conflicts += 1;
251                merged.push(format!("<<<<<<< {}", ours_label));
252                merged.extend(a.output_lines());
253                merged.push("=======".to_string());
254                merged.extend(b.output_lines());
255                merged.push(format!(">>>>>>> {}", theirs_label));
256            }
257        }
258    }
259
260    MergeOutput {
261        lines: merged,
262        is_clean,
263        auto_merged,
264        conflicts,
265    }
266}
267
268/// What one side did at a particular base line position.
269#[derive(Debug, Clone)]
270enum SideAction {
271    /// Deleted N lines starting at this position, then inserted M lines.
272    DeleteInsert {
273        deleted: usize,
274        inserted: Vec<String>,
275    },
276    /// Inserted lines at this position (no deletion).
277    Insert { lines: Vec<String> },
278}
279
280impl SideAction {
281    fn advance(&self) -> usize {
282        match self {
283            SideAction::DeleteInsert { deleted, .. } => *deleted,
284            SideAction::Insert { .. } => 0,
285        }
286    }
287
288    fn output_lines(&self) -> Vec<String> {
289        match self {
290            SideAction::DeleteInsert { inserted, .. } => inserted.clone(),
291            SideAction::Insert { lines } => lines.clone(),
292        }
293    }
294}
295
296/// Build a map from base line index to side action.
297///
298/// Consecutive `Deleted` + `Inserted` changes are merged into a single
299/// `DeleteInsert` so that replacements are handled atomically.
300fn build_change_map(
301    _base: &[&str],
302    changes: &[LineChange],
303) -> std::collections::HashMap<usize, SideAction> {
304    let mut map = std::collections::HashMap::new();
305    let mut base_idx = 0;
306    // Track the index of the most recent DeleteInsert so that a following
307    // Inserted can be merged into it (representing a replacement).
308    let mut last_delete_idx: Option<usize> = None;
309
310    for change in changes {
311        match change {
312            LineChange::Unchanged(lines) => {
313                base_idx += lines.len();
314                last_delete_idx = None;
315            }
316            LineChange::Deleted(lines) => {
317                map.insert(
318                    base_idx,
319                    SideAction::DeleteInsert {
320                        deleted: lines.len(),
321                        inserted: Vec::new(),
322                    },
323                );
324                last_delete_idx = Some(base_idx);
325                base_idx += lines.len();
326            }
327            LineChange::Inserted(lines) => {
328                if let Some(del_idx) = last_delete_idx {
329                    // Merge into the preceding DeleteInsert (replacement).
330                    if let Some(SideAction::DeleteInsert { inserted, .. }) = map.get_mut(&del_idx) {
331                        inserted.extend(lines.iter().cloned());
332                    }
333                } else if let Some(existing) = map.get_mut(&base_idx) {
334                    // Merge with an existing action at this position.
335                    match existing {
336                        SideAction::DeleteInsert { inserted, .. } => {
337                            inserted.extend(lines.iter().cloned());
338                        }
339                        SideAction::Insert { lines: v } => {
340                            v.extend(lines.iter().cloned());
341                        }
342                    }
343                } else {
344                    map.insert(
345                        base_idx,
346                        SideAction::Insert {
347                            lines: lines.clone(),
348                        },
349                    );
350                }
351                // Insertions don't advance base_idx
352            }
353        }
354    }
355
356    map
357}
358
359/// Apply a side action to the merged output.
360fn apply_action(merged: &mut Vec<String>, action: &SideAction, base_idx: &mut usize) {
361    match action {
362        SideAction::DeleteInsert { deleted, inserted } => {
363            merged.extend(inserted.iter().cloned());
364            *base_idx += deleted;
365        }
366        SideAction::Insert { lines } => {
367            merged.extend(lines.iter().cloned());
368        }
369    }
370}
371
372// =============================================================================
373// Tests
374// =============================================================================
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn test_diff_identical() {
382        let base = ["a", "b", "c"];
383        let changes = diff_lines(&base, &base);
384        assert_eq!(changes.len(), 1);
385        assert!(matches!(changes[0], LineChange::Unchanged(_)));
386    }
387
388    #[test]
389    fn test_diff_insert() {
390        let base = ["a", "c"];
391        let modified = ["a", "b", "c"];
392        let changes = diff_lines(&base, &modified);
393        assert_eq!(changes.len(), 3); // Unchanged(a), Inserted(b), Unchanged(c)
394        assert!(matches!(&changes[1], LineChange::Inserted(v) if v == &["b"]));
395    }
396
397    #[test]
398    fn test_diff_delete() {
399        let base = ["a", "b", "c"];
400        let modified = ["a", "c"];
401        let changes = diff_lines(&base, &modified);
402        assert_eq!(changes.len(), 3);
403        assert!(matches!(&changes[1], LineChange::Deleted(v) if v == &["b"]));
404    }
405
406    #[test]
407    fn test_diff_replace() {
408        let base = ["a", "b", "c"];
409        let modified = ["a", "x", "c"];
410        let changes = diff_lines(&base, &modified);
411        assert!(changes.iter().any(|c| matches!(c, LineChange::Deleted(_))));
412        assert!(changes.iter().any(|c| matches!(c, LineChange::Inserted(_))));
413    }
414
415    #[test]
416    fn test_merge_trivial_unchanged() {
417        let base = ["a", "b", "c"];
418        let result = three_way_merge_lines(&base, &base, &base, "ours", "theirs");
419        assert!(result.is_clean);
420        assert_eq!(result.lines, vec!["a", "b", "c"]);
421    }
422
423    #[test]
424    fn test_merge_one_side_changed() {
425        let base = ["a", "b", "c"];
426        let ours = ["a", "X", "c"];
427        let theirs = ["a", "b", "c"];
428        let result = three_way_merge_lines(&base, &ours, &theirs, "ours", "theirs");
429        assert!(result.is_clean);
430        assert_eq!(result.lines, vec!["a", "X", "c"]);
431    }
432
433    #[test]
434    fn test_merge_both_sides_different_regions() {
435        let base = ["a", "b", "c", "d"];
436        let ours = ["a", "X", "c", "d"]; // changed line 2
437        let theirs = ["a", "b", "c", "Y"]; // changed line 4
438        let result = three_way_merge_lines(&base, &ours, &theirs, "ours", "theirs");
439        assert!(result.is_clean);
440        assert_eq!(result.lines, vec!["a", "X", "c", "Y"]);
441    }
442
443    #[test]
444    fn test_merge_both_sides_same_change() {
445        let base = ["a", "b", "c"];
446        let ours = ["a", "X", "c"];
447        let theirs = ["a", "X", "c"];
448        let result = three_way_merge_lines(&base, &ours, &theirs, "ours", "theirs");
449        assert!(result.is_clean);
450        assert_eq!(result.lines, vec!["a", "X", "c"]);
451    }
452
453    #[test]
454    fn test_merge_conflict() {
455        let base = ["a", "b", "c"];
456        let ours = ["a", "X", "c"];
457        let theirs = ["a", "Y", "c"];
458        let result = three_way_merge_lines(&base, &ours, &theirs, "ours", "theirs");
459        assert!(!result.is_clean);
460        assert_eq!(result.conflicts, 1);
461        let content = result.lines.join("\n");
462        assert!(content.contains("X"));
463        assert!(content.contains("Y"));
464        assert!(content.contains("<<<<<<< ours"));
465        assert!(content.contains(">>>>>>> theirs"));
466    }
467
468    #[test]
469    fn test_merge_conflict_markers_format() {
470        let base = ["line1"];
471        let ours = ["ours_version"];
472        let theirs = ["theirs_version"];
473        let result = three_way_merge_lines(&base, &ours, &theirs, "ours (HEAD)", "theirs");
474        assert!(!result.is_clean);
475        let lines = result.lines;
476        assert_eq!(lines[0], "<<<<<<< ours (HEAD)");
477        assert_eq!(lines[1], "ours_version");
478        assert_eq!(lines[2], "=======");
479        assert_eq!(lines[3], "theirs_version");
480        assert_eq!(lines[4], ">>>>>>> theirs");
481    }
482
483    #[test]
484    fn test_merge_additions_both_sides() {
485        let base = ["a", "c"];
486        let ours = ["a", "b", "c"]; // inserted b
487        let theirs = ["a", "c", "d"]; // appended d
488        let result = three_way_merge_lines(&base, &ours, &theirs, "ours", "theirs");
489        assert!(result.is_clean);
490        // Both insertions should be present
491        assert!(result.lines.contains(&"b".to_string()));
492        assert!(result.lines.contains(&"d".to_string()));
493    }
494
495    #[test]
496    fn test_merge_empty_base() {
497        let base: [&str; 0] = [];
498        let ours = ["a", "b"];
499        let theirs = ["c", "d"];
500        let result = three_way_merge_lines(&base, &ours, &theirs, "ours", "theirs");
501        assert!(!result.is_clean);
502        assert_eq!(result.conflicts, 1);
503    }
504
505    #[test]
506    fn test_merge_large_file_different_regions() {
507        let base: Vec<String> = (0..20).map(|i| format!("line {}", i)).collect();
508        let mut ours = base.clone();
509        let mut theirs = base.clone();
510
511        // Ours changes lines 0-4
512        for (i, item) in ours.iter_mut().enumerate().take(5) {
513            *item = format!("OURS {}", i);
514        }
515        // Theirs changes lines 15-19
516        for (i, item) in theirs.iter_mut().enumerate().skip(15) {
517            *item = format!("THEIRS {}", i);
518        }
519
520        let base_refs: Vec<&str> = base.iter().map(|s| s.as_str()).collect();
521        let ours_refs: Vec<&str> = ours.iter().map(|s| s.as_str()).collect();
522        let theirs_refs: Vec<&str> = theirs.iter().map(|s| s.as_str()).collect();
523
524        let result = three_way_merge_lines(&base_refs, &ours_refs, &theirs_refs, "ours", "theirs");
525        assert!(result.is_clean, "should auto-merge non-overlapping changes");
526        assert_eq!(result.lines.len(), 20);
527        assert_eq!(result.lines[0], "OURS 0");
528        assert_eq!(result.lines[15], "THEIRS 15");
529        // Middle lines should be unchanged
530        assert_eq!(result.lines[10], "line 10");
531    }
532}