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 follow: bool,
97 pub ours_label: Option<String>,
99 pub theirs_label: Option<String>,
101 ends_with_newline: bool,
103}
104
105impl FileMerge {
106 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 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 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 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 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 pub fn ready_to_write(&self) -> bool {
162 self.pending_conflicts() == 0
163 }
164
165 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 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 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 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 pub fn undo(&mut self) {
246 self.states[self.cursor] = ChunkState::new();
247 }
248
249 pub fn undo_all(&mut self) {
251 for state in &mut self.states {
252 *state = ChunkState::new();
253 }
254 }
255
256 pub fn set_override(&mut self, lines: Vec<String>) {
258 self.states[self.cursor].override_lines = Some(lines);
259 }
260
261 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 if let Some(&side) = effective_sides(kind).first() {
272 self.apply(side);
273 }
274 }
275 self.cursor = saved;
276 }
277
278 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 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 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 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#[derive(Debug)]
324pub enum FileEntry {
325 Text(FileMerge),
327 Binary {
329 path: String,
331 ours: Vec<u8>,
333 theirs: Vec<u8>,
335 choice: Option<Side>,
337 },
338}
339
340impl FileEntry {
341 pub fn path(&self) -> &str {
343 match self {
344 FileEntry::Text(m) => &m.path,
345 FileEntry::Binary { path, .. } => path,
346 }
347 }
348
349 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 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#[derive(Debug)]
376pub struct Session {
377 pub files: Vec<FileEntry>,
379 pub current: usize,
381 pub written: Vec<bool>,
383 pub op_label: String,
385 pub folded: bool,
387}
388
389impl Session {
390 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 pub fn current_file(&self) -> &FileEntry {
404 &self.files[self.current]
405 }
406
407 pub fn current_file_mut(&mut self) -> &mut FileEntry {
409 &mut self.files[self.current]
410 }
411
412 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 pub fn all_written(&self) -> bool {
422 self.written.iter().all(|&w| w)
423 }
424
425 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 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 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)); 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 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; 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 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); 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 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}