Skip to content

Commit 70cbccf

Browse files
committed
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.
1 parent 46ea861 commit 70cbccf

File tree

53 files changed

+1268
-1
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+1268
-1
lines changed

Cargo.lock

Lines changed: 503 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ color = ["dep:anstyle"]
1919
anstyle = { version = "1.0.13", optional = true }
2020

2121
[dev-dependencies]
22-
snapbox = "0.6.24"
22+
snapbox = { version = "0.6.24", features = ["dir"] }
2323

2424
[[example]]
2525
name = "patch_formatter"

tests/compat/common.rs

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
//! Common utilities for compat tests.
2+
3+
use std::{
4+
fs,
5+
path::{Path, PathBuf},
6+
process::Command,
7+
sync::Once,
8+
};
9+
10+
use diffy::patches::{FileOperation, ParseOptions, PatchKind, Patches, PatchesParseError};
11+
12+
/// A test case with fluent builder API.
13+
pub struct Case<'a> {
14+
case_name: &'a str,
15+
/// Strip level for path prefixes (default: 0)
16+
strip_level: u32,
17+
/// Whether diffy is expected to succeed (default: true)
18+
expect_success: bool,
19+
/// Whether diffy and external tool should agree on success/failure (default: true)
20+
expect_compat: bool,
21+
}
22+
23+
impl<'a> Case<'a> {
24+
/// Create a test case for GNU patch comparison.
25+
pub fn gnu_patch(name: &'a str) -> Self {
26+
Self {
27+
case_name: name,
28+
strip_level: 0,
29+
expect_success: true,
30+
expect_compat: true,
31+
}
32+
}
33+
34+
/// Get the case directory path.
35+
fn case_dir(&self) -> PathBuf {
36+
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
37+
.join("tests/compat/gnu_patch")
38+
.join(self.case_name)
39+
}
40+
41+
pub fn strip(mut self, level: u32) -> Self {
42+
self.strip_level = level;
43+
self
44+
}
45+
46+
pub fn expect_success(mut self, expect: bool) -> Self {
47+
self.expect_success = expect;
48+
self
49+
}
50+
51+
pub fn expect_compat(mut self, expect: bool) -> Self {
52+
self.expect_compat = expect;
53+
self
54+
}
55+
56+
/// Run the test case.
57+
pub fn run(self) {
58+
let case_dir = self.case_dir();
59+
let in_dir = case_dir.join("in");
60+
let patch_path = in_dir.join("foo.patch");
61+
let patch = fs::read_to_string(&patch_path)
62+
.unwrap_or_else(|e| panic!("failed to read {}: {e}", patch_path.display()));
63+
64+
let case_name = self.case_name;
65+
let temp_base = temp_base();
66+
67+
let diffy_output = temp_base.join(format!("gnu-{case_name}-diffy"));
68+
create_output_dir(&diffy_output);
69+
70+
let opts = ParseOptions::unidiff();
71+
72+
// Apply with diffy
73+
let diffy_result = apply_diffy(&in_dir, &patch, &diffy_output, opts, self.strip_level);
74+
75+
// Verify diffy result matches expectation
76+
if self.expect_success {
77+
diffy_result.as_ref().expect("diffy should succeed");
78+
} else {
79+
diffy_result.as_ref().expect_err("diffy should fail");
80+
}
81+
82+
// In CI mode, also verify external tool behavior
83+
if is_ci() {
84+
let external_output = temp_base.join(format!("gnu-{case_name}-external"));
85+
create_output_dir(&external_output);
86+
87+
print_patch_version();
88+
let external_result =
89+
gnu_patch_apply(&in_dir, &patch_path, &external_output, self.strip_level);
90+
91+
// For success cases where both succeed and are expected to be compatible,
92+
// verify outputs match
93+
if diffy_result.is_ok() && external_result.is_ok() && self.expect_compat {
94+
snapbox::assert_subset_eq(&external_output, &diffy_output);
95+
}
96+
97+
// Verify agreement/disagreement based on expectation
98+
if self.expect_compat {
99+
assert_eq!(
100+
diffy_result.is_ok(),
101+
external_result.is_ok(),
102+
"diffy and external tool disagree: diffy={diffy_result:?}, external={external_result:?}",
103+
);
104+
} else {
105+
assert_ne!(
106+
diffy_result.is_ok(),
107+
external_result.is_ok(),
108+
"expected diffy and external tool to DISAGREE, but both returned same result: \
109+
diffy={diffy_result:?}, external={external_result:?}",
110+
);
111+
}
112+
}
113+
114+
// Compare against expected snapshot (only for success cases)
115+
if self.expect_success {
116+
snapbox::assert_subset_eq(case_dir.join("out"), &diffy_output);
117+
}
118+
}
119+
}
120+
121+
// External tool invocations
122+
123+
fn gnu_patch_apply(
124+
in_dir: &Path,
125+
patch_path: &Path,
126+
output_dir: &Path,
127+
strip_level: u32,
128+
) -> Result<(), String> {
129+
copy_input_files(in_dir, output_dir, &["patch"]);
130+
131+
let output = Command::new("patch")
132+
.arg(format!("-p{strip_level}"))
133+
.arg("--force")
134+
.arg("--batch")
135+
.arg("--input")
136+
.arg(patch_path)
137+
.current_dir(output_dir)
138+
.output()
139+
.unwrap();
140+
141+
if output.status.success() {
142+
Ok(())
143+
} else {
144+
Err(format!(
145+
"GNU patch failed with status {}: {}",
146+
output.status,
147+
String::from_utf8_lossy(&output.stderr)
148+
))
149+
}
150+
}
151+
152+
fn print_patch_version() {
153+
static ONCE: Once = Once::new();
154+
ONCE.call_once(|| {
155+
let output = Command::new("patch").arg("--version").output();
156+
match output {
157+
Ok(o) if o.status.success() => {
158+
let version = String::from_utf8_lossy(&o.stdout);
159+
eprintln!(
160+
"patch version: {}",
161+
version.lines().next().unwrap_or("unknown")
162+
);
163+
}
164+
Ok(o) => eprintln!("patch --version failed: {}", o.status),
165+
Err(e) => eprintln!("patch command not found: {e}"),
166+
}
167+
});
168+
}
169+
170+
/// Error type for compat tests.
171+
#[derive(Debug)]
172+
pub enum TestError {
173+
Parse(PatchesParseError),
174+
Apply(diffy::ApplyError),
175+
}
176+
177+
impl std::fmt::Display for TestError {
178+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179+
match self {
180+
TestError::Parse(e) => write!(f, "parse error: {e}"),
181+
TestError::Apply(e) => write!(f, "apply error: {e}"),
182+
}
183+
}
184+
}
185+
186+
/// Get temp output directory base path.
187+
pub fn temp_base() -> PathBuf {
188+
std::env::var("CARGO_TARGET_TMPDIR")
189+
.map(PathBuf::from)
190+
.unwrap_or_else(|_| std::env::temp_dir())
191+
}
192+
193+
/// Create a clean output directory.
194+
pub fn create_output_dir(path: &Path) {
195+
if path.exists() {
196+
fs::remove_dir_all(path).unwrap();
197+
}
198+
fs::create_dir_all(path).unwrap();
199+
}
200+
201+
/// Copy files from src to dst, skipping files with given extensions.
202+
pub fn copy_input_files(src: &Path, dst: &Path, skip_extensions: &[&str]) {
203+
copy_input_files_impl(src, dst, src, skip_extensions);
204+
}
205+
206+
fn copy_input_files_impl(src: &Path, dst: &Path, base: &Path, skip_extensions: &[&str]) {
207+
for entry in fs::read_dir(src).unwrap() {
208+
let entry = entry.unwrap();
209+
let path = entry.path();
210+
211+
// Skip files with specified extensions
212+
if let Some(ext) = path.extension() {
213+
if skip_extensions.iter().any(|e| ext == *e) {
214+
continue;
215+
}
216+
}
217+
218+
let rel_path = path.strip_prefix(base).unwrap();
219+
let target = dst.join(rel_path);
220+
221+
if path.is_dir() {
222+
fs::create_dir_all(&target).unwrap();
223+
copy_input_files_impl(&path, dst, base, skip_extensions);
224+
} else {
225+
if let Some(parent) = target.parent() {
226+
fs::create_dir_all(parent).unwrap();
227+
}
228+
fs::copy(&path, &target).unwrap();
229+
}
230+
}
231+
}
232+
233+
/// Apply patch using diffy to output directory.
234+
pub fn apply_diffy(
235+
in_dir: &Path,
236+
patch: &str,
237+
output_dir: &Path,
238+
opts: ParseOptions,
239+
strip_prefix: u32,
240+
) -> Result<(), TestError> {
241+
let patches: Vec<_> = Patches::parse(patch, opts)
242+
.collect::<Result<_, _>>()
243+
.map_err(TestError::Parse)?;
244+
245+
for file_patch in patches.iter() {
246+
let operation = file_patch.operation().strip_prefix(strip_prefix as usize);
247+
248+
let (original_name, target_name) = match &operation {
249+
FileOperation::Create(path) => (None, path.as_ref()),
250+
FileOperation::Delete(path) => (Some(path.as_ref()), path.as_ref()),
251+
FileOperation::Modify { original, modified } => {
252+
(Some(original.as_ref()), modified.as_ref())
253+
}
254+
FileOperation::Rename { from, to } | FileOperation::Copy { from, to } => {
255+
(Some(from.as_ref()), to.as_ref())
256+
}
257+
};
258+
259+
match file_patch.patch() {
260+
PatchKind::Text(patch) => {
261+
let original = if let Some(name) = original_name {
262+
let original_path = in_dir.join(name);
263+
fs::read_to_string(&original_path).unwrap_or_default()
264+
} else {
265+
String::new()
266+
};
267+
268+
let result = diffy::apply(&original, patch).map_err(TestError::Apply)?;
269+
270+
let result_path = output_dir.join(target_name);
271+
if let Some(parent) = result_path.parent() {
272+
fs::create_dir_all(parent).unwrap();
273+
}
274+
fs::write(&result_path, result.as_bytes()).unwrap();
275+
}
276+
}
277+
}
278+
279+
Ok(())
280+
}
281+
282+
pub fn is_ci() -> bool {
283+
std::env::var("CI").is_ok()
284+
}

0 commit comments

Comments
 (0)