use crate::model::*;
use crate::renumber::{anchor, expected_new_start};
use std::fmt;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Side {
Old,
New,
}
impl fmt::Display for Side {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Side::Old => "old",
Side::New => "new",
})
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum ValidationError {
CountMismatch {
file: String,
hunk_index: usize,
side: Side,
header: u32,
body: u32,
},
OverlappingHunks {
file: String,
hunk_index: usize,
},
EmptyHunk {
file: String,
hunk_index: usize,
},
NoChangeHunk {
file: String,
hunk_index: usize,
},
StaleNewStart {
file: String,
hunk_index: usize,
header: u32,
expected: u32,
},
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ValidationError::CountMismatch {
file,
hunk_index,
side,
header,
body,
} => {
write!(
f,
"{file}: sub-hunk {}: header declares {header} {side} lines, body has {body}",
hunk_index + 1
)
}
ValidationError::OverlappingHunks { file, hunk_index } => write!(
f,
"{file}: sub-hunk {} overlaps the one before it",
hunk_index + 1
),
ValidationError::EmptyHunk { file, hunk_index } => {
write!(f, "{file}: sub-hunk {} has an empty body", hunk_index + 1)
}
ValidationError::NoChangeHunk { file, hunk_index } => write!(
f,
"{file}: sub-hunk {} has no added or deleted lines",
hunk_index + 1
),
ValidationError::StaleNewStart {
file,
hunk_index,
header,
expected,
} => write!(
f,
"{file}: sub-hunk {}: header starts the new side at line {header}, \
but the diff puts it at {expected}",
hunk_index + 1
),
}
}
}
impl std::error::Error for ValidationError {}
pub fn validate_input(patch: &Patch) -> Result<(), ValidationError> {
check_hunks(patch, false)
}
pub fn validate_internal(patch: &Patch) -> Result<(), ValidationError> {
check_hunks(patch, true)
}
fn check_hunks(patch: &Patch, check_new_start: bool) -> 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<i64> = None;
let mut prev_new_end: Option<i64> = None;
let mut delta: i64 = 0;
for (i, h) in hunks.iter().enumerate() {
let (add, del) = check_one_hunk(h, &path, i, check_new_start, delta)?;
delta += i64::from(add) - i64::from(del);
let old_at = anchor(h.old_start, h.old_lines);
let new_at = anchor(h.new_start, h.new_lines);
if prev_old_end.is_some_and(|pe| old_at < pe)
|| prev_new_end.is_some_and(|pe| new_at < pe)
{
return Err(ValidationError::OverlappingHunks {
file: path.clone(),
hunk_index: i,
});
}
prev_old_end = Some(old_at + i64::from(h.old_lines));
prev_new_end = Some(new_at + i64::from(h.new_lines));
}
}
Ok(())
}
fn check_one_hunk(
h: &Hunk,
path: &str,
index: usize,
check_new_start: bool,
delta: i64,
) -> Result<(u32, u32), ValidationError> {
if h.lines.is_empty() {
return Err(ValidationError::EmptyHunk {
file: path.to_string(),
hunk_index: index,
});
}
let (ctx, add, del) = count_kinds(&h.lines);
if add == 0 && del == 0 {
return Err(ValidationError::NoChangeHunk {
file: path.to_string(),
hunk_index: index,
});
}
if h.old_lines != ctx + del {
return Err(ValidationError::CountMismatch {
file: path.to_string(),
hunk_index: index,
side: Side::Old,
header: h.old_lines,
body: ctx + del,
});
}
if h.new_lines != ctx + add {
return Err(ValidationError::CountMismatch {
file: path.to_string(),
hunk_index: index,
side: Side::New,
header: h.new_lines,
body: ctx + add,
});
}
let expected = expected_new_start(h, delta);
if check_new_start && h.new_start != expected {
return Err(ValidationError::StaleNewStart {
file: path.to_string(),
hunk_index: index,
header: h.new_start,
expected,
});
}
Ok((add, del))
}
#[derive(Debug)]
pub enum GitCheckError {
Spawn(std::io::Error),
Io(std::io::Error),
WriterPanicked,
Rejected(String),
}
impl fmt::Display for GitCheckError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GitCheckError::Spawn(e) => write!(f, "failed to run git: {e}"),
GitCheckError::Io(e) => write!(f, "git check failed: {e}"),
GitCheckError::WriterPanicked => write!(f, "the thread feeding git panicked"),
GitCheckError::Rejected(stderr) => {
write!(f, "git apply --check rejected the result diff: {stderr}")
}
}
}
}
impl std::error::Error for GitCheckError {}
pub fn validate_with_git(diff_bytes: &[u8], dir: &Path) -> Result<(), GitCheckError> {
let mut cmd = Command::new("git");
cmd.arg("apply")
.arg("--check")
.current_dir(dir)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped());
crate::gitenv::insulate_repo_location(&mut cmd);
let mut child = cmd.spawn().map_err(GitCheckError::Spawn)?;
let mut stdin = child.stdin.take().expect("stdin was configured as piped");
let writer = std::thread::scope(|scope| {
let handle = scope.spawn(move || stdin.write_all(diff_bytes));
let output = child.wait_with_output();
(handle.join(), output)
});
let (write_result, output) = writer;
match write_result {
Ok(Err(e)) if e.kind() != std::io::ErrorKind::BrokenPipe => {
return Err(GitCheckError::Io(e));
}
Err(_) => return Err(GitCheckError::WriterPanicked),
_ => {}
}
let output = output.map_err(GitCheckError::Io)?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(GitCheckError::Rejected(stderr.trim().to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gittest::repo_with_file;
use crate::parser::parse;
const ONE_CHANGE: &str = "\
--- a/f
+++ b/f
@@ -1,3 +1,3 @@
a
-b
+B
c
";
#[test]
fn well_formed_diff_passes() {
let p = parse(ONE_CHANGE.as_bytes()).unwrap();
assert!(validate_internal(&p).is_ok());
}
#[test]
fn stale_new_start_is_caught() {
let p = parse(
"\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -17,3 +18,3 @@
q
-r
+R
s
"
.as_bytes(),
)
.unwrap();
assert_eq!(
validate_internal(&p),
Err(ValidationError::StaleNewStart {
file: "f".to_string(),
hunk_index: 0,
header: 18,
expected: 17,
})
);
}
#[test]
fn accumulated_offset_across_hunks_passes() {
let p = parse(
"\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -2,3 +2,2 @@
b
-c
d
@@ -17,3 +16,3 @@
q
-r
+R
s
"
.as_bytes(),
)
.unwrap();
assert_eq!(validate_internal(&p), Ok(()));
}
#[test]
fn a_pure_deletion_after_another_hunk_is_not_an_overlap() {
let p = parse(
"\
--- a/f
+++ b/f
@@ -1,3 +1,2 @@
a
-b
c
@@ -4,2 +2,0 @@
-d
-e
"
.as_bytes(),
)
.unwrap();
assert_eq!(validate_internal(&p), Ok(()));
assert_eq!(validate_input(&p), Ok(()));
}
#[test]
fn a_pure_insertion_after_another_hunk_is_not_an_overlap() {
let p = parse(
"\
--- a/f
+++ b/f
@@ -1,2 +1,2 @@
-a
+A
b
@@ -2,0 +3,2 @@
+x
+y
"
.as_bytes(),
)
.unwrap();
assert_eq!(validate_internal(&p), 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 all_context_hunk_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 {
for l in &mut h[0].lines {
l.kind = LineKind::Context;
}
h[0].old_lines = 3;
h[0].new_lines = 3;
}
assert!(matches!(
validate_internal(&p),
Err(ValidationError::NoChangeHunk { .. })
));
}
#[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() {
let dir = repo_with_file("a\nb\nc\n");
assert!(validate_with_git(ONE_CHANGE.as_bytes(), dir.path()).is_ok());
}
#[test]
fn git_check_rejects_bad_result() {
let dir = repo_with_file("totally\ndifferent\ncontent\n");
assert!(validate_with_git(ONE_CHANGE.as_bytes(), dir.path()).is_err());
}
}