Skip to main content

git_pincer/
app.rs

1//! State machine of a conflict-resolution session (pure logic; touches neither the terminal nor git).
2//!
3//! The model mirrors the frontend semantics of the toolkit-rs web version:
4//! each side of a change chunk is in one of three states (pending / applied /
5//! ignored), applied content is appended in take order (taking both sides of a
6//! conflict one after another means "keep both"), and editing via `$EDITOR`
7//! overrides the whole chunk.
8
9use crate::merge::{ChunkKind, MergeChunk, MergeResult, merge_text};
10
11/// 取用方向(改动来源侧)。
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Side {
14    /// 本地(左栏)
15    Ours,
16    /// 远端(右栏)
17    Theirs,
18}
19
20/// 一侧改动的处理状态。
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum SideState {
23    /// 待处理
24    Pending,
25    /// 已取用进结果
26    Applied,
27    /// 已忽略(不进入结果)
28    Ignored,
29}
30
31/// 单个块的解决状态。
32#[derive(Debug, Clone)]
33pub struct ChunkState {
34    /// 本地侧状态
35    pub ours: SideState,
36    /// 远端侧状态
37    pub theirs: SideState,
38    /// 取用顺序,决定内容拼接顺序
39    pub order: Vec<Side>,
40    /// $EDITOR 手动编辑后的整块覆写内容
41    pub override_lines: Option<Vec<String>>,
42}
43
44impl ChunkState {
45    /// 初始待处理状态。
46    fn new() -> Self {
47        Self {
48            ours: SideState::Pending,
49            theirs: SideState::Pending,
50            order: Vec::new(),
51            override_lines: None,
52        }
53    }
54
55    /// 读取某一侧的状态。
56    fn side(&self, side: Side) -> SideState {
57        match side {
58            Side::Ours => self.ours,
59            Side::Theirs => self.theirs,
60        }
61    }
62
63    /// 写入某一侧的状态。
64    fn set_side(&mut self, side: Side, state: SideState) {
65        match side {
66            Side::Ours => self.ours = state,
67            Side::Theirs => self.theirs = state,
68        }
69    }
70}
71
72/// 块上有待处理改动的侧:单侧块只有对应一侧,agree / conflict 两侧都有。
73fn effective_sides(kind: ChunkKind) -> &'static [Side] {
74    match kind {
75        ChunkKind::Ours => &[Side::Ours],
76        ChunkKind::Theirs => &[Side::Theirs],
77        ChunkKind::Agree | ChunkKind::Conflict => &[Side::Ours, Side::Theirs],
78        ChunkKind::Stable => &[],
79    }
80}
81
82/// 一个文本文件的合并会话:块列表 + 各块解决状态 + 光标。
83#[derive(Debug)]
84pub struct FileMerge {
85    /// 文件路径(git 模式为仓库根相对;单文件模式为输入路径)
86    pub path: String,
87    /// 合并块,首尾相接覆盖整个文件
88    pub chunks: Vec<MergeChunk>,
89    /// 与 chunks 一一对应的解决状态
90    pub states: Vec<ChunkState>,
91    /// 光标所在块下标(始终指向非 Stable 块;无改动块时为 0)
92    pub cursor: usize,
93    /// 视口滚动行偏移(由 UI 维护)
94    pub scroll: usize,
95    /// 本地侧栏头标签(分支名)
96    pub ours_label: Option<String>,
97    /// 远端侧栏头标签(分支名)
98    pub theirs_label: Option<String>,
99    /// 任一原始输入以换行结尾时,导出内容补末尾换行
100    ends_with_newline: bool,
101}
102
103impl FileMerge {
104    /// 由三份完整文本构建(git 三方模式)。
105    pub fn from_three_way(path: String, base: &str, ours: &str, theirs: &str) -> Self {
106        let ends = base.ends_with('\n') || ours.ends_with('\n') || theirs.ends_with('\n');
107        Self::from_result(path, merge_text(base, ours, theirs), ends)
108    }
109
110    /// 由已计算好的合并结果构建(冲突文件解析模式)。
111    pub fn from_result(path: String, result: MergeResult, ends_with_newline: bool) -> Self {
112        let states = result.chunks.iter().map(|_| ChunkState::new()).collect();
113        let cursor = result
114            .chunks
115            .iter()
116            .position(|c| c.kind != ChunkKind::Stable)
117            .unwrap_or(0);
118        Self {
119            path,
120            states,
121            cursor,
122            scroll: 0,
123            ours_label: result.ours_label,
124            theirs_label: result.theirs_label,
125            chunks: result.chunks,
126            ends_with_newline,
127        }
128    }
129
130    // —— 查询 ——
131
132    /// 某块是否已处理完毕(被覆写,或所有有效侧均非待处理)。
133    pub fn chunk_resolved(&self, idx: usize) -> bool {
134        let state = &self.states[idx];
135        if state.override_lines.is_some() {
136            return true;
137        }
138        effective_sides(self.chunks[idx].kind)
139            .iter()
140            .all(|&side| state.side(side) != SideState::Pending)
141    }
142
143    /// 未处理完的冲突块数量(写盘的先决条件是归零)。
144    pub fn pending_conflicts(&self) -> usize {
145        (0..self.chunks.len())
146            .filter(|&i| self.chunks[i].kind == ChunkKind::Conflict && !self.chunk_resolved(i))
147            .count()
148    }
149
150    /// 未处理完的改动块总数(含非冲突,状态条展示用)。
151    pub fn pending_changes(&self) -> usize {
152        (0..self.chunks.len())
153            .filter(|&i| self.chunks[i].kind != ChunkKind::Stable && !self.chunk_resolved(i))
154            .count()
155    }
156
157    /// 文件是否可写盘:所有冲突块已解决(非冲突块保持待处理 = 保留 base)。
158    pub fn ready_to_write(&self) -> bool {
159        self.pending_conflicts() == 0
160    }
161
162    /// 某块当前在结果中的内容:覆写 > 按取用顺序拼接 > base。
163    pub fn current_content(&self, idx: usize) -> Vec<String> {
164        let chunk = &self.chunks[idx];
165        let state = &self.states[idx];
166        if let Some(lines) = &state.override_lines {
167            return lines.clone();
168        }
169        if chunk.kind == ChunkKind::Stable {
170            return chunk.base.clone();
171        }
172        if state.order.is_empty() {
173            return chunk.base.clone();
174        }
175        state
176            .order
177            .iter()
178            .flat_map(|side| match side {
179                Side::Ours => chunk.ours.clone(),
180                Side::Theirs => chunk.theirs.clone(),
181            })
182            .collect()
183    }
184
185    /// 导出整个文件的最终内容(按块拼接 + 末尾换行策略)。
186    pub fn resolved_content(&self) -> String {
187        let lines: Vec<String> = (0..self.chunks.len())
188            .flat_map(|i| self.current_content(i))
189            .collect();
190        let text = lines.join("\n");
191        if self.ends_with_newline && !text.is_empty() {
192            format!("{text}\n")
193        } else {
194            text
195        }
196    }
197
198    // —— 操作(作用于光标所在块)——
199
200    /// 取用某一侧:内容按点击顺序追加(冲突两侧先后取用 =「两者都要」);
201    /// agree 块两侧内容一致,取任一侧即整块完成。
202    pub fn apply(&mut self, side: Side) {
203        let idx = self.cursor;
204        let kind = self.chunks[idx].kind;
205        if !effective_sides(kind).contains(&side) {
206            return;
207        }
208        let state = &mut self.states[idx];
209        if state.override_lines.is_some() || state.side(side) != SideState::Pending {
210            return;
211        }
212        if kind == ChunkKind::Agree {
213            state.ours = SideState::Applied;
214            state.theirs = SideState::Applied;
215            state.order = vec![Side::Ours];
216        } else {
217            state.set_side(side, SideState::Applied);
218            state.order.push(side);
219        }
220    }
221
222    /// 忽略某一侧(该侧改动不进入结果);agree 块忽略即整块保留 base。
223    pub fn ignore(&mut self, side: Side) {
224        let idx = self.cursor;
225        let kind = self.chunks[idx].kind;
226        if !effective_sides(kind).contains(&side) {
227            return;
228        }
229        let state = &mut self.states[idx];
230        if state.override_lines.is_some() || state.side(side) != SideState::Pending {
231            return;
232        }
233        if kind == ChunkKind::Agree {
234            state.ours = SideState::Ignored;
235            state.theirs = SideState::Ignored;
236        } else {
237            state.set_side(side, SideState::Ignored);
238        }
239    }
240
241    /// 撤销当前块的全部决定,回到待处理。
242    pub fn undo(&mut self) {
243        self.states[self.cursor] = ChunkState::new();
244    }
245
246    /// 撤销本文件所有块的决定(含 $EDITOR 覆写),全部回到待处理。
247    pub fn undo_all(&mut self) {
248        for state in &mut self.states {
249            *state = ChunkState::new();
250        }
251    }
252
253    /// 用 $EDITOR 编辑后的内容覆写当前块(视为已解决)。
254    pub fn set_override(&mut self, lines: Vec<String>) {
255        self.states[self.cursor].override_lines = Some(lines);
256    }
257
258    /// 一键取用所有非冲突改动(ours/theirs/agree)。
259    pub fn apply_all_nonconflict(&mut self) {
260        let saved = self.cursor;
261        for idx in 0..self.chunks.len() {
262            let kind = self.chunks[idx].kind;
263            if matches!(kind, ChunkKind::Stable | ChunkKind::Conflict) || self.chunk_resolved(idx) {
264                continue;
265            }
266            self.cursor = idx;
267            // 有效侧的第一个即该块的改动来源(agree 取任一侧等价)
268            if let Some(&side) = effective_sides(kind).first() {
269                self.apply(side);
270            }
271        }
272        self.cursor = saved;
273    }
274
275    // —— 光标移动 ——
276
277    /// 移到下一个改动块(跳过 Stable)。
278    pub fn next_change(&mut self) {
279        if let Some(idx) =
280            (self.cursor + 1..self.chunks.len()).find(|&i| self.chunks[i].kind != ChunkKind::Stable)
281        {
282            self.cursor = idx;
283        }
284    }
285
286    /// 移到上一个改动块(跳过 Stable)。
287    pub fn prev_change(&mut self) {
288        if let Some(idx) = (0..self.cursor)
289            .rev()
290            .find(|&i| self.chunks[i].kind != ChunkKind::Stable)
291        {
292            self.cursor = idx;
293        }
294    }
295
296    /// 跳到下一个未解决的冲突块(到底后从头环绕)。
297    pub fn next_conflict(&mut self) {
298        let n = self.chunks.len();
299        if let Some(idx) = (1..=n)
300            .map(|step| (self.cursor + step) % n)
301            .find(|&i| self.chunks[i].kind == ChunkKind::Conflict && !self.chunk_resolved(i))
302        {
303            self.cursor = idx;
304        }
305    }
306
307    /// 跳到上一个未解决的冲突块(到头后从尾环绕)。
308    pub fn prev_conflict(&mut self) {
309        let n = self.chunks.len();
310        if let Some(idx) = (1..=n)
311            .map(|step| (self.cursor + n - step) % n)
312            .find(|&i| self.chunks[i].kind == ChunkKind::Conflict && !self.chunk_resolved(i))
313        {
314            self.cursor = idx;
315        }
316    }
317}
318
319/// 会话中的单个待解决文件。
320#[derive(Debug)]
321pub enum FileEntry {
322    /// 文本文件:三栏逐块解决
323    Text(FileMerge),
324    /// 二进制文件:降级为整文件二选一
325    Binary {
326        /// 文件路径
327        path: String,
328        /// 本地侧完整字节
329        ours: Vec<u8>,
330        /// 远端侧完整字节
331        theirs: Vec<u8>,
332        /// 用户的选择
333        choice: Option<Side>,
334    },
335}
336
337impl FileEntry {
338    /// 文件路径。
339    pub fn path(&self) -> &str {
340        match self {
341            FileEntry::Text(m) => &m.path,
342            FileEntry::Binary { path, .. } => path,
343        }
344    }
345
346    /// 是否满足写盘条件。
347    pub fn ready_to_write(&self) -> bool {
348        match self {
349            FileEntry::Text(m) => m.ready_to_write(),
350            FileEntry::Binary { choice, .. } => choice.is_some(),
351        }
352    }
353
354    /// 导出最终字节内容(前提:ready_to_write)。
355    pub fn resolved_bytes(&self) -> Vec<u8> {
356        match self {
357            FileEntry::Text(m) => m.resolved_content().into_bytes(),
358            FileEntry::Binary {
359                ours,
360                theirs,
361                choice,
362                ..
363            } => match choice {
364                Some(Side::Theirs) => theirs.clone(),
365                _ => ours.clone(),
366            },
367        }
368    }
369}
370
371/// 多文件冲突解决会话。
372#[derive(Debug)]
373pub struct Session {
374    /// 全部待解决文件
375    pub files: Vec<FileEntry>,
376    /// 当前文件下标
377    pub current: usize,
378    /// 各文件是否已写盘
379    pub written: Vec<bool>,
380    /// 状态条展示的操作名(如 "merge" / "rebase" / 文件名)
381    pub op_label: String,
382    /// 是否折叠长稳定区(z 切换)
383    pub folded: bool,
384}
385
386impl Session {
387    /// 构建会话,光标落在第一个文件。
388    pub fn new(files: Vec<FileEntry>, op_label: String) -> Self {
389        let written = files.iter().map(|_| false).collect();
390        Self {
391            files,
392            current: 0,
393            written,
394            op_label,
395            folded: true,
396        }
397    }
398
399    /// 当前文件。
400    pub fn current_file(&self) -> &FileEntry {
401        &self.files[self.current]
402    }
403
404    /// 当前文件(可变)。
405    pub fn current_file_mut(&mut self) -> &mut FileEntry {
406        &mut self.files[self.current]
407    }
408
409    /// 标记当前文件已写盘,并把光标移到下一个未写盘文件(若有)。
410    pub fn mark_written(&mut self) {
411        self.written[self.current] = true;
412        if let Some(idx) = (0..self.files.len()).find(|&i| !self.written[i]) {
413            self.current = idx;
414        }
415    }
416
417    /// 是否所有文件都已写盘。
418    pub fn all_written(&self) -> bool {
419        self.written.iter().all(|&w| w)
420    }
421
422    /// 切换到下一个文件(环绕)。
423    pub fn next_file(&mut self) {
424        if !self.files.is_empty() {
425            self.current = (self.current + 1) % self.files.len();
426        }
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    /// 构造一个含 [Stable, Conflict, Stable, Ours] 的会话文件
435    fn sample() -> FileMerge {
436        FileMerge::from_three_way(
437            "demo.txt".to_owned(),
438            "a\nb\nc\nd\n",
439            "a\nX\nc\nD\n",
440            "a\nY\nc\nd\n",
441        )
442    }
443
444    #[test]
445    fn sample_shape_is_expected() {
446        let merge = sample();
447        let kinds: Vec<ChunkKind> = merge.chunks.iter().map(|c| c.kind).collect();
448        assert_eq!(
449            kinds,
450            vec![
451                ChunkKind::Stable,
452                ChunkKind::Conflict,
453                ChunkKind::Stable,
454                ChunkKind::Ours
455            ]
456        );
457        // 光标初始落在第一个改动块
458        assert_eq!(merge.cursor, 1);
459    }
460
461    #[test]
462    fn apply_ours_resolves_conflict_side() {
463        let mut merge = sample();
464        merge.apply(Side::Ours);
465        assert!(!merge.chunk_resolved(1)); // theirs 侧仍待处理
466        merge.ignore(Side::Theirs);
467        assert!(merge.chunk_resolved(1));
468        assert_eq!(merge.current_content(1), vec!["X"]);
469    }
470
471    #[test]
472    fn apply_both_sides_appends_in_order() {
473        let mut merge = sample();
474        merge.apply(Side::Theirs);
475        merge.apply(Side::Ours);
476        assert_eq!(merge.current_content(1), vec!["Y", "X"]);
477        assert!(merge.chunk_resolved(1));
478    }
479
480    #[test]
481    fn ignore_both_keeps_base() {
482        let mut merge = sample();
483        merge.ignore(Side::Ours);
484        merge.ignore(Side::Theirs);
485        assert_eq!(merge.current_content(1), vec!["b"]);
486        assert!(merge.chunk_resolved(1));
487    }
488
489    #[test]
490    fn undo_restores_pending() {
491        let mut merge = sample();
492        merge.apply(Side::Ours);
493        merge.undo();
494        assert!(!merge.chunk_resolved(1));
495        assert_eq!(merge.current_content(1), vec!["b"]);
496    }
497
498    #[test]
499    fn undo_all_resets_every_chunk() {
500        let mut merge = sample();
501        merge.apply(Side::Ours);
502        merge.ignore(Side::Theirs);
503        merge.cursor = 3;
504        merge.apply(Side::Ours);
505        merge.set_override(vec!["edited".to_owned()]);
506        assert_eq!(merge.pending_changes(), 0);
507        merge.undo_all();
508        assert_eq!(merge.pending_changes(), 2);
509        assert!(!merge.chunk_resolved(1));
510        assert!(!merge.chunk_resolved(3));
511        assert_eq!(merge.current_content(3), vec!["d"]);
512    }
513
514    #[test]
515    fn override_wins_and_resolves() {
516        let mut merge = sample();
517        merge.set_override(vec!["merged".to_owned()]);
518        assert!(merge.chunk_resolved(1));
519        assert_eq!(merge.current_content(1), vec!["merged"]);
520        // 覆写后取用不再生效
521        merge.apply(Side::Ours);
522        assert_eq!(merge.current_content(1), vec!["merged"]);
523    }
524
525    #[test]
526    fn apply_on_ineffective_side_is_noop() {
527        let mut merge = sample();
528        merge.cursor = 3; // Ours 块
529        merge.apply(Side::Theirs);
530        assert!(!merge.chunk_resolved(3));
531        merge.apply(Side::Ours);
532        assert!(merge.chunk_resolved(3));
533        assert_eq!(merge.current_content(3), vec!["D"]);
534    }
535
536    #[test]
537    fn apply_all_nonconflict_skips_conflicts() {
538        let mut merge = sample();
539        merge.apply_all_nonconflict();
540        assert!(merge.chunk_resolved(3));
541        assert!(!merge.chunk_resolved(1));
542        assert_eq!(merge.pending_conflicts(), 1);
543    }
544
545    #[test]
546    fn resolved_content_joins_chunks_with_newline() {
547        let mut merge = sample();
548        merge.apply(Side::Ours);
549        merge.ignore(Side::Theirs);
550        merge.cursor = 3;
551        merge.apply(Side::Ours);
552        assert!(merge.ready_to_write());
553        assert_eq!(merge.resolved_content(), "a\nX\nc\nD\n");
554    }
555
556    #[test]
557    fn conflict_navigation_wraps_and_skips_resolved() {
558        let mut merge = FileMerge::from_three_way(
559            "demo.txt".to_owned(),
560            "a\nb\nc\nd\ne\n",
561            "a\nX\nc\nY\ne\n",
562            "a\nP\nc\nQ\ne\n",
563        );
564        // 两个冲突:块 1 与块 3
565        assert_eq!(merge.pending_conflicts(), 2);
566        assert_eq!(merge.cursor, 1);
567        merge.next_conflict();
568        assert_eq!(merge.cursor, 3);
569        merge.next_conflict();
570        assert_eq!(merge.cursor, 1); // 环绕
571        // 解决块 1 后,导航只在剩余冲突上
572        merge.apply(Side::Ours);
573        merge.ignore(Side::Theirs);
574        merge.next_conflict();
575        assert_eq!(merge.cursor, 3);
576        merge.next_conflict();
577        assert_eq!(merge.cursor, 3);
578    }
579
580    #[test]
581    fn agree_single_apply_resolves_both_sides() {
582        let mut merge =
583            FileMerge::from_three_way("demo.txt".to_owned(), "a\nb\n", "a\nB\n", "a\nB\n");
584        assert_eq!(merge.chunks[merge.cursor].kind, ChunkKind::Agree);
585        merge.apply(Side::Theirs);
586        assert!(merge.chunk_resolved(merge.cursor));
587        assert_eq!(merge.current_content(merge.cursor), vec!["B"]);
588    }
589
590    #[test]
591    fn session_marks_written_and_advances() {
592        let files = vec![
593            FileEntry::Text(sample()),
594            FileEntry::Binary {
595                path: "logo.png".to_owned(),
596                ours: vec![1, 2],
597                theirs: vec![3, 4],
598                choice: None,
599            },
600        ];
601        let mut session = Session::new(files, "merge".to_owned());
602        assert!(!session.all_written());
603        session.mark_written();
604        assert_eq!(session.current, 1);
605        // 二进制文件选边后可写
606        if let FileEntry::Binary { choice, .. } = session.current_file_mut() {
607            *choice = Some(Side::Theirs);
608        }
609        assert!(session.current_file().ready_to_write());
610        assert_eq!(session.current_file().resolved_bytes(), vec![3, 4]);
611        session.mark_written();
612        assert!(session.all_written());
613    }
614}