use std::collections::HashMap;
use std::fmt::Display;
use std::fs::File;
use std::io::Read;
use anyhow::bail;
use camino::Utf8Path;
use flickzeug::{Diff, Line};
use indoc::formatdoc;
use itertools::Itertools;
use tracing::{error, trace, warn};
use crate::Result;
use crate::exit_code::ExitCode;
use crate::mutant::Mutant;
use crate::source::SourceFile;
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum DiffFilterError {
EmptyDiff,
MismatchedDiff(String),
NoMutants,
NoSourceFiles,
InvalidDiff(String),
File(String),
}
impl DiffFilterError {
pub fn exit_code(&self) -> ExitCode {
match self {
DiffFilterError::EmptyDiff
| DiffFilterError::NoSourceFiles
| DiffFilterError::NoMutants => ExitCode::Success,
DiffFilterError::MismatchedDiff(_) => ExitCode::FilterDiffMismatch,
DiffFilterError::File(_) | DiffFilterError::InvalidDiff(_) => {
ExitCode::FilterDiffInvalid
}
}
}
}
impl Display for DiffFilterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DiffFilterError::EmptyDiff => write!(f, "Diff file is empty"),
DiffFilterError::NoSourceFiles => write!(f, "Diff changes no Rust source files"),
DiffFilterError::NoMutants => write!(f, "No mutants to filter"),
DiffFilterError::MismatchedDiff(msg) => write!(f, "{msg}"),
DiffFilterError::InvalidDiff(msg) => write!(f, "Failed to parse diff: {msg}"),
DiffFilterError::File(msg) => write!(f, "Failed to read diff file: {msg}"),
}
}
}
pub fn diff_filter_file(
mutants: Vec<Mutant>,
diff_path: &Utf8Path,
) -> Result<Vec<Mutant>, DiffFilterError> {
let mut diff_file = File::open(diff_path).map_err(|err| {
error!("Failed to open diff file: {err}");
DiffFilterError::File(err.to_string())
})?;
let mut diff_bytes = Vec::new();
diff_file.read_to_end(&mut diff_bytes).map_err(|err| {
error!("Failed to read diff file: {err}");
DiffFilterError::File(err.to_string())
})?;
let diff_text = String::from_utf8_lossy(&diff_bytes);
diff_filter(mutants, &diff_text)
}
pub fn diff_filter(mutants: Vec<Mutant>, diff_text: &str) -> Result<Vec<Mutant>, DiffFilterError> {
let patches = match flickzeug::patch_from_str(diff_text) {
Ok(patches) => patches,
Err(err) => return Err(DiffFilterError::InvalidDiff(err.to_string())), };
if patches.is_empty() {
return Err(DiffFilterError::EmptyDiff);
}
if let Err(err) = check_diff_new_text_matches(&patches, &mutants) {
return Err(DiffFilterError::MismatchedDiff(err.to_string()));
}
let mut lines_changed_by_path: HashMap<&Utf8Path, Vec<usize>> = HashMap::new();
let mut changed_rs_file = false;
for patch in &patches {
let path = strip_patch_path(patch.modified().unwrap_or_default());
if path != "/dev/null" && path.extension() == Some("rs") {
changed_rs_file = true;
lines_changed_by_path
.entry(path)
.or_default()
.extend(affected_lines(patch));
}
}
let mut matched: Vec<Mutant> = Vec::with_capacity(mutants.len());
'mutant: for mutant in mutants {
let path = mutant.source_file.path();
if let Some(lines_changed) = lines_changed_by_path.get(path) {
for line in mutant.span.start.line..=mutant.span.end.line {
if lines_changed.binary_search(&line).is_ok() {
trace!(
?path,
line,
mutant = mutant.name(true),
"diff matched mutant"
);
matched.push(mutant);
continue 'mutant;
}
}
}
}
if matched.is_empty() {
if changed_rs_file {
trace!("diff matched no mutants");
Err(DiffFilterError::NoMutants)
} else {
Err(DiffFilterError::NoSourceFiles)
}
} else {
Ok(matched)
}
}
fn check_diff_new_text_matches(diffs: &[Diff<str>], mutants: &[Mutant]) -> Result<()> {
let mut source_by_name: HashMap<&Utf8Path, &SourceFile> = HashMap::new();
for mutant in mutants {
source_by_name
.entry(mutant.source_file.path())
.or_insert_with(|| &mutant.source_file);
}
for diff in diffs {
let Some(path) = diff.modified() else {
continue;
};
let path = strip_patch_path(path);
if let Some(source_file) = source_by_name.get(&path) {
let reconstructed = partial_new_file(diff);
let lines = source_file.code().lines().collect_vec();
for (lineno, diff_content) in reconstructed {
let source_content = lines.get(lineno - 1).unwrap_or(&"");
if diff_content != *source_content {
warn!(
?path,
lineno,
?diff_content,
?source_content,
"Diff content doesn't match source file"
);
bail!(formatdoc! { "\
Diff content doesn't match source file: {path} line {lineno}
diff has: {diff_content:?}
source has: {source_content:?}
The diff might be out of date with this source tree.
"});
}
}
}
}
Ok(())
}
fn strip_patch_path(path: &str) -> &Utf8Path {
let path = Utf8Path::new(path);
path.strip_prefix("b").unwrap_or(path)
}
fn affected_lines(diff: &Diff<str>) -> Vec<usize> {
let mut affected_lines = Vec::new(); for hunk in diff.hunks() {
let mut lineno: usize = hunk.new_range().start();
let mut prev_removed = false;
for line in hunk.lines() {
match line {
Line::Delete(_) => {
prev_removed = true;
}
Line::Insert(_) | Line::Context(_) => {
if prev_removed {
debug_assert!(
affected_lines.last().is_none_or(|last| *last < lineno),
"{lineno} {affected_lines:?}"
);
debug_assert!(lineno >= 1, "{lineno}");
affected_lines.push(lineno);
prev_removed = false;
}
}
}
match line {
Line::Context(_) => {
lineno += 1;
}
Line::Insert(_) => {
if affected_lines.last().is_none_or(|last| *last != lineno) {
affected_lines.push(lineno);
}
lineno += 1;
}
Line::Delete(_) => {
if lineno > 1
&& affected_lines
.last()
.is_none_or(|last| *last != (lineno - 1))
{
affected_lines.push(lineno - 1);
}
}
}
}
}
debug_assert!(
affected_lines.iter().tuple_windows().all(|(a, b)| a < b),
"remove_context: line numbers not sorted and unique: {affected_lines:?}"
);
affected_lines
}
fn partial_new_file<'d>(diff: &'d Diff<'d, str>) -> Vec<(usize, &'d str)> {
let mut r: Vec<(usize, &'d str)> = Vec::new();
for hunk in diff.hunks() {
let mut lineno: usize = hunk.new_range().start();
for line in hunk.lines() {
match line {
Line::Context((text, _line_end)) | Line::Insert((text, _line_end)) => {
debug_assert!(lineno >= 1, "{lineno}");
debug_assert!(
r.last().is_none_or(|last| last.0 < lineno),
"{lineno} {r:?}"
);
r.push((lineno, text));
lineno += 1;
}
Line::Delete(_) => {}
}
}
debug_assert_eq!(
lineno,
hunk.new_range().end(),
"Wrong number of resulting lines?"
);
}
r
}
#[cfg(test)]
mod test_super {
use std::fs::read_to_string;
use assert_matches::assert_matches;
use pretty_assertions::assert_eq;
use similar::TextDiff;
use super::*;
#[test]
fn patch_parse_error() {
let diff = "not really a diff\n";
let err = diff_filter(Vec::new(), diff).unwrap_err();
dbg!(&err);
assert_eq!(err.to_string(), "Diff file is empty");
}
#[test]
fn in_empty_diff_file() {
let diff = "";
let err = diff_filter(Vec::new(), diff).unwrap_err();
dbg!(&err);
assert_eq!(err.to_string(), "Diff file is empty");
}
#[test]
fn read_diff_with_empty_mutants() {
let diff = "\
diff --git a/src/mutate.rs b/src/mutate.rs
index eb42779..a0091b7 100644
--- a/src/mutate.rs
+++ b/src/mutate.rs
@@ -6,9 +6,7 @@ use std::fmt;
use std::fs;
use std::sync::Arc;
use std::foo;
-use anyhow::ensure;
-use anyhow::Context;
-use anyhow::Result;
+use anyhow::{ensure, Context, Result};
use serde::ser::{SerializeStruct, Serializer};
use serde::Serialize;
use similar::TextDiff;
";
let err = diff_filter(Vec::new(), diff);
assert_eq!(err, Err(DiffFilterError::NoMutants));
assert_eq!(err.unwrap_err().exit_code(), ExitCode::Success);
}
#[test]
fn parse_diff_with_only_moves() {
let diff_str = "\
diff --git a/tests/test-utils/Cargo.toml b/test-utils/Cargo.toml
similarity index 100%
rename from tests/test-utils/Cargo.toml
rename to test-utils/Cargo.toml
diff --git a/tests/test-utils/src/io.rs b/test-utils/src/io.rs
similarity index 100%
rename from tests/test-utils/src/io.rs
rename to test-utils/src/io.rs
diff --git a/tests/test-utils/src/lib.rs b/test-utils/src/lib.rs
similarity index 100%
rename from tests/test-utils/src/lib.rs
rename to test-utils/src/lib.rs
diff --git a/tests/test-utils/src/read.rs b/test-utils/src/read.rs
similarity index 100%
rename from tests/test-utils/src/read.rs
rename to test-utils/src/read.rs
diff --git a/tests/test-utils/src/write.rs b/test-utils/src/write.rs
similarity index 100%
rename from tests/test-utils/src/write.rs
rename to test-utils/src/write.rs
";
let diffs = flickzeug::patch_from_str(diff_str).unwrap();
assert_eq!(diffs.len(), 5);
for diff in &diffs {
assert_eq!(diff.hunks().len(), 0);
}
let err = diff_filter(Vec::new(), diff_str);
assert_eq!(err, Err(DiffFilterError::NoMutants));
}
#[test]
fn parse_diff_with_binary_files() {
let diff_str = "\
Binary files target/release/cargo-mutants and target/debug/cargo-mutants differ
";
let err = diff_filter(Vec::new(), diff_str);
assert_matches!(
err,
Err(DiffFilterError::EmptyDiff | DiffFilterError::NoSourceFiles)
);
}
#[test]
fn read_diff_with_no_sourcecode() {
let diff = "\
diff --git a/book/src/baseline.md b/book/src/baseline.md
index cc3ce8c..8fe9aa0 100644
--- a/book/src/baseline.md
+++ b/book/src/baseline.md
@@ -1,6 +1,6 @@
# Baseline tests
-Normally, cargo-mutants builds
+Normally cargo-mutants builds
";
let err = diff_filter(Vec::new(), diff);
assert_eq!(err, Err(DiffFilterError::NoSourceFiles));
assert_eq!(err.unwrap_err().exit_code(), ExitCode::Success);
}
fn make_diff(old: &str, new: &str) -> String {
TextDiff::from_lines(old, new)
.unified_diff()
.context_radius(2)
.header("a/file.rs", "b/file.rs")
.to_string()
}
#[test]
fn strip_patch_path_prefix() {
assert_eq!(strip_patch_path("b/src/mutate.rs"), "src/mutate.rs");
}
#[test]
fn affected_lines_from_single_insertion() {
let orig_lines = (1..=4).map(|i| format!("line {i}\n")).collect_vec();
for i in 1..=5 {
let mut new = orig_lines.clone();
let new_value = "new line\n".to_owned();
if i < 5 {
new.insert(i - 1, new_value);
} else {
new.push(new_value);
}
let diff_str = make_diff(&orig_lines.join(""), &new.join(""));
println!("{diff_str}");
let diff = Diff::from_str(&diff_str).unwrap();
let affected = affected_lines(&diff);
assert_eq!(affected, &[i]);
}
}
#[test]
fn affected_lines_from_single_deletion() {
let orig_lines = (1..=5).map(|i| format!("line {i}\n")).collect_vec();
for i in 1..=5 {
let mut new = orig_lines.clone();
new.remove(i - 1);
let diff_str = make_diff(&orig_lines.join(""), &new.join(""));
println!("{diff_str}");
let diff = Diff::from_str(&diff_str).unwrap();
let affected = affected_lines(&diff);
match i {
1 => assert_eq!(affected, &[1]),
5 => assert_eq!(affected, &[4]),
i => assert_eq!(affected, &[i - 1, i]),
}
}
}
#[test]
fn affected_lines_from_double_deletion() {
let orig_lines = (1..=5).map(|i| format!("line {i}\n")).collect_vec();
for i in 1..=4 {
let mut new = orig_lines.clone();
new.remove(i - 1);
new.remove(i - 1);
let diff_str = make_diff(&orig_lines.join(""), &new.join(""));
println!("{diff_str}");
let diff = Diff::from_str(&diff_str).unwrap();
let affected = affected_lines(&diff);
match i {
1 => assert_eq!(affected, &[1]),
4 => assert_eq!(affected, &[3]),
2 | 3 => assert_eq!(affected, &[i - 1, i]),
_ => unreachable!(),
}
}
}
#[test]
fn affected_lines_from_replacement() {
let orig_lines = (1..=5).map(|i| format!("line {i}\n")).collect_vec();
for i in 1..=5 {
let insertion = ["new 1\n".to_owned(), "new 2\n".to_owned()];
let new = orig_lines[..(i - 1)]
.iter()
.cloned()
.chain(insertion)
.chain(orig_lines[i..].iter().cloned())
.collect_vec();
let diff_str = make_diff(&orig_lines.join(""), &new.join(""));
println!("{diff_str}");
let diff = Diff::from_str(&diff_str).unwrap();
let affected = affected_lines(&diff);
if i > 1 {
assert_eq!(affected, &[i - 1, i, i + 1]);
} else {
assert_eq!(affected, &[i, i + 1]);
}
}
}
#[test]
fn reconstruct_partial_new_file() {
let old = read_to_string("testdata/diff0/src/lib.rs").unwrap();
let new = read_to_string("testdata/diff1/src/lib.rs").unwrap();
let diff_str = make_diff(&old, &new);
let diff = Diff::from_str(&diff_str).unwrap();
let reconstructed = partial_new_file(&diff);
println!("{reconstructed:#?}");
assert_eq!(reconstructed.len(), 16);
let new_lines = new.lines().collect_vec();
for (lineno, text) in reconstructed {
assert_eq!(text, new_lines[lineno - 1]);
}
}
}