use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{bail, Context, Result};
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Survivor {
pub file: String,
pub line: u32,
pub description: String,
}
pub type MutatedLines = BTreeSet<(String, u32)>;
#[derive(Debug, Clone, Deserialize)]
pub struct MutantsReport {
pub outcomes: Vec<MutantOutcome>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MutantOutcome {
pub summary: String,
pub scenario: Scenario,
}
#[derive(Debug, Clone, Deserialize)]
pub enum Scenario {
Baseline,
Mutant(MutantInfo),
}
#[derive(Debug, Clone, Deserialize)]
pub struct MutantInfo {
pub file: String,
pub span: Span,
pub name: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Span {
pub start: LineCol,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LineCol {
pub line: u32,
}
pub fn parse_mutants_report(json: &str) -> Result<MutantsReport> {
serde_json::from_str(json).context("parsing cargo-mutants outcomes.json")
}
pub fn unexplained_survivors(report: &MutantsReport, exempt: &[String]) -> Vec<Survivor> {
evaluate(cargo_mutants_survivors(report), exempt)
}
fn cargo_mutants_survivors(report: &MutantsReport) -> Vec<Survivor> {
report
.outcomes
.iter()
.filter_map(|outcome| {
if outcome.summary != "MissedMutant" {
return None;
}
let Scenario::Mutant(mutant) = &outcome.scenario else {
return None;
};
Some(Survivor {
file: mutant.file.clone(),
line: mutant.span.start.line,
description: mutant.name.clone(),
})
})
.collect()
}
pub fn mutated_lines(report: &MutantsReport) -> MutatedLines {
report
.outcomes
.iter()
.filter_map(|outcome| {
if outcome.summary != "CaughtMutant" && outcome.summary != "MissedMutant" {
return None;
}
let Scenario::Mutant(mutant) = &outcome.scenario else {
return None;
};
Some((mutant.file.clone(), mutant.span.start.line))
})
.collect()
}
pub fn evaluate(survivors: Vec<Survivor>, exempt: &[String]) -> Vec<Survivor> {
survivors
.into_iter()
.filter(|survivor| !exempt.iter().any(|path| path == &survivor.file))
.collect()
}
pub fn evaluate_scoped(
survivors: Vec<Survivor>,
mutated: &MutatedLines,
whole_file: &[String],
line_scoped: &BTreeMap<String, BTreeSet<u32>>,
) -> Result<Vec<Survivor>> {
let mut over: Vec<String> = Vec::new();
for (file, lines) in line_scoped {
for &line in lines {
let has_survivor = survivors
.iter()
.any(|survivor| survivor.file == *file && survivor.line == line);
if has_survivor {
continue;
}
if mutated.contains(&(file.clone(), line)) {
over.push(format!("\n {file}:{line}"));
}
}
}
if !over.is_empty() {
bail!(
"a line-scoped mutation exemption may only list a line with a surviving mutant, but \
these had mutants that were all caught:{}",
over.concat()
);
}
Ok(survivors
.into_iter()
.filter(|survivor| {
let whole = whole_file.iter().any(|path| path == &survivor.file);
let line = line_scoped
.get(&survivor.file)
.is_some_and(|lines| lines.contains(&survivor.line));
!(whole || line)
})
.collect())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MutantStatus {
Survived,
Killed,
NoCoverage,
Timeout,
CompileError,
RuntimeError,
}
impl MutantStatus {
fn is_survivor(self) -> bool {
matches!(self, MutantStatus::Survived | MutantStatus::NoCoverage)
}
fn is_viable(self) -> bool {
matches!(
self,
MutantStatus::Survived
| MutantStatus::Killed
| MutantStatus::NoCoverage
| MutantStatus::Timeout
)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct NormalizedMutant {
pub file: String,
pub line: u32,
pub status: MutantStatus,
pub mutator: String,
#[serde(default)]
pub replacement: Option<String>,
}
pub fn parse_normalized_results(json: &str) -> Result<Vec<NormalizedMutant>> {
serde_json::from_str(json).context("parsing normalized mutation results")
}
pub fn evaluate_normalized(
mutants: &[NormalizedMutant],
whole_file: &[String],
line_scoped: &BTreeMap<String, BTreeSet<u32>>,
) -> Result<Vec<Survivor>> {
evaluate_scoped(
normalized_survivors(mutants),
&normalized_mutated_lines(mutants),
whole_file,
line_scoped,
)
}
fn normalized_survivors(mutants: &[NormalizedMutant]) -> Vec<Survivor> {
mutants
.iter()
.filter(|mutant| mutant.status.is_survivor())
.map(|mutant| Survivor {
file: mutant.file.clone(),
line: mutant.line,
description: describe_normalized(mutant),
})
.collect()
}
fn normalized_mutated_lines(mutants: &[NormalizedMutant]) -> MutatedLines {
mutants
.iter()
.filter(|mutant| mutant.status.is_viable())
.map(|mutant| (mutant.file.clone(), mutant.line))
.collect()
}
fn describe_normalized(mutant: &NormalizedMutant) -> String {
match &mutant.replacement {
Some(replacement) => format!("{} (-> {})", mutant.mutator, one_line(replacement)),
None => mutant.mutator.clone(),
}
}
pub fn measure_rust(
root: &Path,
exempt: &[String],
exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
base: Option<&str>,
features: &[String],
) -> Result<Vec<Survivor>> {
let out = MutantsOut::new();
let diff = match base {
Some(base) => match write_base_diff(root, base, &out)? {
None => return Ok(Vec::new()),
Some(path) => Some(path),
},
None => None,
};
let engine = ensure_cargo_mutants()?;
run_cargo_mutants(&engine, root, &out.0, diff.as_deref(), features)?;
let outcomes = out.0.join("mutants.out").join("outcomes.json");
let json = match std::fs::read_to_string(&outcomes) {
Ok(json) => json,
Err(_) => return Ok(Vec::new()),
};
let report = parse_mutants_report(&json)?;
evaluate_scoped(
cargo_mutants_survivors(&report),
&mutated_lines(&report),
exempt,
exempt_lines,
)
}
fn one_line(replacement: &str) -> String {
let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
const MAX: usize = 60;
if flat.chars().count() > MAX {
format!("{}…", flat.chars().take(MAX).collect::<String>())
} else {
flat
}
}
pub fn measure_typescript(
root: &Path,
exempt: &[String],
exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
base: Option<&str>,
adapter: &Path,
) -> Result<Vec<Survivor>> {
let mutate = match base {
Some(base) => {
let ranges = mutate_ranges(root, base)?;
if ranges.is_empty() {
return Ok(Vec::new());
}
Some(ranges)
}
None => None,
};
let json = run_ts_adapter(root, adapter, mutate.as_deref())?;
let mutants = parse_normalized_results(&json)?;
evaluate_normalized(&mutants, exempt, exempt_lines)
}
fn run_ts_adapter(root: &Path, adapter: &Path, mutate: Option<&[String]>) -> Result<String> {
let out = AdapterOut::new();
std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
let results = out.0.join("results.json");
let mut command = Command::new("node");
command
.current_dir(root)
.arg(adapter)
.arg("--out")
.arg(&results);
if let Some(specs) = mutate {
command.arg("--mutate").arg(specs.join(","));
}
let output = command
.output()
.context("running the TypeScript mutation adapter (is `node` installed?)")?;
if !output.status.success() {
bail!(
"the TypeScript mutation adapter failed in `{}`:\n{}{}",
root.display(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
std::fs::read_to_string(&results).with_context(|| {
format!(
"reading the TypeScript mutation adapter's results from `{}`",
results.display()
)
})
}
struct AdapterOut(PathBuf);
impl AdapterOut {
fn new() -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let name = format!(
"testing-conventions-ts-adapter-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
);
AdapterOut(std::env::temp_dir().join(name))
}
}
impl Drop for AdapterOut {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
let changed = crate::patch_coverage::changed_lines(root, base)?;
let mut specs = Vec::new();
for (file, lines) in changed {
if !is_mutatable_ts(&file) {
continue;
}
for (start, end) in contiguous_runs(&lines) {
specs.push(format!("{file}:{start}-{end}"));
}
}
Ok(specs)
}
fn is_mutatable_ts(file: &str) -> bool {
let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
.iter()
.any(|ext| file.ends_with(ext));
let is_decl = file.ends_with(".d.ts");
let is_test = file.contains(".test.") || file.contains(".spec.");
is_source && !is_decl && !is_test
}
fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
let mut runs: Vec<(u64, u64)> = Vec::new();
for &line in lines {
match runs.last_mut() {
Some(run) if run.1 + 1 == line => run.1 = line,
_ => runs.push((line, line)),
}
}
runs
}
pub fn measure_python(
root: &Path,
exempt: &[String],
exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
base: Option<&str>,
) -> Result<Vec<Survivor>> {
let changed = match base {
Some(base) => Some(crate::patch_coverage::changed_lines(root, base)?),
None => None,
};
let modules: Vec<String> = match &changed {
None => Vec::new(),
Some(changed) => {
let modules: Vec<String> = changed
.keys()
.filter(|file| is_mutatable_py(file))
.cloned()
.collect();
if modules.is_empty() {
return Ok(Vec::new());
}
modules
}
};
let json = run_py_adapter(root, &modules)?;
let mut mutants = parse_normalized_results(&json)?;
if let Some(changed) = &changed {
mutants.retain(|mutant| {
changed
.get(&mutant.file)
.is_some_and(|lines| lines.contains(&u64::from(mutant.line)))
});
}
evaluate_normalized(&mutants, exempt, exempt_lines)
}
fn run_py_adapter(root: &Path, modules: &[String]) -> Result<String> {
let out = AdapterOut::new();
std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
let results = out.0.join("results.json");
let mut command = Command::new("python3");
command
.current_dir(root)
.args(["-m", "testing_conventions.mutation.main", "--out"])
.arg(&results)
.env("PYTHONDONTWRITEBYTECODE", "1");
for module in modules {
command.arg("--module").arg(module);
}
let output = command
.output()
.context("running the Python mutation adapter (is `python3` installed?)")?;
if !output.status.success() {
bail!(
"the Python mutation adapter failed in `{}`:\n{}{}",
root.display(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
std::fs::read_to_string(&results).with_context(|| {
format!(
"reading the Python mutation adapter's results from `{}`",
results.display()
)
})
}
fn is_mutatable_py(file: &str) -> bool {
if !file.ends_with(".py") {
return false;
}
let base = file.rsplit('/').next().unwrap_or(file);
!(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
}
struct MutantsOut(PathBuf);
impl MutantsOut {
fn new() -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let name = format!(
"testing-conventions-mutants-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
);
MutantsOut(std::env::temp_dir().join(name))
}
}
impl Drop for MutantsOut {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn write_base_diff(root: &Path, base: &str, out: &MutantsOut) -> Result<Option<PathBuf>> {
let range = format!("{base}...HEAD");
let output = Command::new("git")
.current_dir(root)
.args(["diff", "--relative", &range])
.output()
.context("running `git diff` for `--base` (is git installed?)")?;
if !output.status.success() {
bail!(
"git diff {range} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
if output.stdout.is_empty() {
return Ok(None);
}
std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
let path = out.0.join("base.diff");
std::fs::write(&path, &output.stdout).context("writing the base diff")?;
Ok(Some(path))
}
const CARGO_MUTANTS_VERSION: &str = "27.1.0";
fn ensure_cargo_mutants() -> Result<PathBuf> {
let root = cargo_mutants_cache_root();
let bin = root.join("bin").join(cargo_mutants_bin_name());
provision(&bin, || run_install(&root, |command| command.output()))
}
fn cargo_mutants_bin_name() -> &'static str {
if cfg!(windows) {
"cargo-mutants.exe"
} else {
"cargo-mutants"
}
}
fn cargo_mutants_cache_root() -> PathBuf {
cache_base()
.join("testing-conventions")
.join(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}"))
}
fn cache_base() -> PathBuf {
resolve_cache_base(std::env::var_os("XDG_CACHE_HOME"), std::env::var_os("HOME"))
}
fn resolve_cache_base(xdg: Option<OsString>, home: Option<OsString>) -> PathBuf {
if let Some(dir) = xdg.filter(|value| !value.is_empty()) {
return PathBuf::from(dir);
}
if let Some(dir) = home.filter(|value| !value.is_empty()) {
return PathBuf::from(dir).join(".cache");
}
std::env::temp_dir()
}
fn provision(bin: &Path, install: impl FnOnce() -> Result<()>) -> Result<PathBuf> {
if bin.exists() {
return Ok(bin.to_path_buf());
}
install()?;
if !bin.exists() {
bail!(
"provisioning reported success but cargo-mutants is not at `{}`",
bin.display()
);
}
Ok(bin.to_path_buf())
}
fn install_argv(root: &Path) -> Vec<OsString> {
vec![
OsString::from("install"),
OsString::from("cargo-mutants"),
OsString::from("--locked"),
OsString::from("--version"),
OsString::from(CARGO_MUTANTS_VERSION),
OsString::from("--root"),
root.as_os_str().to_os_string(),
]
}
fn run_install(
root: &Path,
run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
) -> Result<()> {
let mut command = Command::new("cargo");
command.args(install_argv(root));
strip_llvm_cov_env(&mut command);
let output = run(&mut command)
.context("provisioning cargo-mutants via `cargo install` (is cargo installed?)")?;
if !output.status.success() {
bail!(
"failed to provision cargo-mutants {CARGO_MUTANTS_VERSION}:\n{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
Ok(())
}
fn strip_llvm_cov_env(command: &mut Command) {
for var in [
"RUSTFLAGS",
"CARGO_ENCODED_RUSTFLAGS",
"RUSTDOCFLAGS",
"CARGO_ENCODED_RUSTDOCFLAGS",
"LLVM_PROFILE_FILE",
"CARGO_LLVM_COV",
"CARGO_LLVM_COV_SHOW_ENV",
"CARGO_LLVM_COV_TARGET_DIR",
"CARGO_LLVM_COV_BUILD_DIR",
"RUSTC_WRAPPER",
"RUSTC_WORKSPACE_WRAPPER",
"__CARGO_LLVM_COV_RUSTC_WRAPPER",
"__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
"__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
] {
command.env_remove(var);
}
}
fn run_cargo_mutants(
engine: &Path,
root: &Path,
out: &Path,
in_diff: Option<&Path>,
features: &[String],
) -> Result<()> {
let mut command = Command::new(engine);
command
.current_dir(root)
.arg("mutants")
.arg("--output")
.arg(out);
if let Some(diff) = in_diff {
command.arg("--in-diff").arg(diff);
}
if !features.is_empty() {
command.args(["--", "--features"]).arg(features.join(","));
}
strip_llvm_cov_env(&mut command);
let output = command.output().context("running cargo-mutants")?;
match output.status.code() {
Some(0) | Some(2) => Ok(()),
_ => bail!(
"cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
root.display(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
const NORMALIZED: &str = r#"[
{"file": "src/a.ts", "line": 2, "status": "survived",
"mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
{"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
{"file": "src/a.ts", "line": 9, "status": "killed",
"mutator": "BooleanLiteral", "replacement": "false"},
{"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
{"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
{"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
]"#;
#[test]
fn parses_the_normalized_schema() {
let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
assert_eq!(mutants.len(), 6);
assert_eq!(mutants[0].status, MutantStatus::Survived);
assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
assert_eq!(mutants[1].replacement, None);
}
#[test]
fn normalized_survivors_are_survived_and_nocoverage_only() {
let mutants = parse_normalized_results(NORMALIZED).unwrap();
let survivors = normalized_survivors(&mutants);
assert_eq!(survivors.len(), 2);
assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
assert!(survivors[0].description.contains("ConditionalExpression"));
assert!(survivors[0].description.contains("-> true"));
assert_eq!(survivors[1].description, "ArithmeticOperator");
}
#[test]
fn normalized_mutated_lines_collects_only_viable_mutants() {
let mutants = parse_normalized_results(NORMALIZED).unwrap();
assert_eq!(
normalized_mutated_lines(&mutants),
[2u32, 5, 9, 12]
.into_iter()
.map(|line| ("src/a.ts".to_string(), line))
.collect()
);
}
#[test]
fn evaluate_normalized_reports_unexempted_survivors() {
let mutants = parse_normalized_results(NORMALIZED).unwrap();
let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
}
#[test]
fn evaluate_normalized_drops_a_whole_file_exemption() {
let mutants = parse_normalized_results(NORMALIZED).unwrap();
let kept =
evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
assert!(
kept.is_empty(),
"the whole-file exemption lifts both survivors"
);
}
#[test]
fn evaluate_normalized_drops_a_line_scoped_exemption() {
let mutants = parse_normalized_results(NORMALIZED).unwrap();
let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].line, 5);
}
#[test]
fn evaluate_normalized_rejects_exempting_a_caught_line() {
let mutants = parse_normalized_results(NORMALIZED).unwrap();
let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
assert!(
err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
"got: {err}"
);
}
#[test]
fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
let mutants = parse_normalized_results(NORMALIZED).unwrap();
let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
assert_eq!(kept.len(), 2);
}
const SAMPLE: &str = r#"{
"outcomes": [
{"scenario": "Baseline", "summary": "Success",
"phase_results": []},
{"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
"replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
"function": {"function_name": "is_positive"},
"span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
"summary": "MissedMutant"},
{"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
"replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
"span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
"summary": "CaughtMutant"}
],
"total_mutants": 2
}"#;
#[test]
fn parses_the_outcomes_export() {
let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
assert_eq!(report.outcomes.len(), 3);
assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
}
#[test]
fn collects_only_missed_mutants_as_survivors() {
let report = parse_mutants_report(SAMPLE).unwrap();
let survivors = unexplained_survivors(&report, &[]);
assert_eq!(survivors.len(), 1);
assert_eq!(survivors[0].file, "src/lib.rs");
assert_eq!(survivors[0].line, 7);
assert!(survivors[0].description.contains("replace > with =="));
}
#[test]
fn an_exemption_drops_a_survivor_in_that_file() {
let report = parse_mutants_report(SAMPLE).unwrap();
let exempt = vec!["src/lib.rs".to_string()];
assert!(unexplained_survivors(&report, &exempt).is_empty());
}
#[test]
fn an_exemption_on_another_file_leaves_the_survivor() {
let report = parse_mutants_report(SAMPLE).unwrap();
let exempt = vec!["src/elsewhere.rs".to_string()];
assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
}
#[test]
fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
assert!(is_mutatable_ts("src/index.ts"));
assert!(is_mutatable_ts("src/util.tsx"));
assert!(is_mutatable_ts("src/util.js"));
assert!(!is_mutatable_ts("src/index.test.ts"));
assert!(!is_mutatable_ts("src/index.spec.ts"));
assert!(!is_mutatable_ts("src/types.d.ts"));
assert!(!is_mutatable_ts("README.md"));
}
#[test]
fn contiguous_runs_collapses_adjacent_lines() {
let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
assert!(contiguous_runs(&BTreeSet::new()).is_empty());
}
#[test]
fn one_line_flattens_and_caps() {
assert_eq!(one_line("a -\n b"), "a - b");
let long = "x".repeat(80);
let capped = one_line(&long);
assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
}
#[test]
fn is_mutatable_py_keeps_sources_and_drops_tests() {
assert!(is_mutatable_py("calc.py"));
assert!(is_mutatable_py("pkg/util.py"));
assert!(!is_mutatable_py("calc_test.py"));
assert!(!is_mutatable_py("test_calc.py"));
assert!(!is_mutatable_py("pkg/conftest.py"));
assert!(!is_mutatable_py("README.md"));
}
#[test]
fn mutated_lines_collects_caught_and_missed() {
let report = parse_mutants_report(SAMPLE).unwrap();
assert_eq!(
mutated_lines(&report),
[
("src/lib.rs".to_string(), 7),
("src/other.rs".to_string(), 3)
]
.into_iter()
.collect()
);
}
#[test]
fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
let report = parse_mutants_report(SAMPLE).unwrap();
let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
let kept = evaluate_scoped(
cargo_mutants_survivors(&report),
&mutated_lines(&report),
&[],
&line_scoped,
)
.unwrap();
assert!(
kept.is_empty(),
"the src/lib.rs:7 survivor should be lifted"
);
}
#[test]
fn evaluate_scoped_rejects_exempting_a_caught_line() {
let report = parse_mutants_report(SAMPLE).unwrap();
let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
let err = evaluate_scoped(
cargo_mutants_survivors(&report),
&mutated_lines(&report),
&[],
&line_scoped,
)
.unwrap_err();
assert!(
err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
"got: {err}"
);
}
#[test]
fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
let report = parse_mutants_report(SAMPLE).unwrap();
let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
let kept = evaluate_scoped(
cargo_mutants_survivors(&report),
&mutated_lines(&report),
&[],
&line_scoped,
)
.unwrap();
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].line, 7);
}
#[test]
fn evaluate_scoped_still_honors_a_whole_file_exemption() {
let report = parse_mutants_report(SAMPLE).unwrap();
let kept = evaluate_scoped(
cargo_mutants_survivors(&report),
&mutated_lines(&report),
&["src/lib.rs".to_string()],
&BTreeMap::new(),
)
.unwrap();
assert!(kept.is_empty());
}
fn unique_tmp() -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"tc-provision-test-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn provision_returns_an_existing_binary_without_installing() {
let tmp = unique_tmp();
let bin = tmp.join("bin").join("cargo-mutants");
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
std::fs::write(&bin, b"binary").unwrap();
let mut installed = false;
let got = provision(&bin, || {
installed = true;
Ok(())
})
.unwrap();
assert_eq!(got, bin);
assert!(!installed, "a present binary must not be reinstalled");
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn provision_installs_when_the_binary_is_absent() {
let tmp = unique_tmp();
let bin = tmp.join("bin").join("cargo-mutants");
let mut installed = false;
let got = provision(&bin, || {
installed = true;
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
std::fs::write(&bin, b"binary").unwrap();
Ok(())
})
.unwrap();
assert!(installed, "an absent binary must be installed");
assert_eq!(got, bin);
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn provision_errors_when_install_produces_no_binary() {
let tmp = unique_tmp();
let bin = tmp.join("bin").join("cargo-mutants");
let err = provision(&bin, || Ok(())).unwrap_err();
assert!(
err.to_string().contains("cargo-mutants is not at"),
"got: {err}"
);
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn provision_propagates_an_install_failure() {
let tmp = unique_tmp();
let bin = tmp.join("bin").join("cargo-mutants");
let err = provision(&bin, || bail!("install blew up")).unwrap_err();
assert!(err.to_string().contains("install blew up"), "got: {err}");
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn resolve_cache_base_prefers_xdg_then_home_then_temp() {
let xdg = |s: &str| Some(OsString::from(s));
assert_eq!(
resolve_cache_base(xdg("/xdg"), xdg("/home")),
PathBuf::from("/xdg")
);
assert_eq!(
resolve_cache_base(xdg(""), xdg("/home")),
PathBuf::from("/home/.cache")
);
assert_eq!(
resolve_cache_base(None, xdg("/home")),
PathBuf::from("/home/.cache")
);
assert_eq!(resolve_cache_base(None, None), std::env::temp_dir());
assert_eq!(
resolve_cache_base(xdg(""), Some(OsString::new())),
std::env::temp_dir()
);
}
#[test]
fn cache_root_is_absolute_and_version_scoped() {
let root = cargo_mutants_cache_root();
assert!(
root.ends_with(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}")),
"version-scoped; got {root:?}"
);
assert!(
root.to_string_lossy().contains("testing-conventions"),
"tool-namespaced; got {root:?}"
);
assert!(
root.is_absolute(),
"expected an absolute path; got {root:?}"
);
}
#[test]
fn install_argv_pins_the_version_and_isolates_the_root() {
let argv: Vec<String> = install_argv(Path::new("/cache/cargo-mutants-27"))
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
assert_eq!(
argv,
vec![
"install",
"cargo-mutants",
"--locked",
"--version",
CARGO_MUTANTS_VERSION,
"--root",
"/cache/cargo-mutants-27",
]
);
}
#[cfg(unix)]
fn fake_output(code: i32, stderr: &str) -> Output {
use std::os::unix::process::ExitStatusExt;
Output {
status: std::process::ExitStatus::from_raw(code << 8),
stdout: Vec::new(),
stderr: stderr.as_bytes().to_vec(),
}
}
#[cfg(unix)]
#[test]
fn run_install_succeeds_on_a_zero_exit() {
let mut ran = false;
run_install(Path::new("/cache/root"), |command| {
ran = true;
let argv: Vec<String> = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
assert!(argv.contains(&CARGO_MUTANTS_VERSION.to_string()));
Ok(fake_output(0, ""))
})
.unwrap();
assert!(ran);
}
#[cfg(unix)]
#[test]
fn run_install_reports_a_nonzero_exit_with_the_engine_output() {
let err = run_install(Path::new("/cache/root"), |_| {
Ok(fake_output(1, "error: could not compile cargo-mutants"))
})
.unwrap_err();
assert!(
err.to_string()
.contains("failed to provision cargo-mutants")
&& err.to_string().contains("could not compile"),
"got: {err}"
);
}
#[cfg(unix)]
#[test]
fn run_install_propagates_a_spawn_failure() {
let err = run_install(Path::new("/cache/root"), |_| {
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no cargo",
))
})
.unwrap_err();
assert!(
err.to_string().contains("is cargo installed?"),
"got: {err}"
);
}
#[test]
fn cargo_mutants_bin_name_matches_the_platform() {
let name = cargo_mutants_bin_name();
if cfg!(windows) {
assert_eq!(name, "cargo-mutants.exe");
} else {
assert_eq!(name, "cargo-mutants");
}
}
}