use std::collections::BTreeSet;
use fallow_output::{
MutantStatus, MutationReport, MutationStatusCounts, MutationSummary, MutationVerdictInput,
MutationVerdictJoin,
};
use rustc_hash::FxHashSet;
use std::fmt::Write as _;
use sha2::{Digest, Sha256};
use crate::ProgrammaticError;
pub const MAX_VERDICT_INPUT_BYTES: usize = 16 * 1024 * 1024;
fn verdict_error(message: impl Into<String>) -> ProgrammaticError {
ProgrammaticError::new(message, 2)
.with_code("FALLOW_MUTATION_VERDICTS_INVALID")
.with_context("mutation.verdicts")
}
fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.fold(String::with_capacity(64), |mut hex, byte| {
let _ = write!(hex, "{byte:02x}");
hex
})
}
enum Effect {
Applied,
Contradicted,
Deferred,
}
const fn effect(status: MutantStatus) -> Effect {
match status {
MutantStatus::Survived
| MutantStatus::NoCoverage
| MutantStatus::Flaky
| MutantStatus::Equivalent => Effect::Applied,
MutantStatus::Killed | MutantStatus::Timeout | MutantStatus::CompileError => {
Effect::Contradicted
}
MutantStatus::Pending | MutantStatus::RuntimeError | MutantStatus::Pruned => {
Effect::Deferred
}
}
}
fn move_in_function(report: &mut MutationReport, index: usize, from: MutantStatus) {
let location = &report.mutants[index].location;
let key = (
location.file.as_str(),
location.function.as_deref(),
location.function_line,
);
let group = report.functions.iter_mut().find(|group| {
(group.file.as_str(), group.name.as_deref(), group.line) == key
&& count_of(&group.counts, from) > 0
});
let Some(group) = group else {
return;
};
group.counts.remove(from);
group.counts.add(MutantStatus::Equivalent);
group.survived.retain(|id| *id != location.mutant_id);
group.score = group.counts.score();
}
const fn count_of(counts: &MutationStatusCounts, status: MutantStatus) -> usize {
match status {
MutantStatus::Killed => counts.killed,
MutantStatus::Timeout => counts.timeout,
MutantStatus::Survived => counts.survived,
MutantStatus::NoCoverage => counts.no_coverage,
MutantStatus::RuntimeError => counts.runtime_error,
MutantStatus::Flaky => counts.flaky,
MutantStatus::Pruned => counts.pruned,
MutantStatus::Pending => counts.pending,
MutantStatus::Equivalent => counts.equivalent,
MutantStatus::CompileError => counts.compile_error,
}
}
fn parse_verdicts(verdict_json: &[u8]) -> Result<MutationVerdictInput, ProgrammaticError> {
if verdict_json.len() > MAX_VERDICT_INPUT_BYTES {
return Err(verdict_error(
"the mutation verdict document is larger than 16 MiB",
));
}
let input: MutationVerdictInput = serde_json::from_slice(verdict_json)
.map_err(|error| verdict_error(format!("invalid mutation verdict document: {error}")))?;
let mut keys = FxHashSet::default();
for verdict in &input.verdicts {
if verdict.review_key.trim().is_empty() {
return Err(verdict_error("a mutation verdict has an empty review_key"));
}
if !keys.insert(verdict.review_key.as_str()) {
return Err(verdict_error(format!(
"the review_key `{}` occurs more than one time",
verdict.review_key
)));
}
}
Ok(input)
}
pub fn validate_mutation_verdicts(verdict_json: &[u8]) -> Result<(), ProgrammaticError> {
parse_verdicts(verdict_json).map(|_| ())
}
pub fn apply_mutation_verdicts(
report: &mut MutationReport,
verdict_json: &[u8],
) -> Result<(), ProgrammaticError> {
let input = parse_verdicts(verdict_json)?;
let keys: FxHashSet<&str> = input
.verdicts
.iter()
.map(|verdict| verdict.review_key.as_str())
.collect();
let mut applied = BTreeSet::new();
let mut contradicted = BTreeSet::new();
let mut deferred = BTreeSet::new();
let mut matched = FxHashSet::default();
for index in 0..report.mutants.len() {
let id = report.mutants[index].location.mutant_id.clone();
if !keys.contains(id.as_str()) {
continue;
}
matched.insert(id.clone());
let status = report.mutants[index].status;
match effect(status) {
Effect::Applied => {
if status != MutantStatus::Equivalent {
report.mutants[index].status = MutantStatus::Equivalent;
move_in_function(report, index, status);
}
applied.insert(id);
}
Effect::Contradicted => {
contradicted.insert(id);
}
Effect::Deferred => {
deferred.insert(id);
}
}
}
let unmatched: BTreeSet<String> = input
.verdicts
.iter()
.filter(|verdict| !matched.contains(&verdict.review_key))
.map(|verdict| verdict.review_key.clone())
.collect();
report.summary = MutationSummary::from_results(&report.mutants);
report.verdicts = Some(MutationVerdictJoin {
verdicts_sha256: sha256_hex(verdict_json),
applied: applied.into_iter().collect(),
contradicted: contradicted.into_iter().collect(),
deferred: deferred.into_iter().collect(),
unmatched: unmatched.into_iter().collect(),
});
Ok(())
}
#[cfg(test)]
#[expect(clippy::unwrap_used, reason = "tests unwrap to keep assertions short")]
mod tests {
use fallow_output::{
MutantLocation, MutantResult, MutationCompletion, MutationFunctionResult,
MutationSchemaVersion, MutationScopeKind, MutationScopeOutput, MutationSummary,
MutationTimings, MutationWorktreeCheck,
};
use fallow_types::envelope::{ElapsedMs, ToolVersion};
use super::*;
fn mutant(id: &str, status: MutantStatus) -> MutantResult {
MutantResult {
location: MutantLocation {
mutant_id: id.to_owned(),
file: "src/a.ts".to_owned(),
line: 2,
column: 3,
span: (10, 15),
operator: "arithmetic-operator".to_owned(),
function: Some("add".to_owned()),
function_line: Some(1),
rank: Some(1),
},
status,
prune_reason: None,
killed_by: None,
covering_tests: match status {
MutantStatus::NoCoverage | MutantStatus::Pending | MutantStatus::Pruned => {
Vec::new()
}
_ => vec!["test/a.test.ts::t1".to_owned()],
},
static_mutant: false,
duration_ms: None,
test_reach: None,
retried: false,
cached: false,
type_error_codes: Vec::new(),
}
}
fn report(mutants: Vec<MutantResult>) -> MutationReport {
let mut counts = MutationStatusCounts::default();
let mut survived = Vec::new();
for result in &mutants {
counts.add(result.status);
if result.status == MutantStatus::Survived {
survived.push(result.location.mutant_id.clone());
}
}
MutationReport {
schema_version: MutationSchemaVersion::V1,
version: ToolVersion("0.0.0".to_owned()),
elapsed_ms: ElapsedMs(0),
completion: MutationCompletion::from_reasons(Vec::new()),
scope: MutationScopeOutput {
kind: MutationScopeKind::All,
base_ref: None,
},
runner: None,
cache: None,
summary: MutationSummary::from_results(&mutants),
functions: vec![MutationFunctionResult {
file: "src/a.ts".to_owned(),
name: Some("add".to_owned()),
line: Some(1),
end_line: Some(4),
in_diff: true,
cyclomatic: Some(1),
counts,
score: counts.score(),
survived,
}],
mutants,
no_test_files: false,
baseline_failures: Vec::new(),
skipped_files: Vec::new(),
unmatched_paths: Vec::new(),
unloaded_paths: Vec::new(),
worktree: MutationWorktreeCheck {
checked: true,
allow_dirty: false,
dirty_at_start: Vec::new(),
changed_paths: Vec::new(),
},
errors: Vec::new(),
timings: MutationTimings::default(),
files: Vec::new(),
verdicts: None,
typecheck: None,
}
}
fn document(keys: &[&str]) -> Vec<u8> {
let verdicts: Vec<serde_json::Value> = keys
.iter()
.map(|key| serde_json::json!({ "review_key": key, "verdict": "equivalent" }))
.collect();
serde_json::to_vec(&serde_json::json!({ "schema_version": "1", "verdicts": verdicts }))
.unwrap()
}
#[test]
fn equivalent_verdicts_leave_the_score_and_the_survivor_list() {
let mut report = report(vec![
mutant("m1", MutantStatus::Killed),
mutant("m2", MutantStatus::Survived),
mutant("m3", MutantStatus::Survived),
mutant("m4", MutantStatus::NoCoverage),
]);
assert_eq!(report.summary.score, Some(1.0 / 3.0));
assert_eq!(report.summary.coverage, Some(0.75));
apply_mutation_verdicts(&mut report, &document(&["m2", "m4"])).unwrap();
let statuses: Vec<MutantStatus> = report.mutants.iter().map(|m| m.status).collect();
assert_eq!(
statuses,
vec![
MutantStatus::Killed,
MutantStatus::Equivalent,
MutantStatus::Survived,
MutantStatus::Equivalent
]
);
assert_eq!(report.summary.counts.equivalent, 2);
assert_eq!(report.summary.counts.survived, 1);
assert_eq!(report.summary.counts.no_coverage, 0);
assert_eq!(report.summary.counts.total, 4);
assert_eq!(report.summary.score, Some(0.5));
assert_eq!(report.summary.coverage, Some(1.0));
let function = &report.functions[0];
assert_eq!(function.counts.equivalent, 2);
assert_eq!(function.counts.no_coverage, 0);
assert_eq!(function.survived, vec!["m3".to_owned()]);
assert_eq!(function.score, Some(0.5));
let join = report.verdicts.unwrap();
assert_eq!(join.applied, vec!["m2".to_owned(), "m4".to_owned()]);
assert!(join.unmatched.is_empty());
assert_eq!(join.verdicts_sha256.len(), 64);
}
#[test]
fn a_kill_contradicts_a_verdict_and_unknown_keys_stay_unmatched() {
let mut report = report(vec![
mutant("m1", MutantStatus::Killed),
mutant("m2", MutantStatus::Pending),
mutant("m3", MutantStatus::Survived),
]);
apply_mutation_verdicts(&mut report, &document(&["gone", "m1", "m2"])).unwrap();
assert_eq!(report.mutants[0].status, MutantStatus::Killed);
assert_eq!(report.mutants[1].status, MutantStatus::Pending);
assert_eq!(report.mutants[2].status, MutantStatus::Survived);
assert_eq!(report.summary.score, Some(0.5));
let join = report.verdicts.unwrap();
assert!(join.applied.is_empty());
assert_eq!(join.contradicted, vec!["m1".to_owned()]);
assert_eq!(join.deferred, vec!["m2".to_owned()]);
assert_eq!(join.unmatched, vec!["gone".to_owned()]);
}
#[test]
fn malformed_and_duplicate_verdicts_fail_closed() {
let cases: [(&[u8], &str); 4] = [
(b"{", "invalid mutation verdict document"),
(
br#"{"schema_version":"1","verdicts":[{"review_key":"m1","verdict":"survived"}]}"#,
"invalid mutation verdict document",
),
(
br#"{"schema_version":"1","verdicts":[{"review_key":" ","verdict":"equivalent"}]}"#,
"empty review_key",
),
(&document(&["m1", "m1"]), "more than one time"),
];
for (json, needle) in cases {
let checked = validate_mutation_verdicts(json).unwrap_err();
assert_eq!(checked.exit_code, 2);
assert!(checked.message.contains(needle), "{}", checked.message);
let mut report = report(vec![mutant("m1", MutantStatus::Survived)]);
let error = apply_mutation_verdicts(&mut report, json).unwrap_err();
assert_eq!(
error.code.as_deref(),
Some("FALLOW_MUTATION_VERDICTS_INVALID")
);
assert_eq!(error.exit_code, 2);
assert!(error.message.contains(needle), "{}", error.message);
assert_eq!(report.mutants[0].status, MutantStatus::Survived);
assert!(report.verdicts.is_none());
}
assert!(validate_mutation_verdicts(&document(&["m1", "m2"])).is_ok());
}
}