1use std::io::Write;
12use std::path::Path;
13
14use crate::error::MarsError;
15
16#[derive(Debug, Clone)]
18pub struct MergeResult {
19 pub content: Vec<u8>,
21 pub has_conflicts: bool,
23 pub conflict_count: usize,
25}
26
27#[derive(Debug, Clone)]
29pub struct MergeLabels {
30 pub base: String,
32 pub local: String,
34 pub theirs: String,
36}
37
38pub fn merge_content(
52 base: &[u8],
53 local: &[u8],
54 theirs: &[u8],
55 labels: &MergeLabels,
56) -> Result<MergeResult, MarsError> {
57 let dir = tempfile::TempDir::new()?;
58
59 let base_path = dir.path().join("base");
60 let local_path = dir.path().join("local");
61 let theirs_path = dir.path().join("theirs");
62
63 write_file(&base_path, base)?;
64 write_file(&local_path, local)?;
65 write_file(&theirs_path, theirs)?;
66
67 let local_path_str = local_path.to_string_lossy();
73 let base_path_str = base_path.to_string_lossy();
74 let theirs_path_str = theirs_path.to_string_lossy();
75 let output = crate::platform::process::run_git_raw(
76 &[
77 "merge-file",
78 "-p",
79 "-L",
80 &labels.local,
81 "-L",
82 &labels.base,
83 "-L",
84 &labels.theirs,
85 &local_path_str,
86 &base_path_str,
87 &theirs_path_str,
88 ],
89 dir.path(),
90 "three-way merge",
91 )?;
92
93 let exit_code = output.status.code().unwrap_or(-1);
94
95 if exit_code < 0 {
97 return Err(MarsError::Source {
98 source_name: "merge".to_string(),
99 message: format!(
100 "git merge-file failed (exit {}): {}",
101 exit_code,
102 String::from_utf8_lossy(&output.stderr)
103 ),
104 });
105 }
106
107 let content = output.stdout;
108 let has_conflicts = exit_code > 0;
109 let conflict_count = count_conflict_markers(&content);
110
111 Ok(MergeResult {
112 content,
113 has_conflicts,
114 conflict_count,
115 })
116}
117
118pub fn has_conflict_markers(content: &[u8]) -> bool {
122 if content.starts_with(b"<<<<<<<") {
124 return true;
125 }
126 content
127 .windows(8)
128 .any(|w| w[0] == b'\n' && &w[1..] == b"<<<<<<<")
129}
130
131pub fn file_has_conflict_markers(path: &Path) -> bool {
133 std::fs::read(path)
135 .map(|content| has_conflict_markers(&content))
136 .unwrap_or(false)
137}
138
139fn count_conflict_markers(content: &[u8]) -> usize {
141 let mut count = 0;
142
143 if content.len() >= 7 && &content[..7] == b"<<<<<<<" {
145 count += 1;
146 }
147
148 for window in content.windows(8) {
150 if window[0] == b'\n' && &window[1..] == b"<<<<<<<" {
151 count += 1;
152 }
153 }
154
155 count
156}
157
158fn write_file(path: &std::path::Path, content: &[u8]) -> Result<(), MarsError> {
160 let mut file = std::fs::File::create(path)?;
161 file.write_all(content)?;
162 Ok(())
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 fn labels() -> MergeLabels {
170 MergeLabels {
171 base: "base (last sync)".to_string(),
172 local: "local".to_string(),
173 theirs: "meridian-base@v0.6.0".to_string(),
174 }
175 }
176
177 #[test]
180 fn all_three_identical() {
181 let content = b"line 1\nline 2\nline 3\n";
182 let result = merge_content(content, content, content, &labels()).unwrap();
183 assert!(!result.has_conflicts);
184 assert_eq!(result.conflict_count, 0);
185 assert_eq!(result.content, content);
186 }
187
188 #[test]
189 fn theirs_changed_local_same_as_base() {
190 let base = b"line 1\nline 2\nline 3\n";
191 let local = b"line 1\nline 2\nline 3\n";
192 let theirs = b"line 1\nline 2 modified\nline 3\n";
193
194 let result = merge_content(base, local, theirs, &labels()).unwrap();
195 assert!(!result.has_conflicts);
196 assert_eq!(result.content, theirs);
197 }
198
199 #[test]
200 fn local_changed_theirs_same_as_base() {
201 let base = b"line 1\nline 2\nline 3\n";
202 let local = b"line 1\nline 2 local edit\nline 3\n";
203 let theirs = b"line 1\nline 2\nline 3\n";
204
205 let result = merge_content(base, local, theirs, &labels()).unwrap();
206 assert!(!result.has_conflicts);
207 assert_eq!(result.content, local);
208 }
209
210 #[test]
211 fn non_overlapping_changes_merge_cleanly() {
212 let base = b"line 1\nline 2\nline 3\nline 4\nline 5\n";
213 let local = b"line 1 local\nline 2\nline 3\nline 4\nline 5\n";
214 let theirs = b"line 1\nline 2\nline 3\nline 4\nline 5 theirs\n";
215
216 let result = merge_content(base, local, theirs, &labels()).unwrap();
217 assert!(!result.has_conflicts);
218 let merged = String::from_utf8(result.content).unwrap();
219 assert!(merged.contains("line 1 local"));
220 assert!(merged.contains("line 5 theirs"));
221 }
222
223 #[test]
226 fn overlapping_changes_produce_conflict() {
227 let base = b"line 1\nline 2\nline 3\n";
228 let local = b"line 1\nlocal change\nline 3\n";
229 let theirs = b"line 1\ntheirs change\nline 3\n";
230
231 let result = merge_content(base, local, theirs, &labels()).unwrap();
232 assert!(result.has_conflicts);
233 assert!(result.conflict_count >= 1);
234 }
235
236 #[test]
237 fn conflict_markers_match_git_format() {
238 let base = b"same\nconflict line\nsame\n";
239 let local = b"same\nlocal version\nsame\n";
240 let theirs = b"same\ntheirs version\nsame\n";
241
242 let result = merge_content(base, local, theirs, &labels()).unwrap();
243 assert!(result.has_conflicts);
244
245 let merged = String::from_utf8(result.content).unwrap();
246 assert!(merged.contains("<<<<<<<"), "should have opening marker");
247 assert!(merged.contains("======="), "should have separator");
248 assert!(merged.contains(">>>>>>>"), "should have closing marker");
249 }
250
251 #[test]
252 fn labels_appear_in_conflict_markers() {
253 let base = b"conflict\n";
254 let local = b"local version\n";
255 let theirs = b"theirs version\n";
256
257 let result = merge_content(base, local, theirs, &labels()).unwrap();
258 let merged = String::from_utf8(result.content).unwrap();
259 assert!(
260 merged.contains("local"),
261 "local label should appear: {merged}"
262 );
263 assert!(
264 merged.contains("meridian-base@v0.6.0"),
265 "theirs label should appear: {merged}"
266 );
267 }
268
269 #[test]
270 fn multiple_conflict_regions() {
271 let base = b"a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
273 let local = b"a-local\nb\nc\nd\ne\nf\ng\nh\ni-local\nj\n";
274 let theirs = b"a-theirs\nb\nc\nd\ne\nf\ng\nh\ni-theirs\nj\n";
275
276 let result = merge_content(base, local, theirs, &labels()).unwrap();
277 assert!(result.has_conflicts);
278 assert!(
279 result.conflict_count >= 2,
280 "should have at least 2 conflicts, got {}",
281 result.conflict_count
282 );
283 }
284
285 #[test]
288 fn empty_base_with_different_content() {
289 let base = b"";
290 let local = b"local content\n";
291 let theirs = b"theirs content\n";
292
293 let result = merge_content(base, local, theirs, &labels()).unwrap();
295 assert!(result.has_conflicts);
297 }
298
299 #[test]
300 fn empty_base_same_additions() {
301 let base = b"";
302 let local = b"same content\n";
303 let theirs = b"same content\n";
304
305 let result = merge_content(base, local, theirs, &labels()).unwrap();
306 assert!(!result.has_conflicts);
307 assert_eq!(result.content, b"same content\n");
308 }
309
310 #[test]
311 fn all_empty() {
312 let result = merge_content(b"", b"", b"", &labels()).unwrap();
313 assert!(!result.has_conflicts);
314 assert!(result.content.is_empty());
315 }
316
317 #[test]
320 fn has_conflict_markers_detects_markers() {
321 let content = b"before\n<<<<<<< local\nlocal\n=======\ntheirs\n>>>>>>> theirs\nafter\n";
322 assert!(has_conflict_markers(content));
323 }
324
325 #[test]
326 fn has_conflict_markers_at_start_of_file() {
327 let content = b"<<<<<<< local\nlocal\n=======\ntheirs\n>>>>>>> theirs\n";
328 assert!(has_conflict_markers(content));
329 }
330
331 #[test]
332 fn has_conflict_markers_no_markers() {
333 let content = b"normal content\nno conflicts here\n";
334 assert!(!has_conflict_markers(content));
335 }
336
337 #[test]
338 fn has_conflict_markers_partial_marker_not_detected() {
339 let content = b"some <<<<<< stuff\n";
341 assert!(!has_conflict_markers(content));
342 }
343
344 #[test]
345 fn has_conflict_markers_in_middle_of_line_not_detected() {
346 let content = b"text <<<<<<< not a real marker\n";
348 assert!(!has_conflict_markers(content));
349 }
350
351 #[test]
354 fn count_zero_conflicts() {
355 assert_eq!(count_conflict_markers(b"no conflicts"), 0);
356 }
357
358 #[test]
359 fn count_one_conflict() {
360 let content = b"before\n<<<<<<< local\nlocal\n=======\ntheirs\n>>>>>>> theirs\nafter\n";
361 assert_eq!(count_conflict_markers(content), 1);
362 }
363
364 #[test]
365 fn count_multiple_conflicts() {
366 let content =
367 b"<<<<<<< a\nx\n=======\ny\n>>>>>>> b\nok\n<<<<<<< a\np\n=======\nq\n>>>>>>> b\n";
368 assert_eq!(count_conflict_markers(content), 2);
369 }
370}