use crate::ops::patch::*;
fn make_hunk(
old_start: usize,
prefix: &[&str],
removes: &[&str],
adds: &[&str],
suffix: &[&str],
) -> Hunk {
let mut lines = Vec::new();
for s in prefix {
lines.push(PatchLine::Context(s.to_string()));
}
for s in removes {
lines.push(PatchLine::Remove(s.to_string()));
}
for s in adds {
lines.push(PatchLine::Add(s.to_string()));
}
for s in suffix {
lines.push(PatchLine::Context(s.to_string()));
}
let old_count = prefix.len() + removes.len() + suffix.len();
let new_count = prefix.len() + adds.len() + suffix.len();
Hunk {
old_start,
old_count,
new_start: old_start,
new_count,
lines,
}
}
fn s(strings: &[&str]) -> Vec<String> {
strings.iter().map(|s| s.to_string()).collect()
}
mod basic {
use super::*;
#[test]
fn parse_patch_single_file() {
let diff = "\
--- a/hello.txt
+++ b/hello.txt
@@ -1,3 +1,3 @@
line1
-line2
+LINE2
line3
";
let files = parse_patch(diff).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "hello.txt");
assert_eq!(files[0].hunks.len(), 1);
assert_eq!(files[0].hunks[0].old_start, 1);
assert_eq!(files[0].hunks[0].old_count, 3);
}
#[test]
fn parse_patch_multiple_files() {
let diff = "\
--- a/a.txt
+++ b/a.txt
@@ -1,1 +1,1 @@
-old
+new
--- a/b.txt
+++ b/b.txt
@@ -1,1 +1,1 @@
-foo
+bar
";
let files = parse_patch(diff).unwrap();
assert_eq!(files.len(), 2);
assert_eq!(files[0].path, "a.txt");
assert_eq!(files[1].path, "b.txt");
}
#[test]
fn apply_hunks_simple_replacement() {
let original = "line1\nline2\nline3\n";
let hunks = vec![Hunk {
old_start: 2,
old_count: 1,
new_start: 2,
new_count: 1,
lines: vec![
PatchLine::Context("line1".into()),
PatchLine::Remove("line2".into()),
PatchLine::Add("LINE2".into()),
PatchLine::Context("line3".into()),
],
}];
let result = apply_hunks(original, &hunks).unwrap();
assert_eq!(result, "line1\nLINE2\nline3\n");
}
#[test]
fn apply_hunks_addition() {
let original = "a\nb\n";
let hunks = vec![Hunk {
old_start: 1,
old_count: 2,
new_start: 1,
new_count: 3,
lines: vec![
PatchLine::Context("a".into()),
PatchLine::Add("inserted".into()),
PatchLine::Context("b".into()),
],
}];
let result = apply_hunks(original, &hunks).unwrap();
assert_eq!(result, "a\ninserted\nb\n");
}
#[test]
fn apply_hunks_deletion() {
let original = "a\nremove_me\nb\n";
let hunks = vec![Hunk {
old_start: 1,
old_count: 3,
new_start: 1,
new_count: 2,
lines: vec![
PatchLine::Context("a".into()),
PatchLine::Remove("remove_me".into()),
PatchLine::Context("b".into()),
],
}];
let result = apply_hunks(original, &hunks).unwrap();
assert_eq!(result, "a\nb\n");
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn apply_patch_with_loader_basic() {
let diff = "\
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
hello
-world
+WORLD
end
";
let results = apply_patch_with_loader(
diff,
|path| {
assert_eq!(path, "test.txt");
Ok("hello\nworld\nend\n".to_string())
},
ApplyHunksOptions::default(),
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].path, "test.txt");
assert_eq!(results[0].content, "hello\nWORLD\nend\n");
}
#[test]
fn apply_hunks_two_hunks_offset_tracking() {
let original = "a\nb\nc\nd\ne\n";
let hunks = vec![
Hunk {
old_start: 1,
old_count: 2,
new_start: 1,
new_count: 3,
lines: vec![
PatchLine::Context("a".into()),
PatchLine::Add("INSERTED".into()),
PatchLine::Context("b".into()),
],
},
Hunk {
old_start: 4,
old_count: 2,
new_start: 5,
new_count: 2,
lines: vec![
PatchLine::Remove("d".into()),
PatchLine::Add("D".into()),
PatchLine::Context("e".into()),
],
},
];
let result = apply_hunks(original, &hunks).unwrap();
assert_eq!(result, "a\nINSERTED\nb\nc\nD\ne\n");
}
#[test]
fn merge_three_way_lines_theirs_wins() {
let base = s(&["same", "base_val", "same"]);
let ours = s(&["same", "base_val", "same"]);
let theirs = s(&["same", "theirs_val", "same"]);
let (result, conflicts) = merge_three_way_lines(&base, &ours, &theirs, 1);
assert_eq!(result, s(&["same", "theirs_val", "same"]));
assert!(conflicts.is_empty());
}
#[test]
fn merge_three_way_lines_ours_wins_theirs_unchanged() {
let base = s(&["A", "base", "C"]);
let ours = s(&["A", "ours", "C"]);
let theirs = s(&["A", "base", "C"]);
let (result, conflicts) = merge_three_way_lines(&base, &ours, &theirs, 1);
assert_eq!(result, s(&["A", "ours", "C"]));
assert!(conflicts.is_empty());
}
#[test]
fn merge_three_way_lines_ours_wins_same_change() {
let base = s(&["X"]);
let ours = s(&["Z"]);
let theirs = s(&["Z"]);
let (result, conflicts) = merge_three_way_lines(&base, &ours, &theirs, 1);
assert_eq!(result, s(&["Z"]));
assert!(conflicts.is_empty());
}
#[test]
fn merge_three_way_lines_conflict() {
let base = s(&["base"]);
let ours = s(&["ours"]);
let theirs = s(&["theirs"]);
let (result, conflicts) = merge_three_way_lines(&base, &ours, &theirs, 1);
assert_eq!(result.len(), 5);
assert_eq!(result[0], CONFLICT_OURS);
assert_eq!(result[1], "ours");
assert_eq!(result[2], CONFLICT_SEP);
assert_eq!(result[3], "theirs");
assert_eq!(result[4], CONFLICT_THEIRS);
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].start_line, 1);
assert_eq!(conflicts[0].end_line, 5);
}
#[test]
fn merge_three_way_lines_mixed_wins() {
let base = s(&["B1", "B2"]);
let ours = s(&["O1", "B2"]);
let theirs = s(&["B1", "T2"]);
let (result, conflicts) = merge_three_way_lines(&base, &ours, &theirs, 1);
assert_eq!(result, s(&["O1", "T2"]));
assert!(conflicts.is_empty());
}
#[test]
fn merge_three_way_block_theirs_wins() {
let base = s(&["A", "B"]);
let ours = s(&["A", "B"]);
let theirs = s(&["A", "B", "C"]);
let (result, conflicts) = merge_three_way_block(&base, &ours, &theirs, 1);
assert_eq!(result, s(&["A", "B", "C"]));
assert!(conflicts.is_empty());
}
#[test]
fn merge_three_way_block_ours_wins() {
let base = s(&["A", "B"]);
let ours = s(&["A", "B", "new"]);
let theirs = s(&["A", "B"]);
let (result, conflicts) = merge_three_way_block(&base, &ours, &theirs, 1);
assert_eq!(result, s(&["A", "B", "new"]));
assert!(conflicts.is_empty());
}
#[test]
fn merge_three_way_block_conflict() {
let base = s(&["B1", "B2"]);
let ours = s(&["O1", "O2", "O3"]);
let theirs = s(&["T1", "T2", "T3", "T4"]);
let (result, conflicts) = merge_three_way_block(&base, &ours, &theirs, 1);
assert_eq!(result[0], CONFLICT_OURS);
assert_eq!(result[1], "O1");
assert_eq!(result[2], "O2");
assert_eq!(result[3], "O3");
assert_eq!(result[4], CONFLICT_SEP);
assert_eq!(result[5], "T1");
assert_eq!(result[6], "T2");
assert_eq!(result[7], "T3");
assert_eq!(result[8], "T4");
assert_eq!(result[9], CONFLICT_THEIRS);
assert_eq!(result.len(), 10);
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].start_line, 1);
assert_eq!(conflicts[0].end_line, 10);
}
#[test]
fn find_match_global_finds_at_start() {
let haystack: Vec<&str> = vec!["A", "B", "C", "D"];
let needle: Vec<&str> = vec!["A", "B"];
assert_eq!(find_match_global(&haystack, &needle), Some(0));
}
#[test]
fn find_match_global_finds_in_middle() {
let haystack: Vec<&str> = vec!["X", "A", "B", "Y"];
let needle: Vec<&str> = vec!["A", "B"];
assert_eq!(find_match_global(&haystack, &needle), Some(1));
}
#[test]
fn find_match_global_finds_at_end() {
let haystack: Vec<&str> = vec!["X", "Y", "A", "B"];
let needle: Vec<&str> = vec!["A", "B"];
assert_eq!(find_match_global(&haystack, &needle), Some(2));
}
#[test]
fn locate_by_context_anchors_prefix_only() {
let hunk = Hunk {
old_start: 1,
old_count: 3,
new_start: 1,
new_count: 3,
lines: vec![
PatchLine::Context("ctx1".into()),
PatchLine::Context("ctx2".into()),
PatchLine::Remove("old".into()),
PatchLine::Add("new".into()),
],
};
let haystack: Vec<&str> = vec!["ctx1", "ctx2", "modified"];
let result = locate_by_context_anchors(&haystack, &hunk, 0);
assert_eq!(result, Some(0));
}
#[test]
fn locate_by_context_anchors_suffix_only() {
let hunk = Hunk {
old_start: 1,
old_count: 2,
new_start: 1,
new_count: 2,
lines: vec![
PatchLine::Remove("old".into()),
PatchLine::Add("new".into()),
PatchLine::Context("suffix".into()),
],
};
let haystack: Vec<&str> = vec!["modified", "suffix"];
let result = locate_by_context_anchors(&haystack, &hunk, 0);
assert_eq!(result, Some(0));
}
#[test]
fn locate_by_context_anchors_both() {
let hunk = Hunk {
old_start: 1,
old_count: 3,
new_start: 1,
new_count: 3,
lines: vec![
PatchLine::Context("prefix".into()),
PatchLine::Remove("old".into()),
PatchLine::Add("new".into()),
PatchLine::Context("suffix".into()),
],
};
let haystack: Vec<&str> = vec!["prefix", "modified", "suffix"];
let result = locate_by_context_anchors(&haystack, &hunk, 0);
assert_eq!(result, Some(0));
}
#[test]
fn merge_hunks_clean_apply() {
let ours = "ctx1\nold\nctx2\n";
let hunks = vec![make_hunk(1, &["ctx1"], &["old"], &["new"], &["ctx2"])];
let result = merge_hunks(ours, &hunks).unwrap();
assert_eq!(result.content, "ctx1\nnew\nctx2\n");
assert!(result.conflicts.is_empty());
}
#[test]
fn merge_hunks_three_way_conflict() {
let ours = "ctx1\nours_modified\nctx2\n";
let hunks = vec![make_hunk(
1,
&["ctx1"],
&["base_val"],
&["theirs_val"],
&["ctx2"],
)];
let result = merge_hunks(ours, &hunks).unwrap();
assert!(!result.conflicts.is_empty());
assert!(result.content.contains(CONFLICT_OURS));
assert!(result.content.contains("ours_modified"));
assert!(result.content.contains("theirs_val"));
assert!(result.content.contains(CONFLICT_THEIRS));
}
#[test]
fn merge_hunks_three_way_clean() {
let ours = "ctx1\nours_val\nchange_this\nctx2\n";
let hunks = vec![Hunk {
old_start: 1,
old_count: 4,
new_start: 1,
new_count: 4,
lines: vec![
PatchLine::Context("ctx1".into()),
PatchLine::Remove("keep_this".into()),
PatchLine::Remove("change_this".into()),
PatchLine::Add("keep_this".into()),
PatchLine::Add("patched".into()),
PatchLine::Context("ctx2".into()),
],
}];
let result = merge_hunks(ours, &hunks).unwrap();
assert_eq!(result.content, "ctx1\nours_val\npatched\nctx2\n");
assert!(result.conflicts.is_empty());
}
#[test]
fn apply_with_options_merge_clean() {
let ours = "ctx1\nold\nctx2\n";
let hunks = vec![make_hunk(1, &["ctx1"], &["old"], &["new"], &["ctx2"])];
let opts = ApplyHunksOptions {
on_stale: OnStale::Merge,
allow_conflicts: false,
};
let result = apply_hunks_with_options(ours, &hunks, opts).unwrap();
assert_eq!(result.status, ApplyHunksStatus::Clean);
assert_eq!(result.content, "ctx1\nnew\nctx2\n");
assert!(result.conflicts.is_empty());
}
#[test]
fn apply_with_options_merge_merged() {
let ours = "ctx1\nours_val\nchange_this\nctx2\n";
let hunks = vec![Hunk {
old_start: 1,
old_count: 4,
new_start: 1,
new_count: 4,
lines: vec![
PatchLine::Context("ctx1".into()),
PatchLine::Remove("keep_this".into()),
PatchLine::Remove("change_this".into()),
PatchLine::Add("keep_this".into()),
PatchLine::Add("patched".into()),
PatchLine::Context("ctx2".into()),
],
}];
let opts = ApplyHunksOptions {
on_stale: OnStale::Merge,
allow_conflicts: false,
};
let result = apply_hunks_with_options(ours, &hunks, opts).unwrap();
assert_eq!(result.status, ApplyHunksStatus::Merged);
assert!(result.conflicts.is_empty());
assert_eq!(result.content, "ctx1\nours_val\npatched\nctx2\n");
}
#[test]
fn apply_with_options_merge_conflict_allowed() {
let ours = "ctx1\nours_mod\nctx2\n";
let hunks = vec![make_hunk(1, &["ctx1"], &["base"], &["theirs"], &["ctx2"])];
let opts = ApplyHunksOptions {
on_stale: OnStale::Merge,
allow_conflicts: true,
};
let result = apply_hunks_with_options(ours, &hunks, opts).unwrap();
assert_eq!(result.status, ApplyHunksStatus::Conflict);
assert!(!result.conflicts.is_empty());
assert!(result.content.contains(CONFLICT_OURS));
assert!(result.content.contains("ours_mod"));
assert!(result.content.contains("theirs"));
assert!(result.content.contains(CONFLICT_THEIRS));
}
#[test]
fn parse_patch_with_diff_git_headers() {
let diff = "\
diff --git a/src/hello.rs b/src/hello.rs
index 1234567..abcdefg 100644
--- a/src/hello.rs
+++ b/src/hello.rs
@@ -1,3 +1,3 @@
fn main() {
- println!(\"hello\");
+ println!(\"Hello, world!\");
}
";
let files = parse_patch(diff).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "src/hello.rs");
assert_eq!(files[0].hunks.len(), 1);
assert_eq!(files[0].hunks[0].old_start, 1);
assert_eq!(files[0].hunks[0].old_count, 3);
assert_eq!(files[0].hunks[0].new_count, 3);
assert_eq!(files[0].hunks[0].lines.len(), 4);
assert_eq!(
files[0].hunks[0].lines[0],
PatchLine::Context("fn main() {".into())
);
assert_eq!(
files[0].hunks[0].lines[1],
PatchLine::Remove(" println!(\"hello\");".into())
);
assert_eq!(
files[0].hunks[0].lines[2],
PatchLine::Add(" println!(\"Hello, world!\");".into())
);
assert_eq!(files[0].hunks[0].lines[3], PatchLine::Context("}".into()));
}
}
mod edge_cases {
use super::*;
#[test]
fn apply_hunks_fuzz_match() {
let original = "a\nb\nc\nd\n";
let hunks = vec![Hunk {
old_start: 2,
old_count: 1,
new_start: 2,
new_count: 1,
lines: vec![PatchLine::Remove("c".into()), PatchLine::Add("C".into())],
}];
let result = apply_hunks(original, &hunks).unwrap();
assert_eq!(result, "a\nb\nC\nd\n");
}
#[test]
fn apply_hunks_pure_addition_on_empty() {
let original = "";
let hunks = vec![Hunk {
old_start: 0,
old_count: 0,
new_start: 1,
new_count: 2,
lines: vec![
PatchLine::Add("new_line1".into()),
PatchLine::Add("new_line2".into()),
],
}];
let result = apply_hunks(original, &hunks).unwrap();
assert_eq!(result, "new_line1\nnew_line2\n");
}
#[test]
fn merge_three_way_lines_all_unchanged() {
let base = s(&["A", "B"]);
let ours = s(&["A", "B"]);
let theirs = s(&["A", "B"]);
let (result, conflicts) = merge_three_way_lines(&base, &ours, &theirs, 1);
assert_eq!(result, s(&["A", "B"]));
assert!(conflicts.is_empty());
}
#[test]
fn merge_three_way_block_ours_equals_theirs() {
let base = s(&["old"]);
let ours = s(&["new1", "new2"]);
let theirs = s(&["new1", "new2"]);
let (result, conflicts) = merge_three_way_block(&base, &ours, &theirs, 1);
assert_eq!(result, s(&["new1", "new2"]));
assert!(conflicts.is_empty());
}
#[test]
fn find_match_global_empty_needle() {
let haystack: Vec<&str> = vec!["A", "B"];
let needle: Vec<&str> = vec![];
assert_eq!(find_match_global(&haystack, &needle), Some(0));
}
#[test]
fn find_match_global_no_match() {
let haystack: Vec<&str> = vec!["A", "B", "C"];
let needle: Vec<&str> = vec!["X", "Y"];
assert_eq!(find_match_global(&haystack, &needle), None);
}
#[test]
fn find_match_global_needle_longer_than_haystack() {
let haystack: Vec<&str> = vec![];
let needle: Vec<&str> = vec!["A", "B", "C"];
assert_eq!(find_match_global(&haystack, &needle), None);
let short_haystack: Vec<&str> = vec!["A"];
assert_eq!(find_match_global(&short_haystack, &needle), None);
}
#[test]
fn locate_by_context_anchors_no_context_returns_none() {
let hunk = Hunk {
old_start: 1,
old_count: 1,
new_start: 1,
new_count: 1,
lines: vec![
PatchLine::Remove("old".into()),
PatchLine::Add("new".into()),
],
};
let haystack: Vec<&str> = vec!["modified"];
let result = locate_by_context_anchors(&haystack, &hunk, 0);
assert_eq!(result, None);
}
#[test]
fn fuzz_at_maximum_boundary_delta_3() {
let original = "a\nb\nc\nd\ntarget\nf\n";
let hunks = vec![Hunk {
old_start: 2,
old_count: 1,
new_start: 2,
new_count: 1,
lines: vec![
PatchLine::Remove("target".into()),
PatchLine::Add("TARGET".into()),
],
}];
let result = apply_hunks(original, &hunks).unwrap();
assert_eq!(result, "a\nb\nc\nd\nTARGET\nf\n");
}
#[test]
fn fuzz_beyond_boundary_delta_4_fails() {
let original = "a\nb\nc\nd\ne\ntarget\nf\n";
let hunks = vec![Hunk {
old_start: 2,
old_count: 1,
new_start: 2,
new_count: 1,
lines: vec![
PatchLine::Remove("target".into()),
PatchLine::Add("TARGET".into()),
],
}];
apply_hunks(original, &hunks).expect_err("expected error");
}
#[test]
fn two_hunks_both_requiring_fuzz() {
let original = "a\nb\nx\nc\nd\ny\ne\n";
let hunks = vec![
Hunk {
old_start: 1,
old_count: 1,
new_start: 1,
new_count: 1,
lines: vec![PatchLine::Remove("x".into()), PatchLine::Add("X".into())],
},
Hunk {
old_start: 4,
old_count: 1,
new_start: 4,
new_count: 1,
lines: vec![PatchLine::Remove("y".into()), PatchLine::Add("Y".into())],
},
];
let result = apply_hunks(original, &hunks).unwrap();
assert_eq!(result, "a\nb\nX\nc\nd\nY\ne\n");
}
#[test]
fn hunk_removes_all_lines() {
let original = "a\nb\nc\n";
let hunks = vec![Hunk {
old_start: 1,
old_count: 3,
new_start: 1,
new_count: 0,
lines: vec![
PatchLine::Remove("a".into()),
PatchLine::Remove("b".into()),
PatchLine::Remove("c".into()),
],
}];
let result = apply_hunks(original, &hunks).unwrap();
assert_eq!(result, "");
}
#[test]
fn context_only_hunk_is_noop() {
let original = "line1\nline2\nline3\n";
let hunks = vec![Hunk {
old_start: 1,
old_count: 3,
new_start: 1,
new_count: 3,
lines: vec![
PatchLine::Context("line1".into()),
PatchLine::Context("line2".into()),
PatchLine::Context("line3".into()),
],
}];
let result = apply_hunks(original, &hunks).unwrap();
assert_eq!(result, original);
}
}
mod error_handling {
use super::*;
#[test]
fn parse_patch_no_files() {
let diff = "just some text\n";
parse_patch(diff).expect_err("expected error");
}
#[test]
fn parse_patch_no_hunks() {
let diff = "--- a/f.txt\n+++ b/f.txt\n";
parse_patch(diff).expect_err("expected error");
}
#[test]
fn apply_hunks_stale_context_fails() {
let original = "a\nb\nc\n";
let hunks = vec![Hunk {
old_start: 1,
old_count: 1,
new_start: 1,
new_count: 1,
lines: vec![
PatchLine::Remove("wrong_context".into()),
PatchLine::Add("x".into()),
],
}];
apply_hunks(original, &hunks).expect_err("expected error");
}
#[test]
fn apply_with_options_merge_conflict_disallowed() {
let ours = "ctx1\nours_mod\nctx2\n";
let hunks = vec![make_hunk(1, &["ctx1"], &["base"], &["theirs"], &["ctx2"])];
let opts = ApplyHunksOptions {
on_stale: OnStale::Merge,
allow_conflicts: false,
};
let result = apply_hunks_with_options(ours, &hunks, opts);
assert!(result.is_err(), "expected error, got Ok: {result:?}");
assert!(result.unwrap_err().contains("conflict"));
}
#[test]
fn apply_with_options_fail_on_stale() {
let ours = "ctx1\nmodified\nctx2\n";
let hunks = vec![make_hunk(1, &["ctx1"], &["original"], &["new"], &["ctx2"])];
let opts = ApplyHunksOptions {
on_stale: OnStale::Fail,
allow_conflicts: false,
};
let result = apply_hunks_with_options(ours, &hunks, opts);
result.expect_err("expected error");
}
}
mod format_preservation {
use super::*;
#[test]
fn apply_hunks_preserves_no_final_newline() {
let original = "line1\nline2";
let hunks = vec![Hunk {
old_start: 2,
old_count: 1,
new_start: 2,
new_count: 1,
lines: vec![
PatchLine::Remove("line2".into()),
PatchLine::Add("LINE2".into()),
],
}];
let result = apply_hunks(original, &hunks).unwrap();
assert_eq!(result, "line1\nLINE2");
}
#[test]
fn merge_hunks_preserves_final_newline() {
let ours = "ctx1\nold\nctx2\n";
let hunks = vec![make_hunk(1, &["ctx1"], &["old"], &["new"], &["ctx2"])];
let result = merge_hunks(ours, &hunks).unwrap();
assert!(result.content.ends_with('\n'));
}
#[test]
fn merge_hunks_no_final_newline() {
let ours = "ctx1\nold\nctx2";
let hunks = vec![make_hunk(1, &["ctx1"], &["old"], &["new"], &["ctx2"])];
let result = merge_hunks(ours, &hunks).unwrap();
assert!(!result.content.ends_with('\n'));
assert_eq!(result.content, "ctx1\nnew\nctx2");
}
#[test]
fn parse_patch_skips_no_newline_marker() {
let diff = "\
--- a/file.txt
+++ b/file.txt
@@ -1,2 +1,2 @@
keep
-old
+new
\\ No newline at end of file
";
let files = parse_patch(diff).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].hunks[0].lines.len(), 3);
assert_eq!(
files[0].hunks[0].lines[0],
PatchLine::Context("keep".into())
);
assert_eq!(files[0].hunks[0].lines[1], PatchLine::Remove("old".into()));
assert_eq!(files[0].hunks[0].lines[2], PatchLine::Add("new".into()));
}
}
mod regression {
use super::*;
#[test]
fn apply_hunks_huge_old_start_does_not_panic() {
let hunks = vec![Hunk {
old_start: usize::MAX,
old_count: 1,
new_start: 1,
new_count: 1,
lines: vec![PatchLine::Context("x".into())],
}];
apply_hunks("x\n", &hunks).expect_err("expected error");
}
#[test]
fn merge_hunks_multi_hunk_conflict_lines_are_correct() {
let hunks = vec![
make_hunk(1, &["line1"], &["line2"], &["changed2"], &["line3"]),
make_hunk(7, &["line7"], &["line8"], &["changed8"], &["line9"]),
];
let ours = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nours8\nline9\nline10\n";
let result = merge_hunks(ours, &hunks).unwrap();
assert_eq!(
result.conflicts.len(),
1,
"should have exactly one conflict"
);
let conflict = &result.conflicts[0];
let lines: Vec<&str> = result.content.lines().collect();
let marker_line = lines
.iter()
.position(|l| l.contains("<<<<<<< patchloom"))
.expect("conflict marker should exist");
assert_eq!(
conflict.start_line,
marker_line + 1,
"conflict start_line should match actual marker position"
);
}
#[test]
fn apply_hunks_huge_fuzz_range_does_not_panic() {
let hunks = vec![Hunk {
old_start: 1,
old_count: 0,
new_start: 1,
new_count: 1,
lines: vec![PatchLine::Add("new".into())],
}];
let _ = apply_hunks("original\n", &hunks);
}
#[test]
fn parse_file_path_strips_tab_timestamp() {
let result = parse_file_path("+++ b/file.txt\t2024-01-01 00:00:01.000000000 +0000");
assert_eq!(result, "file.txt");
}
#[test]
fn parse_file_path_no_tab_unchanged() {
let result = parse_file_path("+++ b/file.txt");
assert_eq!(result, "file.txt");
}
#[test]
fn parse_file_path_minus_with_tab_timestamp() {
let result = parse_file_path("--- a/src/main.rs\t2024-06-01 12:00:00.000 +0000");
assert_eq!(result, "src/main.rs");
}
#[test]
fn parse_patches_deletion_uses_minus_path() {
let diff = "\
--- a/old_file.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-line one
-line two
";
let patches = parse_patch(diff).expect("should parse deletion patch");
assert_eq!(patches.len(), 1);
assert_eq!(
patches[0].path, "old_file.txt",
"path should come from --- line for deletions"
);
}
#[test]
fn hunk_context_anchors_mid_hunk_context_excluded_from_suffix() {
let hunk = Hunk {
old_start: 1,
old_count: 5,
new_start: 1,
new_count: 5,
lines: vec![
PatchLine::Context("before".into()),
PatchLine::Remove("old1".into()),
PatchLine::Add("new1".into()),
PatchLine::Context("mid".into()),
PatchLine::Remove("old2".into()),
PatchLine::Add("new2".into()),
PatchLine::Context("after".into()),
],
};
let (prefix, suffix) = hunk_context_anchors(&hunk);
assert_eq!(
prefix,
vec!["before"],
"prefix should be leading context only"
);
assert_eq!(
suffix,
vec!["after"],
"suffix should be trailing context only, not mid-hunk context"
);
}
#[test]
fn apply_hunks_preserves_crlf() {
let original = "line1\r\nline2\r\nline3\r\n";
let hunk = Hunk {
old_start: 2,
old_count: 1,
new_start: 2,
new_count: 1,
lines: vec![
PatchLine::Remove("line2".into()),
PatchLine::Add("replaced".into()),
],
};
let result = apply_hunks(original, &[hunk]).expect("should apply");
assert!(
result.contains("\r\n"),
"CRLF should be preserved, got: {:?}",
result
);
assert_eq!(result, "line1\r\nreplaced\r\nline3\r\n");
}
#[test]
fn parse_patch_removed_sql_comment_line() {
let diff = "\
--- a/query.sql
+++ b/query.sql
@@ -1,3 +1,2 @@
SELECT 1;
--- This query is slow
SELECT 2;
";
let files = parse_patch(diff).expect("should parse successfully");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "query.sql");
assert_eq!(files[0].hunks.len(), 1);
assert_eq!(files[0].hunks[0].lines.len(), 3);
assert!(
matches!(&files[0].hunks[0].lines[1], PatchLine::Remove(s) if s == "-- This query is slow"),
"SQL comment removal must be parsed as a Remove line"
);
}
#[test]
fn hunk_content_with_minus_minus_plus_plus_not_header() {
let diff = "\
--- a/config.sql
+++ b/config.sql
@@ -1,4 +1,4 @@
SELECT 1;
--- old slow query
+++ new fast query
SELECT 2;
";
let files = parse_patch(diff).expect("should parse successfully");
assert_eq!(
files.len(),
1,
"should be 1 file, not split by in-hunk content: {files:?}"
);
assert_eq!(files[0].path, "config.sql");
assert_eq!(
files[0].hunks[0].lines.len(),
4,
"hunk should have 4 lines: {:?}",
files[0].hunks[0].lines
);
}
}