use crate::model::*;
use std::path::Path;
#[derive(Debug, PartialEq, Eq)]
pub enum ValidationError {
CountMismatch {
file: String,
hunk_index: usize,
field: &'static str,
header: u32,
body: u32,
},
OverlappingHunks {
file: String,
hunk_index: usize,
},
EmptyHunk {
file: String,
hunk_index: usize,
},
}
pub fn validate_internal(patch: &Patch) -> Result<(), ValidationError> {
for f in &patch.files {
let path = f.display_path();
let FileContent::Text(hunks) = &f.content else {
continue; };
let mut prev_old_end: Option<u32> = None;
let mut prev_new_end: Option<u32> = None;
for (i, h) in hunks.iter().enumerate() {
if h.lines.is_empty() {
return Err(ValidationError::EmptyHunk {
file: path.clone(),
hunk_index: i,
});
}
let (ctx, add, del) = count_kinds(&h.lines);
if h.old_lines != ctx + del {
return Err(ValidationError::CountMismatch {
file: path.clone(),
hunk_index: i,
field: "old_lines",
header: h.old_lines,
body: ctx + del,
});
}
if h.new_lines != ctx + add {
return Err(ValidationError::CountMismatch {
file: path.clone(),
hunk_index: i,
field: "new_lines",
header: h.new_lines,
body: ctx + add,
});
}
if let Some(pe) = prev_old_end {
if h.old_start < pe {
return Err(ValidationError::OverlappingHunks {
file: path.clone(),
hunk_index: i,
});
}
}
if let Some(pe) = prev_new_end {
if h.new_start < pe {
return Err(ValidationError::OverlappingHunks {
file: path.clone(),
hunk_index: i,
});
}
}
prev_old_end = Some(h.old_start + h.old_lines);
prev_new_end = Some(h.new_start + h.new_lines);
}
}
Ok(())
}
pub fn validate_with_git(diff_bytes: &[u8], dir: &Path) -> Result<(), String> {
use std::io::Write;
use std::process::{Command, Stdio};
let mut child = Command::new("git")
.arg("apply")
.arg("--check")
.current_dir(dir)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("failed to run git: {e}"))?;
child
.stdin
.take()
.unwrap()
.write_all(diff_bytes)
.map_err(|e| format!("failed to write to git: {e}"))?;
let output = child
.wait_with_output()
.map_err(|e| format!("git wait failed: {e}"))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!(
"git apply --check rejected the result diff: {}",
stderr.trim()
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
#[test]
fn well_formed_diff_passes() {
let p = parse(
"\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,3 +1,3 @@
a
-b
+B
c
"
.as_bytes(),
)
.unwrap();
assert!(validate_internal(&p).is_ok());
}
#[test]
fn count_mismatch_is_caught() {
let mut p = parse(
"\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,3 +1,3 @@
a
-b
+B
c
"
.as_bytes(),
)
.unwrap();
if let FileContent::Text(h) = &mut p.files[0].content {
h[0].old_lines = 99;
}
assert!(matches!(
validate_internal(&p),
Err(ValidationError::CountMismatch { .. })
));
}
#[test]
fn empty_hunk_body_is_caught() {
let mut p = parse(
"\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,3 +1,3 @@
a
-b
+B
c
"
.as_bytes(),
)
.unwrap();
if let FileContent::Text(h) = &mut p.files[0].content {
h[0].lines.clear();
h[0].old_lines = 0;
h[0].new_lines = 0;
}
assert!(matches!(
validate_internal(&p),
Err(ValidationError::EmptyHunk { .. })
));
}
#[test]
fn overlapping_hunks_are_caught() {
let mut p = parse(
"\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,2 +1,2 @@
a
-b
+B
@@ -10,2 +10,2 @@
p
-q
+Q
"
.as_bytes(),
)
.unwrap();
if let FileContent::Text(h) = &mut p.files[0].content {
h[1].old_start = 1;
h[1].new_start = 1;
}
assert!(matches!(
validate_internal(&p),
Err(ValidationError::OverlappingHunks { .. })
));
}
#[test]
fn git_check_accepts_valid_result() {
use std::process::Command;
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("f"), "a\nb\nc\n").unwrap();
Command::new("git")
.arg("init")
.arg("-q")
.current_dir(&dir)
.status()
.unwrap();
let diff = "\
--- a/f
+++ b/f
@@ -1,3 +1,3 @@
a
-b
+B
c
";
assert!(validate_with_git(diff.as_bytes(), dir.path()).is_ok());
}
#[test]
fn git_check_rejects_bad_result() {
use std::process::Command;
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("f"), "totally\ndifferent\ncontent\n").unwrap();
Command::new("git")
.arg("init")
.arg("-q")
.current_dir(&dir)
.status()
.unwrap();
let diff = "\
--- a/f
+++ b/f
@@ -1,3 +1,3 @@
a
-b
+B
c
";
assert!(validate_with_git(diff.as_bytes(), dir.path()).is_err());
}
}