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