use similar::{ChangeTag, TextDiff};
pub enum MergeOutcome {
Merged(String),
Conflicted(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BinaryMergeDecision {
NoConflict,
KeepBoth,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Op {
Kept(String), Changed(String), Deleted, Inserted(String), }
pub fn merge_text(base: &str, local: &str, remote: &str) -> MergeOutcome {
if local == remote {
return MergeOutcome::Merged(local.to_string());
}
if local == base {
return MergeOutcome::Merged(remote.to_string());
}
if remote == base {
return MergeOutcome::Merged(local.to_string());
}
let local_ops = side_ops(base, local);
let remote_ops = side_ops(base, remote);
let n = local_ops.len().max(remote_ops.len());
let mut merged: Vec<String> = Vec::new();
let mut has_conflict = false;
for i in 0..n {
let l = local_ops.get(i);
let r = remote_ops.get(i);
match (l, r) {
(Some(Op::Kept(ls)), Some(Op::Kept(_))) => merged.push(ls.clone()),
(Some(Op::Changed(ls)), Some(Op::Kept(_))) => merged.push(ls.clone()),
(Some(Op::Inserted(ls)), None) => merged.push(ls.clone()),
(Some(Op::Kept(_)), Some(Op::Changed(rs))) => merged.push(rs.clone()),
(None, Some(Op::Inserted(rs))) => merged.push(rs.clone()),
(Some(Op::Inserted(ls)), Some(Op::Inserted(rs))) if ls == rs => merged.push(ls.clone()),
(Some(Op::Changed(ls)), Some(Op::Changed(rs))) if ls == rs => merged.push(ls.clone()),
(Some(Op::Deleted), Some(Op::Deleted)) => {}
(Some(Op::Deleted), Some(Op::Kept(_))) => {}
(Some(Op::Kept(_)), Some(Op::Deleted)) => {}
(l, r) => {
has_conflict = true;
merged.push("<<<<<<< LOCAL".to_string());
match l {
Some(Op::Kept(s) | Op::Changed(s) | Op::Inserted(s)) => merged.push(s.clone()),
Some(Op::Deleted) | None => {}
}
merged.push("=======".to_string());
match r {
Some(Op::Kept(s) | Op::Changed(s) | Op::Inserted(s)) => merged.push(s.clone()),
Some(Op::Deleted) | None => {}
}
merged.push(">>>>>>> REMOTE".to_string());
}
}
}
let body = if merged.is_empty() {
String::new()
} else {
format!("{}\n", merged.join("\n"))
};
if has_conflict {
MergeOutcome::Conflicted(body)
} else {
MergeOutcome::Merged(body)
}
}
fn side_ops(base: &str, side: &str) -> Vec<Op> {
let diff = TextDiff::from_lines(base, side);
let changes: Vec<_> = diff.iter_all_changes().collect();
let mut ops: Vec<Op> = Vec::new();
let mut i = 0;
while i < changes.len() {
match changes[i].tag() {
ChangeTag::Equal => {
ops.push(Op::Kept(
changes[i].value().trim_end_matches('\n').to_string(),
));
i += 1;
}
ChangeTag::Delete => {
if i + 1 < changes.len() && changes[i + 1].tag() == ChangeTag::Insert {
ops.push(Op::Changed(
changes[i + 1].value().trim_end_matches('\n').to_string(),
));
i += 2;
} else {
ops.push(Op::Deleted);
i += 1;
}
}
ChangeTag::Insert => {
ops.push(Op::Inserted(
changes[i].value().trim_end_matches('\n').to_string(),
));
i += 1;
}
}
}
ops
}
pub fn detect_binary_conflict(base: &[u8], local: &[u8], remote: &[u8]) -> BinaryMergeDecision {
if local == remote || local == base || remote == base {
BinaryMergeDecision::NoConflict
} else {
BinaryMergeDecision::KeepBoth
}
}
#[cfg(test)]
mod tests {
use super::{BinaryMergeDecision, MergeOutcome, detect_binary_conflict, merge_text};
#[test]
fn merge_non_overlapping_edits() {
let base = "A=1\nB=2\n";
let local = "A=9\nB=2\n";
let remote = "A=1\nB=8\n";
match merge_text(base, local, remote) {
MergeOutcome::Merged(merged) => {
assert!(merged.contains("A=9"), "local change missing");
assert!(merged.contains("B=8"), "remote change missing");
}
MergeOutcome::Conflicted(_) => panic!("expected auto merge for non-overlapping edits"),
}
}
#[test]
fn merge_overlapping_conflict() {
let conflict_local = "A=9\n";
let conflict_remote = "A=8\n";
match merge_text("A=1\n", conflict_local, conflict_remote) {
MergeOutcome::Conflicted(text) => {
assert!(text.contains("<<<<<<< LOCAL"));
assert!(text.contains(">>>>>>> REMOTE"));
}
MergeOutcome::Merged(_) => panic!("expected conflict markers for overlapping edits"),
}
}
#[test]
fn merge_insertion_by_one_side() {
let base = "line1\nline2\n";
let local = "line1\nline2\nline3\n";
let remote = "line1\nline2\n";
match merge_text(base, local, remote) {
MergeOutcome::Merged(m) => assert!(m.contains("line3")),
MergeOutcome::Conflicted(_) => panic!("expected clean merge"),
}
}
#[test]
fn binary_conflict_detection() {
let base = b"abc";
let local = b"abc";
let remote = b"abd";
assert_eq!(
detect_binary_conflict(base, local, remote),
BinaryMergeDecision::NoConflict
);
let local = b"xyz";
let remote = b"123";
assert_eq!(
detect_binary_conflict(base, local, remote),
BinaryMergeDecision::KeepBoth
);
}
}