use std::collections::BTreeMap;
use std::path::Path;
use std::process::Command;
use serde::Deserialize;
use crate::{aggregate_breakdown, MutateError, MutateResult, MutateRun, SurvivingMutant};
pub(crate) fn run(cfg: &MutateRun) -> Result<MutateResult, MutateError> {
detect_tool()?;
let output = run_cargo_mutants(cfg)?;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
if stdout.trim().is_empty() && !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
return Err(MutateError::SubprocessFailed(stderr));
}
let mut result = parse_outcomes(&stdout, cfg.subject(), cfg.subject_version())?;
let allow = cfg.allow_list_view();
if !allow.is_empty() {
let before = result.survivors.len();
result
.survivors
.retain(|s| !allow.iter().any(|d| d == &s.description));
let removed = (before - result.survivors.len()) as u64;
result.mutants_survived = result.mutants_survived.saturating_sub(removed);
result.mutants_killed = result.mutants_killed.saturating_add(removed);
}
Ok(result)
}
fn detect_tool() -> Result<(), MutateError> {
let probe = Command::new("cargo")
.args(["mutants", "--version"])
.output();
match probe {
Ok(o) if o.status.success() => Ok(()),
_ => Err(MutateError::ToolNotInstalled),
}
}
fn run_cargo_mutants(cfg: &MutateRun) -> Result<std::process::Output, MutateError> {
let mut cmd = Command::new("cargo");
cmd.args(["mutants", "--json", "--no-shuffle"]);
if cfg.workspace_flag() {
cmd.arg("--workspace");
}
if let Some(j) = cfg.jobs_value() {
cmd.args(["--jobs", &j.to_string()]);
}
if let Some(t) = cfg.timeout_value() {
cmd.args(["--timeout", &t.as_secs().max(1).to_string()]);
}
for pat in cfg.exclude_re_view() {
cmd.args(["--exclude-re", pat]);
}
for pat in cfg.file_filters_view() {
cmd.args(["--file", pat]);
}
if let Some(dir) = cfg.workdir_path() {
cmd.current_dir(dir as &Path);
}
cmd.output()
.map_err(|e| MutateError::SubprocessFailed(e.to_string()))
}
#[derive(Deserialize)]
struct MutantRecord {
#[serde(default)]
scenario: String,
#[serde(default)]
mutant: MutantInfo,
#[serde(default)]
outcome: serde_json::Value,
}
#[derive(Deserialize, Default)]
struct MutantInfo {
#[serde(default)]
file: String,
#[serde(default)]
line: u32,
#[serde(default)]
description: String,
#[serde(default)]
function: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Outcome {
Caught,
Missed,
Timeout,
Other,
}
fn outcome_from_value(value: &serde_json::Value) -> Outcome {
let s = match value {
serde_json::Value::String(s) => s.as_str(),
serde_json::Value::Object(map) if !map.is_empty() => {
map.keys().next().map(|s| s.as_str()).unwrap_or("")
}
_ => "",
};
let s = s.to_ascii_lowercase();
if s.contains("caught") {
Outcome::Caught
} else if s.contains("missed") {
Outcome::Missed
} else if s.contains("timeout") {
Outcome::Timeout
} else {
Outcome::Other
}
}
pub(crate) fn parse_outcomes(
ndjson: &str,
subject: &str,
version: &str,
) -> Result<MutateResult, MutateError> {
let mut killed = 0u64;
let mut survived = 0u64;
let mut timeout = 0u64;
let mut total_mutant_records = 0u64;
let mut survivors: Vec<SurvivingMutant> = Vec::new();
let mut by_file_killed: BTreeMap<String, u64> = BTreeMap::new();
let mut by_file_survived: BTreeMap<String, u64> = BTreeMap::new();
let mut by_file_timeout: BTreeMap<String, u64> = BTreeMap::new();
for raw in ndjson.lines() {
let line = raw.trim();
if line.is_empty() {
continue;
}
let record: MutantRecord = match serde_json::from_str(line) {
Ok(r) => r,
Err(_) => continue, };
if !record.scenario.eq_ignore_ascii_case("mutant") {
continue;
}
total_mutant_records += 1;
let outcome = outcome_from_value(&record.outcome);
let file = if record.mutant.file.is_empty() {
"<unknown>".to_string()
} else {
record.mutant.file.clone()
};
match outcome {
Outcome::Caught => {
killed += 1;
*by_file_killed.entry(file).or_insert(0) += 1;
}
Outcome::Missed => {
survived += 1;
*by_file_survived.entry(file.clone()).or_insert(0) += 1;
survivors.push(SurvivingMutant {
file,
line: record.mutant.line,
description: record.mutant.description,
function: record.mutant.function,
});
}
Outcome::Timeout => {
timeout += 1;
*by_file_timeout.entry(file).or_insert(0) += 1;
}
Outcome::Other => {
}
}
}
survivors.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
let mut files = aggregate_breakdown(&by_file_killed, &by_file_survived, &by_file_timeout);
files.sort_by(|a, b| a.file.cmp(&b.file));
Ok(MutateResult {
name: subject.to_string(),
version: version.to_string(),
mutants_total: total_mutant_records,
mutants_killed: killed,
mutants_survived: survived,
mutants_timeout: timeout,
survivors,
files,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_yields_zero_result() {
let r = parse_outcomes("", "x", "0.1.0").unwrap();
assert_eq!(r.mutants_total, 0);
assert_eq!(r.mutants_killed, 0);
assert_eq!(r.mutants_survived, 0);
assert_eq!(r.kill_pct(), 0.0);
}
#[test]
fn parses_caught_missed_timeout() {
let ndjson = concat!(
r#"{"scenario":"Mutant","mutant":{"file":"src/lib.rs","line":10,"description":"replace + with -"},"outcome":"Caught"}"#,
"\n",
r#"{"scenario":"Mutant","mutant":{"file":"src/lib.rs","line":20,"description":"replace - with +"},"outcome":"Missed"}"#,
"\n",
r#"{"scenario":"Mutant","mutant":{"file":"src/lib.rs","line":30,"description":"slow"},"outcome":"Timeout"}"#,
);
let r = parse_outcomes(ndjson, "x", "0.1.0").unwrap();
assert_eq!(r.mutants_killed, 1);
assert_eq!(r.mutants_survived, 1);
assert_eq!(r.mutants_timeout, 1);
assert_eq!(r.survivors.len(), 1);
assert_eq!(r.survivors[0].line, 20);
}
#[test]
fn tagged_enum_outcome_shape_is_recognized() {
let ndjson = concat!(
r#"{"scenario":"Mutant","mutant":{"file":"a","line":1,"description":"x"},"outcome":{"Caught":{}}}"#,
"\n",
r#"{"scenario":"Mutant","mutant":{"file":"a","line":2,"description":"y"},"outcome":{"Missed":{}}}"#,
);
let r = parse_outcomes(ndjson, "x", "0.1.0").unwrap();
assert_eq!(r.mutants_killed, 1);
assert_eq!(r.mutants_survived, 1);
}
#[test]
fn baseline_and_other_scenarios_ignored() {
let ndjson = concat!(
r#"{"scenario":"Baseline","mutant":{"file":"a","line":1,"description":"x"},"outcome":"Success"}"#,
"\n",
r#"{"scenario":"Mutant","mutant":{"file":"a","line":1,"description":"x"},"outcome":"Caught"}"#,
);
let r = parse_outcomes(ndjson, "x", "0.1.0").unwrap();
assert_eq!(r.mutants_total, 1);
assert_eq!(r.mutants_killed, 1);
}
#[test]
fn non_json_lines_are_skipped() {
let ndjson = concat!(
" Building cargo-mutants\n",
r#"{"scenario":"Mutant","mutant":{"file":"a","line":1,"description":"x"},"outcome":"Missed"}"#,
"\n",
"garbage\n",
);
let r = parse_outcomes(ndjson, "x", "0.1.0").unwrap();
assert_eq!(r.mutants_survived, 1);
}
#[test]
fn unrecognized_outcome_strings_stay_uncounted() {
let ndjson = concat!(
r#"{"scenario":"Mutant","mutant":{"file":"a","line":1,"description":"x"},"outcome":"Failure"}"#,
"\n",
r#"{"scenario":"Mutant","mutant":{"file":"a","line":2,"description":"y"},"outcome":"Unviable"}"#,
);
let r = parse_outcomes(ndjson, "x", "0.1.0").unwrap();
assert_eq!(r.mutants_total, 2);
assert_eq!(r.mutants_killed, 0);
assert_eq!(r.mutants_survived, 0);
assert_eq!(r.mutants_timeout, 0);
}
#[test]
fn per_file_breakdown_computed() {
let ndjson = concat!(
r#"{"scenario":"Mutant","mutant":{"file":"a.rs","line":1,"description":"x"},"outcome":"Caught"}"#,
"\n",
r#"{"scenario":"Mutant","mutant":{"file":"a.rs","line":2,"description":"y"},"outcome":"Missed"}"#,
"\n",
r#"{"scenario":"Mutant","mutant":{"file":"b.rs","line":1,"description":"z"},"outcome":"Caught"}"#,
"\n",
r#"{"scenario":"Mutant","mutant":{"file":"b.rs","line":2,"description":"w"},"outcome":"Caught"}"#,
);
let r = parse_outcomes(ndjson, "x", "0.1.0").unwrap();
assert_eq!(r.files.len(), 2);
let a = r.files.iter().find(|f| f.file == "a.rs").unwrap();
let b = r.files.iter().find(|f| f.file == "b.rs").unwrap();
assert!((a.kill_pct() - 50.0).abs() < 1e-9);
assert!((b.kill_pct() - 100.0).abs() < 1e-9);
}
#[test]
fn survivors_sorted_by_file_then_line() {
let ndjson = concat!(
r#"{"scenario":"Mutant","mutant":{"file":"b.rs","line":5,"description":"x"},"outcome":"Missed"}"#,
"\n",
r#"{"scenario":"Mutant","mutant":{"file":"a.rs","line":10,"description":"y"},"outcome":"Missed"}"#,
"\n",
r#"{"scenario":"Mutant","mutant":{"file":"a.rs","line":3,"description":"z"},"outcome":"Missed"}"#,
);
let r = parse_outcomes(ndjson, "x", "0.1.0").unwrap();
assert_eq!(r.survivors[0].file, "a.rs");
assert_eq!(r.survivors[0].line, 3);
assert_eq!(r.survivors[1].file, "a.rs");
assert_eq!(r.survivors[1].line, 10);
assert_eq!(r.survivors[2].file, "b.rs");
}
#[test]
fn missing_file_field_falls_back_to_placeholder() {
let ndjson =
r#"{"scenario":"Mutant","mutant":{"line":1,"description":"x"},"outcome":"Missed"}"#;
let r = parse_outcomes(ndjson, "x", "0.1.0").unwrap();
assert_eq!(r.mutants_survived, 1);
assert_eq!(r.survivors[0].file, "<unknown>");
}
}