Skip to main content

git_pincer/
merge.rs

1//! Three-way merge core: diff3-style chunking built on top of two 2-way diffs from `similar`.
2//!
3//! Ported from the module of the same name in toolkit-rs (with the web
4//! serialization parts removed). The algorithm: compute line-level diffs for
5//! base→ours and base→theirs, extract every non-Equal op as a hunk (a base
6//! line range plus its replacement lines), then group hunks from both sides by
7//! collisions between their base ranges. A group touched by only one side
8//! applies cleanly; identical changes from both sides count as agreement;
9//! everything else is marked as a conflict and handed to the interactive TUI.
10
11use std::ops::Range;
12use std::time::Duration;
13
14use similar::{Algorithm, DiffTag, TextDiff};
15
16/// 单次 diff 的最长计算时间,超时后 similar 会降级为较粗粒度的结果,
17/// 保证超大文件下 TUI 仍能及时进入交互。
18const DIFF_TIMEOUT: Duration = Duration::from_millis(500);
19
20/// 合并块的类型。
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ChunkKind {
23    /// 双方均未改动,内容与 base 一致
24    Stable,
25    /// 仅本地(ours)改动,可无冲突应用
26    Ours,
27    /// 仅远端(theirs)改动,可无冲突应用
28    Theirs,
29    /// 双方改动且内容一致,可无冲突应用
30    Agree,
31    /// 双方改动且内容不同,需人工解决
32    Conflict,
33}
34
35/// 一个合并块:base 的某个连续区间以及双方在该区间的内容。
36#[derive(Debug, Clone)]
37pub struct MergeChunk {
38    /// 块序号,供交互层定位操作
39    pub id: usize,
40    /// 块类型
41    pub kind: ChunkKind,
42    /// base 在该区间的行内容(已去行尾换行)
43    pub base: Vec<String>,
44    /// 本地侧在该区间的行内容;稳定块不重复存储(为空),
45    /// 读取应使用 [`Self::ours_lines`]
46    pub ours: Vec<String>,
47    /// 远端侧在该区间的行内容;稳定块不重复存储(为空),
48    /// 读取应使用 [`Self::theirs_lines`]
49    pub theirs: Vec<String>,
50    /// base 侧起始行号(1-based)
51    pub base_start: usize,
52    /// 本地侧起始行号(1-based)
53    pub ours_start: usize,
54    /// 远端侧起始行号(1-based)
55    pub theirs_start: usize,
56}
57
58impl MergeChunk {
59    /// 本地侧行内容;稳定块双方与 base 一致,共用 base 的存储
60    /// (避免整个文件的稳定区内容被复制三份)。
61    pub fn ours_lines(&self) -> &[String] {
62        if self.kind == ChunkKind::Stable {
63            &self.base
64        } else {
65            &self.ours
66        }
67    }
68
69    /// 远端侧行内容;稳定块双方与 base 一致,共用 base 的存储。
70    pub fn theirs_lines(&self) -> &[String] {
71        if self.kind == ChunkKind::Stable {
72            &self.base
73        } else {
74            &self.theirs
75        }
76    }
77}
78
79/// 三方合并的完整结果:按文件顺序排列的块 + 统计信息。
80#[derive(Debug, Clone)]
81pub struct MergeResult {
82    /// 全部合并块,首尾相接覆盖整个文件
83    pub chunks: Vec<MergeChunk>,
84    /// 冲突块数量
85    pub conflicts: usize,
86    /// 仅本地改动的块数量
87    pub ours_changes: usize,
88    /// 仅远端改动的块数量
89    pub theirs_changes: usize,
90    /// 双方一致改动的块数量
91    pub agree: usize,
92    /// 本地侧标签(来自 `<<<<<<<` 后的分支名;三方模式下为 None)
93    pub ours_label: Option<String>,
94    /// 远端侧标签(来自 `>>>>>>>` 后的分支名;三方模式下为 None)
95    pub theirs_label: Option<String>,
96}
97
98/// 单侧 hunk:base 的某个行区间被替换为新的行内容。
99#[derive(Debug, Clone)]
100struct Hunk {
101    /// 被替换的 base 行区间(左闭右开;纯插入时为空区间)
102    base_range: Range<usize>,
103    /// 替换后的行内容(已去行尾换行)
104    lines: Vec<String>,
105}
106
107/// hunk 的来源侧。
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109enum Side {
110    Ours,
111    Theirs,
112}
113
114/// 带侧别标记的 hunk,分组时两侧混排使用。
115#[derive(Debug, Clone)]
116struct SidedHunk {
117    side: Side,
118    hunk: Hunk,
119}
120
121/// 对三份文本执行三方合并,返回按文件顺序排列的合并块与统计信息。
122pub fn merge_text(base: &str, ours: &str, theirs: &str) -> MergeResult {
123    let ours_diff = TextDiff::configure()
124        .algorithm(Algorithm::Myers)
125        .timeout(DIFF_TIMEOUT)
126        .diff_lines(base, ours);
127    let theirs_diff = TextDiff::configure()
128        .algorithm(Algorithm::Myers)
129        .timeout(DIFF_TIMEOUT)
130        .diff_lines(base, theirs);
131
132    // base 行以 similar 的分词为准,保证与 op 的区间下标一致
133    let base_lines: Vec<String> = (0..ours_diff.old_len())
134        .filter_map(|i| ours_diff.old_slice(i))
135        .map(clean_line)
136        .collect();
137
138    let groups = group_hunks(extract_hunks(&ours_diff), extract_hunks(&theirs_diff));
139    build_chunks(&base_lines, groups)
140}
141
142/// 去除行尾换行符(\n 或 \r\n)。
143fn clean_line(line: &str) -> String {
144    line.trim_end_matches(['\r', '\n']).to_owned()
145}
146
147/// 从一次 2-way diff 中提取全部非 Equal 操作为 hunk。
148fn extract_hunks(diff: &TextDiff<'_, '_, str>) -> Vec<Hunk> {
149    diff.ops()
150        .iter()
151        .filter(|op| op.tag() != DiffTag::Equal)
152        .map(|op| Hunk {
153            base_range: op.old_range(),
154            // 合法区间内 new_slice 必然有值,filter_map 仅为规避 unwrap
155            lines: op
156                .new_range()
157                .filter_map(|i| diff.new_slice(i))
158                .map(clean_line)
159                .collect(),
160        })
161        .collect()
162}
163
164/// 判断两个 base 区间是否「碰撞」(需归入同一组处理)。
165///
166/// 保守策略:空区间(纯插入)与另一区间端点相接也算碰撞,宁可多报冲突,
167/// 也不冒险静默合并出错误结果;两个非空区间仅端点相接则不算碰撞,
168/// 双方改动可独立应用。
169fn collides(a: &Range<usize>, b: &Range<usize>) -> bool {
170    match (a.is_empty(), b.is_empty()) {
171        (true, true) => a.start == b.start,
172        (true, false) => b.start <= a.start && a.start <= b.end,
173        (false, true) => a.start <= b.start && b.start <= a.end,
174        (false, false) => a.start.max(b.start) < a.end.min(b.end),
175    }
176}
177
178/// 将两侧 hunk 按 base 区间的碰撞关系归并成组,组内保持 base 起点有序。
179fn group_hunks(ours: Vec<Hunk>, theirs: Vec<Hunk>) -> Vec<Vec<SidedHunk>> {
180    let mut all: Vec<SidedHunk> = ours
181        .into_iter()
182        .map(|hunk| SidedHunk {
183            side: Side::Ours,
184            hunk,
185        })
186        .chain(theirs.into_iter().map(|hunk| SidedHunk {
187            side: Side::Theirs,
188            hunk,
189        }))
190        .collect();
191    // 按 base 起点排序;同起点时 ours 在前,保证结果确定性
192    all.sort_by_key(|s| {
193        (
194            s.hunk.base_range.start,
195            s.hunk.base_range.end,
196            s.side == Side::Theirs,
197        )
198    });
199
200    // 已排序前提下,只需用组的合并区间与下一个 hunk 判碰撞即可传递扩张
201    let mut groups: Vec<(Range<usize>, Vec<SidedHunk>)> = Vec::new();
202    for sided in all {
203        let range = sided.hunk.base_range.clone();
204        match groups.last_mut() {
205            Some((merged, members)) if collides(merged, &range) => {
206                merged.start = merged.start.min(range.start);
207                merged.end = merged.end.max(range.end);
208                members.push(sided);
209            }
210            _ => groups.push((range, vec![sided])),
211        }
212    }
213    groups.into_iter().map(|(_, members)| members).collect()
214}
215
216/// 计算某一侧在组合 base 区间 [lo, hi) 内的最终内容:
217/// 未被该侧 hunk 覆盖的 base 行保持原样,被覆盖处使用替换行。
218fn side_content(
219    base_lines: &[String],
220    lo: usize,
221    hi: usize,
222    group: &[SidedHunk],
223    side: Side,
224) -> Vec<String> {
225    let mut out = Vec::new();
226    let mut pos = lo;
227    for sided in group.iter().filter(|s| s.side == side) {
228        out.extend_from_slice(&base_lines[pos..sided.hunk.base_range.start]);
229        out.extend(sided.hunk.lines.iter().cloned());
230        pos = sided.hunk.base_range.end;
231    }
232    out.extend_from_slice(&base_lines[pos..hi]);
233    out
234}
235
236/// 由 hunk 组构建首尾相接的合并块序列,并统计各类块数量。
237fn build_chunks(base_lines: &[String], groups: Vec<Vec<SidedHunk>>) -> MergeResult {
238    let mut chunks: Vec<MergeChunk> = Vec::new();
239    let mut conflicts = 0usize;
240    let mut ours_changes = 0usize;
241    let mut theirs_changes = 0usize;
242    let mut agree = 0usize;
243
244    // 三侧各自的行号游标(1-based)与 base 消费位置
245    let mut base_pos = 0usize;
246    let mut base_no = 1usize;
247    let mut ours_no = 1usize;
248    let mut theirs_no = 1usize;
249
250    // 追加一个稳定块;双方内容与 base 一致,只存 base 一份
251    let push_stable = |chunks: &mut Vec<MergeChunk>,
252                       lines: &[String],
253                       base_no: &mut usize,
254                       ours_no: &mut usize,
255                       theirs_no: &mut usize| {
256        chunks.push(MergeChunk {
257            id: chunks.len(),
258            kind: ChunkKind::Stable,
259            base: lines.to_vec(),
260            ours: Vec::new(),
261            theirs: Vec::new(),
262            base_start: *base_no,
263            ours_start: *ours_no,
264            theirs_start: *theirs_no,
265        });
266        *base_no += lines.len();
267        *ours_no += lines.len();
268        *theirs_no += lines.len();
269    };
270
271    for group in groups {
272        // 组的组合 base 区间(组必非空,unwrap_or 仅为规避 unwrap)
273        let lo = group
274            .iter()
275            .map(|s| s.hunk.base_range.start)
276            .min()
277            .unwrap_or(0);
278        let hi = group
279            .iter()
280            .map(|s| s.hunk.base_range.end)
281            .max()
282            .unwrap_or(lo);
283
284        // 组之前的稳定区
285        if lo > base_pos {
286            push_stable(
287                &mut chunks,
288                &base_lines[base_pos..lo],
289                &mut base_no,
290                &mut ours_no,
291                &mut theirs_no,
292            );
293        }
294
295        let ours_lines = side_content(base_lines, lo, hi, &group, Side::Ours);
296        let theirs_lines = side_content(base_lines, lo, hi, &group, Side::Theirs);
297        let has_ours = group.iter().any(|s| s.side == Side::Ours);
298        let has_theirs = group.iter().any(|s| s.side == Side::Theirs);
299        let kind = match (has_ours, has_theirs) {
300            (true, false) => ChunkKind::Ours,
301            (false, true) => ChunkKind::Theirs,
302            _ if ours_lines == theirs_lines => ChunkKind::Agree,
303            _ => ChunkKind::Conflict,
304        };
305        match kind {
306            ChunkKind::Ours => ours_changes += 1,
307            ChunkKind::Theirs => theirs_changes += 1,
308            ChunkKind::Agree => agree += 1,
309            ChunkKind::Conflict => conflicts += 1,
310            ChunkKind::Stable => {}
311        }
312
313        let (ours_len, theirs_len) = (ours_lines.len(), theirs_lines.len());
314        chunks.push(MergeChunk {
315            id: chunks.len(),
316            kind,
317            base: base_lines[lo..hi].to_vec(),
318            ours: ours_lines,
319            theirs: theirs_lines,
320            base_start: base_no,
321            ours_start: ours_no,
322            theirs_start: theirs_no,
323        });
324        base_no += hi - lo;
325        ours_no += ours_len;
326        theirs_no += theirs_len;
327        base_pos = hi;
328    }
329
330    // 末尾的稳定区
331    if base_pos < base_lines.len() {
332        push_stable(
333            &mut chunks,
334            &base_lines[base_pos..],
335            &mut base_no,
336            &mut ours_no,
337            &mut theirs_no,
338        );
339    }
340
341    MergeResult {
342        chunks,
343        conflicts,
344        ours_changes,
345        theirs_changes,
346        agree,
347        ours_label: None,
348        theirs_label: None,
349    }
350}
351
352/// 冲突文件解析错误。
353#[derive(Debug, thiserror::Error)]
354pub enum ConflictParseError {
355    /// 整个文件没有任何冲突标记
356    #[error("未检测到 git 冲突标记(<<<<<<< / ======= / >>>>>>>)")]
357    NoMarkers,
358    /// 冲突标记不完整或顺序错误
359    #[error("第 {0} 行附近的冲突标记不完整或顺序错误")]
360    Malformed(usize),
361}
362
363/// 解析时当前正在收集的冲突段落。
364enum Section {
365    /// 冲突块之外的公共内容
366    Common,
367    /// `<<<<<<<` 之后的本地内容
368    Ours,
369    /// `|||||||` 之后的 base 内容(diff3 风格才有)
370    Base,
371    /// `=======` 之后的远端内容
372    Theirs,
373}
374
375/// 解析带 git 冲突标记的单个文件,还原为合并块序列。
376///
377/// git 合并时非冲突改动已写入文件,因此结果只含 Stable 与 Conflict 两类块;
378/// 支持默认(merge)与 diff3 两种 conflictStyle,并提取 `<<<<<<< / >>>>>>>`
379/// 后的分支标签。默认风格没有 base 段,冲突块的 base 为空。
380pub fn parse_conflict_file(text: &str) -> Result<MergeResult, ConflictParseError> {
381    let mut chunks: Vec<MergeChunk> = Vec::new();
382    let mut conflicts = 0usize;
383    let mut ours_label: Option<String> = None;
384    let mut theirs_label: Option<String> = None;
385
386    // 三侧各自的行号游标(1-based),指向重建后的各侧文档位置
387    let mut base_no = 1usize;
388    let mut ours_no = 1usize;
389    let mut theirs_no = 1usize;
390
391    // 各段落的行缓冲
392    let mut common: Vec<String> = Vec::new();
393    let mut ours: Vec<String> = Vec::new();
394    let mut base: Vec<String> = Vec::new();
395    let mut theirs: Vec<String> = Vec::new();
396    let mut section = Section::Common;
397    let mut conflict_start = 0usize;
398
399    // 把缓冲的公共段落收为一个稳定块(只存 base 一份)
400    let flush_common = |chunks: &mut Vec<MergeChunk>,
401                        common: &mut Vec<String>,
402                        base_no: &mut usize,
403                        ours_no: &mut usize,
404                        theirs_no: &mut usize| {
405        if common.is_empty() {
406            return;
407        }
408        let lines = std::mem::take(common);
409        let len = lines.len();
410        chunks.push(MergeChunk {
411            id: chunks.len(),
412            kind: ChunkKind::Stable,
413            base: lines,
414            ours: Vec::new(),
415            theirs: Vec::new(),
416            base_start: *base_no,
417            ours_start: *ours_no,
418            theirs_start: *theirs_no,
419        });
420        *base_no += len;
421        *ours_no += len;
422        *theirs_no += len;
423    };
424
425    for (idx, raw) in text.lines().enumerate() {
426        let line = raw.trim_end_matches('\r');
427        let line_no = idx + 1;
428
429        if let Some(rest) = line.strip_prefix("<<<<<<<") {
430            // 冲突开始;嵌套的 <<<<<<< 视为格式错误
431            if !matches!(section, Section::Common) {
432                return Err(ConflictParseError::Malformed(line_no));
433            }
434            flush_common(
435                &mut chunks,
436                &mut common,
437                &mut base_no,
438                &mut ours_no,
439                &mut theirs_no,
440            );
441            conflict_start = line_no;
442            let label = rest.trim();
443            if ours_label.is_none() && !label.is_empty() {
444                ours_label = Some(label.to_owned());
445            }
446            section = Section::Ours;
447        } else if matches!(section, Section::Ours) && line.starts_with("|||||||") {
448            section = Section::Base;
449        } else if matches!(section, Section::Ours | Section::Base)
450            && line.len() >= 7
451            && line.bytes().all(|b| b == b'=')
452        {
453            section = Section::Theirs;
454        } else if let Some(rest) = line.strip_prefix(">>>>>>>") {
455            if !matches!(section, Section::Theirs) {
456                return Err(ConflictParseError::Malformed(line_no));
457            }
458            let label = rest.trim();
459            if theirs_label.is_none() && !label.is_empty() {
460                theirs_label = Some(label.to_owned());
461            }
462            let (base_lines, ours_lines, theirs_lines) = (
463                std::mem::take(&mut base),
464                std::mem::take(&mut ours),
465                std::mem::take(&mut theirs),
466            );
467            let lens = (base_lines.len(), ours_lines.len(), theirs_lines.len());
468            chunks.push(MergeChunk {
469                id: chunks.len(),
470                kind: ChunkKind::Conflict,
471                base_start: base_no,
472                ours_start: ours_no,
473                theirs_start: theirs_no,
474                base: base_lines,
475                ours: ours_lines,
476                theirs: theirs_lines,
477            });
478            base_no += lens.0;
479            ours_no += lens.1;
480            theirs_no += lens.2;
481            conflicts += 1;
482            section = Section::Common;
483        } else {
484            let owned = line.to_owned();
485            match section {
486                Section::Common => common.push(owned),
487                Section::Ours => ours.push(owned),
488                Section::Base => base.push(owned),
489                Section::Theirs => theirs.push(owned),
490            }
491        }
492    }
493
494    // 文件结束时仍在冲突块内 → 标记不完整
495    if !matches!(section, Section::Common) {
496        return Err(ConflictParseError::Malformed(conflict_start));
497    }
498    if conflicts == 0 {
499        return Err(ConflictParseError::NoMarkers);
500    }
501    flush_common(
502        &mut chunks,
503        &mut common,
504        &mut base_no,
505        &mut ours_no,
506        &mut theirs_no,
507    );
508
509    Ok(MergeResult {
510        chunks,
511        conflicts,
512        ours_changes: 0,
513        theirs_changes: 0,
514        agree: 0,
515        ours_label,
516        theirs_label,
517    })
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    /// 提取块类型序列,便于断言整体结构
525    fn kinds(result: &MergeResult) -> Vec<ChunkKind> {
526        result.chunks.iter().map(|c| c.kind).collect()
527    }
528
529    #[test]
530    fn identical_inputs_are_stable() {
531        let result = merge_text("a\nb\n", "a\nb\n", "a\nb\n");
532        assert_eq!(kinds(&result), vec![ChunkKind::Stable]);
533        assert_eq!(result.conflicts, 0);
534    }
535
536    #[test]
537    fn non_overlapping_changes_merge() {
538        let result = merge_text("a\nb\nc\nd\n", "A\nb\nc\nd\n", "a\nb\nc\nD\n");
539        assert_eq!(
540            kinds(&result),
541            vec![ChunkKind::Ours, ChunkKind::Stable, ChunkKind::Theirs]
542        );
543        assert_eq!(result.chunks[0].ours, vec!["A"]);
544        assert_eq!(result.chunks[0].base, vec!["a"]);
545        assert_eq!(result.chunks[2].theirs, vec!["D"]);
546        assert_eq!(result.conflicts, 0);
547        assert_eq!(result.ours_changes, 1);
548        assert_eq!(result.theirs_changes, 1);
549    }
550
551    #[test]
552    fn identical_changes_are_agree() {
553        let result = merge_text("a\nb\nc\n", "a\nB\nc\n", "a\nB\nc\n");
554        assert_eq!(
555            kinds(&result),
556            vec![ChunkKind::Stable, ChunkKind::Agree, ChunkKind::Stable]
557        );
558        assert_eq!(result.agree, 1);
559        assert_eq!(result.chunks[1].ours, result.chunks[1].theirs);
560    }
561
562    #[test]
563    fn conflicting_change_detected() {
564        let result = merge_text("a\nb\nc\n", "a\nX\nc\n", "a\nY\nc\n");
565        assert_eq!(result.conflicts, 1);
566        let conflict = &result.chunks[1];
567        assert_eq!(conflict.kind, ChunkKind::Conflict);
568        assert_eq!(conflict.base, vec!["b"]);
569        assert_eq!(conflict.ours, vec!["X"]);
570        assert_eq!(conflict.theirs, vec!["Y"]);
571    }
572
573    #[test]
574    fn insertions_at_same_point_conflict() {
575        let result = merge_text("a\nb\n", "a\nx\nb\n", "a\ny\nb\n");
576        assert_eq!(result.conflicts, 1);
577        let conflict = &result.chunks[1];
578        assert!(conflict.base.is_empty());
579        assert_eq!(conflict.ours, vec!["x"]);
580        assert_eq!(conflict.theirs, vec!["y"]);
581    }
582
583    #[test]
584    fn delete_vs_edit_conflicts() {
585        let result = merge_text("a\nb\nc\n", "a\nc\n", "a\nB\nc\n");
586        assert_eq!(result.conflicts, 1);
587        let conflict = &result.chunks[1];
588        assert!(conflict.ours.is_empty());
589        assert_eq!(conflict.theirs, vec!["B"]);
590    }
591
592    #[test]
593    fn adjacent_changes_stay_separate() {
594        let result = merge_text("a\nb\nc\nd\n", "A\nB\nc\nd\n", "a\nb\nC\nD\n");
595        assert_eq!(kinds(&result), vec![ChunkKind::Ours, ChunkKind::Theirs]);
596        assert_eq!(result.conflicts, 0);
597    }
598
599    #[test]
600    fn empty_base_same_addition_agrees() {
601        let result = merge_text("", "x\n", "x\n");
602        assert_eq!(kinds(&result), vec![ChunkKind::Agree]);
603    }
604
605    #[test]
606    fn empty_base_different_additions_conflict() {
607        let result = merge_text("", "x\n", "y\n");
608        assert_eq!(kinds(&result), vec![ChunkKind::Conflict]);
609    }
610
611    #[test]
612    fn one_side_unchanged_keeps_other() {
613        let result = merge_text("a\nb\n", "a\nB\n", "a\nb\n");
614        assert_eq!(kinds(&result), vec![ChunkKind::Stable, ChunkKind::Ours]);
615        assert_eq!(result.theirs_changes, 0);
616    }
617
618    #[test]
619    fn line_numbers_track_each_side() {
620        // ours 在 a 后插入两行,theirs 修改 c;两处改动互不碰撞
621        let result = merge_text("a\nb\nc\n", "a\nx\ny\nb\nc\n", "a\nb\nC\n");
622        assert_eq!(
623            kinds(&result),
624            vec![
625                ChunkKind::Stable,
626                ChunkKind::Ours,
627                ChunkKind::Stable,
628                ChunkKind::Theirs
629            ]
630        );
631        let theirs_chunk = &result.chunks[3];
632        assert_eq!(theirs_chunk.base_start, 3);
633        assert_eq!(theirs_chunk.ours_start, 5);
634        assert_eq!(theirs_chunk.theirs_start, 3);
635    }
636
637    #[test]
638    fn crlf_lines_are_clean() {
639        let result = merge_text("a\r\nb\r\n", "a\r\nB\r\n", "a\r\nb\r\n");
640        let all_lines = result
641            .chunks
642            .iter()
643            .flat_map(|c| c.base.iter().chain(c.ours.iter()).chain(c.theirs.iter()));
644        for line in all_lines {
645            assert!(!line.contains('\r'));
646        }
647    }
648
649    // —— 冲突文件解析 ——
650
651    #[test]
652    fn parses_standard_conflict_markers() {
653        let text = "a\n<<<<<<< HEAD\nx\n=======\ny\n>>>>>>> feature/demo\nb\n";
654        let result = parse_conflict_file(text).unwrap();
655        assert_eq!(
656            kinds(&result),
657            vec![ChunkKind::Stable, ChunkKind::Conflict, ChunkKind::Stable]
658        );
659        assert_eq!(result.conflicts, 1);
660        assert_eq!(result.ours_label.as_deref(), Some("HEAD"));
661        assert_eq!(result.theirs_label.as_deref(), Some("feature/demo"));
662
663        let conflict = &result.chunks[1];
664        assert_eq!(conflict.ours, vec!["x"]);
665        assert_eq!(conflict.theirs, vec!["y"]);
666        assert!(conflict.base.is_empty());
667        assert_eq!(conflict.ours_start, 2);
668        assert_eq!(conflict.theirs_start, 2);
669    }
670
671    #[test]
672    fn parses_diff3_style_base_section() {
673        let text =
674            "<<<<<<< HEAD\nx\n||||||| merged common ancestors\no\n=======\ny\n>>>>>>> main\n";
675        let result = parse_conflict_file(text).unwrap();
676        let conflict = &result.chunks[0];
677        assert_eq!(conflict.kind, ChunkKind::Conflict);
678        assert_eq!(conflict.base, vec!["o"]);
679        assert_eq!(conflict.ours, vec!["x"]);
680        assert_eq!(conflict.theirs, vec!["y"]);
681    }
682
683    #[test]
684    fn parses_multiple_conflicts() {
685        let text = "a\n<<<<<<<\nx\n=======\ny\n>>>>>>>\nb\n<<<<<<<\np\n=======\nq\n>>>>>>>\n";
686        let result = parse_conflict_file(text).unwrap();
687        assert_eq!(result.conflicts, 2);
688        assert!(result.ours_label.is_none());
689        // 第二个冲突的行号应累计前面所有内容
690        let second = &result.chunks[3];
691        assert_eq!(second.ours_start, 4); // a, x, b 之后
692        assert_eq!(second.theirs_start, 4); // a, y, b 之后
693    }
694
695    #[test]
696    fn conflict_file_without_markers_errors() {
697        assert!(matches!(
698            parse_conflict_file("hello\nworld\n"),
699            Err(ConflictParseError::NoMarkers)
700        ));
701    }
702
703    #[test]
704    fn unterminated_conflict_errors() {
705        assert!(matches!(
706            parse_conflict_file("a\n<<<<<<< HEAD\nx\n=======\ny\n"),
707            Err(ConflictParseError::Malformed(2))
708        ));
709    }
710
711    #[test]
712    fn nested_marker_errors() {
713        assert!(matches!(
714            parse_conflict_file("<<<<<<< HEAD\n<<<<<<< again\n"),
715            Err(ConflictParseError::Malformed(2))
716        ));
717    }
718
719    #[test]
720    fn separator_outside_conflict_is_content() {
721        // 公共区域里恰好出现 ======= 行(如 RST 标题)不应被当作标记
722        let text = "title\n=======\n<<<<<<<\nx\n=======\ny\n>>>>>>>\n";
723        let result = parse_conflict_file(text).unwrap();
724        assert_eq!(result.chunks[0].base, vec!["title", "======="]);
725        assert_eq!(result.conflicts, 1);
726    }
727
728    #[test]
729    fn conflict_file_with_crlf_is_clean() {
730        let text = "a\r\n<<<<<<< HEAD\r\nx\r\n=======\r\ny\r\n>>>>>>> main\r\n";
731        let result = parse_conflict_file(text).unwrap();
732        let conflict = &result.chunks[1];
733        assert_eq!(conflict.ours, vec!["x"]);
734        assert_eq!(conflict.theirs, vec!["y"]);
735    }
736}