use std::fmt::Write as _;
#[derive(Debug)]
pub struct MergeResult {
pub content: String,
pub has_conflicts: bool,
pub conflict_count: usize,
}
pub fn three_way_merge(
base: &str,
ours: &str,
theirs: &str,
ours_label: &str,
theirs_label: &str,
) -> MergeResult {
let base_lines: Vec<&str> = base.lines().collect();
let ours_lines: Vec<&str> = ours.lines().collect();
let theirs_lines: Vec<&str> = theirs.lines().collect();
let ours_ops = diff(&base_lines, &ours_lines);
let theirs_ops = diff(&base_lines, &theirs_lines);
merge_ops(
&base_lines,
&ours_ops,
&theirs_ops,
&ours_lines,
&theirs_lines,
ours_label,
theirs_label,
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Op {
Keep(usize),
Delete(usize),
Insert(usize),
}
fn diff<'a>(a: &[&'a str], b: &[&'a str]) -> Vec<Op> {
let lcs = lcs_table(a, b);
let mut ops = Vec::new();
build_ops(a, b, a.len(), b.len(), &lcs, &mut ops);
ops
}
fn lcs_table(a: &[&str], b: &[&str]) -> Vec<Vec<usize>> {
let m = a.len();
let n = b.len();
let mut dp = vec![vec![0usize; n + 1]; m + 1];
for i in 1..=m {
for j in 1..=n {
dp[i][j] = if a[i - 1] == b[j - 1] {
dp[i - 1][j - 1] + 1
} else {
dp[i - 1][j].max(dp[i][j - 1])
};
}
}
dp
}
fn build_ops(a: &[&str], b: &[&str], i: usize, j: usize, dp: &[Vec<usize>], ops: &mut Vec<Op>) {
if i == 0 && j == 0 {
return;
}
if i == 0 {
build_ops(a, b, i, j - 1, dp, ops);
ops.push(Op::Insert(j - 1));
} else if j == 0 {
build_ops(a, b, i - 1, j, dp, ops);
ops.push(Op::Delete(i - 1));
} else if a[i - 1] == b[j - 1] {
build_ops(a, b, i - 1, j - 1, dp, ops);
ops.push(Op::Keep(i - 1));
} else if dp[i - 1][j] >= dp[i][j - 1] {
build_ops(a, b, i - 1, j, dp, ops);
ops.push(Op::Delete(i - 1));
} else {
build_ops(a, b, i, j - 1, dp, ops);
ops.push(Op::Insert(j - 1));
}
}
struct SideState<'a> {
inserted: Vec<Vec<&'a str>>,
deleted: Vec<bool>,
}
fn get_side_state<'a>(base_len: usize, ops: &[Op], other_lines: &[&'a str]) -> SideState<'a> {
let mut inserted = vec![Vec::new(); base_len + 1];
let mut deleted = vec![false; base_len];
let mut b_idx = 0;
for op in ops {
match op {
Op::Keep(b) => {
b_idx = *b;
if b_idx < base_len {
b_idx += 1;
}
}
Op::Delete(b) => {
b_idx = *b;
if b_idx < base_len {
deleted[b_idx] = true;
b_idx += 1;
}
}
Op::Insert(o) => {
if b_idx <= base_len {
inserted[b_idx].push(other_lines[*o]);
}
}
}
}
SideState { inserted, deleted }
}
#[allow(clippy::too_many_lines)]
#[allow(clippy::too_many_arguments)]
#[allow(clippy::needless_range_loop)]
fn merge_ops(
base_lines: &[&str],
ours_ops: &[Op],
theirs_ops: &[Op],
ours_lines: &[&str],
theirs_lines: &[&str],
ours_label: &str,
theirs_label: &str,
) -> MergeResult {
let base_len = base_lines.len();
let ours_state = get_side_state(base_len, ours_ops, ours_lines);
let theirs_state = get_side_state(base_len, theirs_ops, theirs_lines);
let mut is_boundary = vec![false; base_len];
for b in 0..base_len {
if !ours_state.deleted[b]
&& !theirs_state.deleted[b]
&& ours_state.inserted[b].is_empty()
&& theirs_state.inserted[b].is_empty()
{
is_boundary[b] = true;
}
}
let mut out = String::new();
let mut has_conflicts = false;
let mut conflict_count = 0;
let mut b = 0;
while b <= base_len {
if b < base_len && is_boundary[b] {
out.push_str(base_lines[b]);
out.push('\n');
b += 1;
} else {
let start = b;
let mut end = b;
while end < base_len && !is_boundary[end] {
end += 1;
}
let mut ours_prod = Vec::new();
let mut theirs_prod = Vec::new();
let mut ours_has_edits = false;
let mut theirs_has_edits = false;
for curr in start..=end {
for &line in &ours_state.inserted[curr] {
ours_prod.push(line);
ours_has_edits = true;
}
for &line in &theirs_state.inserted[curr] {
theirs_prod.push(line);
theirs_has_edits = true;
}
if curr < end {
if ours_state.deleted[curr] {
ours_has_edits = true;
} else {
ours_prod.push(base_lines[curr]);
}
if theirs_state.deleted[curr] {
theirs_has_edits = true;
} else {
theirs_prod.push(base_lines[curr]);
}
}
}
if ours_has_edits && theirs_has_edits {
if ours_prod == theirs_prod {
for line in ours_prod {
out.push_str(line);
out.push('\n');
}
} else {
has_conflicts = true;
conflict_count += 1;
let _ = writeln!(out, "<<<<<<< {ours_label}");
for line in ours_prod {
out.push_str(line);
out.push('\n');
}
out.push_str("=======\n");
for line in theirs_prod {
out.push_str(line);
out.push('\n');
}
let _ = writeln!(out, ">>>>>>> {theirs_label}");
}
} else if ours_has_edits {
for line in ours_prod {
out.push_str(line);
out.push('\n');
}
} else if theirs_has_edits {
for line in theirs_prod {
out.push_str(line);
out.push('\n');
}
} else {
for curr in start..end {
out.push_str(base_lines[curr]);
out.push('\n');
}
}
if end == base_len {
b = base_len + 1;
} else {
b = end;
}
}
}
let newline_needed = base_lines
.last()
.or(ours_lines.last())
.or(theirs_lines.last())
.is_some_and(|l| !l.is_empty());
if !newline_needed && out.ends_with('\n') {
out.pop();
}
MergeResult {
content: out,
has_conflicts,
conflict_count,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identical_files_no_conflict() {
let r = three_way_merge("a\nb\nc\n", "a\nb\nc\n", "a\nb\nc\n", "repo", "actual");
assert!(!r.has_conflicts);
assert_eq!(r.conflict_count, 0);
}
#[test]
fn only_ours_changed() {
let r = three_way_merge("a\nb\nc\n", "a\nB\nc\n", "a\nb\nc\n", "repo", "actual");
assert!(!r.has_conflicts);
assert!(r.content.contains('B'));
assert!(!r.content.contains('b'));
}
#[test]
fn only_theirs_changed() {
let r = three_way_merge("a\nb\nc\n", "a\nb\nc\n", "a\nB\nc\n", "repo", "actual");
assert!(!r.has_conflicts);
assert!(r.content.contains('B'));
}
#[test]
fn non_overlapping_changes_auto_merged() {
let base = "a\nb\nc\nd\n";
let ours = "A\nb\nc\nd\n"; let theirs = "a\nb\nc\nD\n"; let r = three_way_merge(base, ours, theirs, "repo", "actual");
assert!(!r.has_conflicts, "no overlap — should auto-merge");
assert!(r.content.contains('A'));
assert!(r.content.contains('D'));
}
#[test]
fn overlapping_changes_produce_conflict() {
let base = "a\nb\nc\n";
let ours = "a\nX\nc\n";
let theirs = "a\nY\nc\n";
let r = three_way_merge(base, ours, theirs, "repo", "actual");
assert!(r.has_conflicts);
assert_eq!(r.conflict_count, 1);
assert!(r.content.contains("<<<<<<< repo"));
assert!(r.content.contains(">>>>>>> actual"));
assert!(r.content.contains('X'));
assert!(r.content.contains('Y'));
}
#[test]
fn both_add_same_line_no_conflict() {
let base = "a\n";
let ours = "a\nnew\n";
let theirs = "a\nnew\n";
let r = three_way_merge(base, ours, theirs, "repo", "actual");
assert!(r.content.contains("new"));
}
}