use std::process::Command;
use std::{env, fs};
use blake3::Hasher;
use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
use super::Plan;
use super::killers::Killers;
use super::workspace_snapshot::WorkspaceSnapshot;
use crate::cfg::Build;
use crate::model::{Mutant, MutantId, Outcome};
use crate::{HashMap, HashSet};
const VERSION: u32 = 9;
const FILE: &str = "last-gamma-run.json";
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum Trust {
#[default]
Free,
Settled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Term {
Features,
Profile,
Rustflags,
Target,
Config,
Extra,
Toolchain,
Tool,
Tests,
Policy,
Environment,
}
impl Term {
pub const ALL: &'static [Self] = &[
Self::Features,
Self::Profile,
Self::Rustflags,
Self::Target,
Self::Config,
Self::Extra,
Self::Toolchain,
Self::Tool,
Self::Tests,
Self::Policy,
Self::Environment,
];
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Features => "features",
Self::Profile => "profile",
Self::Rustflags => "rustflags",
Self::Target => "target",
Self::Config => "config",
Self::Extra => "extra",
Self::Toolchain => "toolchain",
Self::Tool => "tool",
Self::Tests => "tests",
Self::Policy => "policy",
Self::Environment => "environment",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tier {
Unviability,
Verdict,
Ordering,
}
impl Tier {
#[must_use]
pub const fn requires(self) -> &'static [Term] {
match self {
Self::Unviability => &[
Term::Features,
Term::Profile,
Term::Rustflags,
Term::Target,
Term::Config,
Term::Extra,
Term::Toolchain,
Term::Tool,
Term::Policy,
Term::Environment,
],
Self::Verdict => &[
Term::Features,
Term::Profile,
Term::Rustflags,
Term::Target,
Term::Config,
Term::Extra,
Term::Toolchain,
Term::Tool,
Term::Tests,
Term::Policy,
Term::Environment,
],
Self::Ordering => &[],
}
}
#[must_use]
pub fn admits(self, recorded: &ContextDigest, current: &ContextDigest) -> bool {
self.requires()
.iter()
.all(|term| recorded.states(*term) && current.states(*term) && recorded.term(*term) == current.term(*term))
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ContextDigest {
features: String,
profile: String,
rustflags: String,
#[serde(default)]
target: Option<String>,
#[serde(default)]
config: Option<String>,
extra: String,
toolchain: String,
tool: String,
#[serde(default)]
tests: String,
#[serde(default)]
policy: String,
#[serde(default)]
environment: Option<String>,
}
impl ContextDigest {
#[must_use]
pub fn term(&self, term: Term) -> &str {
match term {
Term::Features => &self.features,
Term::Profile => &self.profile,
Term::Rustflags => &self.rustflags,
Term::Target => self.target.as_deref().unwrap_or_default(),
Term::Config => self.config.as_deref().unwrap_or_default(),
Term::Extra => &self.extra,
Term::Toolchain => &self.toolchain,
Term::Tool => &self.tool,
Term::Tests => &self.tests,
Term::Policy => &self.policy,
Term::Environment => self.environment.as_deref().unwrap_or_default(),
}
}
#[must_use]
pub const fn states(&self, term: Term) -> bool {
match term {
Term::Target => self.target.is_some(),
Term::Config => self.config.is_some(),
Term::Environment => self.environment.is_some(),
_stated_by_the_command_line => true,
}
}
#[must_use]
pub fn differences(&self, other: &Self) -> Vec<Term> {
Term::ALL
.iter()
.copied()
.filter(|term| self.states(*term) && other.states(*term) && self.term(*term) != other.term(*term))
.collect()
}
#[must_use]
pub fn resolved_at(&self, root: &Utf8Path) -> Self {
let settings = Build::settings(root);
let parts: Vec<&[u8]> = settings.iter().map(String::as_bytes).collect();
Self {
config: Some(term(Term::Config, &parts)),
..self.clone()
}
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct RunRecord {
version: u32,
context: ContextDigest,
files: Vec<RecordedFile>,
#[serde(default)]
inputs: WorkspaceSnapshot,
#[serde(default)]
compilation_roots: HashMap<String, Vec<Utf8PathBuf>>,
#[serde(default)]
hints: HashMap<MutantId, Killer>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Killer {
pub package: String,
pub target: String,
pub test: String,
}
impl Killer {
#[must_use]
pub fn names(&self, package: &str, target: &str) -> bool {
self.package == package && self.target == target
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RecordedFile {
path: Utf8PathBuf,
#[serde(default)]
package: String,
digest: String,
size: u64,
mutants: Vec<Entry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Entry {
id: MutantId,
outcome: Outcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
killed_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
killer_file: Option<Utf8PathBuf>,
#[serde(default)]
elapsed_ms: u64,
}
#[derive(Debug, Clone)]
pub struct Entries<'a> {
files: core::slice::Iter<'a, RecordedFile>,
mutants: Option<core::slice::Iter<'a, Entry>>,
}
impl<'a> Iterator for Entries<'a> {
type Item = (&'a str, Outcome);
fn next(&mut self) -> Option<Self::Item> {
loop {
if let Some(mutants) = self.mutants.as_mut()
&& let Some(entry) = mutants.next()
{
return Some((entry.id.as_str(), entry.outcome));
}
self.mutants = Some(self.files.next()?.mutants.iter());
}
}
}
impl<'a> IntoIterator for &'a RunRecord {
type Item = (&'a str, Outcome);
type IntoIter = Entries<'a>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
const fn settled_verdict(outcome: Outcome) -> bool {
matches!(outcome, Outcome::CompileError)
}
impl RunRecord {
#[cfg(test)]
#[must_use]
fn snapshot(root: &Utf8Path, scratch_base: &Utf8Path) -> WorkspaceSnapshot {
WorkspaceSnapshot::capture(root, &[scratch_base.to_path_buf()])
}
#[must_use]
pub(crate) fn snapshot_with_external(
root: &Utf8Path,
scratch_base: &Utf8Path,
external_roots: &[Utf8PathBuf],
untracked_build_script_inputs: bool,
) -> WorkspaceSnapshot {
WorkspaceSnapshot::capture_with_external(root, &[scratch_base.to_path_buf()], external_roots, untracked_build_script_inputs)
}
#[must_use]
pub fn load(base: &Utf8Path) -> Self {
Self::load_raw(base).unwrap_or_default()
}
fn load_raw(base: &Utf8Path) -> Option<Self> {
let text = fs::read_to_string(base.join(FILE)).ok()?;
let record = serde_json::from_str::<Self>(&text).ok()?;
(record.version == VERSION).then_some(record)
}
#[must_use]
pub const fn probes(&self) -> &HashMap<MutantId, Killer> {
&self.hints
}
#[must_use]
pub fn iter(&self) -> Entries<'_> {
Entries {
files: self.files.iter(),
mutants: None,
}
}
#[must_use]
pub fn holds_unviability(&self) -> bool {
self.files
.iter()
.flat_map(|file| file.mutants.iter())
.any(|entry| entry.outcome == Outcome::CompileError)
}
#[must_use]
pub fn ordering(&self) -> Vec<&str> {
let mut ids: Vec<&str> = self
.iter()
.filter(|(_id, outcome)| *outcome == Outcome::CompileError)
.map(|(id, _outcome)| id)
.collect();
ids.sort_unstable();
ids.dedup();
ids
}
#[must_use]
pub const fn context(&self) -> &ContextDigest {
&self.context
}
pub fn store_probes(base: &Utf8Path, probes: &HashMap<MutantId, Killer>) {
let mut record = Self::load_raw(base).unwrap_or_default();
record.version = VERSION;
record.hints.clone_from(probes);
let Ok(text) = serde_json::to_string(&record) else {
return;
};
if let Err(failure) = crate::elements::write(&base.join(FILE), &text) {
crate::notes::note(format!("could not save run-record probes: {failure}"));
}
}
#[must_use]
pub fn settled(
&self,
root: &Utf8Path,
trust: Trust,
killers: &Killers,
context: &ContextDigest,
) -> (HashMap<MutantId, Outcome>, usize) {
let current_inputs = self.inputs.recapture(root);
self.settled_against(root, trust, killers, context, ¤t_inputs)
}
pub(crate) fn settled_against(
&self,
root: &Utf8Path,
trust: Trust,
killers: &Killers,
context: &ContextDigest,
current_inputs: &WorkspaceSnapshot,
) -> (HashMap<MutantId, Outcome>, usize) {
let mut settled = HashMap::default();
let mut declined = 0;
if !current_inputs.is_complete() {
return (settled, declined);
}
let workspace_unchanged = self.inputs == *current_inputs;
let current = context.resolved_at(root);
let unviability = Tier::Unviability.admits(&self.context, ¤t);
let verdicts = Tier::Verdict.admits(&self.context, ¤t);
if !(unviability || verdicts && workspace_unchanged) {
return (settled, declined);
}
let files_by_path: HashMap<&Utf8Path, &RecordedFile> = self.files.iter().map(|file| (file.path.as_path(), file)).collect();
for file in &self.files {
if !is_unchanged(file, current_inputs) {
continue;
}
for entry in &file.mutants {
let admitted = if entry.outcome == Outcome::CompileError {
unviability
&& (workspace_unchanged
|| self
.compilation_roots
.get(&file.package)
.is_some_and(|roots| self.inputs.matches_compilation_inputs(current_inputs, roots)))
} else {
verdicts && workspace_unchanged && trust == Trust::Settled
};
if !admitted || entry.outcome == Outcome::Timeout {
continue;
}
if entry.outcome == Outcome::Killed && !still_killed(entry, killers, &files_by_path, root, current_inputs) {
declined += 1;
continue;
}
let _previous = settled.insert(entry.id.clone(), entry.outcome);
}
}
(settled, declined)
}
#[cfg(test)]
#[must_use]
pub fn from_run(root: &Utf8Path, mutants: &[Mutant], context: &ContextDigest, _source_dirs: &[Utf8PathBuf]) -> Self {
let inputs = WorkspaceSnapshot::capture(root, &[root.join(FILE)]);
let killers = Killers::scan(&inputs.rust_files(root));
Self::from_snapshot(root, mutants, context, inputs, &killers).unwrap_or_default()
}
#[cfg(test)]
#[must_use]
fn from_snapshot(
root: &Utf8Path,
mutants: &[Mutant],
context: &ContextDigest,
inputs: WorkspaceSnapshot,
killers: &Killers,
) -> Option<Self> {
let compilation_roots = mutants
.iter()
.map(|mutant| ((*mutant.package).to_owned(), vec![Utf8PathBuf::new()]))
.collect();
Self::from_snapshot_with_roots(root, mutants, context, inputs, killers, compilation_roots)
}
#[must_use]
pub(crate) fn from_plan_snapshot(plan: &Plan, context: &ContextDigest, inputs: WorkspaceSnapshot, killers: &Killers) -> Option<Self> {
let mut compilation_roots = HashMap::default();
for package in plan.specs.keys() {
let mut dependencies = plan.reach.get(package).cloned().unwrap_or_default();
let _self = dependencies.insert(package.clone());
let mut roots: Vec<Utf8PathBuf> = dependencies
.iter()
.filter_map(|dependency| plan.specs.get(dependency).map(|(root, _version)| root.clone()))
.collect();
roots.sort();
roots.dedup();
let _previous = compilation_roots.insert(package.clone(), roots);
}
Self::from_snapshot_with_roots(&plan.root, &plan.mutants, context, inputs, killers, compilation_roots)
}
fn from_snapshot_with_roots(
root: &Utf8Path,
mutants: &[Mutant],
context: &ContextDigest,
inputs: WorkspaceSnapshot,
killers: &Killers,
compilation_roots: HashMap<String, Vec<Utf8PathBuf>>,
) -> Option<Self> {
if !inputs.matches_current(root) {
return None;
}
let packages: HashMap<Utf8PathBuf, String> = mutants
.iter()
.map(|mutant| (mutant.file.to_path_buf(), (*mutant.package).to_owned()))
.collect();
let mut by_file: HashMap<Utf8PathBuf, Vec<Entry>> = inputs
.files
.iter()
.filter(|file| file.path.extension() == Some("rs"))
.map(|file| (file.path.clone(), Vec::new()))
.collect();
for mutant in mutants.iter().filter(|mutant| settled_verdict(mutant.outcome)) {
let file = mutant.file.to_path_buf();
let _known = inputs.file(&file)?;
let killer_file = if mutant.outcome == Outcome::Killed {
mutant.killed_by.as_deref().and_then(|name| {
killers
.verdict_file_for(name)
.and_then(|path| path.strip_prefix(root).ok())
.map(Utf8Path::to_path_buf)
})
} else {
None
};
by_file.entry(file).or_default().push(Entry {
id: mutant.id.clone(),
outcome: mutant.outcome,
killed_by: mutant.killed_by.clone(),
killer_file,
elapsed_ms: mutant.elapsed_ms,
});
}
let mut files: Vec<RecordedFile> = by_file
.into_iter()
.filter_map(|(path, mut mutants)| {
let input = inputs.file(&path)?;
mutants.sort_by(|left, right| left.id.cmp(&right.id));
mutants.dedup_by(|left, right| left.id == right.id);
Some(RecordedFile {
package: packages.get(&path).cloned().unwrap_or_default(),
path,
digest: input.digest.clone(),
size: input.size,
mutants,
})
})
.collect();
files.sort_by(|left, right| left.path.cmp(&right.path));
Some(Self {
version: VERSION,
context: context.resolved_at(root),
files,
inputs,
compilation_roots,
hints: HashMap::default(),
})
}
pub fn store(&self, base: &Utf8Path, root: &Utf8Path) {
let earlier = Self::load_raw(base).unwrap_or_default();
let merged = self.absorbing(&earlier);
if !merged.inputs.matches_current(root) {
return;
}
let Ok(text) = serde_json::to_string(&merged) else {
return;
};
if let Err(failure) = crate::elements::write(&base.join(FILE), &text) {
crate::notes::note(format!("could not save run record: {failure}"));
}
}
fn absorbing(&self, earlier: &Self) -> Self {
let workspace_unchanged = earlier.inputs == self.inputs;
let unviability = Tier::Unviability.admits(&earlier.context, &self.context);
let verdicts = Tier::Verdict.admits(&earlier.context, &self.context) && workspace_unchanged;
let mut carried = Vec::new();
for file in &earlier.files {
let compilation_unchanged = earlier
.compilation_roots
.get(&file.package)
.is_some_and(|roots| earlier.inputs.matches_compilation_inputs(&self.inputs, roots));
let mut admitted = file.clone();
admitted.mutants.retain(|entry| {
if entry.outcome == Outcome::CompileError {
unviability && compilation_unchanged
} else {
verdicts
}
});
if !admitted.mutants.is_empty() {
carried.push(admitted);
}
}
let carried_map: HashMap<&Utf8PathBuf, &RecordedFile> = carried.iter().map(|f| (&f.path, f)).collect();
let mut seen = HashSet::default();
let mut files: Vec<RecordedFile> = Vec::new();
for file in &self.files {
let _ = seen.insert(&file.path);
if !file.mutants.is_empty() {
files.push(file.clone());
} else if let Some(earlier_file) = carried_map.get(&file.path) {
if !earlier_file.mutants.is_empty() && is_unchanged(earlier_file, &self.inputs) {
files.push(RecordedFile {
path: file.path.clone(),
package: earlier_file.package.clone(),
digest: file.digest.clone(),
size: file.size,
mutants: earlier_file.mutants.clone(),
});
} else {
files.push(file.clone());
}
} else {
files.push(file.clone());
}
}
for file in &carried {
if !seen.contains(&file.path) && is_unchanged(file, &self.inputs) {
files.push(file.clone());
}
}
files.sort_by(|left, right| left.path.cmp(&right.path));
let mut compilation_roots = self.compilation_roots.clone();
for file in &files {
if !compilation_roots.contains_key(&file.package)
&& let Some(roots) = earlier.compilation_roots.get(&file.package)
{
let _previous = compilation_roots.insert(file.package.clone(), roots.clone());
}
}
Self {
version: VERSION,
context: self.context.clone(),
files,
inputs: self.inputs.clone(),
compilation_roots,
hints: if self.hints.is_empty() {
earlier.hints.clone()
} else {
self.hints.clone()
},
}
}
#[cfg(test)]
fn len(&self) -> usize {
self.files.iter().map(|file| file.mutants.len()).sum()
}
}
fn still_killed(
entry: &Entry,
killers: &Killers,
files_by_path: &HashMap<&Utf8Path, &RecordedFile>,
root: &Utf8Path,
current_inputs: &WorkspaceSnapshot,
) -> bool {
let Some(name) = entry.killed_by.as_deref() else {
return false;
};
let Some(recorded_path) = entry.killer_file.as_deref() else {
return false;
};
let Some(test_file_path) = killers.verdict_file_for(name) else {
return false;
};
let Ok(current_path) = test_file_path.strip_prefix(root) else {
return false;
};
if current_path != recorded_path {
return false;
}
let Some(recorded) = files_by_path.get(recorded_path) else {
return false;
};
killers.file_digest(test_file_path) == Some(recorded.digest.as_str()) && is_unchanged(recorded, current_inputs)
}
fn is_unchanged(file: &RecordedFile, current_inputs: &WorkspaceSnapshot) -> bool {
current_inputs
.file(&file.path)
.is_some_and(|current| current.size == file.size && current.digest == file.digest)
}
pub(crate) fn digest(bytes: &[u8]) -> String {
let mut hasher = Hasher::new();
let _ = hasher.update(bytes);
hasher.finalize().to_hex().to_string()
}
#[must_use]
pub fn toolchain() -> Option<String> {
let program = env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
let rustc = Command::new(&program)
.arg("-vV")
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())?;
let cargo_program = env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let cargo = Command::new(&cargo_program)
.arg("--version")
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())?;
let wrapper = env::var_os("RUSTC_WRAPPER").unwrap_or_default();
let workspace_wrapper = env::var_os("RUSTC_WORKSPACE_WRAPPER").unwrap_or_default();
Some(format!(
"rustc={}\ncargo={}\nrustc_wrapper={}\nrustc_workspace_wrapper={}\n{rustc}\n{cargo}",
program.to_string_lossy(),
cargo_program.to_string_lossy(),
wrapper.to_string_lossy(),
workspace_wrapper.to_string_lossy()
))
}
#[must_use]
pub fn rustflags() -> Option<String> {
let mut targeted: Vec<(String, String)> = env::vars_os()
.filter_map(|(name, value)| {
let name = name.into_string().ok()?;
(name.starts_with("CARGO_TARGET_") && name.ends_with("_RUSTFLAGS"))
.then(|| Some((name, value.into_string().ok()?)))
.flatten()
})
.collect();
targeted.sort();
rustflags_in(
env::var("CARGO_ENCODED_RUSTFLAGS").ok(),
env::var("RUSTFLAGS").ok(),
env::var("CARGO_BUILD_RUSTFLAGS").ok(),
&targeted,
)
}
fn rustflags_in(
encoded: Option<String>,
plain: Option<String>,
configured: Option<String>,
targeted: &[(String, String)],
) -> Option<String> {
let chosen = encoded.or(plain).or(configured);
if targeted.is_empty() {
return chosen;
}
let mut text = chosen.unwrap_or_default();
for (name, value) in targeted {
text.push('\n');
text.push_str(name);
text.push('=');
text.push_str(value);
}
Some(text)
}
#[derive(Debug, Default, Clone, Copy)]
#[expect(
clippy::struct_excessive_bools,
reason = "captures discrete boolean CLI and build flags for context digest"
)]
pub struct Context<'a> {
pub features: &'a [String],
pub all_features: bool,
pub no_default_features: bool,
pub profile: Option<&'a str>,
pub extra: &'a [String],
pub rustflags: Option<&'a str>,
pub toolchain: Option<&'a str>,
pub test_packages: &'a [String],
pub include_tests: &'a [String],
pub exclude_tests: &'a [String],
pub test_workspace: bool,
pub whole_test_binaries: bool,
pub nextest: bool,
pub cargo_test_args: &'a [String],
pub test_args: &'a [String],
pub baseline: bool,
pub confirm: bool,
pub stall: bool,
pub test_timeout_multiplier: Option<f64>,
pub minimum_test_timeout: Option<f64>,
pub memory: Option<crate::exec::MemoryControl>,
pub memory_multiplier: Option<f64>,
pub memory_headroom: Option<u64>,
pub memory_limit: Option<u64>,
pub baseline_memory_limit: Option<u64>,
pub no_relaunch: bool,
pub copy_ignored: bool,
pub jobs: Option<usize>,
pub build_timeout: Option<f64>,
pub build_timeout_multiplier: Option<f64>,
pub rollback_rounds: u32,
}
#[must_use]
pub fn context(of: &Context<'_>) -> Option<ContextDigest> {
let environment = inherited_environment();
context_in(of, env::var("CARGO_BUILD_TARGET").ok().as_deref(), &environment)
}
fn context_in(of: &Context<'_>, build_target: Option<&str>, environment: &[(Vec<u8>, Vec<u8>)]) -> Option<ContextDigest> {
let toolchain = of.toolchain?;
let mut features: Vec<&[u8]> = of.features.iter().map(String::as_bytes).collect();
let flags = [u8::from(of.all_features), u8::from(of.no_default_features)];
features.push(&flags);
let targets = Build::requested_targets(of.extra, build_target);
let named: Vec<&[u8]> = targets.iter().map(String::as_bytes).collect();
let mut test_parts: Vec<&[u8]> = Vec::new();
for pkg in of.test_packages {
test_parts.push(pkg.as_bytes());
}
test_parts.push(b":inc:");
for inc in of.include_tests {
test_parts.push(inc.as_bytes());
}
test_parts.push(b":exc:");
for exc in of.exclude_tests {
test_parts.push(exc.as_bytes());
}
test_parts.push(b":case-reachability-default:");
let test_flags = [u8::from(of.test_workspace), u8::from(of.whole_test_binaries), u8::from(of.nextest)];
test_parts.push(&test_flags);
let mut policy_parts: Vec<&[u8]> = Vec::new();
policy_parts.push(b":cargo-test:");
policy_parts.extend(of.cargo_test_args.iter().map(String::as_bytes));
policy_parts.push(b":post--:");
policy_parts.extend(of.test_args.iter().map(String::as_bytes));
let policy = format!(
"baseline={};confirm={};stall={};timeout_multiplier={:?};timeout_floor={:?};memory={:?};\
memory_multiplier={:?};memory_headroom={:?};memory_limit={:?};baseline_memory_limit={:?};\
no_relaunch={};copy_ignored={};jobs={:?};build_timeout={:?};build_timeout_multiplier={:?};\
rollback_rounds={}",
of.baseline,
of.confirm,
of.stall,
of.test_timeout_multiplier,
of.minimum_test_timeout,
of.memory,
of.memory_multiplier,
of.memory_headroom,
of.memory_limit,
of.baseline_memory_limit,
of.no_relaunch,
of.copy_ignored,
of.jobs,
of.build_timeout,
of.build_timeout_multiplier,
of.rollback_rounds
);
policy_parts.push(policy.as_bytes());
let mut environment_parts = Vec::with_capacity(environment.len().saturating_mul(2));
for (name, value) in environment {
environment_parts.push(name.as_slice());
environment_parts.push(value.as_slice());
}
Some(ContextDigest {
features: term(Term::Features, &features),
profile: term(Term::Profile, &[of.profile.unwrap_or_default().as_bytes()]),
rustflags: term(Term::Rustflags, &[of.rustflags.unwrap_or_default().as_bytes()]),
target: Some(term(Term::Target, &named)),
config: None,
extra: term(Term::Extra, &of.extra.iter().map(String::as_bytes).collect::<Vec<&[u8]>>()),
toolchain: term(Term::Toolchain, &[toolchain.as_bytes()]),
tool: term(Term::Tool, &[env!("CARGO_PKG_VERSION").as_bytes()]),
tests: term(Term::Tests, &test_parts),
policy: term(Term::Policy, &policy_parts),
environment: Some(term(Term::Environment, &environment_parts)),
})
}
fn inherited_environment() -> Vec<(Vec<u8>, Vec<u8>)> {
let mut variables: Vec<(Vec<u8>, Vec<u8>)> = env::vars_os()
.map(|(name, value)| (name.as_encoded_bytes().to_vec(), value.as_encoded_bytes().to_vec()))
.collect();
variables.sort_unstable();
variables
}
#[cfg(test)]
const COVERAGE: &[(&str, Term)] = &[
("CARGO_BUILD_TARGET", Term::Target),
("CARGO_ENCODED_RUSTFLAGS", Term::Rustflags),
("RUSTFLAGS", Term::Rustflags),
("CARGO_BUILD_RUSTFLAGS", Term::Rustflags),
("CARGO_TARGET_<triple>_RUSTFLAGS", Term::Rustflags),
("CARGO_HOME", Term::Config),
("cargo config files", Term::Config),
("build.target", Term::Config),
("build.rustflags", Term::Config),
("target.*.rustflags", Term::Config),
("profile.*", Term::Config),
];
fn term(name: Term, parts: &[&[u8]]) -> String {
let mut hasher = Hasher::new();
let named = name.name().as_bytes();
let _ = hasher.update(&(named.len() as u64).to_le_bytes());
let _ = hasher.update(named);
for part in parts {
let _ = hasher.update(&(part.len() as u64).to_le_bytes());
let _ = hasher.update(part);
}
hasher.finalize().to_hex().to_string()
}
#[cfg(test)]
#[cfg(not(miri))]
mod tests {
use std::sync::{Arc, Barrier};
use std::thread;
use super::*;
use crate::fixtures;
use crate::testing::workdir;
fn mutant(id: &str, file: &str, outcome: Outcome) -> Mutant {
Mutant {
id: id.to_owned().into(),
file: (Utf8PathBuf::from(file)).into(),
mutator: ("arith".to_owned()).into(),
item_path: ("subject::add".to_owned()).into(),
original: "+".to_owned().into(),
replacement: "-".to_owned().into(),
outcome,
..fixtures::mutant()
}
}
fn workspace(prefix: &str, body: &str) -> (tempfile::TempDir, Utf8PathBuf) {
let dir = workdir(prefix);
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("the work directory should be UTF-8");
fs::create_dir_all(root.join("src")).expect("the source directory should be creatable");
fs::write(root.join("src/lib.rs"), body).expect("the source should be writable");
(dir, root)
}
fn killers(root: &Utf8PathBuf, body: &str) -> Killers {
let path = root.join("src/tests.rs");
fs::write(&path, body).expect("the test source should be writable");
Killers::scan(&[path])
}
fn killed(id: &str, by: Option<&str>) -> Mutant {
Mutant {
killed_by: by.map(str::to_owned),
elapsed_ms: 42,
..mutant(id, "src/lib.rs", Outcome::Killed)
}
}
fn envelope() -> ContextDigest {
context(&plain()).expect("a named toolchain gives a context")
}
fn from_run(root: &Utf8Path, mutants: &[Mutant], context: &ContextDigest) -> RunRecord {
RunRecord::from_run(root, mutants, context, &[root.join("src"), root.join("tests")])
}
fn package_mutant(id: &str, package: &str, file: &str, outcome: Outcome) -> Mutant {
Mutant {
package: package.to_owned().into(),
..mutant(id, file, outcome)
}
}
fn cache_plan(root: &Utf8Path, mutants: Vec<Mutant>, a_depends_on_b: bool) -> Plan {
let mut a_reach = HashSet::default();
let _inserted = a_reach.insert("a".to_owned());
if a_depends_on_b {
let _inserted = a_reach.insert("b".to_owned());
}
let mut b_reach = HashSet::default();
let _inserted = b_reach.insert("b".to_owned());
let mut reach = HashMap::default();
let _previous = reach.insert("a".to_owned(), a_reach);
let _previous = reach.insert("b".to_owned(), b_reach);
let mut specs = HashMap::default();
let _previous = specs.insert("a".to_owned(), (Utf8PathBuf::from("crates/a"), "0.1.0".to_owned()));
let _previous = specs.insert("b".to_owned(), (Utf8PathBuf::from("crates/b"), "0.1.0".to_owned()));
Plan {
root: root.to_owned(),
files: Vec::new(),
mutants,
suppressed: 0,
idle: Vec::new(),
sharded_out: 0,
settled_out: 0,
digests: HashMap::default(),
skipped: Vec::new(),
reach,
specs,
}
}
fn cache_workspace(prefix: &str) -> (tempfile::TempDir, Utf8PathBuf) {
let dir = workdir(prefix);
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("the work directory should be UTF-8");
for package in ["a", "b"] {
fs::create_dir_all(root.join(format!("crates/{package}/src"))).expect("package source directory");
fs::write(
root.join(format!("crates/{package}/Cargo.toml")),
format!("[package]\nname = \"{package}\"\nversion = \"0.1.0\"\n"),
)
.expect("package manifest");
fs::write(
root.join(format!("crates/{package}/src/lib.rs")),
format!("pub fn {package}() -> bool {{ true }}\n"),
)
.expect("package source");
}
fs::write(root.join("Cargo.toml"), "[workspace]\nmembers = [\"crates/a\", \"crates/b\"]\n").expect("workspace manifest");
fs::write(root.join("Cargo.lock"), "# lock\n").expect("workspace lockfile");
(dir, root)
}
fn from_plan(plan: &Plan) -> RunRecord {
let inputs = WorkspaceSnapshot::capture(&plan.root, &[]);
RunRecord::from_plan_snapshot(plan, &envelope(), inputs, &Killers::default())
.expect("an unchanged workspace should produce a record")
}
fn under_another_toolchain() -> ContextDigest {
context(&Context {
toolchain: Some("1.91.0"),
..plain()
})
.expect("a named toolchain gives a context")
}
fn under_another_feature_set() -> ContextDigest {
let features = ["extra".to_owned()];
context(&Context {
features: &features,
..plain()
})
.expect("a named toolchain gives a context")
}
#[test]
fn only_compiler_unviability_is_recorded() {
let (_dir, root) = workspace("record-unsettled-", "fn add() {}");
let population = [
mutant("killed", "src/lib.rs", Outcome::Killed),
mutant("survivor", "src/lib.rs", Outcome::Survived),
mutant("flake", "src/lib.rs", Outcome::Flaky),
mutant("unbuilt", "src/lib.rs", Outcome::NotBuilt),
mutant("hungry", "src/lib.rs", Outcome::OutOfMemory),
mutant("uncovered", "src/lib.rs", Outcome::NoCoverage),
mutant("pending", "src/lib.rs", Outcome::Pending),
mutant("slow", "src/lib.rs", Outcome::Timeout),
mutant("skipped", "src/lib.rs", Outcome::Ignored),
mutant("unviable", "src/lib.rs", Outcome::CompileError),
];
let record = from_run(&root, &population, &envelope());
let (settled, _declined) = record.settled(&root, Trust::Settled, &Killers::default(), &envelope());
assert_eq!(settled.len(), 1, "{settled:?}");
assert_eq!(settled.get("unviable"), Some(&Outcome::CompileError));
}
#[test]
fn a_cached_mutant_is_settled_when_its_file_is_unchanged() {
let (_dir, root) = workspace("record-hit-", "fn add() {}");
let cache = from_run(&root, &[mutant("abc", "src/lib.rs", Outcome::CompileError)], &envelope());
assert_eq!(
cache.settled(&root, Trust::Free, &Killers::default(), &envelope()).0.get("abc"),
Some(&Outcome::CompileError)
);
}
#[test]
fn a_cached_mutant_is_retried_when_its_file_changed() {
let (_dir, root) = workspace("record-miss-", "fn add() {}");
let cache = from_run(&root, &[mutant("abc", "src/lib.rs", Outcome::CompileError)], &envelope());
fs::write(root.join("src/lib.rs"), "fn add(a: i32) -> i32 { a }").expect("the source should be rewritable");
assert!(
cache.settled(&root, Trust::Free, &Killers::default(), &envelope()).0.is_empty(),
"an edited file must not carry its unviability"
);
}
#[test]
fn a_cached_mutant_is_retried_when_its_file_is_gone() {
let (_dir, root) = workspace("record-gone-", "fn add() {}");
let cache = from_run(&root, &[mutant("abc", "src/lib.rs", Outcome::CompileError)], &envelope());
fs::remove_file(root.join("src/lib.rs")).expect("the source should be removable");
assert!(cache.settled(&root, Trust::Free, &Killers::default(), &envelope()).0.is_empty());
}
#[test]
fn an_unrelated_package_change_preserves_unviability_but_not_test_verdicts() {
let (_dir, root) = cache_workspace("record-package-input-");
let unviable = package_mutant("unviable", "a", "crates/a/src/lib.rs", Outcome::CompileError);
let survived = package_mutant("survived", "a", "crates/a/src/lib.rs", Outcome::Survived);
let plan = cache_plan(&root, vec![unviable, survived], false);
let record = from_plan(&plan);
fs::write(root.join("crates/b/src/lib.rs"), "pub fn b() -> bool { false }\n").expect("unrelated source edit");
let settled = record.settled(&root, Trust::Settled, &Killers::default(), &envelope()).0;
assert_eq!(settled.get("unviable"), Some(&Outcome::CompileError));
assert!(!settled.contains_key("survived"));
}
#[test]
fn a_narrow_run_writes_back_safe_unviability_after_an_unrelated_change() {
let (_dir, root) = cache_workspace("record-package-writeback-");
let unviable = package_mutant("unviable", "a", "crates/a/src/lib.rs", Outcome::CompileError);
let survived = package_mutant("survived", "a", "crates/a/src/lib.rs", Outcome::Survived);
let earlier_plan = cache_plan(&root, vec![unviable, survived], false);
let earlier = from_plan(&earlier_plan);
fs::write(root.join("crates/b/src/lib.rs"), "pub fn b() -> bool { false }\n").expect("unrelated source edit");
let fresh = package_mutant("fresh", "b", "crates/b/src/lib.rs", Outcome::CompileError);
let current_plan = cache_plan(&root, vec![fresh], false);
let current = from_plan(¤t_plan);
let merged = current.absorbing(&earlier);
let settled = merged.settled(&root, Trust::Settled, &Killers::default(), &envelope()).0;
assert_eq!(settled.get("unviable"), Some(&Outcome::CompileError));
assert_eq!(settled.get("fresh"), Some(&Outcome::CompileError));
assert!(!settled.contains_key("survived"));
}
#[test]
fn a_package_source_change_never_carries_stale_unviability() {
let (_dir, root) = cache_workspace("record-package-source-");
let unviable = package_mutant("unviable", "a", "crates/a/src/lib.rs", Outcome::CompileError);
let plan = cache_plan(&root, vec![unviable], false);
let record = from_plan(&plan);
fs::write(root.join("crates/a/src/lib.rs"), "pub fn a() -> bool { false }\n").expect("package source edit");
assert!(
record.settled(&root, Trust::Free, &Killers::default(), &envelope()).0.is_empty(),
"a newly viable mutant must remain in the denominator"
);
}
#[test]
fn a_workspace_dependency_change_invalidates_dependent_unviability() {
let (_dir, root) = cache_workspace("record-dependency-input-");
let unviable = package_mutant("unviable", "a", "crates/a/src/lib.rs", Outcome::CompileError);
let plan = cache_plan(&root, vec![unviable], true);
let record = from_plan(&plan);
fs::write(root.join("crates/b/src/lib.rs"), "pub fn b() -> bool { false }\n").expect("dependency source edit");
assert!(record.settled(&root, Trust::Free, &Killers::default(), &envelope()).0.is_empty());
}
#[test]
fn every_global_and_package_compilation_input_invalidates_unviability() {
let edits = [
("Cargo.toml", "[workspace]\nmembers = []\n"),
("Cargo.lock", "# changed lock\n"),
(".cargo/config.toml", "[build]\nrustflags = [\"--cfg\", \"changed\"]\n"),
("rust-toolchain.toml", "[toolchain]\nchannel = \"stable\"\n"),
("rust-toolchain", "1.72.0\n"),
("crates/a/Cargo.toml", "[package]\nname = \"a\"\nversion = \"0.2.0\"\n"),
("crates/a/build.rs", "fn main() { println!(\"cargo:rustc-cfg=changed\"); }\n"),
];
for (path, contents) in edits {
let (_dir, root) = cache_workspace("record-compilation-input-");
let unviable = package_mutant("unviable", "a", "crates/a/src/lib.rs", Outcome::CompileError);
let plan = cache_plan(&root, vec![unviable], false);
let record = from_plan(&plan);
let changed = root.join(path);
fs::create_dir_all(changed.parent().expect("fixture paths have parents")).expect("fixture parent");
fs::write(changed, contents).expect("compilation input edit");
assert!(
record.settled(&root, Trust::Free, &Killers::default(), &envelope()).0.is_empty(),
"{path} retained stale unviability"
);
}
}
#[test]
fn every_workspace_cargo_input_invalidates_a_carried_outcome() {
let edits = [
("src/helper.rs", "pub fn answer() -> u32 { 1 }\n", "pub fn answer() -> u32 { 2 }\n"),
("tests/behaviour.rs", "#[test]\nfn answer() {}\n", "#[test]\nfn answer_now() {}\n"),
(
"Cargo.toml",
"[package]\nname = \"subject\"\n",
"[package]\nname = \"subject-two\"\n",
),
("Cargo.lock", "version = 4\n", "version = 4\n# changed\n"),
(
".cargo/config.toml",
"[build]\ntarget-dir = \"build\"\n",
"[build]\ntarget-dir = \"other\"\n",
),
(
"build.rs",
"fn main() {}\n",
"fn main() { println!(\"cargo:rerun-if-changed=x\"); }\n",
),
];
for (path, before, after) in edits {
let (_dir, root) = workspace("record-workspace-input-", "fn add() {}");
let file = root.join(path);
fs::create_dir_all(file.parent().expect("fixture paths have parents").as_std_path()).expect("fixture parent");
fs::write(file.as_std_path(), before).expect("fixture input");
let record = from_run(&root, &[mutant("abc", "src/lib.rs", Outcome::CompileError)], &envelope());
fs::write(file.as_std_path(), after).expect("changed input");
assert!(
record.settled(&root, Trust::Free, &Killers::default(), &envelope()).0.is_empty(),
"{path} changed without invalidating the outcome"
);
}
}
#[test]
fn an_external_path_dependency_change_carries_neither_verdicts_nor_unviability() {
let directory = workdir("record-external-path-");
let container = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("the work directory should be UTF-8");
let root = container.join("workspace");
let dependency = container.join("dependency");
fs::create_dir_all(root.join("src")).expect("workspace source directory");
fs::create_dir_all(dependency.join("src")).expect("dependency source directory");
fs::write(root.join("src/lib.rs"), "fn add() {}\n").expect("workspace source");
fs::write(root.join("src/tests.rs"), "#[test]\nfn caught() {}\n").expect("workspace test");
fs::write(
dependency.join("Cargo.toml"),
"[package]\nname = \"dependency\"\nversion = \"0.1.0\"\n",
)
.expect("dependency manifest");
fs::write(dependency.join("src/lib.rs"), "pub fn answer() -> u8 { 1 }\n").expect("dependency source");
let inputs = RunRecord::snapshot_with_external(&root, &root.join("target/gamma"), core::slice::from_ref(&dependency), false);
let index = Killers::scan(&[root.join("src/tests.rs")]);
let record = RunRecord::from_snapshot(
&root,
&[
mutant("unviable", "src/lib.rs", Outcome::CompileError),
killed("killed", Some("caught")),
],
&envelope(),
inputs,
&index,
)
.expect("the external dependency belongs to the pre-execution snapshot");
fs::write(dependency.join("src/lib.rs"), "pub fn answer() -> u8 { 2 }\n").expect("changed dependency source");
assert!(
record.settled(&root, Trust::Settled, &index, &envelope()).0.is_empty(),
"an external dependency change must recompile and retest every recorded outcome"
);
}
#[cfg(unix)]
#[test]
fn an_external_symlink_referent_cannot_create_a_reusable_record() {
let directory = workdir("record-external-symlink-");
let container = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("the work directory should be UTF-8");
let root = container.join("workspace");
let external = container.join("external.rs");
fs::create_dir_all(root.join("src")).expect("workspace source directory");
fs::write(root.join("src/lib.rs"), "fn add() {}\n").expect("workspace source");
fs::write(root.join("src/tests.rs"), "#[test]\nfn caught() {}\n").expect("workspace test");
fs::write(&external, "pub fn external() {}\n").expect("external source");
std::os::unix::fs::symlink(&external, root.join("src/linked.rs")).expect("external source link");
let index = Killers::scan(&[root.join("src/tests.rs")]);
let inputs = RunRecord::snapshot(&root, &root.join("target/gamma"));
assert!(
RunRecord::from_snapshot(
&root,
&[
mutant("unviable", "src/lib.rs", Outcome::CompileError),
killed("killed", Some("caught")),
],
&envelope(),
inputs,
&index,
)
.is_none(),
"a symlink referent outside the workspace must carry neither a verdict nor an unviability"
);
}
#[test]
fn an_edit_after_the_pre_execution_snapshot_records_no_outcome() {
let (_dir, root) = workspace("record-mid-run-edit-", "fn add() {}");
let snapshot = RunRecord::snapshot(&root, &root.join("target/gamma"));
let barrier = Arc::new(Barrier::new(2));
let editor_barrier = Arc::clone(&barrier);
let edited = root.join("src/lib.rs");
let editor = thread::spawn(move || {
let _waiting = editor_barrier.wait();
fs::write(edited, "fn add() { panic!() }").expect("changed source");
let _written = editor_barrier.wait();
});
let _snapshot_taken = barrier.wait();
let _edit_finished = barrier.wait();
editor.join().expect("editor thread");
assert!(
RunRecord::from_snapshot(
&root,
&[mutant("abc", "src/lib.rs", Outcome::CompileError)],
&envelope(),
snapshot,
&Killers::scan(&[])
)
.is_none(),
"post-run bytes must never be stamped onto a pre-edit outcome"
);
}
#[test]
fn a_revert_to_the_original_bytes_does_not_restore_the_snapshot() {
let (_dir, root) = workspace("record-aba-edit-", "fn add() {}");
let source = root.join("src/lib.rs");
let original = fs::read(source.as_std_path()).expect("fixture source");
let snapshot = RunRecord::snapshot(&root, &root.join("target/gamma"));
fs::write(source.as_std_path(), "fn add() { panic!() }").expect("mid-run edit");
fs::write(source.as_std_path(), &original).expect("revert");
stamp_later(&source);
assert_eq!(
fs::read(source.as_std_path()).expect("reverted source"),
original,
"the fixture must end byte-identical, or the test proves nothing new"
);
assert!(
RunRecord::from_snapshot(
&root,
&[mutant("abc", "src/lib.rs", Outcome::CompileError)],
&envelope(),
snapshot,
&Killers::scan(&[])
)
.is_none(),
"a workspace edited and put back was still edited while the outcomes were produced"
);
}
fn stamp_later(path: &Utf8Path) {
let file = fs::File::options()
.write(true)
.open(path.as_std_path())
.expect("fixture file opens for writing");
file.set_modified(std::time::SystemTime::now() + core::time::Duration::from_secs(2))
.expect("fixture filesystem records modification times");
}
#[test]
fn target_and_scratch_artifacts_do_not_invalidate_the_workspace_snapshot() {
let (_dir, root) = workspace("record-artifacts-", "fn add() {}");
let scratch = root.join(".gamma-work/gamma");
let snapshot = RunRecord::snapshot(&root, &scratch);
fs::create_dir_all(root.join("target/debug").as_std_path()).expect("target directory");
fs::create_dir_all(scratch.as_std_path()).expect("scratch directory");
fs::write(root.join("target/debug/artifact"), "generated").expect("target artifact");
fs::write(scratch.join("tree"), "generated").expect("scratch artifact");
let record = RunRecord::from_snapshot(
&root,
&[mutant("abc", "src/lib.rs", Outcome::CompileError)],
&envelope(),
snapshot,
&Killers::scan(&[]),
)
.expect("generated artifacts are not workspace inputs");
assert_eq!(
record.settled(&root, Trust::Free, &Killers::default(), &envelope()).0.get("abc"),
Some(&Outcome::CompileError)
);
}
#[test]
fn no_other_verdict_is_ever_carried_for_free() {
let (_dir, root) = workspace("record-only-", "fn add() {}");
let population = [
mutant("killed", "src/lib.rs", Outcome::Killed),
mutant("survived", "src/lib.rs", Outcome::Survived),
mutant("timeout", "src/lib.rs", Outcome::Timeout),
mutant("unviable", "src/lib.rs", Outcome::CompileError),
];
let settled = from_run(&root, &population, &envelope())
.settled(&root, Trust::Free, &Killers::default(), &envelope())
.0;
assert_eq!(settled.len(), 1);
assert_eq!(settled.get("unviable"), Some(&Outcome::CompileError));
}
#[test]
fn a_timeout_is_not_stored_for_a_later_run() {
let (_dir, root) = workspace("record-timeout-", "fn add() {}");
let record = from_run(&root, &[mutant("timeout", "src/lib.rs", Outcome::Timeout)], &envelope());
assert_eq!(record.len(), 0);
assert!(record.settled(&root, Trust::Settled, &Killers::scan(&[]), &envelope()).0.is_empty());
}
#[test]
fn a_cache_survives_a_round_trip_through_the_scratch_directory() {
let (_dir, root) = workspace("record-round-", "fn add() {}");
from_run(&root, &[mutant("abc", "src/lib.rs", Outcome::CompileError)], &envelope()).store(&root, &root);
assert_eq!(RunRecord::load(&root).len(), 1);
}
#[test]
fn every_completed_record_publication_is_parseable() {
let (_dir, root) = workspace("record-atomic-round-", "fn add() {}");
for generation in 0..8 {
let id = format!("mutant-{generation}");
from_run(&root, &[mutant(&id, "src/lib.rs", Outcome::CompileError)], &envelope()).store(&root, &root);
let stored = fs::read_to_string(root.join(FILE)).expect("completed record");
let _record: RunRecord = serde_json::from_str(&stored).expect("completed record parses");
RunRecord::store_probes(
&root,
&core::iter::once((
id.into(),
Killer {
package: "subject".to_owned(),
target: "lib".to_owned(),
test: format!("caught_{generation}"),
},
))
.collect(),
);
let stored = fs::read_to_string(root.join(FILE)).expect("completed probe record");
let _record: RunRecord = serde_json::from_str(&stored).expect("completed probe record parses");
}
}
#[test]
fn failed_record_publications_preserve_the_prior_generation() {
let (_dir, root) = workspace("record-atomic-fail-", "fn add() {}");
from_run(&root, &[mutant("original", "src/lib.rs", Outcome::CompileError)], &envelope()).store(&root, &root);
let before = fs::read_to_string(root.join(FILE)).expect("original record");
crate::elements::before_next_publication(|scratch| {
fs::remove_file(scratch).expect("remove staged record");
});
from_run(&root, &[mutant("replacement", "src/lib.rs", Outcome::CompileError)], &envelope()).store(&root, &root);
assert_eq!(fs::read_to_string(root.join(FILE)).expect("prior record"), before);
crate::elements::before_next_publication(|scratch| {
fs::remove_file(scratch).expect("remove staged probes");
});
RunRecord::store_probes(
&root,
&core::iter::once((
"replacement".into(),
Killer {
package: "subject".to_owned(),
target: "lib".to_owned(),
test: "caught".to_owned(),
},
))
.collect(),
);
assert_eq!(fs::read_to_string(root.join(FILE)).expect("prior record"), before);
}
#[test]
fn unviability_from_a_different_context_is_not_settled() {
let (_dir, root) = workspace("record-context-", "fn add() {}");
from_run(&root, &[mutant("abc", "src/lib.rs", Outcome::CompileError)], &envelope()).store(&root, &root);
let settled = RunRecord::load(&root)
.settled(&root, Trust::Free, &Killers::default(), &under_another_feature_set())
.0;
assert!(settled.is_empty(), "unviability crossed a feature change");
}
#[test]
fn a_missing_cache_is_an_empty_one() {
let (_dir, root) = workspace("record-absent-", "fn add() {}");
assert_eq!(RunRecord::load(&root).len(), 0);
}
#[test]
fn a_corrupt_cache_is_an_empty_one() {
let (_dir, root) = workspace("record-corrupt-", "fn add() {}");
fs::write(root.join(FILE), "{ this is not json").expect("the cache should be writable");
assert_eq!(RunRecord::load(&root).len(), 0);
}
#[test]
fn adopted_mutants_are_written_back() {
let (_dir, root) = workspace("record-writeback-", "fn add() {}");
let population = [
mutant("adopted", "src/lib.rs", Outcome::CompileError),
mutant("fresh", "src/lib.rs", Outcome::CompileError),
];
from_run(&root, &population, &envelope()).store(&root, &root);
assert_eq!(RunRecord::load(&root).len(), 2);
}
#[test]
fn a_narrowed_run_keeps_the_entries_it_never_looked_at() {
let (_dir, root) = workspace("record-narrow-", "fn add() {}");
fs::write(root.join("src/other.rs"), "fn other() {}").expect("the source should be writable");
from_run(&root, &[mutant("first", "src/other.rs", Outcome::CompileError)], &envelope()).store(&root, &root);
from_run(&root, &[mutant("second", "src/lib.rs", Outcome::CompileError)], &envelope()).store(&root, &root);
let settled = RunRecord::load(&root)
.settled(&root, Trust::Free, &Killers::default(), &envelope())
.0;
assert_eq!(
settled.get("first"),
Some(&Outcome::CompileError),
"the earlier run's file was dropped"
);
assert_eq!(settled.get("second"), Some(&Outcome::CompileError));
}
#[test]
fn a_narrowed_run_drops_prior_entries_after_any_workspace_input_changes() {
let (_dir, root) = workspace("record-narrow-snapshot-", "fn add() {}");
fs::write(root.join("src/helper.rs"), "pub fn helper() -> u8 { 1 }").expect("helper source is writable");
fs::write(root.join("src/other.rs"), "fn other() {}").expect("other source is writable");
from_run(&root, &[mutant("first", "src/lib.rs", Outcome::CompileError)], &envelope()).store(&root, &root);
fs::write(root.join("src/helper.rs"), "pub fn helper() -> u8 { 2 }").expect("helper source is writable");
from_run(&root, &[mutant("second", "src/other.rs", Outcome::CompileError)], &envelope()).store(&root, &root);
let settled = RunRecord::load(&root)
.settled(&root, Trust::Free, &Killers::default(), &envelope())
.0;
assert_eq!(settled.get("first"), None, "the changed helper recertified an old outcome");
assert_eq!(settled.get("second"), Some(&Outcome::CompileError));
}
#[test]
fn a_carried_entry_is_dropped_once_its_file_changes() {
let (_dir, root) = workspace("record-stale-", "fn add() {}");
fs::write(root.join("src/other.rs"), "fn other() {}").expect("the source should be writable");
from_run(&root, &[mutant("first", "src/other.rs", Outcome::CompileError)], &envelope()).store(&root, &root);
fs::write(root.join("src/other.rs"), "fn other() -> usize { 0 }").expect("the source should be writable");
from_run(&root, &[mutant("second", "src/lib.rs", Outcome::CompileError)], &envelope()).store(&root, &root);
let reloaded = RunRecord::load(&root);
assert_eq!(reloaded.len(), 1, "a stale entry was carried forward");
assert_eq!(
reloaded
.settled(&root, Trust::Free, &Killers::default(), &envelope())
.0
.get("second"),
Some(&Outcome::CompileError)
);
}
#[test]
fn this_runs_answer_replaces_the_earlier_one_for_a_file_it_visited() {
let (_dir, root) = workspace("record-replace-", "fn add() {}");
let earlier = [
mutant("gone", "src/lib.rs", Outcome::CompileError),
mutant("kept", "src/lib.rs", Outcome::CompileError),
];
from_run(&root, &earlier, &envelope()).store(&root, &root);
from_run(&root, &[mutant("kept", "src/lib.rs", Outcome::CompileError)], &envelope()).store(&root, &root);
let settled = RunRecord::load(&root)
.settled(&root, Trust::Free, &Killers::default(), &envelope())
.0;
assert_eq!(settled.get("gone"), None, "a mutant this run found viable was carried anyway");
assert_eq!(settled.get("kept"), Some(&Outcome::CompileError));
}
#[test]
fn a_file_that_has_grown_is_rejected() {
let (_dir, root) = workspace("record-grown-", "fn add() {}");
let cache = from_run(&root, &[mutant("abc", "src/lib.rs", Outcome::CompileError)], &envelope());
fs::write(root.join("src/lib.rs"), "fn add() {} // and more").expect("the source should be writable");
assert!(cache.settled(&root, Trust::Free, &Killers::default(), &envelope()).0.is_empty());
}
fn plain() -> Context<'static> {
Context {
toolchain: Some("1.90.0"),
baseline: true,
confirm: true,
stall: true,
..Context::default()
}
}
#[test]
fn a_changed_inherited_environment_refuses_carried_unviability() {
let (_dir, root) = workspace("record-unviability-environment-", "fn add() {}");
let before =
context_in(&plain(), None, &[(b"SUBJECT_BUILD_MODE".to_vec(), b"before".to_vec())]).expect("a named toolchain gives a context");
let after =
context_in(&plain(), None, &[(b"SUBJECT_BUILD_MODE".to_vec(), b"after".to_vec())]).expect("a named toolchain gives a context");
let record = from_run(&root, &[unviable("abc")], &before);
assert_eq!(
record.settled(&root, Trust::Free, &Killers::default(), &before).0.get("abc"),
Some(&Outcome::CompileError),
"the unchanged inherited environment may reuse unviability"
);
assert!(
record.settled(&root, Trust::Free, &Killers::default(), &after).0.is_empty(),
"a changed inherited environment must recompile an unviable mutant"
);
}
#[test]
#[expect(
clippy::too_many_lines,
reason = "exhaustive test verifying each context axis distinguishes the digest"
)]
fn the_context_distinguishes_the_things_that_decide_what_compiles() {
let features = ["extra".to_owned()];
let extra = ["--cfg=loom".to_owned()];
let test_packages = ["pkg_a".to_owned()];
let include_tests = ["test_a".to_owned()];
let exclude_tests = ["test_b".to_owned()];
let cargo_test_args = ["--nocapture".to_owned()];
let test_args = ["--skip".to_owned(), "slow".to_owned()];
let Context {
features: _f,
all_features: _a,
no_default_features: _n,
profile: _p,
extra: _e,
rustflags: _r,
toolchain: _t,
test_packages: _tp,
include_tests: _it,
exclude_tests: _et,
test_workspace: _tw,
whole_test_binaries: _wtb,
nextest: _nx,
cargo_test_args: _cta,
test_args: _ta,
baseline: _b,
confirm: _c,
stall: _s,
test_timeout_multiplier: _ttm,
minimum_test_timeout: _mtt,
memory: _m,
memory_multiplier: _mm,
memory_headroom: _mh,
memory_limit: _ml,
baseline_memory_limit: _bml,
no_relaunch: _nr,
copy_ignored: _ci,
jobs: _j,
build_timeout: _bt,
build_timeout_multiplier: _btm,
rollback_rounds: _rr,
} = plain();
let varied: &[(&str, Context<'_>)] = &[
(
"features",
Context {
features: &features,
..plain()
},
),
(
"--all-features",
Context {
all_features: true,
..plain()
},
),
(
"--no-default-features",
Context {
no_default_features: true,
..plain()
},
),
(
"the profile, which turns debug_assertions off",
Context {
profile: Some("release"),
..plain()
},
),
(
"passthrough build arguments, which can carry --cfg, -C or --target",
Context { extra: &extra, ..plain() },
),
(
"ambient rustflags, which can select different code entirely",
Context {
rustflags: Some("--cfg loom"),
..plain()
},
),
(
"the compiler",
Context {
toolchain: Some("1.91.0"),
..plain()
},
),
(
"test packages",
Context {
test_packages: &test_packages,
..plain()
},
),
(
"include tests",
Context {
include_tests: &include_tests,
..plain()
},
),
(
"exclude tests",
Context {
exclude_tests: &exclude_tests,
..plain()
},
),
(
"test workspace",
Context {
test_workspace: true,
..plain()
},
),
(
"whole test binaries",
Context {
whole_test_binaries: true,
..plain()
},
),
("nextest", Context { nextest: true, ..plain() }),
(
"cargo test arguments",
Context {
cargo_test_args: &cargo_test_args,
..plain()
},
),
(
"post-separator test arguments",
Context {
test_args: &test_args,
..plain()
},
),
(
"baseline mode",
Context {
baseline: false,
..plain()
},
),
("confirmation mode", Context { confirm: false, ..plain() }),
("stall detection", Context { stall: false, ..plain() }),
(
"test timeout multiplier",
Context {
test_timeout_multiplier: Some(1.5),
..plain()
},
),
(
"minimum test timeout",
Context {
minimum_test_timeout: Some(30.0),
..plain()
},
),
(
"memory mode",
Context {
memory: Some(crate::exec::MemoryControl::Off),
..plain()
},
),
(
"memory multiplier",
Context {
memory_multiplier: Some(3.0),
..plain()
},
),
(
"memory headroom",
Context {
memory_headroom: Some(1024),
..plain()
},
),
(
"memory limit",
Context {
memory_limit: Some(2048),
..plain()
},
),
(
"baseline memory limit",
Context {
baseline_memory_limit: Some(4096),
..plain()
},
),
(
"memory relaunch",
Context {
no_relaunch: true,
..plain()
},
),
(
"ignored-file copying",
Context {
copy_ignored: true,
..plain()
},
),
("parallel scheduling", Context { jobs: Some(2), ..plain() }),
(
"build timeout",
Context {
build_timeout: Some(60.0),
..plain()
},
),
(
"build timeout multiplier",
Context {
build_timeout_multiplier: Some(2.0),
..plain()
},
),
(
"rollback rounds",
Context {
rollback_rounds: 1,
..plain()
},
),
];
let base = context(&plain()).expect("a named toolchain gives a context");
let mut digests = HashSet::default();
let _ = digests.insert(base.clone());
for (axis, varied) in varied {
let digest = context(varied).expect("a named toolchain gives a context");
assert_ne!(base, digest, "{axis}");
assert!(digests.insert(digest), "{axis} digests the same as another axis");
}
assert_eq!(base, context(&plain()).unwrap(), "the same context digests the same");
}
#[test]
fn the_tool_version_is_a_term_of_the_digest_rather_than_loose_bytes() {
let spelled = [env!("CARGO_PKG_VERSION").to_owned()];
assert_ne!(
context(&plain()).unwrap(),
context(&Context {
extra: &spelled,
..plain()
})
.unwrap()
);
}
#[test]
fn a_toolchain_that_cannot_be_named_yields_no_context_at_all() {
assert_eq!(
context(&Context {
toolchain: None,
..plain()
}),
None
);
}
#[test]
fn two_different_contexts_cannot_digest_the_same_by_running_their_parts_together() {
let split = ["ab".to_owned(), "c".to_owned()];
let joined = ["a".to_owned(), "bc".to_owned()];
assert_ne!(
context(&Context {
features: &split,
..plain()
}),
context(&Context {
features: &joined,
..plain()
})
);
}
#[test]
fn a_toolchain_change_discards_unviability_and_keeps_the_probes() {
let (_dir, root) = workspace("record-tiers-", "fn add() {}");
let index = killers(&root, "#[test]\nfn caught() {}\n");
from_run(&root, &[mutant("abc", "src/lib.rs", Outcome::CompileError)], &envelope()).store(&root, &root);
RunRecord::store_probes(
&root,
&core::iter::once((
"abc".into(),
Killer {
package: "subject".to_owned(),
target: "lib".to_owned(),
test: "caught".to_owned(),
},
))
.collect(),
);
let record = RunRecord::load(&root);
let settled = record.settled(&root, Trust::Free, &index, &under_another_toolchain()).0;
assert!(settled.is_empty(), "unviability survived a compiler it was never checked against");
assert_eq!(
RunRecord::load(&root).probes().get("abc").map(|killer| killer.test.as_str()),
Some("caught"),
"the probe was thrown away with the unviability"
);
assert_eq!(record.ordering(), vec!["abc"], "the demoted unviability was thrown away too");
}
#[test]
fn a_toolchain_change_rechecks_the_verdicts() {
let (_dir, root) = workspace("record-verdict-toolchain-", "fn add() {}");
let index = killers(&root, "#[test]\nfn caught() {}\n");
let record = from_run(&root, &[killed("abc", Some("caught"))], &envelope());
let settled = record.settled(&root, Trust::Settled, &index, &under_another_toolchain()).0;
assert!(settled.is_empty());
}
#[test]
fn every_required_term_invalidates_a_verdict() {
let (_dir, root) = workspace("record-verdict-terms-", "fn add() {}");
let index = killers(&root, "#[test]\nfn caught() {}\n");
let record = from_run(&root, &[killed("abc", Some("caught"))], &envelope());
let extra = ["--cfg=loom".to_owned()];
let test_packages = ["pkg_b".to_owned()];
let varied: &[(&str, Context<'_>)] = &[
(
"the profile",
Context {
profile: Some("release"),
..plain()
},
),
(
"rustflags",
Context {
rustflags: Some("--cfg loom"),
..plain()
},
),
("passthrough arguments", Context { extra: &extra, ..plain() }),
(
"test filtering",
Context {
test_packages: &test_packages,
..plain()
},
),
];
for (axis, context_of) in varied {
let digest = context(context_of).expect("a named toolchain gives a context");
assert!(
record.settled(&root, Trust::Settled, &index, &digest).0.is_empty(),
"a verdict crossed a change of {axis}"
);
}
assert!(
record
.settled(&root, Trust::Settled, &index, &under_another_feature_set())
.0
.is_empty(),
"a verdict crossed a feature change"
);
}
#[test]
fn every_digested_term_is_one_a_tier_can_require() {
let (_dir, root) = workspace("record-terms-", "fn add() {}");
let digest = envelope().resolved_at(&root);
let named: HashSet<&str> = Term::ALL.iter().map(|term| digest.term(*term)).collect();
assert_eq!(named.len(), Term::ALL.len(), "two terms digest the same, so one cannot invalidate");
assert_eq!(
Tier::Unviability.requires(),
&[
Term::Features,
Term::Profile,
Term::Rustflags,
Term::Target,
Term::Config,
Term::Extra,
Term::Toolchain,
Term::Tool,
Term::Policy,
Term::Environment,
]
);
assert!(Tier::Ordering.requires().is_empty());
assert!(
Tier::Verdict.requires().contains(&Term::Toolchain),
"verdicts are rechecked after a compiler change"
);
assert!(
Tier::Verdict.requires().contains(&Term::Tests),
"the verdict tier depends on the tests"
);
assert!(
Tier::Verdict.requires().contains(&Term::Policy),
"verdicts depend on their execution policy"
);
assert!(
Tier::Verdict.requires().contains(&Term::Environment),
"verdicts depend on what test processes inherit"
);
}
fn unviable(id: &str) -> Mutant {
mutant(id, "src/lib.rs", Outcome::CompileError)
}
fn free(record: &RunRecord, root: &Utf8Path, context: &ContextDigest) -> HashMap<MutantId, Outcome> {
record.settled(root, Trust::Free, &Killers::default(), context).0
}
#[test]
fn a_record_written_for_one_target_is_not_read_for_another() {
let (_dir, root) = workspace("record-target-", "fn add() {}");
let here = context_in(&plain(), None, &[]).expect("a named toolchain gives a context");
let elsewhere = context_in(&plain(), Some("x86_64-unknown-linux-musl"), &[]).expect("a named toolchain gives a context");
let record = from_run(&root, &[unviable("abc")], &here);
assert_eq!(
free(&record, &root, &here).get("abc"),
Some(&Outcome::CompileError),
"the record has to apply to the run that wrote it"
);
assert!(
free(&record, &root, &elsewhere).is_empty(),
"unviability crossed a change of target, so a mutant is out of the denominator for a build it never saw"
);
}
#[test]
fn a_record_written_for_one_passthrough_target_is_not_read_for_another() {
let (_dir, root) = workspace("record-target-argument-", "fn add() {}");
let extra = ["--target".to_owned(), "wasm32-unknown-unknown".to_owned()];
let here = context_in(&plain(), None, &[]).expect("a named toolchain gives a context");
let elsewhere = context_in(&Context { extra: &extra, ..plain() }, None, &[]).expect("a named toolchain gives a context");
let record = from_run(&root, &[unviable("abc")], &here);
assert!(
free(&record, &root, &elsewhere).is_empty(),
"unviability crossed a change of target"
);
assert_ne!(
here.term(Term::Target),
elsewhere.term(Term::Target),
"the target term is what has to notice, so that the diagnostic can name it"
);
}
#[test]
fn a_record_written_before_a_configured_rustflag_is_not_read_after_it() {
let (_dir, root) = workspace("record-configured-rustflags-", "fn add() {}");
let envelope = envelope();
from_run(&root, &[unviable("abc")], &envelope).store(&root, &root);
assert_eq!(
free(&RunRecord::load(&root), &root, &envelope).get("abc"),
Some(&Outcome::CompileError),
"the record has to apply while nothing has changed"
);
fs::create_dir_all(root.join(".cargo").as_std_path()).expect("the configuration directory should be creatable");
fs::write(
root.join(".cargo/config.toml").as_std_path(),
"[build]\nrustflags = [\"--cfg\", \"loom\"]\n",
)
.expect("the configuration should be writable");
assert!(
free(&RunRecord::load(&root), &root, &envelope).is_empty(),
"unviability crossed a configured rustflag, which selects different code entirely"
);
}
#[test]
fn a_change_to_any_setting_the_workspace_holds_moves_the_config_term() {
let edits: &[(&str, &str, &str)] = &[
(
"build.target",
".cargo/config.toml",
"[build]\ntarget = \"wasm32-unknown-unknown\"\n",
),
(
"build.rustflags",
".cargo/config.toml",
"[build]\nrustflags = [\"--cfg\", \"loom\"]\n",
),
(
"target.*.rustflags",
".cargo/config.toml",
"[target.'cfg(unix)']\nrustflags = [\"--cfg\", \"loom\"]\n",
),
("profile.*", "Cargo.toml", "[profile.dev]\ndebug-assertions = false\n"),
];
for (input, file, body) in edits {
let (_dir, root) = workspace("record-settings-", "fn add() {}");
let path = root.join(file);
let before = envelope().resolved_at(&root);
fs::create_dir_all(path.parent().expect("every fixture path has a parent").as_std_path())
.expect("the directory should be creatable");
fs::write(path.as_std_path(), *body).expect("the setting should be writable");
assert_ne!(
before.term(Term::Config),
envelope().resolved_at(&root).term(Term::Config),
"a change to {input} left the key where it was"
);
}
}
#[test]
fn every_input_the_build_resolution_reads_is_covered_by_a_term() {
let uncovered: Vec<&str> = Build::INPUTS
.iter()
.copied()
.filter(|input| !COVERAGE.iter().any(|(name, _term)| name == input))
.collect();
assert!(
uncovered.is_empty(),
"these decide what compiles and no term covers them: {uncovered:?}"
);
let unrequired: Vec<&str> = COVERAGE
.iter()
.filter(|(_name, term)| !Term::ALL.contains(term))
.map(|(name, _term)| *name)
.collect();
assert!(
unrequired.is_empty(),
"these are covered by a term no tier can require, so the cover is not one: {unrequired:?}"
);
let stale: Vec<&str> = COVERAGE
.iter()
.filter(|(name, _term)| !Build::INPUTS.contains(name))
.map(|(name, _term)| *name)
.collect();
assert!(stale.is_empty(), "these are covered and nothing reads them: {stale:?}");
}
#[test]
fn a_record_that_states_none_of_the_newer_terms_keeps_only_the_tiers_that_ask_nothing() {
let (_dir, root) = workspace("record-older-terms-", "fn add() {}");
from_run(&root, &[unviable("abc")], &envelope()).store(&root, &root);
let text = fs::read_to_string(root.join(FILE).as_std_path()).expect("the record should be readable");
let mut written: serde_json::Value = serde_json::from_str(&text).expect("the record is JSON");
let context = written
.get_mut("context")
.and_then(serde_json::Value::as_object_mut)
.expect("the record states its context");
let _target = context.remove("target");
let _config = context.remove("config");
fs::write(
root.join(FILE).as_std_path(),
serde_json::to_string(&written).expect("the record serializes"),
)
.expect("the record should be writable");
let older = RunRecord::load(&root);
assert!(
free(&older, &root, &envelope()).is_empty(),
"unviability was adopted from a record that cannot say what build it was written under"
);
assert_eq!(older.ordering(), ["abc"], "the tiers that require no term still apply");
}
#[test]
fn a_term_nobody_states_admits_nothing() {
let unresolved = envelope();
assert!(!unresolved.states(Term::Config));
assert!(
!Tier::Unviability.admits(&unresolved, &unresolved),
"an unstated term must not match itself"
);
assert!(
unresolved.differences(&unresolved).is_empty(),
"a term neither side states is not an axis that moved"
);
assert!(
Tier::Ordering.admits(&unresolved, &unresolved),
"the tiers that require nothing are unaffected by what nobody states"
);
}
#[test]
fn the_flags_in_the_key_are_the_ones_cargo_will_read() {
let of = |value: &str| Some(value.to_owned());
assert_eq!(rustflags_in(None, None, of("--cfg loom"), &[]).as_deref(), Some("--cfg loom"));
assert_eq!(rustflags_in(None, of("plain"), of("configured"), &[]).as_deref(), Some("plain"));
assert_eq!(
rustflags_in(of("encoded"), of("plain"), of("configured"), &[]).as_deref(),
Some("encoded")
);
assert_eq!(rustflags_in(None, None, None, &[]), None);
}
#[test]
fn a_target_specific_rustflags_variable_moves_the_key() {
let of = |value: &str| Some(value.to_owned());
let targeted = [(
"CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS".to_owned(),
"--cfg live".to_owned(),
)];
let alone = rustflags_in(None, None, None, &targeted);
assert_ne!(alone, None, "a target-specific flag on its own said nothing");
assert_ne!(
alone,
rustflags_in(None, None, None, &[]),
"the key did not move when the only rustflag in force was a target-specific one"
);
assert_ne!(
rustflags_in(of("encoded"), None, None, &targeted),
rustflags_in(of("encoded"), None, None, &[]),
"a global variable hid a target-specific one that cargo would also apply"
);
assert_ne!(
rustflags_in(None, None, None, &targeted),
rustflags_in(None, None, None, &[(targeted[0].0.clone(), "--cfg other".to_owned())]),
"a change to the value of a target-specific variable left the key where it was"
);
}
#[test]
fn a_record_from_the_previous_format_is_ignored() {
let (_dir, root) = workspace("record-old-format-", "fn add() {}");
fs::write(
root.join(FILE),
r#"{"version":3,"context":"deadbeef","files":[{"path":"src/lib.rs","digest":"x","size":11,"mutants":[{"id":"abc","outcome":"unviable"}]}],"hints":{}}"#,
)
.expect("the cache should be writable");
let record = RunRecord::load(&root);
assert_eq!(record.len(), 0);
assert!(record.ordering().is_empty());
assert!(RunRecord::load(&root).probes().is_empty());
}
#[test]
fn the_ordering_tier_survives_every_term_and_is_stable() {
let (_dir, root) = workspace("record-ordering-", "fn add() {}");
let population = [
mutant("zeta", "src/lib.rs", Outcome::CompileError),
mutant("alpha", "src/lib.rs", Outcome::CompileError),
mutant("killed", "src/lib.rs", Outcome::Killed),
];
let record = from_run(&root, &population, &envelope());
assert_eq!(
record.ordering(),
vec!["alpha", "zeta"],
"only unviability orders, and it is sorted"
);
}
}