use core::time::Duration;
use std::time::Instant;
use camino::Utf8PathBuf;
use super::cargo_options::BuildLimits;
use super::events::Events;
use super::test_binary::{TestBinary, test_binaries};
use super::verdict::tail;
use super::workspace::Workspace;
use crate::discover::Plan;
use crate::error::{Error, error};
use crate::model::{Mutant, Outcome};
use crate::schema::Guard;
use crate::{HashMap, HashSet, Result};
mod blame;
mod complaints;
mod invoke;
mod messages;
mod splices;
#[cfg(all(test, not(miri)))]
mod tests;
use blame::blame;
use complaints::{DIAGNOSTIC_LIMIT, complaints, diagnostics, leading, manifests_of, prioritize};
use invoke::run_cargo;
use messages::compiled_sources;
use splices::Splices;
type Guards = HashMap<u32, (Utf8PathBuf, Guard)>;
#[cfg(test)]
type SubsetOracle = fn(&HashSet<u32>) -> Option<bool>;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct OrderingHints {
pub offered: usize,
pub confirmed: usize,
pub rounds: u32,
}
const PROBE_FLOOR: usize = 4;
#[derive(Debug, Clone)]
pub struct Round {
pub elapsed: Duration,
pub withdrew: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Withdrawal {
pub code: String,
pub mutator: String,
pub mutants: usize,
}
#[derive(Debug, Default)]
pub(super) struct Build {
pub(super) history: Vec<Round>,
pub(super) binaries: Vec<TestBinary>,
pub(super) withdrawn: usize,
pub(super) rounds: u32,
pub(super) widened: bool,
pub(super) census: Vec<Withdrawal>,
pub(super) stuck: Option<Abandoned>,
pub(super) ordering: OrderingHints,
}
#[derive(Debug)]
enum Convergence {
Built(String),
Stuck(Error),
}
enum Isolation {
Blamed(Vec<u32>),
Item(Vec<u32>),
}
#[derive(Debug)]
pub(super) struct Abandoned {
pub(super) reason: String,
pub(super) ordinals: Vec<u32>,
}
#[derive(Debug, Default)]
pub(super) struct Converger {
withdrawn: HashSet<u32>,
abandoned: HashSet<u32>,
rounds: u32,
total_rounds: u32,
per_round: Vec<usize>,
history: Vec<Round>,
first_round: Option<Duration>,
splices: Splices,
compiled: Option<HashSet<Utf8PathBuf>>,
census: HashMap<u32, String>,
hinted: HashSet<crate::model::MutantId>,
probed: HashSet<u32>,
ordering: OrderingHints,
whole_workspace: bool,
#[cfg(test)]
subset_oracle: Option<SubsetOracle>,
}
#[derive(Debug)]
pub(super) struct Preflight {
pub(super) whole_workspace: bool,
pub(super) dropped: Vec<String>,
}
impl Preflight {
const fn narrow(dropped: Vec<String>) -> Self {
Self {
whole_workspace: false,
dropped,
}
}
}
impl Converger {
pub(super) fn guided(hinted: HashSet<crate::model::MutantId>) -> Self {
Self { hinted, ..Self::default() }
}
pub(super) const fn require_whole_workspace(&mut self) {
self.whole_workspace = true;
}
pub(super) fn plan_reordered(&mut self) {
self.splices.plan_reordered();
}
const fn scoped<'names>(&self, select: Option<&'names [String]>) -> Option<&'names [String]> {
if self.whole_workspace { None } else { select }
}
fn converge(
&mut self,
work: &Workspace,
plan: &Plan,
select: Option<&[String]>,
verb: &[&str],
limits: BuildLimits,
events: &mut dyn Events,
) -> Result<Convergence> {
self.rounds = 0;
self.per_round.clear();
self.probe(work, plan, select, verb, limits, events)?;
loop {
self.rounds = self.rounds.saturating_add(1);
self.total_rounds = self.total_rounds.saturating_add(1);
let guards = self.splices.instrument(work, plan, &self.withdrawn)?;
let started = Instant::now();
let outcome = run_cargo(work, plan, verb, select, limits, self.first_round, events)?;
let elapsed = started.elapsed();
let Some(stdout) = outcome.stdout else {
let budget = limits.budget(self.first_round).unwrap_or(elapsed);
return Err(Self::build_timeout_error(budget));
};
if self.first_round.is_none() {
self.first_round = Some(elapsed);
}
if outcome.succeeded {
self.history.push(Round { elapsed, withdrew: 0 });
return Ok(Convergence::Built(stdout));
}
let blamed = blame(&stdout, &work.root, &guards);
if blamed.is_empty() {
if let Some(isolated) = self.isolate(work, plan, select, verb, limits, events)? {
let ordinals = match &isolated {
Isolation::Blamed(ordinals) | Isolation::Item(ordinals) => ordinals,
};
self.history.push(Round {
elapsed,
withdrew: ordinals.len(),
});
for ordinal in ordinals {
let _ = self.withdrawn.insert(*ordinal);
let _ = self.census.entry(*ordinal).or_default();
}
if let Isolation::Item(ordinals) = isolated {
self.abandoned.extend(ordinals);
}
continue;
}
self.history.push(Round { elapsed, withdrew: 0 });
return Ok(Convergence::Stuck(Self::unattributed_build_error(work, &stdout, &outcome.stderr)));
}
self.per_round.push(blamed.len());
if self.rounds >= limits.rounds() {
let error = Self::rollback_limit_error(self.rounds, limits.rounds(), &self.per_round, work, &stdout);
self.history.push(Round { elapsed, withdrew: 0 });
return Ok(Convergence::Stuck(error));
}
self.history.push(Round {
elapsed,
withdrew: blamed.len(),
});
for (ordinal, code) in blamed {
let _ = self.withdrawn.insert(ordinal);
let _ = self.census.entry(ordinal).or_insert(code);
}
}
}
fn isolate(
&mut self,
work: &Workspace,
plan: &Plan,
select: Option<&[String]>,
verb: &[&str],
limits: BuildLimits,
events: &mut dyn Events,
) -> Result<Option<Isolation>> {
let mut candidates: Vec<&Mutant> = plan
.mutants
.iter()
.filter(|mutant| {
mutant.ordinal > 0
&& !self.withdrawn.contains(&mutant.ordinal)
&& select.is_none_or(|packages| packages.iter().any(|package| package.as_str() == &*mutant.package))
})
.collect();
if candidates.is_empty() || self.subset_fails(work, plan, select, verb, limits, events, &candidates, &[])? != Some(false) {
return Ok(None);
}
candidates.sort_by(|left, right| left.item_path.cmp(&right.item_path).then_with(|| left.ordinal.cmp(&right.ordinal)));
let population = candidates.clone();
let mut items: Vec<Vec<&Mutant>> = Vec::new();
for mutant in candidates {
if items
.last()
.and_then(|item| item.first())
.is_some_and(|first| first.item_path == mutant.item_path)
{
items
.last_mut()
.unwrap_or_else(|| unreachable!("the item was just observed"))
.push(mutant);
} else {
items.push(vec![mutant]);
}
}
while items.len() > 1 {
let middle = items.len() / 2;
let left = items[..middle].concat();
let right = items[middle..].concat();
if self.subset_fails(work, plan, select, verb, limits, events, &population, &left)? == Some(true) {
items.truncate(middle);
continue;
}
if self.subset_fails(work, plan, select, verb, limits, events, &population, &right)? == Some(true) {
drop(items.drain(..middle));
continue;
}
for item in &items {
let active: Vec<&Mutant> = population
.iter()
.copied()
.filter(|candidate| !item.iter().any(|removed| removed.ordinal == candidate.ordinal))
.collect();
if self.subset_fails(work, plan, select, verb, limits, events, &population, &active)? == Some(false) {
return Ok(Some(Isolation::Item(item.iter().map(|mutant| mutant.ordinal).collect())));
}
}
return Ok(None);
}
let item = items.pop().unwrap_or_default();
let mut narrowed = item.clone();
while narrowed.len() > 1 {
let middle = narrowed.len() / 2;
let left = &narrowed[..middle];
let right = &narrowed[middle..];
if self.subset_fails(work, plan, select, verb, limits, events, &population, left)? == Some(true) {
narrowed.truncate(middle);
} else if self.subset_fails(work, plan, select, verb, limits, events, &population, right)? == Some(true) {
drop(narrowed.drain(..middle));
} else {
return Ok(Some(Isolation::Item(item.iter().map(|mutant| mutant.ordinal).collect())));
}
}
Ok(Some(Isolation::Blamed(narrowed.iter().map(|mutant| mutant.ordinal).collect())))
}
#[expect(
clippy::too_many_arguments,
reason = "a proof build needs the same complete context as convergence"
)]
fn subset_fails(
&mut self,
work: &Workspace,
plan: &Plan,
select: Option<&[String]>,
verb: &[&str],
limits: BuildLimits,
events: &mut dyn Events,
population: &[&Mutant],
active: &[&Mutant],
) -> Result<Option<bool>> {
let active: HashSet<u32> = active.iter().map(|mutant| mutant.ordinal).collect();
#[cfg(test)]
if let Some(oracle) = self.subset_oracle {
return Ok(oracle(&active));
}
let mut withdrawn = self.withdrawn.clone();
for mutant in population {
if !active.contains(&mutant.ordinal) {
let _ = withdrawn.insert(mutant.ordinal);
}
}
let _guards = self.splices.instrument(work, plan, &withdrawn)?;
let started = Instant::now();
let outcome = run_cargo(work, plan, verb, select, limits, self.first_round, events)?;
let elapsed = started.elapsed();
self.total_rounds = self.total_rounds.saturating_add(1);
self.history.push(Round { elapsed, withdrew: 0 });
Ok(outcome.stdout.map(|_stdout| !outcome.succeeded))
}
fn probe(
&mut self,
work: &Workspace,
plan: &Plan,
select: Option<&[String]>,
verb: &[&str],
limits: BuildLimits,
events: &mut dyn Events,
) -> Result<()> {
let (candidates, deferred) = self.probe_sets(plan, select);
if candidates.len() < PROBE_FLOOR {
return Ok(());
}
events.build_progress(&format!(
"probing {} that did not compile for an earlier run before building the rest",
crate::report::quantity(candidates.len(), "mutant")
));
self.probed.extend(candidates.iter().copied());
self.ordering.offered = self.ordering.offered.saturating_add(candidates.len());
self.ordering.rounds = self.ordering.rounds.saturating_add(1);
let guards = self.splices.instrument(work, plan, &deferred)?;
let started = Instant::now();
let outcome = run_cargo(work, plan, verb, select, limits, self.first_round, events)?;
let elapsed = started.elapsed();
self.total_rounds = self.total_rounds.saturating_add(1);
let Some(stdout) = outcome.stdout else {
self.history.push(Round { elapsed, withdrew: 0 });
return Ok(());
};
if outcome.succeeded {
self.history.push(Round { elapsed, withdrew: 0 });
return Ok(());
}
let blamed = blame(&stdout, &work.root, &guards);
self.history.push(Round {
elapsed,
withdrew: blamed.len(),
});
self.ordering.confirmed = self.ordering.confirmed.saturating_add(blamed.len());
for (ordinal, code) in blamed {
let _ = self.withdrawn.insert(ordinal);
let _ = self.census.entry(ordinal).or_insert(code);
}
Ok(())
}
fn probe_sets(&self, plan: &Plan, select: Option<&[String]>) -> (Vec<u32>, HashSet<u32>) {
let mine = |mutant: &Mutant| select.is_none_or(|names| names.iter().any(|name| name.as_str() == &*mutant.package));
let mut candidates: Vec<u32> = Vec::new();
let mut deferred = self.withdrawn.clone();
for mutant in &plan.mutants {
if mutant.ordinal == 0 || self.withdrawn.contains(&mutant.ordinal) || !mine(mutant) {
continue;
}
if self.hinted.contains(&mutant.id) && !self.probed.contains(&mutant.ordinal) {
candidates.push(mutant.ordinal);
} else {
let _ = deferred.insert(mutant.ordinal);
}
}
candidates.sort_unstable();
(candidates, deferred)
}
fn build_timeout_error(budget: Duration) -> Error {
error!(
"the build was still running after {budget:.0?} and was stopped. A run builds once, so a \
build that does not finish costs the whole run; raise --build-timeout if this one is simply slow."
)
}
fn unattributed_build_error(work: &Workspace, stdout: &str, stderr: &str) -> Error {
let diagnostics = diagnostics(stdout);
if diagnostics.is_empty() {
return error!(
"the instrumented tree failed to build, and the compiler reported nothing, so no \
mutant can be blamed for it. The cause is usually something cargo hit before it \
reached the code — a build script, a missing native dependency, a bad invocation — \
and it is almost always in what cargo said:\n\n{}\n\n{}",
tail(&complaints(stderr), 30),
work.inspect_hint()
);
}
error!(
"the instrumented tree does not compile and the failure could not be attributed to a mutant.\n\
{}\n\n{}",
work.inspect_hint(),
leading(&diagnostics, DIAGNOSTIC_LIMIT)
)
}
fn rollback_limit_error(rounds: u32, limit: u32, per_round: &[usize], work: &Workspace, stdout: &str) -> Error {
let blamed: usize = per_round.iter().sum();
let recent: Vec<String> = per_round.iter().rev().take(5).rev().map(usize::to_string).collect();
error!(
"the instrumented tree still does not compile after {rounds} of the {limit} rollback rounds \
this build is allowed, having blamed unviable mutants in each of them ({blamed} blamed \
during this build, the last round's among them not withdrawn because the limit stopped it).\n\
Mutants blamed in the last rounds of this build: {}.\n\
If those counts are falling, the tree was converging and --rollback-rounds is simply too \
low for it. If they are flat, each round is uncovering as much as the last and raising \
the limit will only make the failure slower.\n\
{}\n\n{}",
recent.join(", "),
work.inspect_hint(),
leading(&diagnostics(stdout), DIAGNOSTIC_LIMIT)
)
}
fn missing_guard_error(missing: &Mutant) -> Error {
error!(
"internal error: no guard was emitted for the mutant at {}:{}, so it could not \
be tested. Please report this.\n {}",
missing.file,
missing.line,
missing.describe()
)
}
pub(super) fn preflight(
work: &Workspace,
plan: &Plan,
select: Option<&[String]>,
mutating: &[String],
limits: BuildLimits,
events: &mut dyn Events,
) -> Result<Preflight> {
match Self::check(work, plan, select, mutating, limits, events) {
Ok(()) => Ok(Preflight::narrow(Vec::new())),
Err(narrow) if select.is_some() => {
events.build_progress("the selected packages alone did not build, checking the whole workspace instead");
if Self::check(work, plan, None, mutating, limits, events).is_ok() {
return Ok(Preflight {
whole_workspace: true,
dropped: Vec::new(),
});
}
Self::retreat(work, plan, select, mutating, limits, events, narrow)
}
Err(error) => Err(error),
}
}
fn retreat(
work: &Workspace,
plan: &Plan,
select: Option<&[String]>,
mutating: &[String],
limits: BuildLimits,
events: &mut dyn Events,
narrow: Error,
) -> Result<Preflight> {
let dropped: Vec<String> = select
.unwrap_or_default()
.iter()
.filter(|package| !mutating.contains(package))
.cloned()
.collect();
if dropped.is_empty() {
return Err(narrow);
}
events.build_progress("the whole workspace did not build either, checking only the packages being mutated");
Self::check(work, plan, Some(mutating), mutating, limits, events).map_err(|_last| narrow)?;
Ok(Preflight::narrow(dropped))
}
fn check(
work: &Workspace,
plan: &Plan,
select: Option<&[String]>,
mutating: &[String],
limits: BuildLimits,
events: &mut dyn Events,
) -> Result<()> {
let outcome = run_cargo(work, plan, &["check", "--tests", "--keep-going"], select, limits, None, events)?;
let Some(stdout) = outcome.stdout else {
return Err(Self::build_timeout_error(limits.budget(None).unwrap_or_default()));
};
if outcome.succeeded {
return Ok(());
}
let mut diagnostics = diagnostics(&stdout);
prioritize(&mut diagnostics, &manifests_of(plan, &work.root, mutating));
if diagnostics.is_empty() {
return Err(error!(
"the tree could not be checked, and the compiler reported nothing, so the cause is \
something cargo hit before it reached the code — a build script, a missing native \
dependency, a bad invocation:\n\n{}",
tail(&complaints(&outcome.stderr), 30)
));
}
Err(error!(
"this tree does not compile before any mutation is applied, so there is nothing to \
measure against.\n\
These are the compiler's own errors, on the unmodified sources. Note that `cargo build` \
alone would not show them, because it does not build test targets; `cargo check --tests` \
reproduces them. A feature selection that leaves a test target's dependencies switched \
off is the usual cause.\n\n{}",
leading(&diagnostics, DIAGNOSTIC_LIMIT)
))
}
pub(super) fn stage(
&mut self,
work: &Workspace,
plan: &mut Plan,
packages: &[String],
limits: BuildLimits,
events: &mut dyn Events,
) -> Result<Option<Abandoned>> {
match self.converge(work, plan, self.scoped(Some(packages)), &["build", "--keep-going"], limits, events)? {
Convergence::Built(stdout) => {
self.remember_compiled(&stdout, &work.root);
Ok(None)
}
Convergence::Stuck(reason) => Ok(Some(self.abandon(plan, Some(packages), &reason))),
}
}
fn abandon(&mut self, plan: &mut Plan, packages: Option<&[String]>, reason: &Error) -> Abandoned {
let mut ordinals = Vec::new();
for mutant in &mut plan.mutants {
let mine = packages.is_none_or(|packages| packages.iter().any(|package| package.as_str() == &*mutant.package));
if mutant.ordinal == 0 || !mine || self.withdrawn.contains(&mutant.ordinal) {
continue;
}
mutant.outcome = Outcome::NotBuilt;
mutant.note = Some("the build this mutant belongs to could not be made to compile, so it was never run".to_owned());
ordinals.push(mutant.ordinal);
}
self.withdrawn.extend(ordinals.iter().copied());
self.abandoned.extend(ordinals.iter().copied());
ordinals.sort_unstable();
Abandoned {
reason: reason.to_string(),
ordinals,
}
}
pub(super) fn settle(&self, plan: &mut Plan) {
for mutant in &mut plan.mutants {
if self.abandoned.contains(&mutant.ordinal) {
mutant.outcome = Outcome::NotBuilt;
mutant.note =
Some("the instrumented forms in this item could not compile together, so its mutants were not run".to_owned());
} else if self.withdrawn.contains(&mutant.ordinal) {
mutant.outcome = Outcome::CompileError;
}
}
}
fn compile(
&mut self,
work: &Workspace,
plan: &Plan,
select: Option<&[String]>,
limits: BuildLimits,
events: &mut dyn Events,
) -> Result<Convergence> {
self.converge(
work,
plan,
select,
&["build", "--tests", "--examples", "--keep-going"],
limits,
events,
)
}
pub(super) fn finish(
mut self,
work: &Workspace,
plan: &mut Plan,
select: Option<&[String]>,
limits: BuildLimits,
events: &mut dyn Events,
) -> Result<Build> {
let select = self.scoped(select);
let mut widened = false;
let converged = match self.compile(work, plan, select, limits, events)? {
Convergence::Built(stdout) => Convergence::Built(stdout),
Convergence::Stuck(narrow) if select.is_some() => {
widened = true;
match self.compile(work, plan, None, limits, events)? {
Convergence::Built(stdout) => Convergence::Built(stdout),
Convergence::Stuck(_whole) => Convergence::Stuck(narrow),
}
}
Convergence::Stuck(reason) => Convergence::Stuck(reason),
};
let stdout = match converged {
Convergence::Built(stdout) => stdout,
Convergence::Stuck(reason) => {
let stuck = self.abandon(plan, None, &reason);
self.settle(plan);
return Ok(Build {
history: self.history.clone(),
census: self.tally(plan),
binaries: Vec::new(),
withdrawn: self.withdrawn.len().saturating_sub(self.abandoned.len()),
rounds: self.total_rounds,
widened,
stuck: Some(stuck),
ordering: self.ordering,
});
}
};
self.settle(plan);
self.remember_compiled(&stdout, &work.root);
if let Some(compiled) = &self.compiled {
withdraw_uncompiled(plan, compiled);
}
Ok(Build {
history: self.history.clone(),
census: self.tally(plan),
binaries: test_binaries(&stdout),
withdrawn: self.withdrawn.len().saturating_sub(self.abandoned.len()),
rounds: self.total_rounds,
widened,
stuck: None,
ordering: self.ordering,
})
}
pub(super) fn withdrawn(&self) -> usize {
self.withdrawn.len()
}
fn remember_compiled(&mut self, stdout: &str, root: &camino::Utf8Path) {
if let Some(found) = compiled_sources(stdout, root) {
self.compiled.get_or_insert_with(HashSet::default).extend(found);
}
}
fn tally(&self, plan: &Plan) -> Vec<Withdrawal> {
let mut mutators: HashMap<u32, &str> = HashMap::default();
for mutant in &plan.mutants {
let _ = mutators.insert(mutant.ordinal, &mutant.mutator);
}
let mut counts: HashMap<(&str, &str), usize> = HashMap::default();
for (ordinal, code) in &self.census {
let mutator = mutators.get(ordinal).copied().unwrap_or("");
*counts.entry((code.as_str(), mutator)).or_default() += 1;
}
let mut census: Vec<Withdrawal> = counts
.into_iter()
.map(|((code, mutator), mutants)| Withdrawal {
code: code.to_owned(),
mutator: mutator.to_owned(),
mutants,
})
.collect();
census.sort_by(|left, right| {
right
.mutants
.cmp(&left.mutants)
.then_with(|| left.code.cmp(&right.code))
.then_with(|| left.mutator.cmp(&right.mutator))
});
census
}
}
fn withdraw_uncompiled(plan: &mut Plan, compiled: &HashSet<Utf8PathBuf>) {
if !plan.files.iter().any(|file| compiled.contains(&file.path)) {
return;
}
for mutant in &mut plan.mutants {
if mutant.outcome == Outcome::Pending && !compiled.contains(&*mutant.file) {
mutant.outcome = Outcome::NotBuilt;
}
}
}