1use std::fmt::Write as _;
20
21#[derive(Debug)]
25pub struct MergeResult {
26 pub content: String,
28 pub has_conflicts: bool,
30 pub conflict_count: usize,
32}
33
34pub fn three_way_merge(
42 base: &str,
43 ours: &str,
44 theirs: &str,
45 ours_label: &str,
46 theirs_label: &str,
47) -> MergeResult {
48 let base_lines: Vec<&str> = base.lines().collect();
49 let ours_lines: Vec<&str> = ours.lines().collect();
50 let theirs_lines: Vec<&str> = theirs.lines().collect();
51
52 let ours_ops = diff(&base_lines, &ours_lines);
53 let theirs_ops = diff(&base_lines, &theirs_lines);
54
55 merge_ops(
56 &base_lines,
57 &ours_ops,
58 &theirs_ops,
59 &ours_lines,
60 &theirs_lines,
61 ours_label,
62 theirs_label,
63 )
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
70enum Op {
71 Keep(usize),
73 Delete(usize),
75 Insert(usize),
77}
78
79fn diff<'a>(a: &[&'a str], b: &[&'a str]) -> Vec<Op> {
82 let lcs = lcs_table(a, b);
83 let mut ops = Vec::new();
84 build_ops(a, b, a.len(), b.len(), &lcs, &mut ops);
85 ops
86}
87
88fn lcs_table(a: &[&str], b: &[&str]) -> Vec<Vec<usize>> {
90 let m = a.len();
91 let n = b.len();
92 let mut dp = vec![vec![0usize; n + 1]; m + 1];
93 for i in 1..=m {
94 for j in 1..=n {
95 dp[i][j] = if a[i - 1] == b[j - 1] {
96 dp[i - 1][j - 1] + 1
97 } else {
98 dp[i - 1][j].max(dp[i][j - 1])
99 };
100 }
101 }
102 dp
103}
104
105fn build_ops(a: &[&str], b: &[&str], i: usize, j: usize, dp: &[Vec<usize>], ops: &mut Vec<Op>) {
107 if i == 0 && j == 0 {
108 return;
109 }
110 if i == 0 {
111 build_ops(a, b, i, j - 1, dp, ops);
112 ops.push(Op::Insert(j - 1));
113 } else if j == 0 {
114 build_ops(a, b, i - 1, j, dp, ops);
115 ops.push(Op::Delete(i - 1));
116 } else if a[i - 1] == b[j - 1] {
117 build_ops(a, b, i - 1, j - 1, dp, ops);
118 ops.push(Op::Keep(i - 1));
119 } else if dp[i - 1][j] >= dp[i][j - 1] {
120 build_ops(a, b, i - 1, j, dp, ops);
121 ops.push(Op::Delete(i - 1));
122 } else {
123 build_ops(a, b, i, j - 1, dp, ops);
124 ops.push(Op::Insert(j - 1));
125 }
126}
127
128struct SideState<'a> {
131 inserted: Vec<Vec<&'a str>>,
132 deleted: Vec<bool>,
133}
134
135fn get_side_state<'a>(base_len: usize, ops: &[Op], other_lines: &[&'a str]) -> SideState<'a> {
136 let mut inserted = vec![Vec::new(); base_len + 1];
137 let mut deleted = vec![false; base_len];
138 let mut b_idx = 0;
139 for op in ops {
140 match op {
141 Op::Keep(b) => {
142 b_idx = *b;
143 if b_idx < base_len {
144 b_idx += 1;
145 }
146 }
147 Op::Delete(b) => {
148 b_idx = *b;
149 if b_idx < base_len {
150 deleted[b_idx] = true;
151 b_idx += 1;
152 }
153 }
154 Op::Insert(o) => {
155 if b_idx <= base_len {
156 inserted[b_idx].push(other_lines[*o]);
157 }
158 }
159 }
160 }
161 SideState { inserted, deleted }
162}
163
164#[allow(clippy::too_many_lines)]
166#[allow(clippy::too_many_arguments)]
167#[allow(clippy::needless_range_loop)]
168fn merge_ops(
169 base_lines: &[&str],
170 ours_ops: &[Op],
171 theirs_ops: &[Op],
172 ours_lines: &[&str],
173 theirs_lines: &[&str],
174 ours_label: &str,
175 theirs_label: &str,
176) -> MergeResult {
177 let base_len = base_lines.len();
178 let ours_state = get_side_state(base_len, ours_ops, ours_lines);
179 let theirs_state = get_side_state(base_len, theirs_ops, theirs_lines);
180
181 let mut is_boundary = vec![false; base_len];
187 for b in 0..base_len {
188 if !ours_state.deleted[b]
189 && !theirs_state.deleted[b]
190 && ours_state.inserted[b].is_empty()
191 && theirs_state.inserted[b].is_empty()
192 {
193 is_boundary[b] = true;
194 }
195 }
196
197 let mut out = String::new();
198 let mut has_conflicts = false;
199 let mut conflict_count = 0;
200
201 let mut b = 0;
202 while b <= base_len {
203 if b < base_len && is_boundary[b] {
204 out.push_str(base_lines[b]);
206 out.push('\n');
207 b += 1;
208 } else {
209 let start = b;
211 let mut end = b;
212 while end < base_len && !is_boundary[end] {
213 end += 1;
214 }
215
216 let mut ours_prod = Vec::new();
217 let mut theirs_prod = Vec::new();
218 let mut ours_has_edits = false;
219 let mut theirs_has_edits = false;
220
221 for curr in start..=end {
222 for &line in &ours_state.inserted[curr] {
224 ours_prod.push(line);
225 ours_has_edits = true;
226 }
227 for &line in &theirs_state.inserted[curr] {
228 theirs_prod.push(line);
229 theirs_has_edits = true;
230 }
231
232 if curr < end {
234 if ours_state.deleted[curr] {
235 ours_has_edits = true;
236 } else {
237 ours_prod.push(base_lines[curr]);
238 }
239
240 if theirs_state.deleted[curr] {
241 theirs_has_edits = true;
242 } else {
243 theirs_prod.push(base_lines[curr]);
244 }
245 }
246 }
247
248 if ours_has_edits && theirs_has_edits {
250 if ours_prod == theirs_prod {
252 for line in ours_prod {
254 out.push_str(line);
255 out.push('\n');
256 }
257 } else {
258 has_conflicts = true;
260 conflict_count += 1;
261 let _ = writeln!(out, "<<<<<<< {ours_label}");
262 for line in ours_prod {
263 out.push_str(line);
264 out.push('\n');
265 }
266 out.push_str("=======\n");
267 for line in theirs_prod {
268 out.push_str(line);
269 out.push('\n');
270 }
271 let _ = writeln!(out, ">>>>>>> {theirs_label}");
272 }
273 } else if ours_has_edits {
274 for line in ours_prod {
276 out.push_str(line);
277 out.push('\n');
278 }
279 } else if theirs_has_edits {
280 for line in theirs_prod {
282 out.push_str(line);
283 out.push('\n');
284 }
285 } else {
286 for curr in start..end {
288 out.push_str(base_lines[curr]);
289 out.push('\n');
290 }
291 }
292
293 if end == base_len {
294 b = base_len + 1;
295 } else {
296 b = end;
297 }
298 }
299 }
300
301 let newline_needed = base_lines
302 .last()
303 .or(ours_lines.last())
304 .or(theirs_lines.last())
305 .is_some_and(|l| !l.is_empty());
306
307 if !newline_needed && out.ends_with('\n') {
309 out.pop();
310 }
311
312 MergeResult {
313 content: out,
314 has_conflicts,
315 conflict_count,
316 }
317}
318
319#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn identical_files_no_conflict() {
327 let r = three_way_merge("a\nb\nc\n", "a\nb\nc\n", "a\nb\nc\n", "repo", "actual");
328 assert!(!r.has_conflicts);
329 assert_eq!(r.conflict_count, 0);
330 }
331
332 #[test]
333 fn only_ours_changed() {
334 let r = three_way_merge("a\nb\nc\n", "a\nB\nc\n", "a\nb\nc\n", "repo", "actual");
335 assert!(!r.has_conflicts);
336 assert!(r.content.contains('B'));
337 assert!(!r.content.contains('b'));
338 }
339
340 #[test]
341 fn only_theirs_changed() {
342 let r = three_way_merge("a\nb\nc\n", "a\nb\nc\n", "a\nB\nc\n", "repo", "actual");
343 assert!(!r.has_conflicts);
344 assert!(r.content.contains('B'));
345 }
346
347 #[test]
348 fn non_overlapping_changes_auto_merged() {
349 let base = "a\nb\nc\nd\n";
350 let ours = "A\nb\nc\nd\n"; let theirs = "a\nb\nc\nD\n"; let r = three_way_merge(base, ours, theirs, "repo", "actual");
353 assert!(!r.has_conflicts, "no overlap — should auto-merge");
354 assert!(r.content.contains('A'));
355 assert!(r.content.contains('D'));
356 }
357
358 #[test]
359 fn overlapping_changes_produce_conflict() {
360 let base = "a\nb\nc\n";
361 let ours = "a\nX\nc\n";
362 let theirs = "a\nY\nc\n";
363 let r = three_way_merge(base, ours, theirs, "repo", "actual");
364 assert!(r.has_conflicts);
365 assert_eq!(r.conflict_count, 1);
366 assert!(r.content.contains("<<<<<<< repo"));
367 assert!(r.content.contains(">>>>>>> actual"));
368 assert!(r.content.contains('X'));
369 assert!(r.content.contains('Y'));
370 }
371
372 #[test]
373 fn both_add_same_line_no_conflict() {
374 let base = "a\n";
376 let ours = "a\nnew\n";
377 let theirs = "a\nnew\n";
378 let r = three_way_merge(base, ours, theirs, "repo", "actual");
379 assert!(r.content.contains("new"));
382 }
383}