1use similar::{ChangeTag, TextDiff};
2
3pub enum MergeOutcome {
4 Merged(String),
5 Conflicted(String),
6}
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum BinaryMergeDecision {
10 NoConflict,
11 KeepBoth,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15enum Op {
16 Kept(String), Changed(String), Deleted, Inserted(String), }
21
22pub fn merge_text(base: &str, local: &str, remote: &str) -> MergeOutcome {
25 if local == remote {
26 return MergeOutcome::Merged(local.to_string());
27 }
28 if local == base {
29 return MergeOutcome::Merged(remote.to_string());
30 }
31 if remote == base {
32 return MergeOutcome::Merged(local.to_string());
33 }
34
35 let local_ops = side_ops(base, local);
36 let remote_ops = side_ops(base, remote);
37
38 let n = local_ops.len().max(remote_ops.len());
39 let mut merged: Vec<String> = Vec::new();
40 let mut has_conflict = false;
41
42 for i in 0..n {
43 let l = local_ops.get(i);
44 let r = remote_ops.get(i);
45
46 match (l, r) {
47 (Some(Op::Kept(ls)), Some(Op::Kept(_))) => merged.push(ls.clone()),
49
50 (Some(Op::Changed(ls)), Some(Op::Kept(_))) => merged.push(ls.clone()),
52 (Some(Op::Inserted(ls)), None) => merged.push(ls.clone()),
53
54 (Some(Op::Kept(_)), Some(Op::Changed(rs))) => merged.push(rs.clone()),
56 (None, Some(Op::Inserted(rs))) => merged.push(rs.clone()),
57
58 (Some(Op::Inserted(ls)), Some(Op::Inserted(rs))) if ls == rs => merged.push(ls.clone()),
60
61 (Some(Op::Changed(ls)), Some(Op::Changed(rs))) if ls == rs => merged.push(ls.clone()),
63
64 (Some(Op::Deleted), Some(Op::Deleted)) => {}
66
67 (Some(Op::Deleted), Some(Op::Kept(_))) => {}
69
70 (Some(Op::Kept(_)), Some(Op::Deleted)) => {}
72
73 (l, r) => {
75 has_conflict = true;
76 merged.push("<<<<<<< LOCAL".to_string());
77 match l {
78 Some(Op::Kept(s) | Op::Changed(s) | Op::Inserted(s)) => merged.push(s.clone()),
79 Some(Op::Deleted) | None => {}
80 }
81 merged.push("=======".to_string());
82 match r {
83 Some(Op::Kept(s) | Op::Changed(s) | Op::Inserted(s)) => merged.push(s.clone()),
84 Some(Op::Deleted) | None => {}
85 }
86 merged.push(">>>>>>> REMOTE".to_string());
87 }
88 }
89 }
90
91 let body = if merged.is_empty() {
92 String::new()
93 } else {
94 format!("{}\n", merged.join("\n"))
95 };
96
97 if has_conflict {
98 MergeOutcome::Conflicted(body)
99 } else {
100 MergeOutcome::Merged(body)
101 }
102}
103
104fn side_ops(base: &str, side: &str) -> Vec<Op> {
107 let diff = TextDiff::from_lines(base, side);
108 let changes: Vec<_> = diff.iter_all_changes().collect();
109 let mut ops: Vec<Op> = Vec::new();
110
111 let mut i = 0;
112 while i < changes.len() {
113 match changes[i].tag() {
114 ChangeTag::Equal => {
115 ops.push(Op::Kept(
116 changes[i].value().trim_end_matches('\n').to_string(),
117 ));
118 i += 1;
119 }
120 ChangeTag::Delete => {
121 if i + 1 < changes.len() && changes[i + 1].tag() == ChangeTag::Insert {
123 ops.push(Op::Changed(
124 changes[i + 1].value().trim_end_matches('\n').to_string(),
125 ));
126 i += 2;
127 } else {
128 ops.push(Op::Deleted);
129 i += 1;
130 }
131 }
132 ChangeTag::Insert => {
133 ops.push(Op::Inserted(
135 changes[i].value().trim_end_matches('\n').to_string(),
136 ));
137 i += 1;
138 }
139 }
140 }
141
142 ops
143}
144
145pub fn detect_binary_conflict(base: &[u8], local: &[u8], remote: &[u8]) -> BinaryMergeDecision {
146 if local == remote || local == base || remote == base {
147 BinaryMergeDecision::NoConflict
148 } else {
149 BinaryMergeDecision::KeepBoth
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::{BinaryMergeDecision, MergeOutcome, detect_binary_conflict, merge_text};
156
157 #[test]
158 fn merge_non_overlapping_edits() {
159 let base = "A=1\nB=2\n";
160 let local = "A=9\nB=2\n";
161 let remote = "A=1\nB=8\n";
162
163 match merge_text(base, local, remote) {
164 MergeOutcome::Merged(merged) => {
165 assert!(merged.contains("A=9"), "local change missing");
166 assert!(merged.contains("B=8"), "remote change missing");
167 }
168 MergeOutcome::Conflicted(_) => panic!("expected auto merge for non-overlapping edits"),
169 }
170 }
171
172 #[test]
173 fn merge_overlapping_conflict() {
174 let conflict_local = "A=9\n";
175 let conflict_remote = "A=8\n";
176 match merge_text("A=1\n", conflict_local, conflict_remote) {
177 MergeOutcome::Conflicted(text) => {
178 assert!(text.contains("<<<<<<< LOCAL"));
179 assert!(text.contains(">>>>>>> REMOTE"));
180 }
181 MergeOutcome::Merged(_) => panic!("expected conflict markers for overlapping edits"),
182 }
183 }
184
185 #[test]
186 fn merge_insertion_by_one_side() {
187 let base = "line1\nline2\n";
188 let local = "line1\nline2\nline3\n";
189 let remote = "line1\nline2\n";
190 match merge_text(base, local, remote) {
191 MergeOutcome::Merged(m) => assert!(m.contains("line3")),
192 MergeOutcome::Conflicted(_) => panic!("expected clean merge"),
193 }
194 }
195
196 #[test]
197 fn binary_conflict_detection() {
198 let base = b"abc";
199 let local = b"abc";
200 let remote = b"abd";
201 assert_eq!(
202 detect_binary_conflict(base, local, remote),
203 BinaryMergeDecision::NoConflict
204 );
205
206 let local = b"xyz";
207 let remote = b"123";
208 assert_eq!(
209 detect_binary_conflict(base, local, remote),
210 BinaryMergeDecision::KeepBoth
211 );
212 }
213}