From e7f04ddb828a7be98f33244c7ca937f12518d41b Mon Sep 17 00:00:00 2001 From: Weihang Lo Date: Thu, 9 Apr 2026 21:59:12 -0400 Subject: [PATCH 1/5] test(compat): add GNU patch compatibility tests Tests verify diffy produces results compatible with reference tools. Here we have GNU patch. In the future we'll have `git apply`. We don't run reference tool locally, as user may not have the tool. To run it, set `env CI=1` and run the test. Please see the module level doc for more. --- Cargo.lock | 197 ++++++++++++ Cargo.toml | 2 +- tests/compat/common.rs | 284 ++++++++++++++++++ tests/compat/gnu_patch.rs | 200 ++++++++++++ .../create_empty_file_gitdiff/in/foo.patch | 3 + .../create_empty_file_unidiff/in/foo.patch | 3 + .../create_empty_file_unidiff/out/empty.txt | 0 .../compat/gnu_patch/create_file/in/foo.patch | 6 + .../compat/gnu_patch/create_file/out/file.txt | 3 + .../compat/gnu_patch/delete_file/in/file.txt | 5 + .../compat/gnu_patch/delete_file/in/foo.patch | 8 + .../compat/gnu_patch/delete_file/out/file.txt | 0 .../gnu_patch/fail_both_devnull/in/foo.patch | 5 + .../fail_context_mismatch/in/file.txt | 4 + .../fail_context_mismatch/in/foo.patch | 8 + .../gnu_patch/fail_hunk_not_found/in/file.txt | 4 + .../fail_hunk_not_found/in/foo.patch | 8 + .../gnu_patch/fail_truncated_file/in/file.txt | 2 + .../fail_truncated_file/in/foo.patch | 9 + .../in/file1.rs | 2 + .../in/file2.rs | 1 + .../in/foo.patch | 11 + .../out/file1.rs | 2 + .../out/file2.rs | 1 + .../gnu_patch/junk_between_files/in/bar.txt | 1 + .../gnu_patch/junk_between_files/in/foo.patch | 13 + .../gnu_patch/junk_between_files/in/foo.txt | 1 + .../gnu_patch/junk_between_files/out/bar.txt | 1 + .../gnu_patch/junk_between_files/out/foo.txt | 1 + .../gnu_patch/junk_between_hunks/in/file.txt | 9 + .../gnu_patch/junk_between_hunks/in/foo.patch | 13 + .../gnu_patch/junk_between_hunks/out/file.txt | 9 + .../missing_minus_header/in/file.txt | 5 + .../missing_minus_header/in/foo.patch | 6 + .../missing_minus_header/out/file.txt | 5 + .../gnu_patch/missing_plus_header/in/file.txt | 5 + .../missing_plus_header/in/foo.patch | 6 + .../missing_plus_header/out/file.txt | 5 + .../nested_diff_signature/in/example.rs | 1 + .../nested_diff_signature/in/foo.patch | 25 ++ .../nested_diff_signature/in/mir-test.diff | 12 + .../nested_diff_signature/out/example.rs | 2 + .../nested_diff_signature/out/mir-test.diff | 0 tests/compat/gnu_patch/no_hunk/in/foo.patch | 2 + tests/compat/gnu_patch/no_hunk/out/empty.txt | 0 .../path_quoted_named_escape/in/foo.patch | 4 + .../path_quoted_named_escape/out/bel\a" | 1 + .../path_quoted_octal_escape/in/foo.patch | 4 + .../path_quoted_octal_escape/out/tl\033" | 1 + .../preamble_git_headers/in/file.txt | 5 + .../preamble_git_headers/in/foo.patch | 9 + .../preamble_git_headers/out/file.txt | 5 + .../reversed_header_order/in/file.txt | 5 + .../reversed_header_order/in/foo.patch | 7 + .../reversed_header_order/out/file.txt | 5 + .../gnu_patch/trailing_signature/in/file.txt | 5 + .../gnu_patch/trailing_signature/in/foo.patch | 9 + .../gnu_patch/trailing_signature/out/file.txt | 5 + tests/compat/main.rs | 47 +++ 59 files changed, 1001 insertions(+), 1 deletion(-) create mode 100644 tests/compat/common.rs create mode 100644 tests/compat/gnu_patch.rs create mode 100644 tests/compat/gnu_patch/create_empty_file_gitdiff/in/foo.patch create mode 100644 tests/compat/gnu_patch/create_empty_file_unidiff/in/foo.patch create mode 100644 tests/compat/gnu_patch/create_empty_file_unidiff/out/empty.txt create mode 100644 tests/compat/gnu_patch/create_file/in/foo.patch create mode 100644 tests/compat/gnu_patch/create_file/out/file.txt create mode 100644 tests/compat/gnu_patch/delete_file/in/file.txt create mode 100644 tests/compat/gnu_patch/delete_file/in/foo.patch create mode 100644 tests/compat/gnu_patch/delete_file/out/file.txt create mode 100644 tests/compat/gnu_patch/fail_both_devnull/in/foo.patch create mode 100644 tests/compat/gnu_patch/fail_context_mismatch/in/file.txt create mode 100644 tests/compat/gnu_patch/fail_context_mismatch/in/foo.patch create mode 100644 tests/compat/gnu_patch/fail_hunk_not_found/in/file.txt create mode 100644 tests/compat/gnu_patch/fail_hunk_not_found/in/foo.patch create mode 100644 tests/compat/gnu_patch/fail_truncated_file/in/file.txt create mode 100644 tests/compat/gnu_patch/fail_truncated_file/in/foo.patch create mode 100644 tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/in/file1.rs create mode 100644 tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/in/file2.rs create mode 100644 tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/in/foo.patch create mode 100644 tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/out/file1.rs create mode 100644 tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/out/file2.rs create mode 100644 tests/compat/gnu_patch/junk_between_files/in/bar.txt create mode 100644 tests/compat/gnu_patch/junk_between_files/in/foo.patch create mode 100644 tests/compat/gnu_patch/junk_between_files/in/foo.txt create mode 100644 tests/compat/gnu_patch/junk_between_files/out/bar.txt create mode 100644 tests/compat/gnu_patch/junk_between_files/out/foo.txt create mode 100644 tests/compat/gnu_patch/junk_between_hunks/in/file.txt create mode 100644 tests/compat/gnu_patch/junk_between_hunks/in/foo.patch create mode 100644 tests/compat/gnu_patch/junk_between_hunks/out/file.txt create mode 100644 tests/compat/gnu_patch/missing_minus_header/in/file.txt create mode 100644 tests/compat/gnu_patch/missing_minus_header/in/foo.patch create mode 100644 tests/compat/gnu_patch/missing_minus_header/out/file.txt create mode 100644 tests/compat/gnu_patch/missing_plus_header/in/file.txt create mode 100644 tests/compat/gnu_patch/missing_plus_header/in/foo.patch create mode 100644 tests/compat/gnu_patch/missing_plus_header/out/file.txt create mode 100644 tests/compat/gnu_patch/nested_diff_signature/in/example.rs create mode 100644 tests/compat/gnu_patch/nested_diff_signature/in/foo.patch create mode 100644 tests/compat/gnu_patch/nested_diff_signature/in/mir-test.diff create mode 100644 tests/compat/gnu_patch/nested_diff_signature/out/example.rs create mode 100644 tests/compat/gnu_patch/nested_diff_signature/out/mir-test.diff create mode 100644 tests/compat/gnu_patch/no_hunk/in/foo.patch create mode 100644 tests/compat/gnu_patch/no_hunk/out/empty.txt create mode 100644 tests/compat/gnu_patch/path_quoted_named_escape/in/foo.patch create mode 100644 "tests/compat/gnu_patch/path_quoted_named_escape/out/bel\a" create mode 100644 tests/compat/gnu_patch/path_quoted_octal_escape/in/foo.patch create mode 100644 "tests/compat/gnu_patch/path_quoted_octal_escape/out/tl\033" create mode 100644 tests/compat/gnu_patch/preamble_git_headers/in/file.txt create mode 100644 tests/compat/gnu_patch/preamble_git_headers/in/foo.patch create mode 100644 tests/compat/gnu_patch/preamble_git_headers/out/file.txt create mode 100644 tests/compat/gnu_patch/reversed_header_order/in/file.txt create mode 100644 tests/compat/gnu_patch/reversed_header_order/in/foo.patch create mode 100644 tests/compat/gnu_patch/reversed_header_order/out/file.txt create mode 100644 tests/compat/gnu_patch/trailing_signature/in/file.txt create mode 100644 tests/compat/gnu_patch/trailing_signature/in/foo.patch create mode 100644 tests/compat/gnu_patch/trailing_signature/out/file.txt create mode 100644 tests/compat/main.rs diff --git a/Cargo.lock b/Cargo.lock index e692479c..6dcb098f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,12 +52,33 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "content_inspector" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7bda66e858c683005a53a9a60c69a4aca7eeaa45d124526e389f7aec8e62f38" +dependencies = [ + "memchr", +] + [[package]] name = "diffy" version = "0.4.2" @@ -66,24 +87,148 @@ dependencies = [ "snapbox", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + [[package]] name = "normalize-line-endings" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "similar" version = "2.7.0" @@ -98,9 +243,14 @@ checksum = "6c1abc378119f77310836665f8523018532cf7e3faeb3b10b01da5a7321bf8e1" dependencies = [ "anstream", "anstyle", + "content_inspector", + "dunce", + "filetime", "normalize-line-endings", "similar", "snapbox-macros", + "tempfile", + "walkdir", ] [[package]] @@ -112,12 +262,53 @@ dependencies = [ "anstream", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -197,3 +388,9 @@ name = "windows_x86_64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" diff --git a/Cargo.toml b/Cargo.toml index cae78dcc..cb8a2bf6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ color = ["dep:anstyle"] anstyle = { version = "1.0.13", optional = true } [dev-dependencies] -snapbox = "0.6.24" +snapbox = { version = "0.6.24", features = ["dir"] } [[example]] name = "patch_formatter" diff --git a/tests/compat/common.rs b/tests/compat/common.rs new file mode 100644 index 00000000..ad5cddf8 --- /dev/null +++ b/tests/compat/common.rs @@ -0,0 +1,284 @@ +//! Common utilities for compat tests. + +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, + sync::Once, +}; + +use diffy::patch_set::{FileOperation, ParseOptions, PatchKind, PatchSet, PatchSetParseError}; + +/// A test case with fluent builder API. +pub struct Case<'a> { + case_name: &'a str, + /// Strip level for path prefixes (default: 0) + strip_level: u32, + /// Whether diffy is expected to succeed (default: true) + expect_success: bool, + /// Whether diffy and external tool should agree on success/failure (default: true) + expect_compat: bool, +} + +impl<'a> Case<'a> { + /// Create a test case for GNU patch comparison. + pub fn gnu_patch(name: &'a str) -> Self { + Self { + case_name: name, + strip_level: 0, + expect_success: true, + expect_compat: true, + } + } + + /// Get the case directory path. + fn case_dir(&self) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/compat/gnu_patch") + .join(self.case_name) + } + + pub fn strip(mut self, level: u32) -> Self { + self.strip_level = level; + self + } + + pub fn expect_success(mut self, expect: bool) -> Self { + self.expect_success = expect; + self + } + + pub fn expect_compat(mut self, expect: bool) -> Self { + self.expect_compat = expect; + self + } + + /// Run the test case. + pub fn run(self) { + let case_dir = self.case_dir(); + let in_dir = case_dir.join("in"); + let patch_path = in_dir.join("foo.patch"); + let patch = fs::read_to_string(&patch_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", patch_path.display())); + + let case_name = self.case_name; + let temp_base = temp_base(); + + let diffy_output = temp_base.join(format!("gnu-{case_name}-diffy")); + create_output_dir(&diffy_output); + + let opts = ParseOptions::unidiff(); + + // Apply with diffy + let diffy_result = apply_diffy(&in_dir, &patch, &diffy_output, opts, self.strip_level); + + // Verify diffy result matches expectation + if self.expect_success { + diffy_result.as_ref().expect("diffy should succeed"); + } else { + diffy_result.as_ref().expect_err("diffy should fail"); + } + + // In CI mode, also verify external tool behavior + if is_ci() { + let external_output = temp_base.join(format!("gnu-{case_name}-external")); + create_output_dir(&external_output); + + print_patch_version(); + let external_result = + gnu_patch_apply(&in_dir, &patch_path, &external_output, self.strip_level); + + // For success cases where both succeed and are expected to be compatible, + // verify outputs match + if diffy_result.is_ok() && external_result.is_ok() && self.expect_compat { + snapbox::assert_subset_eq(&external_output, &diffy_output); + } + + // Verify agreement/disagreement based on expectation + if self.expect_compat { + assert_eq!( + diffy_result.is_ok(), + external_result.is_ok(), + "diffy and external tool disagree: diffy={diffy_result:?}, external={external_result:?}", + ); + } else { + assert_ne!( + diffy_result.is_ok(), + external_result.is_ok(), + "expected diffy and external tool to DISAGREE, but both returned same result: \ + diffy={diffy_result:?}, external={external_result:?}", + ); + } + } + + // Compare against expected snapshot (only for success cases) + if self.expect_success { + snapbox::assert_subset_eq(case_dir.join("out"), &diffy_output); + } + } +} + +// External tool invocations + +fn gnu_patch_apply( + in_dir: &Path, + patch_path: &Path, + output_dir: &Path, + strip_level: u32, +) -> Result<(), String> { + copy_input_files(in_dir, output_dir, &["patch"]); + + let output = Command::new("patch") + .arg(format!("-p{strip_level}")) + .arg("--force") + .arg("--batch") + .arg("--input") + .arg(patch_path) + .current_dir(output_dir) + .output() + .unwrap(); + + if output.status.success() { + Ok(()) + } else { + Err(format!( + "GNU patch failed with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + )) + } +} + +fn print_patch_version() { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let output = Command::new("patch").arg("--version").output(); + match output { + Ok(o) if o.status.success() => { + let version = String::from_utf8_lossy(&o.stdout); + eprintln!( + "patch version: {}", + version.lines().next().unwrap_or("unknown") + ); + } + Ok(o) => eprintln!("patch --version failed: {}", o.status), + Err(e) => eprintln!("patch command not found: {e}"), + } + }); +} + +/// Error type for compat tests. +#[derive(Debug)] +pub enum TestError { + Parse(PatchSetParseError), + Apply(diffy::ApplyError), +} + +impl std::fmt::Display for TestError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TestError::Parse(e) => write!(f, "parse error: {e}"), + TestError::Apply(e) => write!(f, "apply error: {e}"), + } + } +} + +/// Get temp output directory base path. +pub fn temp_base() -> PathBuf { + std::env::var("CARGO_TARGET_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir()) +} + +/// Create a clean output directory. +pub fn create_output_dir(path: &Path) { + if path.exists() { + fs::remove_dir_all(path).unwrap(); + } + fs::create_dir_all(path).unwrap(); +} + +/// Copy files from src to dst, skipping files with given extensions. +pub fn copy_input_files(src: &Path, dst: &Path, skip_extensions: &[&str]) { + copy_input_files_impl(src, dst, src, skip_extensions); +} + +fn copy_input_files_impl(src: &Path, dst: &Path, base: &Path, skip_extensions: &[&str]) { + for entry in fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + + // Skip files with specified extensions + if let Some(ext) = path.extension() { + if skip_extensions.iter().any(|e| ext == *e) { + continue; + } + } + + let rel_path = path.strip_prefix(base).unwrap(); + let target = dst.join(rel_path); + + if path.is_dir() { + fs::create_dir_all(&target).unwrap(); + copy_input_files_impl(&path, dst, base, skip_extensions); + } else { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::copy(&path, &target).unwrap(); + } + } +} + +/// Apply patch using diffy to output directory. +pub fn apply_diffy( + in_dir: &Path, + patch: &str, + output_dir: &Path, + opts: ParseOptions, + strip_prefix: u32, +) -> Result<(), TestError> { + let patches: Vec<_> = PatchSet::parse(patch, opts) + .collect::>() + .map_err(TestError::Parse)?; + + for file_patch in patches.iter() { + let operation = file_patch.operation().strip_prefix(strip_prefix as usize); + + let (original_name, target_name) = match &operation { + FileOperation::Create(path) => (None, path.as_ref()), + FileOperation::Delete(path) => (Some(path.as_ref()), path.as_ref()), + FileOperation::Modify { original, modified } => { + (Some(original.as_ref()), modified.as_ref()) + } + FileOperation::Rename { from, to } | FileOperation::Copy { from, to } => { + (Some(from.as_ref()), to.as_ref()) + } + }; + + match file_patch.patch() { + PatchKind::Text(patch) => { + let original = if let Some(name) = original_name { + let original_path = in_dir.join(name); + fs::read_to_string(&original_path).unwrap_or_default() + } else { + String::new() + }; + + let result = diffy::apply(&original, patch).map_err(TestError::Apply)?; + + let result_path = output_dir.join(target_name); + if let Some(parent) = result_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&result_path, result.as_bytes()).unwrap(); + } + } + } + + Ok(()) +} + +pub fn is_ci() -> bool { + std::env::var("CI").is_ok() +} diff --git a/tests/compat/gnu_patch.rs b/tests/compat/gnu_patch.rs new file mode 100644 index 00000000..ba3d3fc3 --- /dev/null +++ b/tests/compat/gnu_patch.rs @@ -0,0 +1,200 @@ +//! GNU patch compatibility tests. See [`crate`] for test structure and usage. +//! +//! Focus areas: +//! +//! - UniDiff format edge cases (missing headers, reversed order) +//! - Agreement between diffy and `patch` command + +use crate::common::Case; + +// Success cases + +#[test] +fn create_file() { + Case::gnu_patch("create_file").run(); +} + +// GNU patch decodes C-style named escapes (\a, \b, \f, \v) in quoted +// filenames in ---/+++ headers. +// +// Observed with GNU patch 2.7.1: +// $ patch -p0 < test.patch # with +++ "bel\a" +// patching file bel +// +// diffy now decodes these correctly. +#[test] +fn path_quoted_named_escape() { + Case::gnu_patch("path_quoted_named_escape").run(); +} + +// GNU patch decodes 3-digit octal escapes (\000–\377) in quoted filenames. +// +// Observed with GNU patch 2.7.1: +// $ patch -p0 < test.patch # with +++ "tl\033" +// patching file tl +// +// diffy currently misparsed \033: the \0 is consumed as a standalone NUL +// byte, leaving "33" as literal characters. +// +#[test] +fn path_quoted_octal_escape() { + Case::gnu_patch("path_quoted_octal_escape").run(); +} + +#[test] +fn reversed_header_order() { + Case::gnu_patch("reversed_header_order").run(); +} + +#[test] +fn missing_plus_header() { + Case::gnu_patch("missing_plus_header").run(); +} + +#[test] +fn missing_minus_header() { + Case::gnu_patch("missing_minus_header").run(); +} + +// Empty file creation using unified diff format with empty hunk. +// +// Platform compatibility: +// - Apple patch 2.0 (macOS/BSD): ✅ Accepts, creates empty file (0 bytes) +// - GNU patch 2.8 (Linux): ❌ Rejects as "malformed patch at line 3" +// - diffy: ✅ Accepts (with our current implementation) +#[test] +fn create_empty_file_unidiff() { + Case::gnu_patch("create_empty_file_unidiff") + .expect_compat(false) + .run(); +} + +// Empty file creation using git diff format (no unified diff headers/hunks). +// +// - GNU patch: succeeds, creates empty file +// - diffy: fails (no ---/+++ headers means no valid UniDiff patches) +#[test] +fn create_empty_file_gitdiff() { + Case::gnu_patch("create_empty_file_gitdiff") + .strip(1) + .expect_success(false) + .expect_compat(false) + .run(); +} + +#[test] +fn delete_file() { + Case::gnu_patch("delete_file").run(); +} + +#[test] +fn preamble_git_headers() { + Case::gnu_patch("preamble_git_headers").run(); +} + +// Multi-file patch with junk/preamble text between different files. +// +// GNU patch behavior: Treats content before `---` as "text leading up to" +// the next patch (preamble), which is silently ignored. +// +// Verified with: +// ``` +// patch -p0 --dry-run --verbose < multi-file-junk.patch +// ``` +// Output shows: +// ``` +// Hmm... The next patch looks like a unified diff to me... +// The text leading up to this was: +// -------------------------- +// |JUNK BETWEEN FILES!!!! +// |This preamble text should be ignored +// ... +// ``` +// +// This is different from junk between HUNKS of the same file (which fails). +#[test] +fn junk_between_files() { + Case::gnu_patch("junk_between_files").run(); +} + +#[test] +fn trailing_signature() { + Case::gnu_patch("trailing_signature").run(); +} + +// Patch that deletes a diff file containing `-- ` patterns within its content, +// followed by a real email signature at the end. +// +// This tests that we correctly distinguish between: +// - `-- ` appearing as patch content (from inner diff's empty context lines) +// - `-- ` appearing as the actual email signature separator +// +// Both GNU patch and git apply handle this correctly without pre-stripping. +#[test] +fn nested_diff_signature() { + Case::gnu_patch("nested_diff_signature").strip(1).run(); +} + +// A hunk that adds a line whose content is literally "++ foo" renders in the +// diff as "+++ foo" (the leading "+" is the add marker). Both GNU patch and +// git apply parse this correctly as 2 patches without splitting the hunk. +#[test] +fn false_positive_plus_plus_in_hunk() { + Case::gnu_patch("false_positive_plus_plus_in_hunk").run(); +} + +// Failure cases + +#[test] +fn fail_context_mismatch() { + Case::gnu_patch("fail_context_mismatch") + .expect_success(false) + .run(); +} + +#[test] +fn fail_hunk_not_found() { + Case::gnu_patch("fail_hunk_not_found") + .expect_success(false) + .run(); +} + +#[test] +fn fail_truncated_file() { + Case::gnu_patch("fail_truncated_file") + .expect_success(false) + .run(); +} + +// Single-file patch with junk between hunks. +// +// - GNU patch: succeeds, ignores trailing junk, applies first hunk only +// - git apply: errors ("patch fragment without header") +// - diffy: succeeds, matches GNU patch behavior +#[test] +fn junk_between_hunks() { + Case::gnu_patch("junk_between_hunks").run(); +} + +// Patch with ---/+++ headers but no @@ hunks. +// +// - GNU patch: rejects ("Only garbage was found in the patch input") +// - diffy: succeeds, parses as 1 patch with 0 hunks +// +// diffy allows 0-hunk patches for GitDiff mode where empty/binary files have no hunks. +#[test] +fn no_hunk() { + Case::gnu_patch("no_hunk") + .expect_success(true) + .expect_compat(false) + .run(); +} + +// Both --- and +++ point to /dev/null. +// GNU patch rejects: "can't find file to patch" (exit 1) +#[test] +fn fail_both_devnull() { + Case::gnu_patch("fail_both_devnull") + .expect_success(false) + .run(); +} diff --git a/tests/compat/gnu_patch/create_empty_file_gitdiff/in/foo.patch b/tests/compat/gnu_patch/create_empty_file_gitdiff/in/foo.patch new file mode 100644 index 00000000..e3c7fdf4 --- /dev/null +++ b/tests/compat/gnu_patch/create_empty_file_gitdiff/in/foo.patch @@ -0,0 +1,3 @@ +diff --git a/empty.txt b/empty.txt +new file mode 100644 +index 0000000..e69de29 \ No newline at end of file diff --git a/tests/compat/gnu_patch/create_empty_file_unidiff/in/foo.patch b/tests/compat/gnu_patch/create_empty_file_unidiff/in/foo.patch new file mode 100644 index 00000000..cea48360 --- /dev/null +++ b/tests/compat/gnu_patch/create_empty_file_unidiff/in/foo.patch @@ -0,0 +1,3 @@ +--- /dev/null ++++ empty.txt +@@ -0,0 +1,0 @@ \ No newline at end of file diff --git a/tests/compat/gnu_patch/create_empty_file_unidiff/out/empty.txt b/tests/compat/gnu_patch/create_empty_file_unidiff/out/empty.txt new file mode 100644 index 00000000..e69de29b diff --git a/tests/compat/gnu_patch/create_file/in/foo.patch b/tests/compat/gnu_patch/create_file/in/foo.patch new file mode 100644 index 00000000..23d41ab7 --- /dev/null +++ b/tests/compat/gnu_patch/create_file/in/foo.patch @@ -0,0 +1,6 @@ +--- /dev/null ++++ file.txt +@@ -0,0 +1,3 @@ ++This is a brand new file. ++It was created from nothing. ++End of new file. diff --git a/tests/compat/gnu_patch/create_file/out/file.txt b/tests/compat/gnu_patch/create_file/out/file.txt new file mode 100644 index 00000000..c2a2743c --- /dev/null +++ b/tests/compat/gnu_patch/create_file/out/file.txt @@ -0,0 +1,3 @@ +This is a brand new file. +It was created from nothing. +End of new file. diff --git a/tests/compat/gnu_patch/delete_file/in/file.txt b/tests/compat/gnu_patch/delete_file/in/file.txt new file mode 100644 index 00000000..b3c5a95f --- /dev/null +++ b/tests/compat/gnu_patch/delete_file/in/file.txt @@ -0,0 +1,5 @@ +line1 +line2 +line3 +line4 +line5 diff --git a/tests/compat/gnu_patch/delete_file/in/foo.patch b/tests/compat/gnu_patch/delete_file/in/foo.patch new file mode 100644 index 00000000..a2f26cea --- /dev/null +++ b/tests/compat/gnu_patch/delete_file/in/foo.patch @@ -0,0 +1,8 @@ +--- file.txt ++++ /dev/null +@@ -1,5 +0,0 @@ +-line1 +-line2 +-line3 +-line4 +-line5 diff --git a/tests/compat/gnu_patch/delete_file/out/file.txt b/tests/compat/gnu_patch/delete_file/out/file.txt new file mode 100644 index 00000000..e69de29b diff --git a/tests/compat/gnu_patch/fail_both_devnull/in/foo.patch b/tests/compat/gnu_patch/fail_both_devnull/in/foo.patch new file mode 100644 index 00000000..7e898c92 --- /dev/null +++ b/tests/compat/gnu_patch/fail_both_devnull/in/foo.patch @@ -0,0 +1,5 @@ +--- /dev/null ++++ /dev/null +@@ -1 +1 @@ +-old ++new diff --git a/tests/compat/gnu_patch/fail_context_mismatch/in/file.txt b/tests/compat/gnu_patch/fail_context_mismatch/in/file.txt new file mode 100644 index 00000000..8bdf8860 --- /dev/null +++ b/tests/compat/gnu_patch/fail_context_mismatch/in/file.txt @@ -0,0 +1,4 @@ +This is line one. +This is line two. +This is line three. +This is line four. diff --git a/tests/compat/gnu_patch/fail_context_mismatch/in/foo.patch b/tests/compat/gnu_patch/fail_context_mismatch/in/foo.patch new file mode 100644 index 00000000..473a1e30 --- /dev/null +++ b/tests/compat/gnu_patch/fail_context_mismatch/in/foo.patch @@ -0,0 +1,8 @@ +--- file.txt ++++ file.txt +@@ -1,4 +1,4 @@ + This is line one. +-This is WRONG context. ++This is the replacement. + This is line three. + This is line four. \ No newline at end of file diff --git a/tests/compat/gnu_patch/fail_hunk_not_found/in/file.txt b/tests/compat/gnu_patch/fail_hunk_not_found/in/file.txt new file mode 100644 index 00000000..01b22dd4 --- /dev/null +++ b/tests/compat/gnu_patch/fail_hunk_not_found/in/file.txt @@ -0,0 +1,4 @@ +Alpha +Beta +Gamma +Delta diff --git a/tests/compat/gnu_patch/fail_hunk_not_found/in/foo.patch b/tests/compat/gnu_patch/fail_hunk_not_found/in/foo.patch new file mode 100644 index 00000000..0b13f2c3 --- /dev/null +++ b/tests/compat/gnu_patch/fail_hunk_not_found/in/foo.patch @@ -0,0 +1,8 @@ +--- file.txt ++++ file.txt +@@ -1,4 +1,4 @@ + Alpha +-This line does not exist ++Replacement line + Gamma + Delta \ No newline at end of file diff --git a/tests/compat/gnu_patch/fail_truncated_file/in/file.txt b/tests/compat/gnu_patch/fail_truncated_file/in/file.txt new file mode 100644 index 00000000..51da5d4c --- /dev/null +++ b/tests/compat/gnu_patch/fail_truncated_file/in/file.txt @@ -0,0 +1,2 @@ +Line one. +Line two. diff --git a/tests/compat/gnu_patch/fail_truncated_file/in/foo.patch b/tests/compat/gnu_patch/fail_truncated_file/in/foo.patch new file mode 100644 index 00000000..7316089b --- /dev/null +++ b/tests/compat/gnu_patch/fail_truncated_file/in/foo.patch @@ -0,0 +1,9 @@ +--- file.txt ++++ file.txt +@@ -1,5 +1,5 @@ + Line one. + Line two. +-Line three. ++Line THREE. + Line four. + Line five. \ No newline at end of file diff --git a/tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/in/file1.rs b/tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/in/file1.rs new file mode 100644 index 00000000..7f272cf6 --- /dev/null +++ b/tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/in/file1.rs @@ -0,0 +1,2 @@ +line1 +old diff --git a/tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/in/file2.rs b/tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/in/file2.rs new file mode 100644 index 00000000..78981922 --- /dev/null +++ b/tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/in/file2.rs @@ -0,0 +1 @@ +a diff --git a/tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/in/foo.patch b/tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/in/foo.patch new file mode 100644 index 00000000..5d06f51d --- /dev/null +++ b/tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/in/foo.patch @@ -0,0 +1,11 @@ +--- file1.rs ++++ file1.rs +@@ -1,2 +1,2 @@ + line1 +-old ++++ foo +--- file2.rs ++++ file2.rs +@@ -1 +1 @@ +-a ++b diff --git a/tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/out/file1.rs b/tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/out/file1.rs new file mode 100644 index 00000000..35d57ea6 --- /dev/null +++ b/tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/out/file1.rs @@ -0,0 +1,2 @@ +line1 +++ foo diff --git a/tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/out/file2.rs b/tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/out/file2.rs new file mode 100644 index 00000000..61780798 --- /dev/null +++ b/tests/compat/gnu_patch/false_positive_plus_plus_in_hunk/out/file2.rs @@ -0,0 +1 @@ +b diff --git a/tests/compat/gnu_patch/junk_between_files/in/bar.txt b/tests/compat/gnu_patch/junk_between_files/in/bar.txt new file mode 100644 index 00000000..601d8ee1 --- /dev/null +++ b/tests/compat/gnu_patch/junk_between_files/in/bar.txt @@ -0,0 +1 @@ +bar line1 diff --git a/tests/compat/gnu_patch/junk_between_files/in/foo.patch b/tests/compat/gnu_patch/junk_between_files/in/foo.patch new file mode 100644 index 00000000..c7e9f819 --- /dev/null +++ b/tests/compat/gnu_patch/junk_between_files/in/foo.patch @@ -0,0 +1,13 @@ +--- foo.txt ++++ foo.txt +@@ -1 +1 @@ +-foo line1 ++FOO LINE1 +JUNK BETWEEN FILES!!!! +This preamble text should be ignored +by both GNU patch and diffy +--- bar.txt ++++ bar.txt +@@ -1 +1 @@ +-bar line1 ++BAR LINE1 diff --git a/tests/compat/gnu_patch/junk_between_files/in/foo.txt b/tests/compat/gnu_patch/junk_between_files/in/foo.txt new file mode 100644 index 00000000..b11358e1 --- /dev/null +++ b/tests/compat/gnu_patch/junk_between_files/in/foo.txt @@ -0,0 +1 @@ +foo line1 diff --git a/tests/compat/gnu_patch/junk_between_files/out/bar.txt b/tests/compat/gnu_patch/junk_between_files/out/bar.txt new file mode 100644 index 00000000..76c036d0 --- /dev/null +++ b/tests/compat/gnu_patch/junk_between_files/out/bar.txt @@ -0,0 +1 @@ +BAR LINE1 diff --git a/tests/compat/gnu_patch/junk_between_files/out/foo.txt b/tests/compat/gnu_patch/junk_between_files/out/foo.txt new file mode 100644 index 00000000..787bc665 --- /dev/null +++ b/tests/compat/gnu_patch/junk_between_files/out/foo.txt @@ -0,0 +1 @@ +FOO LINE1 diff --git a/tests/compat/gnu_patch/junk_between_hunks/in/file.txt b/tests/compat/gnu_patch/junk_between_hunks/in/file.txt new file mode 100644 index 00000000..822aed3f --- /dev/null +++ b/tests/compat/gnu_patch/junk_between_hunks/in/file.txt @@ -0,0 +1,9 @@ +line1 +line2 +line3 +line4 +line5 +line6 +line7 +line8 +line9 diff --git a/tests/compat/gnu_patch/junk_between_hunks/in/foo.patch b/tests/compat/gnu_patch/junk_between_hunks/in/foo.patch new file mode 100644 index 00000000..5e3389ba --- /dev/null +++ b/tests/compat/gnu_patch/junk_between_hunks/in/foo.patch @@ -0,0 +1,13 @@ +--- file.txt ++++ file.txt +@@ -1,3 +1,3 @@ +-line1 ++LINE1 + line2 + line3 +JUNK BETWEEN HUNKS +@@ -7,3 +7,3 @@ + line7 +-line8 ++LINE8 + line9 diff --git a/tests/compat/gnu_patch/junk_between_hunks/out/file.txt b/tests/compat/gnu_patch/junk_between_hunks/out/file.txt new file mode 100644 index 00000000..2e5e454d --- /dev/null +++ b/tests/compat/gnu_patch/junk_between_hunks/out/file.txt @@ -0,0 +1,9 @@ +LINE1 +line2 +line3 +line4 +line5 +line6 +line7 +line8 +line9 diff --git a/tests/compat/gnu_patch/missing_minus_header/in/file.txt b/tests/compat/gnu_patch/missing_minus_header/in/file.txt new file mode 100644 index 00000000..b3c5a95f --- /dev/null +++ b/tests/compat/gnu_patch/missing_minus_header/in/file.txt @@ -0,0 +1,5 @@ +line1 +line2 +line3 +line4 +line5 diff --git a/tests/compat/gnu_patch/missing_minus_header/in/foo.patch b/tests/compat/gnu_patch/missing_minus_header/in/foo.patch new file mode 100644 index 00000000..033da249 --- /dev/null +++ b/tests/compat/gnu_patch/missing_minus_header/in/foo.patch @@ -0,0 +1,6 @@ ++++ file.txt +@@ -1,3 +1,3 @@ + line1 +-line2 ++modified line2 + line3 diff --git a/tests/compat/gnu_patch/missing_minus_header/out/file.txt b/tests/compat/gnu_patch/missing_minus_header/out/file.txt new file mode 100644 index 00000000..06e58be2 --- /dev/null +++ b/tests/compat/gnu_patch/missing_minus_header/out/file.txt @@ -0,0 +1,5 @@ +line1 +modified line2 +line3 +line4 +line5 diff --git a/tests/compat/gnu_patch/missing_plus_header/in/file.txt b/tests/compat/gnu_patch/missing_plus_header/in/file.txt new file mode 100644 index 00000000..b3c5a95f --- /dev/null +++ b/tests/compat/gnu_patch/missing_plus_header/in/file.txt @@ -0,0 +1,5 @@ +line1 +line2 +line3 +line4 +line5 diff --git a/tests/compat/gnu_patch/missing_plus_header/in/foo.patch b/tests/compat/gnu_patch/missing_plus_header/in/foo.patch new file mode 100644 index 00000000..ae18ba7a --- /dev/null +++ b/tests/compat/gnu_patch/missing_plus_header/in/foo.patch @@ -0,0 +1,6 @@ +--- file.txt +@@ -1,3 +1,3 @@ + line1 +-line2 ++modified line2 + line3 diff --git a/tests/compat/gnu_patch/missing_plus_header/out/file.txt b/tests/compat/gnu_patch/missing_plus_header/out/file.txt new file mode 100644 index 00000000..06e58be2 --- /dev/null +++ b/tests/compat/gnu_patch/missing_plus_header/out/file.txt @@ -0,0 +1,5 @@ +line1 +modified line2 +line3 +line4 +line5 diff --git a/tests/compat/gnu_patch/nested_diff_signature/in/example.rs b/tests/compat/gnu_patch/nested_diff_signature/in/example.rs new file mode 100644 index 00000000..8f3b7ef1 --- /dev/null +++ b/tests/compat/gnu_patch/nested_diff_signature/in/example.rs @@ -0,0 +1 @@ +fn foo() {} diff --git a/tests/compat/gnu_patch/nested_diff_signature/in/foo.patch b/tests/compat/gnu_patch/nested_diff_signature/in/foo.patch new file mode 100644 index 00000000..5d876c61 --- /dev/null +++ b/tests/compat/gnu_patch/nested_diff_signature/in/foo.patch @@ -0,0 +1,25 @@ +diff --git a/mir-test.diff b/mir-test.diff +deleted file mode 100644 +index 98012d7..0000000 +--- a/mir-test.diff ++++ /dev/null +@@ -1,12 +0,0 @@ +-- // MIR before +-+ // MIR after +- +- fn opt() { +- bb0: { +-- nop; +-- } +-- +-- bb1: { +-- nop; +- } +- } +diff --git a/example.rs b/example.rs +index 8f3b7ef..2a40712 100644 +--- a/example.rs ++++ b/example.rs +@@ -1 +1,2 @@ + fn foo() {} ++fn bar() {} diff --git a/tests/compat/gnu_patch/nested_diff_signature/in/mir-test.diff b/tests/compat/gnu_patch/nested_diff_signature/in/mir-test.diff new file mode 100644 index 00000000..98012d7e --- /dev/null +++ b/tests/compat/gnu_patch/nested_diff_signature/in/mir-test.diff @@ -0,0 +1,12 @@ +- // MIR before ++ // MIR after + + fn opt() { + bb0: { +- nop; +- } +- +- bb1: { +- nop; + } + } diff --git a/tests/compat/gnu_patch/nested_diff_signature/out/example.rs b/tests/compat/gnu_patch/nested_diff_signature/out/example.rs new file mode 100644 index 00000000..2a40712e --- /dev/null +++ b/tests/compat/gnu_patch/nested_diff_signature/out/example.rs @@ -0,0 +1,2 @@ +fn foo() {} +fn bar() {} diff --git a/tests/compat/gnu_patch/nested_diff_signature/out/mir-test.diff b/tests/compat/gnu_patch/nested_diff_signature/out/mir-test.diff new file mode 100644 index 00000000..e69de29b diff --git a/tests/compat/gnu_patch/no_hunk/in/foo.patch b/tests/compat/gnu_patch/no_hunk/in/foo.patch new file mode 100644 index 00000000..92e4d88d --- /dev/null +++ b/tests/compat/gnu_patch/no_hunk/in/foo.patch @@ -0,0 +1,2 @@ +--- /dev/null ++++ empty.txt diff --git a/tests/compat/gnu_patch/no_hunk/out/empty.txt b/tests/compat/gnu_patch/no_hunk/out/empty.txt new file mode 100644 index 00000000..e69de29b diff --git a/tests/compat/gnu_patch/path_quoted_named_escape/in/foo.patch b/tests/compat/gnu_patch/path_quoted_named_escape/in/foo.patch new file mode 100644 index 00000000..15ad4965 --- /dev/null +++ b/tests/compat/gnu_patch/path_quoted_named_escape/in/foo.patch @@ -0,0 +1,4 @@ +--- /dev/null ++++ "bel\a" +@@ -0,0 +1 @@ ++hello diff --git "a/tests/compat/gnu_patch/path_quoted_named_escape/out/bel\a" "b/tests/compat/gnu_patch/path_quoted_named_escape/out/bel\a" new file mode 100644 index 00000000..ce013625 --- /dev/null +++ "b/tests/compat/gnu_patch/path_quoted_named_escape/out/bel\a" @@ -0,0 +1 @@ +hello diff --git a/tests/compat/gnu_patch/path_quoted_octal_escape/in/foo.patch b/tests/compat/gnu_patch/path_quoted_octal_escape/in/foo.patch new file mode 100644 index 00000000..e865b9d3 --- /dev/null +++ b/tests/compat/gnu_patch/path_quoted_octal_escape/in/foo.patch @@ -0,0 +1,4 @@ +--- /dev/null ++++ "tl\033" +@@ -0,0 +1 @@ ++hello diff --git "a/tests/compat/gnu_patch/path_quoted_octal_escape/out/tl\033" "b/tests/compat/gnu_patch/path_quoted_octal_escape/out/tl\033" new file mode 100644 index 00000000..ce013625 --- /dev/null +++ "b/tests/compat/gnu_patch/path_quoted_octal_escape/out/tl\033" @@ -0,0 +1 @@ +hello diff --git a/tests/compat/gnu_patch/preamble_git_headers/in/file.txt b/tests/compat/gnu_patch/preamble_git_headers/in/file.txt new file mode 100644 index 00000000..b3c5a95f --- /dev/null +++ b/tests/compat/gnu_patch/preamble_git_headers/in/file.txt @@ -0,0 +1,5 @@ +line1 +line2 +line3 +line4 +line5 diff --git a/tests/compat/gnu_patch/preamble_git_headers/in/foo.patch b/tests/compat/gnu_patch/preamble_git_headers/in/foo.patch new file mode 100644 index 00000000..aa74dfc2 --- /dev/null +++ b/tests/compat/gnu_patch/preamble_git_headers/in/foo.patch @@ -0,0 +1,9 @@ +diff --git a/file.txt b/file.txt +index 1234567..89abcdef 100644 +--- file.txt ++++ file.txt +@@ -1,3 +1,3 @@ + line1 +-line2 ++modified line2 + line3 diff --git a/tests/compat/gnu_patch/preamble_git_headers/out/file.txt b/tests/compat/gnu_patch/preamble_git_headers/out/file.txt new file mode 100644 index 00000000..06e58be2 --- /dev/null +++ b/tests/compat/gnu_patch/preamble_git_headers/out/file.txt @@ -0,0 +1,5 @@ +line1 +modified line2 +line3 +line4 +line5 diff --git a/tests/compat/gnu_patch/reversed_header_order/in/file.txt b/tests/compat/gnu_patch/reversed_header_order/in/file.txt new file mode 100644 index 00000000..b3c5a95f --- /dev/null +++ b/tests/compat/gnu_patch/reversed_header_order/in/file.txt @@ -0,0 +1,5 @@ +line1 +line2 +line3 +line4 +line5 diff --git a/tests/compat/gnu_patch/reversed_header_order/in/foo.patch b/tests/compat/gnu_patch/reversed_header_order/in/foo.patch new file mode 100644 index 00000000..845de127 --- /dev/null +++ b/tests/compat/gnu_patch/reversed_header_order/in/foo.patch @@ -0,0 +1,7 @@ ++++ file.txt +--- file.txt +@@ -1,3 +1,3 @@ + line1 +-line2 ++modified line2 + line3 diff --git a/tests/compat/gnu_patch/reversed_header_order/out/file.txt b/tests/compat/gnu_patch/reversed_header_order/out/file.txt new file mode 100644 index 00000000..06e58be2 --- /dev/null +++ b/tests/compat/gnu_patch/reversed_header_order/out/file.txt @@ -0,0 +1,5 @@ +line1 +modified line2 +line3 +line4 +line5 diff --git a/tests/compat/gnu_patch/trailing_signature/in/file.txt b/tests/compat/gnu_patch/trailing_signature/in/file.txt new file mode 100644 index 00000000..b3c5a95f --- /dev/null +++ b/tests/compat/gnu_patch/trailing_signature/in/file.txt @@ -0,0 +1,5 @@ +line1 +line2 +line3 +line4 +line5 diff --git a/tests/compat/gnu_patch/trailing_signature/in/foo.patch b/tests/compat/gnu_patch/trailing_signature/in/foo.patch new file mode 100644 index 00000000..01cdd67c --- /dev/null +++ b/tests/compat/gnu_patch/trailing_signature/in/foo.patch @@ -0,0 +1,9 @@ +--- file.txt ++++ file.txt +@@ -1,3 +1,3 @@ + line1 +-line2 ++modified line2 + line3 +-- +2.40.0 diff --git a/tests/compat/gnu_patch/trailing_signature/out/file.txt b/tests/compat/gnu_patch/trailing_signature/out/file.txt new file mode 100644 index 00000000..06e58be2 --- /dev/null +++ b/tests/compat/gnu_patch/trailing_signature/out/file.txt @@ -0,0 +1,5 @@ +line1 +modified line2 +line3 +line4 +line5 diff --git a/tests/compat/main.rs b/tests/compat/main.rs new file mode 100644 index 00000000..8faf9eac --- /dev/null +++ b/tests/compat/main.rs @@ -0,0 +1,47 @@ +//! Compatibility tests against reference implementations. +//! +//! These tests verify diffy produces results compatible with established tools +//! Focus is on edge cases and ambiguous behavior, +//! not basic functionality which is covered by unit tests in `src/patches/tests.rs`. +//! +//! ## Test structure +//! +//! Each test case has: +//! +//! - `in/` directory with original file(s) and `foo.patch` +//! - `out/` directory with expected patched file(s) (for success cases) +//! +//! For failure test cases: +//! +//! - Only `in/` directory is needed (no `out/`) +//! - Both diffy and reference tool should fail to apply +//! +//! ## Running tests +//! +//! ```sh +//! # Run all compat tests +//! cargo test --test compat +//! +//! # Run with reference tool comparison (CI mode) +//! CI=1 cargo test --test compat +//! +//! # For Nix users, run this to ensure you have GNU patch +//! CI=1 nix shell nixpkgs#gnupatch -c cargo test --test compat +//! ``` +//! +//! ## Regenerating snapshots +//! +//! ```sh +//! SNAPSHOTS=overwrite cargo test --test compat +//! ``` +//! +//! ## Adding new test cases +//! +//! 1. Create `case_name/in/` with input file(s) and `foo.patch` +//! 2. Run `SNAPSHOTS=overwrite cargo test --test compat` to generate `out/` +//! 3. Add `#[test] fn case_name() { Case::gnu_patch(...).run(); }` in the module +//! +//! For failure tests, use `.expect_success(false)` and skip step 2. + +mod common; +mod gnu_patch; From 96104bacc7b4b2d2befb8b06364874474af8b266 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 11 Apr 2026 09:42:33 -0500 Subject: [PATCH 2/5] test(compat): drop stale misparsing note on path_quoted_octal_escape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed diffy "currently misparsed" `\033` in quoted filename headers, but the test runs unconditionally and passes — diffy already decodes 3-digit octal escapes correctly. Replace the stale note with a one-liner confirming the behavior. --- tests/compat/gnu_patch.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/compat/gnu_patch.rs b/tests/compat/gnu_patch.rs index ba3d3fc3..8d545c3a 100644 --- a/tests/compat/gnu_patch.rs +++ b/tests/compat/gnu_patch.rs @@ -33,9 +33,7 @@ fn path_quoted_named_escape() { // $ patch -p0 < test.patch # with +++ "tl\033" // patching file tl // -// diffy currently misparsed \033: the \0 is consumed as a standalone NUL -// byte, leaving "33" as literal characters. -// +// diffy decodes these correctly. #[test] fn path_quoted_octal_escape() { Case::gnu_patch("path_quoted_octal_escape").run(); From f5a9d8b0cb495978e41c932c464dd1125b1ced48 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 11 Apr 2026 09:43:17 -0500 Subject: [PATCH 3/5] test(compat): group junk_between_hunks with success cases junk_between_hunks was placed under the `// Failure cases` divider, but its doc comment and assertions both document a success: diffy matches GNU patch by applying the first hunk and ignoring trailing junk. Move it up alongside the other success cases so the section dividers stay meaningful. --- tests/compat/gnu_patch.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/compat/gnu_patch.rs b/tests/compat/gnu_patch.rs index 8d545c3a..fe59fcb7 100644 --- a/tests/compat/gnu_patch.rs +++ b/tests/compat/gnu_patch.rs @@ -141,6 +141,16 @@ fn false_positive_plus_plus_in_hunk() { Case::gnu_patch("false_positive_plus_plus_in_hunk").run(); } +// Single-file patch with junk between hunks. +// +// - GNU patch: succeeds, ignores trailing junk, applies first hunk only +// - git apply: errors ("patch fragment without header") +// - diffy: succeeds, matches GNU patch behavior +#[test] +fn junk_between_hunks() { + Case::gnu_patch("junk_between_hunks").run(); +} + // Failure cases #[test] @@ -164,16 +174,6 @@ fn fail_truncated_file() { .run(); } -// Single-file patch with junk between hunks. -// -// - GNU patch: succeeds, ignores trailing junk, applies first hunk only -// - git apply: errors ("patch fragment without header") -// - diffy: succeeds, matches GNU patch behavior -#[test] -fn junk_between_hunks() { - Case::gnu_patch("junk_between_hunks").run(); -} - // Patch with ---/+++ headers but no @@ hunks. // // - GNU patch: rejects ("Only garbage was found in the patch input") From 0ec93cd2ece466272f643e64e13c6628b86eee2b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 11 Apr 2026 09:43:36 -0500 Subject: [PATCH 4/5] test(compat): fail loudly when original input file is missing Previously, `apply_diffy` used `unwrap_or_default()` when reading the original file for `Modify`, `Delete`, `Rename`, and `Copy` operations. A missing or unreadable fixture would silently substitute an empty string, so a broken test fixture could still produce a "successful" apply against nothing and mask the bug. Replace the fallback with a panic that names the path, so fixture problems surface as test failures instead of silent wrong answers. The `Create` branch already takes the `None` arm and is unaffected. --- tests/compat/common.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/compat/common.rs b/tests/compat/common.rs index ad5cddf8..7bc6f37a 100644 --- a/tests/compat/common.rs +++ b/tests/compat/common.rs @@ -260,7 +260,9 @@ pub fn apply_diffy( PatchKind::Text(patch) => { let original = if let Some(name) = original_name { let original_path = in_dir.join(name); - fs::read_to_string(&original_path).unwrap_or_default() + fs::read_to_string(&original_path).unwrap_or_else(|e| { + panic!("failed to read {}: {e}", original_path.display()) + }) } else { String::new() }; From 81ca817b3469f14f9554ca59997ca990dcf6c923 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 11 Apr 2026 09:45:06 -0500 Subject: [PATCH 5/5] rename gnu_patch.rs gnu_patch/mod.rs --- tests/compat/{gnu_patch.rs => gnu_patch/mod.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/compat/{gnu_patch.rs => gnu_patch/mod.rs} (100%) diff --git a/tests/compat/gnu_patch.rs b/tests/compat/gnu_patch/mod.rs similarity index 100% rename from tests/compat/gnu_patch.rs rename to tests/compat/gnu_patch/mod.rs