use crate::merge::{ChunkKind, MergeChunk, MergeResult, merge_text};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
Ours,
Theirs,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SideState {
Pending,
Applied,
Ignored,
}
#[derive(Debug, Clone)]
pub struct ChunkState {
pub ours: SideState,
pub theirs: SideState,
pub order: Vec<Side>,
pub override_lines: Option<Vec<String>>,
}
impl ChunkState {
fn new() -> Self {
Self {
ours: SideState::Pending,
theirs: SideState::Pending,
order: Vec::new(),
override_lines: None,
}
}
fn side(&self, side: Side) -> SideState {
match side {
Side::Ours => self.ours,
Side::Theirs => self.theirs,
}
}
fn set_side(&mut self, side: Side, state: SideState) {
match side {
Side::Ours => self.ours = state,
Side::Theirs => self.theirs = state,
}
}
}
fn effective_sides(kind: ChunkKind) -> &'static [Side] {
match kind {
ChunkKind::Ours => &[Side::Ours],
ChunkKind::Theirs => &[Side::Theirs],
ChunkKind::Agree | ChunkKind::Conflict => &[Side::Ours, Side::Theirs],
ChunkKind::Stable => &[],
}
}
#[derive(Debug)]
pub struct FileMerge {
pub path: String,
pub chunks: Vec<MergeChunk>,
pub states: Vec<ChunkState>,
pub cursor: usize,
pub scroll: usize,
pub follow: bool,
pub ours_label: Option<String>,
pub theirs_label: Option<String>,
ends_with_newline: bool,
}
impl FileMerge {
pub fn from_three_way(path: String, base: &str, ours: &str, theirs: &str) -> Self {
let ends = base.ends_with('\n') || ours.ends_with('\n') || theirs.ends_with('\n');
Self::from_result(path, merge_text(base, ours, theirs), ends)
}
pub fn from_result(path: String, result: MergeResult, ends_with_newline: bool) -> Self {
let states = result.chunks.iter().map(|_| ChunkState::new()).collect();
let cursor = result
.chunks
.iter()
.position(|c| c.kind != ChunkKind::Stable)
.unwrap_or(0);
Self {
path,
states,
cursor,
scroll: 0,
follow: true,
ours_label: result.ours_label,
theirs_label: result.theirs_label,
chunks: result.chunks,
ends_with_newline,
}
}
pub fn chunk_resolved(&self, idx: usize) -> bool {
let state = &self.states[idx];
if state.override_lines.is_some() {
return true;
}
effective_sides(self.chunks[idx].kind)
.iter()
.all(|&side| state.side(side) != SideState::Pending)
}
pub fn pending_conflicts(&self) -> usize {
(0..self.chunks.len())
.filter(|&i| self.chunks[i].kind == ChunkKind::Conflict && !self.chunk_resolved(i))
.count()
}
pub fn pending_changes(&self) -> usize {
(0..self.chunks.len())
.filter(|&i| self.chunks[i].kind != ChunkKind::Stable && !self.chunk_resolved(i))
.count()
}
pub fn ready_to_write(&self) -> bool {
self.pending_conflicts() == 0
}
pub fn current_content(&self, idx: usize) -> Vec<String> {
let chunk = &self.chunks[idx];
let state = &self.states[idx];
if let Some(lines) = &state.override_lines {
return lines.clone();
}
if chunk.kind == ChunkKind::Stable {
return chunk.base.clone();
}
if state.order.is_empty() {
return chunk.base.clone();
}
state
.order
.iter()
.flat_map(|side| match side {
Side::Ours => chunk.ours_lines().to_vec(),
Side::Theirs => chunk.theirs_lines().to_vec(),
})
.collect()
}
pub fn resolved_content(&self) -> String {
let lines: Vec<String> = (0..self.chunks.len())
.flat_map(|i| self.current_content(i))
.collect();
let text = lines.join("\n");
if self.ends_with_newline && !text.is_empty() {
format!("{text}\n")
} else {
text
}
}
pub fn apply(&mut self, side: Side) {
let idx = self.cursor;
let kind = self.chunks[idx].kind;
if !effective_sides(kind).contains(&side) {
return;
}
let state = &mut self.states[idx];
if state.override_lines.is_some() || state.side(side) != SideState::Pending {
return;
}
if kind == ChunkKind::Agree {
state.ours = SideState::Applied;
state.theirs = SideState::Applied;
state.order = vec![Side::Ours];
} else {
state.set_side(side, SideState::Applied);
state.order.push(side);
}
}
pub fn ignore(&mut self, side: Side) {
let idx = self.cursor;
let kind = self.chunks[idx].kind;
if !effective_sides(kind).contains(&side) {
return;
}
let state = &mut self.states[idx];
if state.override_lines.is_some() || state.side(side) != SideState::Pending {
return;
}
if kind == ChunkKind::Agree {
state.ours = SideState::Ignored;
state.theirs = SideState::Ignored;
} else {
state.set_side(side, SideState::Ignored);
}
}
pub fn undo(&mut self) {
self.states[self.cursor] = ChunkState::new();
}
pub fn undo_all(&mut self) {
for state in &mut self.states {
*state = ChunkState::new();
}
}
pub fn set_override(&mut self, lines: Vec<String>) {
self.states[self.cursor].override_lines = Some(lines);
}
pub fn apply_all_nonconflict(&mut self) {
let saved = self.cursor;
for idx in 0..self.chunks.len() {
let kind = self.chunks[idx].kind;
if matches!(kind, ChunkKind::Stable | ChunkKind::Conflict) || self.chunk_resolved(idx) {
continue;
}
self.cursor = idx;
if let Some(&side) = effective_sides(kind).first() {
self.apply(side);
}
}
self.cursor = saved;
}
pub fn next_change(&mut self) {
if let Some(idx) =
(self.cursor + 1..self.chunks.len()).find(|&i| self.chunks[i].kind != ChunkKind::Stable)
{
self.cursor = idx;
}
}
pub fn prev_change(&mut self) {
if let Some(idx) = (0..self.cursor)
.rev()
.find(|&i| self.chunks[i].kind != ChunkKind::Stable)
{
self.cursor = idx;
}
}
pub fn next_conflict(&mut self) {
let n = self.chunks.len();
if let Some(idx) = (1..=n)
.map(|step| (self.cursor + step) % n)
.find(|&i| self.chunks[i].kind == ChunkKind::Conflict && !self.chunk_resolved(i))
{
self.cursor = idx;
}
}
pub fn prev_conflict(&mut self) {
let n = self.chunks.len();
if let Some(idx) = (1..=n)
.map(|step| (self.cursor + n - step) % n)
.find(|&i| self.chunks[i].kind == ChunkKind::Conflict && !self.chunk_resolved(i))
{
self.cursor = idx;
}
}
}
#[derive(Debug)]
pub enum FileEntry {
Text(FileMerge),
Binary {
path: String,
ours: Vec<u8>,
theirs: Vec<u8>,
choice: Option<Side>,
},
}
impl FileEntry {
pub fn path(&self) -> &str {
match self {
FileEntry::Text(m) => &m.path,
FileEntry::Binary { path, .. } => path,
}
}
pub fn ready_to_write(&self) -> bool {
match self {
FileEntry::Text(m) => m.ready_to_write(),
FileEntry::Binary { choice, .. } => choice.is_some(),
}
}
pub fn resolved_bytes(&self) -> Vec<u8> {
match self {
FileEntry::Text(m) => m.resolved_content().into_bytes(),
FileEntry::Binary {
ours,
theirs,
choice,
..
} => match choice {
Some(Side::Theirs) => theirs.clone(),
_ => ours.clone(),
},
}
}
}
#[derive(Debug)]
pub struct Session {
pub files: Vec<FileEntry>,
pub current: usize,
pub written: Vec<bool>,
pub op_label: String,
pub folded: bool,
}
impl Session {
pub fn new(files: Vec<FileEntry>, op_label: String) -> Self {
let written = files.iter().map(|_| false).collect();
Self {
files,
current: 0,
written,
op_label,
folded: true,
}
}
pub fn current_file(&self) -> &FileEntry {
&self.files[self.current]
}
pub fn current_file_mut(&mut self) -> &mut FileEntry {
&mut self.files[self.current]
}
pub fn mark_written(&mut self) {
self.written[self.current] = true;
if let Some(idx) = (0..self.files.len()).find(|&i| !self.written[i]) {
self.current = idx;
}
}
pub fn all_written(&self) -> bool {
self.written.iter().all(|&w| w)
}
pub fn next_file(&mut self) {
if !self.files.is_empty() {
self.current = (self.current + 1) % self.files.len();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> FileMerge {
FileMerge::from_three_way(
"demo.txt".to_owned(),
"a\nb\nc\nd\n",
"a\nX\nc\nD\n",
"a\nY\nc\nd\n",
)
}
#[test]
fn sample_shape_is_expected() {
let merge = sample();
let kinds: Vec<ChunkKind> = merge.chunks.iter().map(|c| c.kind).collect();
assert_eq!(
kinds,
vec![
ChunkKind::Stable,
ChunkKind::Conflict,
ChunkKind::Stable,
ChunkKind::Ours
]
);
assert_eq!(merge.cursor, 1);
}
#[test]
fn apply_ours_resolves_conflict_side() {
let mut merge = sample();
merge.apply(Side::Ours);
assert!(!merge.chunk_resolved(1)); merge.ignore(Side::Theirs);
assert!(merge.chunk_resolved(1));
assert_eq!(merge.current_content(1), vec!["X"]);
}
#[test]
fn apply_both_sides_appends_in_order() {
let mut merge = sample();
merge.apply(Side::Theirs);
merge.apply(Side::Ours);
assert_eq!(merge.current_content(1), vec!["Y", "X"]);
assert!(merge.chunk_resolved(1));
}
#[test]
fn ignore_both_keeps_base() {
let mut merge = sample();
merge.ignore(Side::Ours);
merge.ignore(Side::Theirs);
assert_eq!(merge.current_content(1), vec!["b"]);
assert!(merge.chunk_resolved(1));
}
#[test]
fn undo_restores_pending() {
let mut merge = sample();
merge.apply(Side::Ours);
merge.undo();
assert!(!merge.chunk_resolved(1));
assert_eq!(merge.current_content(1), vec!["b"]);
}
#[test]
fn undo_all_resets_every_chunk() {
let mut merge = sample();
merge.apply(Side::Ours);
merge.ignore(Side::Theirs);
merge.cursor = 3;
merge.apply(Side::Ours);
merge.set_override(vec!["edited".to_owned()]);
assert_eq!(merge.pending_changes(), 0);
merge.undo_all();
assert_eq!(merge.pending_changes(), 2);
assert!(!merge.chunk_resolved(1));
assert!(!merge.chunk_resolved(3));
assert_eq!(merge.current_content(3), vec!["d"]);
}
#[test]
fn override_wins_and_resolves() {
let mut merge = sample();
merge.set_override(vec!["merged".to_owned()]);
assert!(merge.chunk_resolved(1));
assert_eq!(merge.current_content(1), vec!["merged"]);
merge.apply(Side::Ours);
assert_eq!(merge.current_content(1), vec!["merged"]);
}
#[test]
fn apply_on_ineffective_side_is_noop() {
let mut merge = sample();
merge.cursor = 3; merge.apply(Side::Theirs);
assert!(!merge.chunk_resolved(3));
merge.apply(Side::Ours);
assert!(merge.chunk_resolved(3));
assert_eq!(merge.current_content(3), vec!["D"]);
}
#[test]
fn apply_all_nonconflict_skips_conflicts() {
let mut merge = sample();
merge.apply_all_nonconflict();
assert!(merge.chunk_resolved(3));
assert!(!merge.chunk_resolved(1));
assert_eq!(merge.pending_conflicts(), 1);
}
#[test]
fn resolved_content_joins_chunks_with_newline() {
let mut merge = sample();
merge.apply(Side::Ours);
merge.ignore(Side::Theirs);
merge.cursor = 3;
merge.apply(Side::Ours);
assert!(merge.ready_to_write());
assert_eq!(merge.resolved_content(), "a\nX\nc\nD\n");
}
#[test]
fn conflict_navigation_wraps_and_skips_resolved() {
let mut merge = FileMerge::from_three_way(
"demo.txt".to_owned(),
"a\nb\nc\nd\ne\n",
"a\nX\nc\nY\ne\n",
"a\nP\nc\nQ\ne\n",
);
assert_eq!(merge.pending_conflicts(), 2);
assert_eq!(merge.cursor, 1);
merge.next_conflict();
assert_eq!(merge.cursor, 3);
merge.next_conflict();
assert_eq!(merge.cursor, 1); merge.apply(Side::Ours);
merge.ignore(Side::Theirs);
merge.next_conflict();
assert_eq!(merge.cursor, 3);
merge.next_conflict();
assert_eq!(merge.cursor, 3);
}
#[test]
fn agree_single_apply_resolves_both_sides() {
let mut merge =
FileMerge::from_three_way("demo.txt".to_owned(), "a\nb\n", "a\nB\n", "a\nB\n");
assert_eq!(merge.chunks[merge.cursor].kind, ChunkKind::Agree);
merge.apply(Side::Theirs);
assert!(merge.chunk_resolved(merge.cursor));
assert_eq!(merge.current_content(merge.cursor), vec!["B"]);
}
#[test]
fn session_marks_written_and_advances() {
let files = vec![
FileEntry::Text(sample()),
FileEntry::Binary {
path: "logo.png".to_owned(),
ours: vec![1, 2],
theirs: vec![3, 4],
choice: None,
},
];
let mut session = Session::new(files, "merge".to_owned());
assert!(!session.all_written());
session.mark_written();
assert_eq!(session.current, 1);
if let FileEntry::Binary { choice, .. } = session.current_file_mut() {
*choice = Some(Side::Theirs);
}
assert!(session.current_file().ready_to_write());
assert_eq!(session.current_file().resolved_bytes(), vec![3, 4]);
session.mark_written();
assert!(session.all_written());
}
}