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