1use processkit::{Error, Result};
17
18use crate::BINARY;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ResolutionSide {
29 Ours,
31 Base,
33 Theirs,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
43#[non_exhaustive]
44pub struct ConflictRegion {
45 pub ours_label: String,
47 pub base_label: Option<String>,
49 pub theirs_label: String,
51 pub ours: Vec<String>,
53 pub base: Option<Vec<String>>,
55 pub theirs: Vec<String>,
57 pub marker_len: usize,
59 marker_ours: String,
61 marker_base: Option<String>,
62 marker_sep: String,
63 marker_end: String,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum ConflictSegment {
75 Text(Vec<String>),
77 Conflict(Box<ConflictRegion>),
79}
80
81pub fn has_conflict_markers(content: &str) -> bool {
84 content
85 .split_inclusive('\n')
86 .any(|line| marker_run(line, '<').is_some_and(|n| n >= 7))
87}
88
89fn marker_run(line: &str, ch: char) -> Option<usize> {
92 let trimmed = line.trim_end_matches(['\r', '\n']);
93 let n = trimmed.chars().take_while(|&c| c == ch).count();
94 if n == 0 {
95 return None;
96 }
97 let rest = &trimmed[n..];
98 (rest.is_empty() || rest.starts_with(' ')).then_some(n)
99}
100
101fn marker_label(line: &str, n: usize) -> String {
103 line.trim_end_matches(['\r', '\n'])[n..]
104 .trim_start()
105 .to_string()
106}
107
108fn parse_error(message: String) -> Error {
109 Error::Parse {
110 program: BINARY.to_string(),
111 message,
112 }
113}
114
115pub fn parse_conflicts(content: &str) -> Result<Vec<ConflictSegment>> {
124 let mut segments = Vec::new();
125 let mut text: Vec<String> = Vec::new();
126 let mut lines = content.split_inclusive('\n').peekable();
127
128 while let Some(line) = lines.next() {
129 let Some(n) = marker_run(line, '<').filter(|&n| n >= 7) else {
138 text.push(line.to_string());
139 continue;
140 };
141 if !text.is_empty() {
142 segments.push(ConflictSegment::Text(std::mem::take(&mut text)));
143 }
144
145 let marker_ours = line.to_string();
146 let ours_label = marker_label(line, n);
147 let mut ours = Vec::new();
148 let mut base: Option<Vec<String>> = None;
149 let mut marker_base = None;
150 let mut base_label = None;
151
152 let marker_sep = loop {
154 let Some(line) = lines.next() else {
155 return Err(parse_error(format!(
156 "unterminated conflict (no ======= after {:?})",
157 marker_ours.trim_end()
158 )));
159 };
160 if base.is_none() && marker_run(line, '|') == Some(n) {
164 base_label = Some(marker_label(line, n));
165 marker_base = Some(line.to_string());
166 base = Some(Vec::new());
167 continue;
168 }
169 if marker_run(line, '=') == Some(n) {
170 break line.to_string();
171 }
172 match &mut base {
173 Some(base_lines) => base_lines.push(line.to_string()),
174 None => ours.push(line.to_string()),
175 }
176 };
177
178 let mut theirs = Vec::new();
180 let marker_end = loop {
181 let Some(line) = lines.next() else {
182 return Err(parse_error(format!(
183 "unterminated conflict (no >>>>>>> after {:?})",
184 marker_ours.trim_end()
185 )));
186 };
187 if marker_run(line, '>') == Some(n) {
188 break line.to_string();
189 }
190 theirs.push(line.to_string());
191 };
192 let theirs_label = marker_label(&marker_end, n);
193
194 segments.push(ConflictSegment::Conflict(Box::new(ConflictRegion {
195 ours_label,
196 base_label,
197 theirs_label,
198 ours,
199 base,
200 theirs,
201 marker_len: n,
202 marker_ours,
203 marker_base,
204 marker_sep,
205 marker_end,
206 })));
207 }
208 if !text.is_empty() {
209 segments.push(ConflictSegment::Text(text));
210 }
211 Ok(segments)
212}
213
214pub fn render(segments: &[ConflictSegment]) -> String {
217 let mut out = String::new();
218 for segment in segments {
219 match segment {
220 ConflictSegment::Text(lines) => lines.iter().for_each(|l| out.push_str(l)),
221 ConflictSegment::Conflict(region) => {
222 out.push_str(®ion.marker_ours);
223 region.ours.iter().for_each(|l| out.push_str(l));
224 if let Some(marker) = ®ion.marker_base {
225 out.push_str(marker);
226 if let Some(base) = ®ion.base {
227 base.iter().for_each(|l| out.push_str(l));
228 }
229 }
230 out.push_str(®ion.marker_sep);
231 region.theirs.iter().for_each(|l| out.push_str(l));
232 out.push_str(®ion.marker_end);
233 }
234 }
235 }
236 out
237}
238
239pub fn resolve(segments: &[ConflictSegment], side: ResolutionSide) -> Result<String> {
244 let mut out = String::new();
245 for segment in segments {
246 match segment {
247 ConflictSegment::Text(lines) => lines.iter().for_each(|l| out.push_str(l)),
248 ConflictSegment::Conflict(region) => {
249 let chosen = match side {
250 ResolutionSide::Ours => ®ion.ours,
251 ResolutionSide::Theirs => ®ion.theirs,
252 ResolutionSide::Base => region.base.as_ref().ok_or_else(|| Error::Spawn {
253 program: BINARY.to_string(),
254 source: std::io::Error::new(
255 std::io::ErrorKind::InvalidInput,
256 "cannot resolve to Base: this conflict records no base \
257 (2-way `merge` style; use diff3/zdiff3)",
258 ),
259 })?,
260 };
261 chosen.iter().for_each(|l| out.push_str(l));
262 }
263 }
264 }
265 Ok(out)
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 const MERGE_2WAY: &str =
273 "line 1\n<<<<<<< HEAD\nmain line 2\n=======\nfeature line 2\n>>>>>>> feature\nline 3\n";
274 const DIFF3: &str = "line 1\n<<<<<<< HEAD\nmain line 2\n||||||| 0b025ce\nline 2\n=======\nfeature line 2\n>>>>>>> feature\nline 3\n";
275
276 #[test]
277 fn parses_two_way_merge_style() {
278 let segments = parse_conflicts(MERGE_2WAY).expect("parse");
279 assert_eq!(segments.len(), 3);
280 let ConflictSegment::Conflict(region) = &segments[1] else {
281 panic!("expected a conflict, got {segments:?}");
282 };
283 assert_eq!(region.ours_label, "HEAD");
284 assert_eq!(region.theirs_label, "feature");
285 assert_eq!(region.ours, ["main line 2\n"]);
286 assert_eq!(region.theirs, ["feature line 2\n"]);
287 assert!(region.base.is_none());
288 assert_eq!(region.marker_len, 7);
289 }
290
291 #[test]
292 fn parses_diff3_with_base() {
293 let segments = parse_conflicts(DIFF3).expect("parse");
294 let ConflictSegment::Conflict(region) = &segments[1] else {
295 panic!("expected a conflict");
296 };
297 assert_eq!(region.base_label.as_deref(), Some("0b025ce"));
298 assert_eq!(region.base.as_deref(), Some(&["line 2\n".to_string()][..]));
299 }
300
301 #[test]
306 fn repeated_base_marker_line_is_base_content() {
307 let s = "<<<<<<<< HEAD\n|||||||| base\n|||||||| base\n========\n>>>>>>>> branché\n";
308 let segments = parse_conflicts(s).expect("parse");
309 let ConflictSegment::Conflict(region) = &segments[0] else {
310 panic!("expected a conflict, got {segments:?}");
311 };
312 assert_eq!(
313 region.base.as_deref(),
314 Some(&["|||||||| base\n".to_string()][..]),
315 "the second |-run line is content of the base section"
316 );
317 assert_eq!(render(&segments), s, "roundtrip must be byte-exact");
318 }
319
320 #[test]
323 fn render_roundtrips_exactly() {
324 let crlf = "a\r\n<<<<<<< HEAD\r\nours\r\n=======\r\ntheirs\r\n>>>>>>> b\r\nz\r\n";
325 let wide = "<<<<<<<<<<<<<<< HEAD\nours\n===============\ntheirs\n>>>>>>>>>>>>>>> b\n";
326 let eof = "x\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> b";
327 for sample in [MERGE_2WAY, DIFF3, crlf, wide, eof] {
328 let segments = parse_conflicts(sample).expect("parse");
329 assert_eq!(render(&segments), sample, "roundtrip");
330 }
331 let segments = parse_conflicts(wide).unwrap();
333 let ConflictSegment::Conflict(region) = &segments[0] else {
334 panic!()
335 };
336 assert_eq!(region.marker_len, 15);
337 }
338
339 #[test]
340 fn resolve_takes_one_side_everywhere() {
341 let two = format!("{MERGE_2WAY}between\n{MERGE_2WAY}");
342 let segments = parse_conflicts(&two).expect("parse");
343 assert_eq!(
344 resolve(&segments, ResolutionSide::Ours).unwrap(),
345 "line 1\nmain line 2\nline 3\nbetween\nline 1\nmain line 2\nline 3\n"
346 );
347 assert_eq!(
348 resolve(&segments, ResolutionSide::Theirs).unwrap(),
349 "line 1\nfeature line 2\nline 3\nbetween\nline 1\nfeature line 2\nline 3\n"
350 );
351 assert!(resolve(&segments, ResolutionSide::Base).is_err());
353
354 let diff3 = parse_conflicts(DIFF3).expect("parse");
355 assert_eq!(
356 resolve(&diff3, ResolutionSide::Base).unwrap(),
357 "line 1\nline 2\nline 3\n"
358 );
359 }
360
361 #[test]
362 fn empty_sides_and_clean_files_parse() {
363 let deletion = "<<<<<<< HEAD\n=======\nkept\n>>>>>>> b\n";
365 let segments = parse_conflicts(deletion).expect("parse");
366 assert_eq!(resolve(&segments, ResolutionSide::Ours).unwrap(), "");
367 let clean = parse_conflicts("just\ntext\n").expect("parse");
369 assert_eq!(clean.len(), 1);
370 assert!(!has_conflict_markers("just\ntext\n"));
371 assert!(has_conflict_markers(MERGE_2WAY));
372 }
373
374 #[test]
375 fn malformed_files_are_parse_errors() {
376 for bad in [
379 "<<<<<<< HEAD\nours\n", "<<<<<<< HEAD\nours\n=======\ntheirs\n", ] {
382 assert!(
383 matches!(parse_conflicts(bad), Err(Error::Parse { .. })),
384 "{bad:?} must fail"
385 );
386 }
387 }
388
389 #[test]
393 fn marker_like_content_outside_a_region_is_text() {
394 for content in [
395 "Heading\n=======\nbody\n", "a\n=======================\nb\n", ">>>>>>> deep email quote\nreply\n", "code: a <<<<<<< b\n", ] {
400 let segments = parse_conflicts(content).expect("parses as text, no error");
401 assert!(
402 segments
403 .iter()
404 .all(|s| matches!(s, ConflictSegment::Text(_))),
405 "{content:?} must be all text, got {segments:?}"
406 );
407 assert_eq!(render(&segments), content, "round-trips byte-exact");
408 }
409 }
410}
411
412#[cfg(test)]
417mod proptests {
418 use super::*;
419 use proptest::prelude::*;
420
421 fn conflict_line() -> impl Strategy<Value = String> {
425 prop_oneof![
426 (7usize..16).prop_map(|n| format!("{} HEAD\n", "<".repeat(n))),
427 (7usize..16).prop_map(|n| format!("{}\n", "=".repeat(n))),
428 (7usize..16).prop_map(|n| format!("{} branché\n", ">".repeat(n))),
429 (7usize..16).prop_map(|n| format!("{} base\n", "|".repeat(n))),
430 "[a-zé<>=|]{0,14}\r?\n", Just("\n".to_string()),
432 ]
433 }
434
435 fn conflict_doc() -> impl Strategy<Value = String> {
436 prop::collection::vec(conflict_line(), 0..30).prop_map(|lines| lines.concat())
437 }
438
439 proptest! {
440 #[test]
441 fn parse_never_panics_on_arbitrary_text(s in any::<String>()) {
442 let _ = has_conflict_markers(&s);
443 if let Ok(segments) = parse_conflicts(&s) {
447 prop_assert_eq!(render(&segments), s);
448 }
449 }
450
451 #[test]
452 fn parse_never_panics_on_structured_text(s in conflict_doc()) {
453 let _ = parse_conflicts(&s);
454 }
455
456 #[test]
459 fn render_roundtrips_whatever_parses(s in conflict_doc()) {
460 if let Ok(segments) = parse_conflicts(&s) {
461 prop_assert_eq!(render(&segments), s);
462 }
463 }
464
465 #[test]
467 fn marker_free_files_are_a_single_text_segment(s in "[a-zé \t\r\n]{0,80}") {
468 prop_assume!(!has_conflict_markers(&s));
469 let segments = parse_conflicts(&s).expect("no markers → Ok");
470 prop_assert_eq!(render(&segments), s);
471 }
472 }
473}