1use crate::merge::{ChunkKind, MergeChunk, MergeResult, merge_text};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Side {
14 Ours,
16 Theirs,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum SideState {
23 Pending,
25 Applied,
27 Ignored,
29}
30
31#[derive(Debug, Clone)]
33pub struct ChunkState {
34 pub ours: SideState,
36 pub theirs: SideState,
38 pub order: Vec<Side>,
40 pub override_lines: Option<Vec<String>>,
42}
43
44impl ChunkState {
45 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 fn side(&self, side: Side) -> SideState {
57 match side {
58 Side::Ours => self.ours,
59 Side::Theirs => self.theirs,
60 }
61 }
62
63 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
72fn 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#[derive(Debug)]
84pub struct FileMerge {
85 pub path: String,
87 pub chunks: Vec<MergeChunk>,
89 pub states: Vec<ChunkState>,
91 pub cursor: usize,
93 pub scroll: usize,
95 pub ours_label: Option<String>,
97 pub theirs_label: Option<String>,
99 ends_with_newline: bool,
101}
102
103impl FileMerge {
104 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 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 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 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 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 pub fn ready_to_write(&self) -> bool {
159 self.pending_conflicts() == 0
160 }
161
162 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 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 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 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 pub fn undo(&mut self) {
243 self.states[self.cursor] = ChunkState::new();
244 }
245
246 pub fn undo_all(&mut self) {
248 for state in &mut self.states {
249 *state = ChunkState::new();
250 }
251 }
252
253 pub fn set_override(&mut self, lines: Vec<String>) {
255 self.states[self.cursor].override_lines = Some(lines);
256 }
257
258 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 if let Some(&side) = effective_sides(kind).first() {
269 self.apply(side);
270 }
271 }
272 self.cursor = saved;
273 }
274
275 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 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 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 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#[derive(Debug)]
321pub enum FileEntry {
322 Text(FileMerge),
324 Binary {
326 path: String,
328 ours: Vec<u8>,
330 theirs: Vec<u8>,
332 choice: Option<Side>,
334 },
335}
336
337impl FileEntry {
338 pub fn path(&self) -> &str {
340 match self {
341 FileEntry::Text(m) => &m.path,
342 FileEntry::Binary { path, .. } => path,
343 }
344 }
345
346 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 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#[derive(Debug)]
373pub struct Session {
374 pub files: Vec<FileEntry>,
376 pub current: usize,
378 pub written: Vec<bool>,
380 pub op_label: String,
382 pub folded: bool,
384}
385
386impl Session {
387 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 pub fn current_file(&self) -> &FileEntry {
401 &self.files[self.current]
402 }
403
404 pub fn current_file_mut(&mut self) -> &mut FileEntry {
406 &mut self.files[self.current]
407 }
408
409 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 pub fn all_written(&self) -> bool {
419 self.written.iter().all(|&w| w)
420 }
421
422 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 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 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)); 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 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; 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 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); 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 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}