use std::path::{Path, PathBuf};
use crate::finding::{EvidenceClass, Finding, Location, OneBasedLine, Origin, Severity};
pub const MUTATION_SURVIVOR_RULE: &str = "mutation-survivor";
pub const MUTATION_SURVIVOR_RULE_REVISION: u32 = 1;
#[derive(Debug)]
pub enum MutantsImportError {
Io(PathBuf, std::io::Error),
}
impl std::fmt::Display for MutantsImportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(path, err) => {
write!(
f,
"{}",
crate::advisory::advisories::describe_report_io_error(path, err)
)
}
}
}
}
impl std::error::Error for MutantsImportError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(_, err) => crate::advisory::advisories::report_io_error_source(err),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MissedMutant {
pub name: String,
pub file: String,
pub function_name: Option<String>,
pub line: usize,
pub genre: String,
pub replacement: String,
}
#[derive(Debug, Default)]
pub struct MutantsReport {
pub findings: Vec<Finding>,
pub errors: Vec<String>,
}
pub fn parse_mutants_report(text: &str) -> MutantsReport {
let mut report = MutantsReport::default();
let Ok(root) = serde_json::from_str::<serde_json::Value>(text) else {
return report;
};
let Some(outcomes) = root.get("outcomes").and_then(|v| v.as_array()) else {
return report;
};
let missed_mutants: Vec<MissedMutant> = outcomes
.iter()
.filter(|outcome| outcome.get("summary").and_then(|v| v.as_str()) == Some("MissedMutant"))
.filter_map(|outcome| {
let mutant = outcome.get("scenario")?.get("Mutant")?;
let name = mutant.get("name")?.as_str()?.to_string();
let file = mutant.get("file")?.as_str()?.to_string();
let genre = mutant.get("genre")?.as_str()?.to_string();
let replacement = mutant.get("replacement")?.as_str()?.to_string();
let line = mutant.get("span")?.get("start")?.get("line")?.as_u64()? as usize;
let function_name = mutant
.get("function")
.and_then(|f| f.as_object())
.and_then(|f| f.get("function_name"))
.and_then(|v| v.as_str())
.map(str::to_string);
Some(MissedMutant {
name,
file,
function_name,
line,
genre,
replacement,
})
})
.collect();
if let Some(declared_missed) = root.get("missed").and_then(|v| v.as_u64())
&& declared_missed as usize != missed_mutants.len()
{
report.errors.push(format!(
"outcomes.json declares missed={declared_missed}, but {} \
\"MissedMutant\" outcomes were parsed from outcomes[] — the report \
may be from a different run, or partially malformed",
missed_mutants.len()
));
}
report.findings = missed_mutants.iter().map(missed_mutant_finding).collect();
report
}
pub fn read_mutants_report(path: &Path) -> Result<MutantsReport, MutantsImportError> {
let text = crate::advisory::advisories::read_report_text(path, MutantsImportError::Io)?;
Ok(parse_mutants_report(&text))
}
fn missed_mutant_finding(mutant: &MissedMutant) -> Finding {
let item_path = mutant
.function_name
.clone()
.unwrap_or_else(|| format!("{}:{}", mutant.file, mutant.line));
Finding::new(
format!("{MUTATION_SURVIVOR_RULE}:{}", mutant.name),
MUTATION_SURVIVOR_RULE,
Severity::Warn,
Location {
file: PathBuf::from(&mutant.file),
line: OneBasedLine::new(mutant.line).unwrap_or(OneBasedLine::FIRST),
item_path,
},
EvidenceClass::ExternalMeasurement,
Origin::Code,
Some(serde_json::json!({
"file": mutant.file,
"function": mutant.function_name,
"line": mutant.line,
"genre": mutant.genre,
"replacement": mutant.replacement,
"name": mutant.name,
})),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::TempDir;
#[test]
fn parses_a_realistic_multi_outcome_report_and_only_flags_missed_mutants() {
let text = r#"{
"outcomes": [
{
"scenario": "Baseline",
"summary": "Success",
"log_path": "baseline.log",
"diff_path": null,
"phase_results": []
},
{
"scenario": { "Mutant": {
"name": "src/foo.rs:12:5: replace foo -> bool with false",
"package": "my-crate",
"file": "src/foo.rs",
"function": {
"function_name": "foo",
"return_type": "-> bool",
"span": { "start": {"line": 10, "column": 1}, "end": {"line": 14, "column": 1} }
},
"span": { "start": {"line": 12, "column": 5}, "end": {"line": 12, "column": 20} },
"replacement": "false",
"genre": "FnValue"
}},
"summary": "CaughtMutant",
"log_path": "caught.log",
"diff_path": "caught.diff",
"phase_results": []
},
{
"scenario": { "Mutant": {
"name": "src/bar.rs:20:9: replace >= with < in is_valid",
"package": "my-crate",
"file": "src/bar.rs",
"function": {
"function_name": "is_valid",
"return_type": "-> bool",
"span": { "start": {"line": 18, "column": 1}, "end": {"line": 24, "column": 1} }
},
"span": { "start": {"line": 20, "column": 9}, "end": {"line": 20, "column": 11} },
"replacement": "<",
"genre": "BinaryOperator"
}},
"summary": "MissedMutant",
"log_path": "missed.log",
"diff_path": "missed.diff",
"phase_results": []
},
{
"scenario": { "Mutant": {
"name": "src/baz.rs:5:1: replace baz with ()",
"package": "my-crate",
"file": "src/baz.rs",
"function": null,
"span": { "start": {"line": 5, "column": 1}, "end": {"line": 5, "column": 3} },
"replacement": "()",
"genre": "FnValue"
}},
"summary": "Unviable",
"log_path": "unviable.log",
"diff_path": "unviable.diff",
"phase_results": []
}
],
"total_mutants": 4,
"missed": 1,
"caught": 1,
"timeout": 0,
"unviable": 1,
"success": 0,
"start_time": "2026-01-01T00:00:00Z",
"end_time": "2026-01-01T00:05:00Z",
"cargo_mutants_version": "25.0.0"
}"#;
let report = parse_mutants_report(text);
assert!(
report.errors.is_empty(),
"unexpected errors: {:?}",
report.errors
);
assert_eq!(report.findings.len(), 1);
let finding = &report.findings[0];
assert_eq!(finding.rule, MUTATION_SURVIVOR_RULE);
assert_eq!(finding.location.file, PathBuf::from("src/bar.rs"));
assert_eq!(finding.location.line, 20);
assert_eq!(finding.location.item_path, "is_valid");
let evidence = finding.evidence.as_ref().unwrap();
assert_eq!(evidence["file"], "src/bar.rs");
assert_eq!(evidence["function"], "is_valid");
assert_eq!(evidence["line"], 20);
assert_eq!(evidence["genre"], "BinaryOperator");
assert_eq!(evidence["replacement"], "<");
assert_eq!(
evidence["name"],
"src/bar.rs:20:9: replace >= with < in is_valid"
);
}
#[test]
fn a_missed_mutant_with_no_function_still_produces_a_finding() {
let text = r#"{
"outcomes": [
{
"scenario": { "Mutant": {
"name": "src/consts.rs:3:1: replace LIMIT with 0",
"package": "my-crate",
"file": "src/consts.rs",
"function": null,
"span": { "start": {"line": 3, "column": 1}, "end": {"line": 3, "column": 10} },
"replacement": "0",
"genre": "FnValue"
}},
"summary": "MissedMutant",
"log_path": "missed.log",
"diff_path": "missed.diff",
"phase_results": []
}
],
"total_mutants": 1,
"missed": 1,
"caught": 0,
"timeout": 0,
"unviable": 0,
"success": 0,
"start_time": "2026-01-01T00:00:00Z",
"end_time": "2026-01-01T00:05:00Z",
"cargo_mutants_version": "25.0.0"
}"#;
let report = parse_mutants_report(text);
assert!(report.errors.is_empty());
assert_eq!(report.findings.len(), 1);
let finding = &report.findings[0];
assert_eq!(finding.location.file, PathBuf::from("src/consts.rs"));
assert_eq!(finding.location.line, 3);
assert_eq!(finding.location.item_path, "src/consts.rs:3");
assert!(finding.evidence.as_ref().unwrap()["function"].is_null());
}
#[test]
fn invalid_json_yields_an_empty_report_rather_than_panicking() {
let report = parse_mutants_report("this is not json");
assert!(report.findings.is_empty());
assert!(report.errors.is_empty());
}
#[test]
fn a_missing_outcomes_key_yields_an_empty_report() {
let report = parse_mutants_report("{}");
assert!(report.findings.is_empty());
assert!(report.errors.is_empty());
}
#[test]
fn an_entry_missing_a_required_field_is_skipped_not_fatal() {
let text = r#"{
"outcomes": [
{
"summary": "MissedMutant",
"log_path": "missed.log",
"diff_path": "missed.diff",
"phase_results": []
},
{
"scenario": { "Mutant": {
"name": "src/ok.rs:1:1: replace ok with false",
"package": "my-crate",
"file": "src/ok.rs",
"function": null,
"span": { "start": {"line": 1, "column": 1}, "end": {"line": 1, "column": 5} },
"replacement": "false",
"genre": "FnValue"
}},
"summary": "MissedMutant",
"log_path": "missed2.log",
"diff_path": "missed2.diff",
"phase_results": []
}
],
"total_mutants": 2,
"missed": 2,
"caught": 0,
"timeout": 0,
"unviable": 0,
"success": 0,
"start_time": "2026-01-01T00:00:00Z",
"end_time": "2026-01-01T00:05:00Z",
"cargo_mutants_version": "25.0.0"
}"#;
let report = parse_mutants_report(text);
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].location.file, PathBuf::from("src/ok.rs"));
assert_eq!(report.errors.len(), 1);
assert!(report.errors[0].contains("missed=2"));
}
#[test]
fn read_mutants_report_errors_clearly_for_a_missing_file() {
let dir = TempDir::new("mutants-missing-file");
let err = read_mutants_report(&dir.join("nope.json")).unwrap_err();
assert!(err.to_string().contains("failed to read file"));
}
#[test]
fn mutation_survivor_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(MUTATION_SURVIVOR_RULE)
.expect("mutation-survivor has a registry entry")
.example
.expect("mutation-survivor has a curated example")
.before;
let report = parse_mutants_report(example);
assert_eq!(
report.findings.len(),
1,
"curated example must parse to exactly one mutation-survivor finding"
);
assert_eq!(report.findings[0].rule, MUTATION_SURVIVOR_RULE);
}
}