use crate::diff::structured_diff_native;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MergeStats {
pub total_lines: usize,
pub auto_merged_lines: usize,
pub conflict_count: usize,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Conflict {
pub ours_start: usize,
pub ours_end: usize,
pub theirs_start: usize,
pub theirs_end: usize,
pub base_start: usize,
pub base_end: usize,
pub ours_content: String,
pub theirs_content: String,
pub base_content: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThreeWayMergeResult {
pub merged: String,
pub has_conflicts: bool,
pub conflicts: Vec<Conflict>,
pub stats: MergeStats,
}
#[derive(Debug, Clone, PartialEq)]
enum Edit {
Keep,
Delete,
}
struct SideEdits {
line_edit: Vec<Edit>,
inserts_before: Vec<Vec<String>>,
inserts_after: Vec<String>,
}
fn compute_side_edits(base_lines: &[&str], side_lines: &[&str]) -> SideEdits {
let n = base_lines.len();
let diff = structured_diff_native(&base_lines.join("\n"), &side_lines.join("\n"));
let mut line_edit: Vec<Edit> = vec![Edit::Keep; n];
let mut inserts_before: Vec<Vec<String>> = vec![Vec::new(); n];
let mut inserts_after: Vec<String> = Vec::new();
let mut base_idx: usize = 0;
for entry in &diff.lines {
match entry.line_type.as_str() {
"context" => {
if let Some(old_line) = entry.old_line {
base_idx = old_line as usize; }
}
"removed" => {
if let Some(old_line) = entry.old_line {
let idx = (old_line as usize) - 1;
line_edit[idx] = Edit::Delete;
base_idx = idx + 1;
}
}
"added" => {
if base_idx < n {
inserts_before[base_idx].push(entry.content.clone());
} else {
inserts_after.push(entry.content.clone());
}
}
_ => {}
}
}
SideEdits {
line_edit,
inserts_before,
inserts_after,
}
}
pub fn three_way_merge_native(base: &str, ours: &str, theirs: &str) -> ThreeWayMergeResult {
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 n = base_lines.len();
if ours == theirs {
let total = ours_lines.len();
return ThreeWayMergeResult {
merged: ours.to_string(),
has_conflicts: false,
conflicts: vec![],
stats: MergeStats {
total_lines: total,
auto_merged_lines: total,
conflict_count: 0,
},
};
}
let our_edits = compute_side_edits(&base_lines, &ours_lines);
let their_edits = compute_side_edits(&base_lines, &theirs_lines);
let mut merged_lines: Vec<String> = Vec::new();
let mut conflicts: Vec<Conflict> = Vec::new();
let mut auto_merged_lines: usize = 0;
let mut ours_line: usize = 1;
let mut theirs_line: usize = 1;
#[allow(clippy::needless_range_loop)]
for i in 0..n {
let our_ins = &our_edits.inserts_before[i];
let their_ins = &their_edits.inserts_before[i];
let ins_conflict = !our_ins.is_empty() && !their_ins.is_empty() && our_ins != their_ins;
let only_ours_inserts = !our_ins.is_empty() && their_ins.is_empty();
let only_theirs_inserts = our_ins.is_empty() && !their_ins.is_empty();
let both_agree_inserts =
!our_ins.is_empty() && !their_ins.is_empty() && our_ins == their_ins;
if ins_conflict {
let ours_start = ours_line;
let ours_end = ours_line + our_ins.len().saturating_sub(1);
let theirs_start = theirs_line;
let theirs_end = theirs_line + their_ins.len().saturating_sub(1);
emit_conflict_markers(&mut merged_lines, our_ins, their_ins);
conflicts.push(Conflict {
ours_start,
ours_end,
theirs_start,
theirs_end,
base_start: i + 1,
base_end: i + 1,
ours_content: our_ins.join("\n"),
theirs_content: their_ins.join("\n"),
base_content: String::new(),
});
ours_line += our_ins.len();
theirs_line += their_ins.len();
} else if both_agree_inserts {
for line in our_ins {
merged_lines.push(line.clone());
}
auto_merged_lines += our_ins.len();
ours_line += our_ins.len();
theirs_line += their_ins.len();
} else if only_ours_inserts {
for line in our_ins {
merged_lines.push(line.clone());
}
auto_merged_lines += our_ins.len();
ours_line += our_ins.len();
} else if only_theirs_inserts {
for line in their_ins {
merged_lines.push(line.clone());
}
auto_merged_lines += their_ins.len();
theirs_line += their_ins.len();
}
let our_edit = &our_edits.line_edit[i];
let their_edit = &their_edits.line_edit[i];
match (our_edit, their_edit) {
(Edit::Keep, Edit::Keep) => {
merged_lines.push(base_lines[i].to_string());
auto_merged_lines += 1;
ours_line += 1;
theirs_line += 1;
}
(Edit::Delete, Edit::Delete) => {
}
(Edit::Delete, Edit::Keep) => {
theirs_line += 1;
}
(Edit::Keep, Edit::Delete) => {
ours_line += 1;
}
}
}
let our_tail = &our_edits.inserts_after;
let their_tail = &their_edits.inserts_after;
let tail_conflict = !our_tail.is_empty() && !their_tail.is_empty() && our_tail != their_tail;
if tail_conflict {
let ours_start = ours_line;
let ours_end = ours_line + our_tail.len().saturating_sub(1);
let theirs_start = theirs_line;
let theirs_end = theirs_line + their_tail.len().saturating_sub(1);
emit_conflict_markers(&mut merged_lines, our_tail, their_tail);
conflicts.push(Conflict {
ours_start,
ours_end,
theirs_start,
theirs_end,
base_start: n + 1,
base_end: n + 1,
ours_content: our_tail.join("\n"),
theirs_content: their_tail.join("\n"),
base_content: String::new(),
});
} else if !our_tail.is_empty() && their_tail.is_empty() {
for line in our_tail {
merged_lines.push(line.clone());
}
auto_merged_lines += our_tail.len();
} else if our_tail.is_empty() && !their_tail.is_empty() {
for line in their_tail {
merged_lines.push(line.clone());
}
auto_merged_lines += their_tail.len();
} else if !our_tail.is_empty() {
for line in our_tail {
merged_lines.push(line.clone());
}
auto_merged_lines += our_tail.len();
}
let total_lines = merged_lines.len();
let conflict_count = conflicts.len();
let has_conflicts = conflict_count > 0;
ThreeWayMergeResult {
merged: merged_lines.join("\n"),
has_conflicts,
conflicts,
stats: MergeStats {
total_lines,
auto_merged_lines,
conflict_count,
},
}
}
fn emit_conflict_markers(out: &mut Vec<String>, ours: &[String], theirs: &[String]) {
out.push("<<<<<<< ours".to_string());
for line in ours {
out.push(line.clone());
}
out.push("=======".to_string());
for line in theirs {
out.push(line.clone());
}
out.push(">>>>>>> theirs".to_string());
}
pub fn three_way_merge(base: &str, ours: &str, theirs: &str) -> String {
let result = three_way_merge_native(base, ours, theirs);
serde_json::to_string(&result)
.unwrap_or_else(|e| format!(r#"{{"error":"three_way_merge serialization failed: {e}"}}"#))
}
#[cfg(test)]
mod tests {
use super::*;
fn merge(base: &str, ours: &str, theirs: &str) -> ThreeWayMergeResult {
three_way_merge_native(base, ours, theirs)
}
fn lines(s: &str) -> Vec<&str> {
s.lines().collect()
}
#[test]
fn test_clean_merge_different_lines_modified() {
let base = "A\nB\nC";
let ours = "A\nB_modified\nC";
let theirs = "A\nB\nC_modified";
let result = merge(base, ours, theirs);
assert!(!result.has_conflicts, "Expected no conflict");
assert_eq!(result.conflicts.len(), 0);
let m = lines(&result.merged);
assert!(m.contains(&"A"));
assert!(m.contains(&"B_modified"));
assert!(m.contains(&"C_modified"));
}
#[test]
fn test_clean_merge_ours_adds_theirs_unchanged() {
let base = "line1\nline3";
let ours = "line1\nline2\nline3";
let theirs = "line1\nline3";
let result = merge(base, ours, theirs);
assert!(!result.has_conflicts, "Expected no conflict");
let m = lines(&result.merged);
assert_eq!(m, vec!["line1", "line2", "line3"]);
}
#[test]
fn test_clean_merge_theirs_adds_ours_unchanged() {
let base = "line1\nline3";
let ours = "line1\nline3";
let theirs = "line1\nline2\nline3";
let result = merge(base, ours, theirs);
assert!(!result.has_conflicts, "Expected no conflict");
let m = lines(&result.merged);
assert_eq!(m, vec!["line1", "line2", "line3"]);
}
#[test]
fn test_conflict_both_modify_same_line() {
let base = "line1\nshared_line\nline3";
let ours = "line1\nours_version\nline3";
let theirs = "line1\ntheirs_version\nline3";
let result = merge(base, ours, theirs);
assert!(result.has_conflicts);
assert_eq!(result.conflicts.len(), 1);
let merged = &result.merged;
assert!(merged.contains("<<<<<<< ours"));
assert!(merged.contains("ours_version"));
assert!(merged.contains("======="));
assert!(merged.contains("theirs_version"));
assert!(merged.contains(">>>>>>> theirs"));
}
#[test]
fn test_one_sided_edit_ours_only() {
let base = "A\nB\nC";
let ours = "A\nB_ours\nC";
let theirs = "A\nB\nC"; let result = merge(base, ours, theirs);
assert!(!result.has_conflicts);
let m = lines(&result.merged);
assert!(m.contains(&"B_ours"), "ours change should be accepted");
}
#[test]
fn test_one_sided_edit_theirs_only() {
let base = "A\nB\nC";
let ours = "A\nB\nC"; let theirs = "A\nB_theirs\nC";
let result = merge(base, ours, theirs);
assert!(!result.has_conflicts);
let m = lines(&result.merged);
assert!(m.contains(&"B_theirs"), "theirs change should be accepted");
}
#[test]
fn test_empty_base_both_add_same() {
let base = "";
let ours = "new content";
let theirs = "new content";
let result = merge(base, ours, theirs);
assert!(!result.has_conflicts);
assert_eq!(result.merged, "new content");
}
#[test]
fn test_empty_base_both_add_different() {
let base = "";
let ours = "ours content";
let theirs = "theirs content";
let result = merge(base, ours, theirs);
assert!(result.has_conflicts);
assert!(result.merged.contains("<<<<<<< ours"));
assert!(result.merged.contains("ours content"));
assert!(result.merged.contains("theirs content"));
}
#[test]
fn test_both_delete_same_line() {
let base = "keep\ndelete_me\nkeep2";
let ours = "keep\nkeep2";
let theirs = "keep\nkeep2";
let result = merge(base, ours, theirs);
assert!(!result.has_conflicts);
let m = lines(&result.merged);
assert!(!m.contains(&"delete_me"), "deleted line must not appear");
assert_eq!(m, vec!["keep", "keep2"]);
}
#[test]
fn test_ours_deletes_theirs_keeps() {
let base = "A\nB\nC";
let ours = "A\nC"; let theirs = "A\nB\nC"; let result = merge(base, ours, theirs);
assert!(!result.has_conflicts);
let m = lines(&result.merged);
assert!(
!m.contains(&"B"),
"B was deleted by ours and should not appear"
);
}
#[test]
fn test_multiple_conflicts() {
let base = "A\nB\nC\nD\nE";
let ours = "A\nB_ours\nC\nD_ours\nE";
let theirs = "A\nB_theirs\nC\nD_theirs\nE";
let result = merge(base, ours, theirs);
assert!(result.has_conflicts);
assert_eq!(result.conflicts.len(), 2, "Expected 2 conflicts");
let marker_count = result.merged.matches("<<<<<<< ours").count();
assert_eq!(marker_count, 2);
}
#[test]
fn test_conflict_marker_format() {
let base = "x";
let ours = "x_ours";
let theirs = "x_theirs";
let result = merge(base, ours, theirs);
assert!(result.has_conflicts);
let m = &result.merged;
let ours_pos = m.find("<<<<<<< ours").expect("missing ours marker");
let sep_pos = m.find("=======").expect("missing separator");
let theirs_pos = m.find(">>>>>>> theirs").expect("missing theirs marker");
assert!(ours_pos < sep_pos, "ours marker must precede separator");
assert!(sep_pos < theirs_pos, "separator must precede theirs marker");
}
#[test]
fn test_stats_no_conflict() {
let base = "A\nB\nC";
let ours = "A\nB_ours\nC";
let theirs = "A\nB\nC_theirs";
let result = merge(base, ours, theirs);
assert_eq!(result.stats.conflict_count, 0);
assert!(result.stats.auto_merged_lines > 0);
assert_eq!(result.stats.total_lines, result.merged.lines().count());
}
#[test]
fn test_stats_with_conflict() {
let base = "A\nB\nC";
let ours = "A\nB_ours\nC";
let theirs = "A\nB_theirs\nC";
let result = merge(base, ours, theirs);
assert_eq!(result.stats.conflict_count, 1);
assert_eq!(result.stats.total_lines, result.merged.lines().count());
}
#[test]
fn test_json_output_shape() {
let base = "a\nb";
let ours = "a\nb_ours";
let theirs = "a\nb_theirs";
let json = three_way_merge(base, ours, theirs);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["merged"].is_string());
assert!(parsed["hasConflicts"].is_boolean());
assert!(parsed["conflicts"].is_array());
assert!(parsed["stats"]["conflictCount"].is_number());
assert!(parsed["stats"]["autoMergedLines"].is_number());
assert!(parsed["stats"]["totalLines"].is_number());
}
#[test]
fn test_identical_ours_and_theirs() {
let base = "A\nB\nC";
let ours = "A\nX\nC";
let theirs = "A\nX\nC"; let result = merge(base, ours, theirs);
assert!(!result.has_conflicts);
assert_eq!(result.merged, ours);
}
#[test]
fn test_insertion_conflict_same_position() {
let base = "A\nC";
let ours = "A\nB_ours\nC";
let theirs = "A\nB_theirs\nC";
let result = merge(base, ours, theirs);
assert!(result.has_conflicts);
assert!(result.merged.contains("B_ours"));
assert!(result.merged.contains("B_theirs"));
}
}