use tracing::warn;
use crate::{Chunk, Operation, Patch};
pub enum Change {
Equal(usize, usize), Delete(usize, usize), Insert(usize, usize), }
pub fn handle_empty_files(old_lines: &[&str], new_lines: &[&str]) -> Option<Patch> {
if old_lines.is_empty() && !new_lines.is_empty() {
let operations = new_lines
.iter()
.map(|&line| Operation::Add(line.to_string()))
.collect();
return Some(Patch {
preamble: None,
old_file: "original".to_string(),
new_file: "modified".to_string(),
chunks: vec![Chunk {
old_start: 0,
old_lines: 0,
new_start: 0,
new_lines: new_lines.len(),
operations,
}],
});
} else if !old_lines.is_empty() && new_lines.is_empty() {
let operations = old_lines
.iter()
.map(|&line| Operation::Remove(line.to_string()))
.collect();
return Some(Patch {
preamble: None,
old_file: "original".to_string(),
new_file: "modified".to_string(),
chunks: vec![Chunk {
old_start: 0,
old_lines: old_lines.len(),
new_start: 0,
new_lines: 0,
operations,
}],
});
} else if old_lines.is_empty() && new_lines.is_empty() {
return Some(Patch {
preamble: None,
old_file: "original".to_string(),
new_file: "modified".to_string(),
chunks: Vec::new(),
});
}
None
}
fn find_next_block(
changes: &[Change],
start_index: usize,
context_lines: usize,
) -> Option<(usize, usize)> {
let merge_threshold = context_lines * 2;
let Some(relative_start_pos) = changes[start_index..]
.iter()
.position(|c| !matches!(c, Change::Equal(_, _)))
else {
return None; };
let block_start_idx = start_index + relative_start_pos;
let mut block_end_idx = block_start_idx;
let mut consecutive_equals = 0;
while block_end_idx < changes.len() {
match changes[block_end_idx] {
Change::Equal(_, _) => {
consecutive_equals += 1;
}
_ => {
if consecutive_equals > merge_threshold {
block_end_idx = block_end_idx.saturating_sub(consecutive_equals);
return Some((block_start_idx, block_end_idx));
}
consecutive_equals = 0; }
}
block_end_idx += 1;
if block_end_idx == changes.len() && consecutive_equals > merge_threshold {
block_end_idx = block_end_idx.saturating_sub(consecutive_equals);
return Some((block_start_idx, block_end_idx));
}
}
if block_end_idx < block_start_idx {
warn!(
"Warning: find_next_block calculated block_end ({}) < block_start ({}). Returning None.",
block_end_idx, block_start_idx
);
return None;
}
Some((block_start_idx, block_end_idx))
}
fn build_chunk_operations<'a>(
changes: &[Change],
old_lines: &'a [&'a str],
new_lines: &'a [&'a str],
context_lines: usize,
context_start_change_idx: usize,
block_start_idx: usize,
block_end_idx: usize,
) -> (Vec<Operation>, usize, usize, usize) {
let mut operations = Vec::new();
let mut chunk_old_lines_count = 0;
let mut chunk_new_lines_count = 0;
for idx in context_start_change_idx..block_start_idx {
if let Change::Equal(o, _) = changes[idx] {
if let Some(line) = old_lines.get(o) {
operations.push(Operation::Context(line.to_string()));
chunk_old_lines_count += 1;
chunk_new_lines_count += 1;
}
}
}
for idx in block_start_idx..block_end_idx {
match changes[idx] {
Change::Equal(o, _) => {
if let Some(line) = old_lines.get(o) {
operations.push(Operation::Context(line.to_string()));
chunk_old_lines_count += 1;
chunk_new_lines_count += 1;
}
}
Change::Delete(o, count) => {
for j in 0..count {
if let Some(line) = old_lines.get(o + j) {
operations.push(Operation::Remove(line.to_string()));
chunk_old_lines_count += 1;
}
}
}
Change::Insert(n, count) => {
for j in 0..count {
if let Some(line) = new_lines.get(n + j) {
operations.push(Operation::Add(line.to_string()));
chunk_new_lines_count += 1;
}
}
}
}
}
let mut context_scan_idx = block_end_idx;
let mut context_added_after = 0;
while context_added_after < context_lines && context_scan_idx < changes.len() {
if let Change::Equal(o, _) = changes[context_scan_idx] {
if let Some(line) = old_lines.get(o) {
operations.push(Operation::Context(line.to_string()));
chunk_old_lines_count += 1;
chunk_new_lines_count += 1;
context_added_after += 1;
} else {
break; }
} else {
break; }
context_scan_idx += 1;
}
(
operations,
chunk_old_lines_count,
chunk_new_lines_count,
context_scan_idx, )
}
pub fn process_changes_to_chunks(
changes: &[Change],
old_lines: &[&str],
new_lines: &[&str],
context_lines: usize,
) -> Vec<Chunk> {
let mut chunks = Vec::new();
if changes.is_empty() {
return chunks;
}
let mut current_change_idx = 0;
while current_change_idx < changes.len() {
let Some((block_start_idx, block_end_idx)) =
find_next_block(changes, current_change_idx, context_lines)
else {
break; };
if block_end_idx <= current_change_idx {
warn!(
"Warning: find_next_block did not advance index. current={}, block_end={}. Forcing advance.",
current_change_idx, block_end_idx
);
current_change_idx += 1;
continue;
}
let context_start_change_idx = block_start_idx.saturating_sub(context_lines);
let (chunk_old_start, chunk_new_start) =
determine_chunk_start_indices(changes, context_start_change_idx, block_start_idx);
let (operations, chunk_old_lines_count, chunk_new_lines_count, next_change_idx) =
build_chunk_operations(
changes,
old_lines,
new_lines,
context_lines,
context_start_change_idx,
block_start_idx,
block_end_idx,
);
if !operations.is_empty() {
warn!(
"[Debug] Creating chunk: old_start={}, new_start={}, old_lines={}, new_lines={}",
chunk_old_start, chunk_new_start, chunk_old_lines_count, chunk_new_lines_count
);
let chunk = Chunk {
old_start: chunk_old_start,
old_lines: chunk_old_lines_count,
new_start: chunk_new_start,
new_lines: chunk_new_lines_count,
operations,
};
chunks.push(chunk);
}
if next_change_idx <= current_change_idx {
warn!(
"Warning: next_change_idx did not advance index after building chunk. current={}, next={}. Forcing advance.",
current_change_idx, next_change_idx
);
current_change_idx += 1; } else {
current_change_idx = next_change_idx;
}
}
chunks
}
fn determine_chunk_start_indices(
changes: &[Change],
context_start_idx: usize,
block_start_idx: usize,
) -> (usize, usize) {
let context_start = changes[context_start_idx..block_start_idx]
.iter()
.find_map(|c| match c {
Change::Equal(o, n) => Some((*o, *n)),
_ => None,
});
if let Some((old_start, new_start)) = context_start {
(old_start, new_start)
} else {
match changes.get(block_start_idx) {
Some(Change::Equal(o, n)) => (*o, *n),
Some(Change::Delete(o, _)) => (*o, infer_previous_new_index(changes, block_start_idx)), Some(Change::Insert(_, n)) => (infer_previous_old_index(changes, block_start_idx), *n), None => (0, 0), }
}
}
fn infer_previous_new_index(changes: &[Change], current_idx: usize) -> usize {
if current_idx == 0 {
return 0;
}
match changes[current_idx - 1] {
Change::Equal(_, n_prev) => n_prev + 1,
Change::Insert(n_prev, count) => n_prev + count,
Change::Delete(_, _) => infer_previous_new_index(changes, current_idx - 1), }
}
fn infer_previous_old_index(changes: &[Change], current_idx: usize) -> usize {
if current_idx == 0 {
return 0;
}
match changes[current_idx - 1] {
Change::Equal(o_prev, _) => o_prev + 1,
Change::Delete(o_prev, count) => o_prev + count,
Change::Insert(_, _) => infer_previous_old_index(changes, current_idx - 1), }
}
pub fn create_patch(chunks: Vec<Chunk>) -> Patch {
Patch {
preamble: None,
old_file: "original".to_string(),
new_file: "modified".to_string(),
chunks,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Chunk, Operation, Patch};
#[test]
fn test_handle_empty_files_add_to_empty() {
let old_lines: Vec<&str> = vec![];
let new_lines = vec!["a", "b"];
let expected_patch = Patch {
preamble: None,
old_file: "original".to_string(),
new_file: "modified".to_string(),
chunks: vec![Chunk {
old_start: 0,
old_lines: 0,
new_start: 0,
new_lines: 2,
operations: vec![
Operation::Add("a".to_string()),
Operation::Add("b".to_string()),
],
}],
};
assert_eq!(
handle_empty_files(&old_lines, &new_lines),
Some(expected_patch)
);
}
#[test]
fn test_handle_empty_files_remove_all() {
let old_lines = vec!["a", "b"];
let new_lines: Vec<&str> = vec![];
let expected_patch = Patch {
preamble: None,
old_file: "original".to_string(),
new_file: "modified".to_string(),
chunks: vec![Chunk {
old_start: 0,
old_lines: 2,
new_start: 0,
new_lines: 0,
operations: vec![
Operation::Remove("a".to_string()),
Operation::Remove("b".to_string()),
],
}],
};
assert_eq!(
handle_empty_files(&old_lines, &new_lines),
Some(expected_patch)
);
}
#[test]
fn test_handle_empty_files_both_empty() {
let old_lines: Vec<&str> = vec![];
let new_lines: Vec<&str> = vec![];
let expected_patch = Patch {
preamble: None,
old_file: "original".to_string(),
new_file: "modified".to_string(),
chunks: Vec::new(),
};
assert_eq!(
handle_empty_files(&old_lines, &new_lines),
Some(expected_patch)
);
}
#[test]
fn test_handle_empty_files_no_change() {
let old_lines = vec!["a"];
let new_lines = vec!["a"];
assert_eq!(handle_empty_files(&old_lines, &new_lines), None);
}
#[test]
fn test_process_chunks_basic_insert() {
let old_lines = vec!["a", "b"];
let new_lines = vec!["a", "x", "y", "b"];
let changes = vec![
Change::Equal(0, 0), Change::Insert(1, 2), Change::Equal(1, 3), ];
let context_lines = 1;
let chunks = process_changes_to_chunks(&changes, &old_lines, &new_lines, context_lines);
assert_eq!(chunks.len(), 1);
let chunk = &chunks[0];
assert_eq!(chunk.old_start, 0);
assert_eq!(chunk.new_start, 0);
assert_eq!(chunk.old_lines, 2);
assert_eq!(chunk.new_lines, 4);
assert_eq!(
chunk.operations,
vec![
Operation::Context("a".to_string()),
Operation::Add("x".to_string()),
Operation::Add("y".to_string()),
Operation::Context("b".to_string()),
]
);
}
#[test]
fn test_process_chunks_basic_delete() {
let old_lines = vec!["a", "x", "y", "b"];
let new_lines = vec!["a", "b"];
let changes = vec![
Change::Equal(0, 0), Change::Delete(1, 2), Change::Equal(3, 1), ];
let context_lines = 1;
let chunks = process_changes_to_chunks(&changes, &old_lines, &new_lines, context_lines);
assert_eq!(chunks.len(), 1);
let chunk = &chunks[0];
assert_eq!(chunk.old_start, 0);
assert_eq!(chunk.new_start, 0);
assert_eq!(chunk.old_lines, 4);
assert_eq!(chunk.new_lines, 2);
assert_eq!(
chunk.operations,
vec![
Operation::Context("a".to_string()),
Operation::Remove("x".to_string()),
Operation::Remove("y".to_string()),
Operation::Context("b".to_string()),
]
);
}
#[test]
fn test_process_chunks_basic_replace() {
let old_lines = vec!["a", "b", "c"];
let new_lines = vec!["a", "x", "c"];
let changes = vec![
Change::Equal(0, 0), Change::Delete(1, 1), Change::Insert(1, 1), Change::Equal(2, 2), ];
let context_lines = 1;
let chunks = process_changes_to_chunks(&changes, &old_lines, &new_lines, context_lines);
assert_eq!(chunks.len(), 1);
let chunk = &chunks[0];
assert_eq!(chunk.old_start, 0);
assert_eq!(chunk.new_start, 0);
assert_eq!(chunk.old_lines, 3);
assert_eq!(chunk.new_lines, 3);
assert_eq!(
chunk.operations,
vec![
Operation::Context("a".to_string()),
Operation::Remove("b".to_string()),
Operation::Add("x".to_string()),
Operation::Context("c".to_string()),
]
);
}
#[test]
fn test_process_chunks_multiple_blocks() {
let old_lines = vec!["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]; let new_lines = vec!["a", "X", "c", "d", "e", "f", "g", "Y", "i", "j"]; let changes = vec![
Change::Equal(0, 0), Change::Delete(1, 1), Change::Insert(1, 1), Change::Equal(2, 2), Change::Equal(3, 3), Change::Equal(4, 4), Change::Equal(5, 5), Change::Equal(6, 6), Change::Delete(7, 1), Change::Insert(7, 1), Change::Equal(8, 8), Change::Equal(9, 9), ];
let context_lines = 1;
let chunks = process_changes_to_chunks(&changes, &old_lines, &new_lines, context_lines);
assert_eq!(chunks.len(), 2);
let chunk1 = &chunks[0];
assert_eq!(chunk1.old_start, 0);
assert_eq!(chunk1.new_start, 0);
assert_eq!(chunk1.old_lines, 3); assert_eq!(chunk1.new_lines, 3); assert_eq!(
chunk1.operations,
vec![
Operation::Context("a".to_string()),
Operation::Remove("b".to_string()),
Operation::Add("X".to_string()),
Operation::Context("c".to_string()), ]
);
let chunk2 = &chunks[1];
assert_eq!(chunk2.old_start, 6); assert_eq!(chunk2.new_start, 6); assert_eq!(chunk2.old_lines, 4); assert_eq!(chunk2.new_lines, 4); assert_eq!(
chunk2.operations,
vec![
Operation::Context("g".to_string()), Operation::Remove("h".to_string()),
Operation::Add("Y".to_string()),
Operation::Context("i".to_string()),
Operation::Context("j".to_string()), ]
);
}
#[test]
fn test_process_chunks_zero_context() {
let old_lines = vec!["a", "b", "c"];
let new_lines = vec!["a", "x", "c"];
let changes = vec![
Change::Equal(0, 0), Change::Delete(1, 1), Change::Insert(1, 1), Change::Equal(2, 2), ];
let context_lines = 0;
let chunks = process_changes_to_chunks(&changes, &old_lines, &new_lines, context_lines);
assert_eq!(chunks.len(), 1);
let chunk = &chunks[0];
assert_eq!(chunk.old_start, 1); assert_eq!(chunk.new_start, 1); assert_eq!(chunk.old_lines, 1); assert_eq!(chunk.new_lines, 1); assert_eq!(
chunk.operations,
vec![
Operation::Remove("b".to_string()),
Operation::Add("x".to_string()),
]
);
}
#[test]
fn test_process_chunks_context_at_ends() {
let old_lines = vec!["a", "b", "c", "d", "e"];
let new_lines = vec!["x", "b", "c", "d", "y"];
let changes = vec![
Change::Delete(0, 1), Change::Insert(0, 1), Change::Equal(1, 1), Change::Equal(2, 2), Change::Equal(3, 3), Change::Delete(4, 1), Change::Insert(4, 1), ];
let context_lines = 1;
let chunks = process_changes_to_chunks(&changes, &old_lines, &new_lines, context_lines);
assert_eq!(chunks.len(), 2);
let chunk1 = &chunks[0];
assert_eq!(chunk1.old_start, 0); assert_eq!(chunk1.new_start, 0); assert_eq!(chunk1.old_lines, 2); assert_eq!(chunk1.new_lines, 2); assert_eq!(
chunk1.operations,
vec![
Operation::Remove("a".to_string()),
Operation::Add("x".to_string()),
Operation::Context("b".to_string()), ]
);
let chunk2 = &chunks[1];
assert_eq!(chunk2.old_start, 3); assert_eq!(chunk2.new_start, 3); assert_eq!(chunk2.old_lines, 2); assert_eq!(chunk2.new_lines, 2); assert_eq!(
chunk2.operations,
vec![
Operation::Context("d".to_string()), Operation::Remove("e".to_string()),
Operation::Add("y".to_string()),
]
);
}
#[test]
fn test_find_next_block_all_equal() {
let changes = vec![Change::Equal(0, 0), Change::Equal(1, 1)];
assert_eq!(find_next_block(&changes, 0, 1), None);
}
#[test]
fn test_find_next_block_single_block_start() {
let changes = vec![
Change::Delete(0, 1),
Change::Equal(1, 0),
Change::Equal(2, 1),
];
assert_eq!(find_next_block(&changes, 0, 1), Some((0, 3))); }
#[test]
fn test_find_next_block_single_block_middle() {
let changes = vec![
Change::Equal(0, 0),
Change::Insert(1, 1),
Change::Equal(1, 2),
];
assert_eq!(find_next_block(&changes, 0, 1), Some((1, 3))); assert_eq!(find_next_block(&changes, 3, 1), None); }
#[test]
fn test_find_next_block_merges_small_gap() {
let changes = vec![
Change::Delete(0, 1),
Change::Equal(1, 0), Change::Insert(1, 1),
Change::Equal(2, 2),
];
assert_eq!(find_next_block(&changes, 0, 1), Some((0, 4))); }
#[test]
fn test_find_next_block_splits_large_gap() {
let changes = vec![
Change::Delete(0, 1),
Change::Equal(1, 0),
Change::Equal(2, 1),
Change::Equal(3, 2), Change::Insert(4, 1),
];
assert_eq!(find_next_block(&changes, 0, 1), Some((0, 1))); assert_eq!(find_next_block(&changes, 1, 1), Some((4, 5))); }
#[test]
fn test_find_next_block_trailing_equals_split() {
let changes = vec![
Change::Delete(0, 1),
Change::Equal(1, 0),
Change::Equal(2, 1),
Change::Equal(3, 2), ];
assert_eq!(find_next_block(&changes, 0, 1), Some((0, 1))); }
#[test]
fn test_find_next_block_trailing_equals_merge() {
let changes = vec![
Change::Delete(0, 1),
Change::Equal(1, 0), ];
assert_eq!(find_next_block(&changes, 0, 1), Some((0, 2))); }
#[test]
fn test_determine_start_indices_with_context() {
let changes = vec![
Change::Equal(5, 5), Change::Equal(6, 6),
Change::Delete(7, 1), ];
assert_eq!(determine_chunk_start_indices(&changes, 0, 2), (5, 5));
}
#[test]
fn test_determine_start_indices_no_context_equal_start() {
let changes = vec![
Change::Equal(7, 7), Change::Delete(8, 1),
];
assert_eq!(determine_chunk_start_indices(&changes, 0, 0), (7, 7));
}
#[test]
fn test_determine_start_indices_no_context_delete_start() {
let changes = vec![
Change::Equal(5, 5), Change::Delete(6, 1), ];
assert_eq!(determine_chunk_start_indices(&changes, 1, 1), (6, 6)); }
#[test]
fn test_determine_start_indices_no_context_insert_start() {
let changes = vec![
Change::Equal(5, 5), Change::Insert(6, 1), ];
assert_eq!(determine_chunk_start_indices(&changes, 1, 1), (6, 1)); }
#[test]
fn test_determine_start_indices_no_context_delete_start_at_file_start() {
let changes = vec![
Change::Delete(0, 1), ];
assert_eq!(determine_chunk_start_indices(&changes, 0, 0), (0, 0)); }
#[test]
fn test_determine_start_indices_no_context_insert_start_at_file_start() {
let changes = vec![
Change::Insert(0, 1), ];
assert_eq!(determine_chunk_start_indices(&changes, 0, 0), (0, 1));
}
}