use crate::model::*;
use crate::renumber::{anchor, expected_new_start};
use std::fmt;
use std::path::{Path, PathBuf};
use std::process::Command;
#[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 {
source: std::io::Error,
dir: PathBuf,
},
Io(std::io::Error),
WriterPanicked,
Rejected(String),
Failed {
code: Option<i32>,
stderr: String,
},
}
impl fmt::Display for GitCheckError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GitCheckError::Spawn { source, dir } => {
write!(f, "failed to run git in {}: {source}", dir.display())
}
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}")
}
GitCheckError::Failed { code, stderr } => {
let how = match code {
Some(c) => format!("exited with code {c}"),
None => "was killed by a signal".to_string(),
};
if stderr.is_empty() {
write!(
f,
"git apply --check {how} without a diagnostic; \
the result diff was not checked"
)
} else {
write!(
f,
"git apply --check {how} without checking the result diff: {stderr}"
)
}
}
}
}
}
impl std::error::Error for GitCheckError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
GitCheckError::Spawn { source, .. } => Some(source),
GitCheckError::Io(e) => Some(e),
GitCheckError::WriterPanicked | GitCheckError::Rejected(_) => None,
GitCheckError::Failed { .. } => None,
}
}
}
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);
crate::gitenv::insulate_repo_location(&mut cmd);
crate::gitenv::pin_message_locale(&mut cmd);
let output = crate::gitenv::feed_and_wait(&mut cmd, diff_bytes).map_err(|e| match e {
crate::gitenv::FeedError::Spawn(source) => GitCheckError::Spawn {
source,
dir: dir.to_path_buf(),
},
crate::gitenv::FeedError::Write(e) | crate::gitenv::FeedError::Wait(e) => {
GitCheckError::Io(e)
}
crate::gitenv::FeedError::WriterPanicked => GitCheckError::WriterPanicked,
})?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
match output.status.code() {
Some(0) => Ok(()),
Some(1) => Err(GitCheckError::Rejected(stderr)),
code => Err(GitCheckError::Failed { code, stderr }),
}
}
#[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());
}
}