#[cfg(test)]
use core::cell::RefCell;
use std::fs;
use std::io::ErrorKind;
use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
use super::record::{ContextDigest, Killer, RunRecord, Tier};
use crate::elements::Publication;
use crate::error::error;
use crate::model::{Mutant, MutantId, Outcome};
use crate::{HashMap, HashSet, Result};
const FILE: &str = "gamma-hints.json";
const VERSION: u32 = 1;
#[must_use]
pub fn path(root: &Utf8Path) -> Utf8PathBuf {
root.join(FILE)
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Hints {
version: u32,
tool: String,
context: ContextDigest,
mutants: Vec<Hint>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Hint {
file: Utf8PathBuf,
id: MutantId,
#[serde(default, skip_serializing_if = "Option::is_none")]
killer: Option<Killer>,
#[serde(default, skip_serializing_if = "is_not_set")]
unviable: bool,
}
#[expect(clippy::trivially_copy_pass_by_ref, reason = "the signature is dictated by serde")]
const fn is_not_set(flag: &bool) -> bool {
!*flag
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Promotion {
pub mutants: usize,
pub probes: usize,
pub ordering: usize,
pub changed: bool,
}
impl Hints {
#[must_use]
pub fn load(root: &Utf8Path) -> Self {
Self::read(&path(root)).unwrap_or_default()
}
#[must_use]
pub(crate) fn is_missing(root: &Utf8Path) -> bool {
matches!(fs::metadata(path(root).as_std_path()), Err(cause) if cause.kind() == ErrorKind::NotFound)
}
fn read(path: &Utf8Path) -> Option<Self> {
let text = fs::read_to_string(path.as_std_path()).ok()?;
let hints = serde_json::from_str::<Self>(&text).ok()?;
(hints.version == VERSION).then_some(hints)
}
#[must_use]
pub fn probes(&self) -> HashMap<MutantId, Killer> {
self.mutants
.iter()
.filter_map(|hint| hint.killer.clone().map(|killer| (hint.id.clone(), killer)))
.collect()
}
#[must_use]
pub fn ordering(&self) -> Vec<&str> {
self.mutants
.iter()
.filter(|hint| hint.unviable)
.map(|hint| hint.id.as_str())
.collect()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.mutants.is_empty()
}
#[must_use]
pub fn counts(&self) -> Promotion {
self.promotion(false)
}
#[must_use]
pub fn promoted(record: &RunRecord, population: &[Mutant]) -> Self {
let probes = record.probes();
let unviable: HashSet<&str> = record
.iter()
.filter(|(_id, outcome)| tier_of(*outcome) == Some(Tier::Ordering))
.map(|(id, _outcome)| id)
.collect();
let mut mutants: Vec<Hint> = population
.iter()
.filter_map(|mutant| {
let hint = Hint {
file: mutant.file.to_path_buf(),
id: mutant.id.clone(),
killer: probes.get(&mutant.id).cloned(),
unviable: unviable.contains(mutant.id.as_str()),
};
(hint.killer.is_some() || hint.unviable).then_some(hint)
})
.collect();
mutants.sort_by(|left, right| left.file.cmp(&right.file).then_with(|| left.id.cmp(&right.id)));
mutants.dedup_by(|left, right| left.file == right.file && left.id == right.id);
Self {
version: VERSION,
tool: format!("cargo-gamma {}", env!("CARGO_PKG_VERSION")),
context: record.context().clone(),
mutants,
}
}
pub fn write(&self, path: &Utf8Path) -> Result<Promotion> {
let text = self.rendered()?;
let workspace = path.parent().unwrap_or_else(|| Utf8Path::new("."));
let before = match fs::read_to_string(path.as_std_path()) {
Ok(text) => Some(text),
Err(cause) if cause.kind() == ErrorKind::NotFound => None,
Err(cause) => {
return Err(error!("`{path}` is already there and could not be read, so it must not be replaced").caused_by(cause));
}
};
if before.as_deref() == Some(text.as_str()) {
return Ok(self.promotion(false));
}
match crate::elements::write_if_unchanged(workspace, path, before.as_deref(), &text)
.map_err(|cause| error!("could not write `{path}`").caused_by(cause))?
{
Publication::Conflict => {
return Err(error!(
"`{path}` changed while these hints were being promoted; the newer generation was left alone"
));
}
Publication::Published => {}
Publication::PublishedUndurable(cause) => return Err(cause),
}
after_publication(path);
match Self::verified(path, self) {
Ok(()) => Ok(self.promotion(true)),
Err(cause) => Err(restored(workspace, path, before.as_deref(), &text, cause)),
}
}
fn rendered(&self) -> Result<String> {
let mut text = serde_json::to_string_pretty(self)
.map_err(|cause| error!("the hints could not be serialized; please report this").caused_by(cause))?;
text.push('\n');
Ok(text)
}
fn verified(path: &Utf8Path, intended: &Self) -> Result<()> {
let Some(written) = Self::read(path) else {
return Err(error!("`{path}` could not be read back after being written"));
};
if &written == intended {
return Ok(());
}
Err(error!("`{path}` does not hold what was written to it"))
}
fn promotion(&self, changed: bool) -> Promotion {
Promotion {
mutants: self.mutants.len(),
probes: self.mutants.iter().filter(|hint| hint.killer.is_some()).count(),
ordering: self.mutants.iter().filter(|hint| hint.unviable).count(),
changed,
}
}
}
fn restored(
workspace: &Utf8Path,
path: &Utf8Path,
before: Option<&str>,
published: &str,
cause: crate::error::Error,
) -> crate::error::Error {
let restored = before.map_or_else(
|| crate::elements::remove_if_unchanged(workspace, path, published),
|text| crate::elements::write_if_unchanged(workspace, path, Some(published), text),
);
match restored {
Ok(Publication::Published) => cause,
Ok(Publication::Conflict) => {
error!("`{path}` changed after this promotion was published, so its later generation was left alone").caused_by(cause)
}
Ok(Publication::PublishedUndurable(failure)) => {
error!("`{path}` was put back after a promotion that could not be verified, but its directory could not be synced ({failure})")
.caused_by(cause)
}
Err(failure) => error!("`{path}` could not be put back after a promotion that could not be verified ({failure})").caused_by(cause),
}
}
#[cfg(test)]
type PublicationHook = Box<dyn FnOnce(&Utf8Path)>;
#[cfg(test)]
thread_local! {
static AFTER_PUBLICATION: RefCell<Option<PublicationHook>> = const { RefCell::new(None) };
}
#[cfg(test)]
fn after_next_publication(hook: impl FnOnce(&Utf8Path) + 'static) {
AFTER_PUBLICATION.with(|next| *next.borrow_mut() = Some(Box::new(hook)));
}
#[cfg(test)]
fn after_publication(path: &Utf8Path) {
let hook = AFTER_PUBLICATION.with(|next| next.borrow_mut().take());
if let Some(hook) = hook {
hook(path);
}
}
#[cfg(not(test))]
const fn after_publication(_path: &Utf8Path) {}
#[must_use]
const fn tier_of(outcome: Outcome) -> Option<Tier> {
match outcome {
Outcome::CompileError => Some(Tier::Ordering),
_other => None,
}
}
#[cfg(test)]
#[cfg(not(miri))]
mod tests {
use super::super::record;
use super::*;
use crate::fixtures;
use crate::testing::workdir;
fn mutant(id: &str, file: &str) -> Mutant {
Mutant {
id: id.to_owned().into(),
file: (Utf8PathBuf::from(file)).into(),
..fixtures::mutant()
}
}
fn killer(test: &str) -> Killer {
Killer {
package: "subject".to_owned(),
target: "lib".to_owned(),
test: test.to_owned(),
}
}
fn 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");
fs::create_dir_all(root.join("src")).expect("the source directory should be creatable");
fs::write(root.join("src/lib.rs"), "fn add() {}").expect("the source should be writable");
(dir, root)
}
fn context_of() -> ContextDigest {
record::context(&record::Context {
toolchain: Some("1.90.0"),
..record::Context::default()
})
.expect("a named toolchain gives a context")
}
fn recorded(root: &Utf8Path) -> RunRecord {
let mut unviable = mutant("unviable", "src/lib.rs");
unviable.outcome = Outcome::CompileError;
RunRecord::from_run(root, &[unviable], &context_of(), &[root.join("src")]).store(root, root);
RunRecord::store_probes(root, &core::iter::once(("killed".into(), killer("tests::caught"))).collect());
RunRecord::load(root)
}
#[test]
fn a_missing_artifact_is_no_hints_at_all() {
let (_dir, root) = workspace("hints-absent-");
assert!(Hints::load(&root).is_empty());
assert!(Hints::is_missing(&root));
}
#[test]
fn a_corrupt_artifact_is_no_hints_at_all() {
let (_dir, root) = workspace("hints-corrupt-");
fs::write(path(&root).as_std_path(), "{ not json").expect("the artifact should be writable");
assert!(Hints::load(&root).is_empty());
assert!(!Hints::is_missing(&root));
}
#[test]
fn a_foreign_artifact_is_no_hints_at_all() {
let (_dir, root) = workspace("hints-foreign-");
fs::write(path(&root).as_std_path(), r#"{"version":99,"tool":"other","mutants":[]}"#).expect("writable");
assert!(Hints::load(&root).is_empty());
assert!(!Hints::is_missing(&root));
}
#[test]
fn promotion_carries_the_two_score_neutral_tiers_and_nothing_else() {
let (_dir, root) = workspace("hints-promote-");
let mut unviable = mutant("unviable", "src/lib.rs");
unviable.outcome = Outcome::CompileError;
let record = {
let mut killed = mutant("killed", "src/lib.rs");
killed.outcome = Outcome::Killed;
killed.killed_by = Some("tests::caught".to_owned());
RunRecord::from_run(&root, &[unviable, killed], &context_of(), &[root.join("src")]).store(&root, &root);
RunRecord::store_probes(&root, &core::iter::once(("killed".into(), killer("tests::caught"))).collect());
RunRecord::load(&root)
};
let hints = Hints::promoted(&record, &[mutant("killed", "src/lib.rs"), mutant("unviable", "src/lib.rs")]);
assert_eq!(hints.probes().get("killed"), Some(&killer("tests::caught")));
assert_eq!(hints.ordering(), vec!["unviable"]);
let text = hints.rendered().expect("the hints should serialize");
assert!(!text.contains("killed\":"), "{text}");
assert!(!text.contains("outcome"), "a verdict reached the artifact: {text}");
}
#[test]
fn promotion_drops_hints_for_mutants_the_population_no_longer_holds() {
let (_dir, root) = workspace("hints-gone-");
let record = recorded(&root);
let hints = Hints::promoted(&record, &[mutant("survivor", "src/lib.rs")]);
assert!(hints.is_empty(), "a hint for a mutant nobody scanned was promoted");
}
#[test]
fn promotion_orders_by_file_and_then_by_id() {
let (_dir, root) = workspace("hints-order-");
fs::write(root.join("src/other.rs"), "fn other() {}").expect("the source should be writable");
let population = [
mutant("zeta", "src/other.rs"),
mutant("alpha", "src/other.rs"),
mutant("mid", "src/lib.rs"),
];
let mut unviable: Vec<Mutant> = population.to_vec();
for entry in &mut unviable {
entry.outcome = Outcome::CompileError;
}
RunRecord::from_run(&root, &unviable, &context_of(), &[root.join("src")]).store(&root, &root);
let record = RunRecord::load(&root);
let hints = Hints::promoted(&record, &population);
let order: Vec<&str> = hints.mutants.iter().map(|hint| hint.id.as_str()).collect();
assert_eq!(order, vec!["mid", "alpha", "zeta"]);
let again = Hints::promoted(&record, &population);
assert_eq!(hints.rendered().unwrap(), again.rendered().unwrap());
}
#[test]
fn a_written_artifact_reads_back_as_what_was_written() {
let (_dir, root) = workspace("hints-write-");
let record = recorded(&root);
let population = vec![mutant("unviable", "src/lib.rs"), mutant("killed", "src/lib.rs")];
let hints = Hints::promoted(&record, &population);
let promotion = hints.write(&path(&root)).expect("the artifact should be writable");
assert!(promotion.changed);
assert_eq!(promotion.mutants, 2);
assert_eq!(promotion.probes, 1);
assert_eq!(promotion.ordering, 1);
assert_eq!(Hints::load(&root), hints);
assert!(!Hints::is_missing(&root));
}
#[test]
fn a_hints_write_uses_the_workspace_lock() {
let (_dir, root) = workspace("hints-lock-");
let record = recorded(&root);
let hints = Hints::promoted(&record, &[mutant("unviable", "src/lib.rs")]);
let _held = crate::exec::claim_workspace(&root).expect("the workspace lock should be available");
let error = hints
.write(&path(&root))
.expect_err("the existing workspace claim must block the write");
assert!(error.to_string().contains("already using"), "{error}");
}
#[test]
fn a_post_rename_hints_sync_failure_is_reported() {
let (_dir, root) = workspace("hints-sync-failure-");
let record = recorded(&root);
let hints = Hints::promoted(&record, &[mutant("unviable", "src/lib.rs")]);
crate::elements::fail_next_directory_sync();
let error = hints.write(&path(&root)).expect_err("the post-rename sync fails");
assert!(error.to_string().contains("injected directory sync failure"), "{error}");
assert_eq!(Hints::load(&root), hints, "the published hints must still be readable");
}
#[test]
fn a_failed_hints_rollback_leaves_a_later_successful_promotion_intact() {
let (_dir, root) = workspace("hints-rollback-generation-");
let record = recorded(&root);
let first = Hints::promoted(&record, &[mutant("unviable", "src/lib.rs")]);
let mut second = first.clone();
second.tool = "cargo-gamma second writer".to_owned();
let later = second.clone();
after_next_publication(move |destination| {
assert!(later.write(destination).expect("the later promotion").changed);
});
let error = first.write(&path(&root)).expect_err("the later generation changes read-back");
assert!(error.to_string().contains("later generation was left alone"), "{error}");
assert_eq!(Hints::load(&root), second, "the first rollback removed the later promotion");
}
#[test]
fn an_artifact_that_is_there_but_unreadable_is_not_replaced_and_not_deleted() {
let (_dir, root) = workspace("hints-unreadable-");
let destination = path(&root);
fs::create_dir_all(destination.parent().expect("a parent").as_std_path()).expect("the directory");
fs::write(destination.as_std_path(), [0xff_u8, 0xfe, 0xfd]).expect("the artifact");
let record = recorded(&root);
let population = [mutant("unviable", "src/lib.rs")];
let hints = Hints::promoted(&record, &population);
let cause = hints.write(&destination).expect_err("an artifact that cannot be read back");
assert!(cause.to_string().contains("must not be replaced"), "{cause}");
assert_eq!(
fs::read(destination.as_std_path()).expect("the artifact afterwards"),
[0xff_u8, 0xfe, 0xfd],
"a promotion that could not read the artifact removed it"
);
}
#[test]
fn writing_the_same_artifact_twice_reports_no_change() {
let (_dir, root) = workspace("hints-idempotent-");
let record = recorded(&root);
let population = [mutant("unviable", "src/lib.rs")];
let hints = Hints::promoted(&record, &population);
assert!(hints.write(&path(&root)).expect("writable").changed);
assert!(!hints.write(&path(&root)).expect("writable").changed);
}
#[test]
fn only_unviability_is_admitted_as_a_tier() {
assert_eq!(tier_of(Outcome::CompileError), Some(Tier::Ordering));
for refused in [
Outcome::Killed,
Outcome::Survived,
Outcome::Timeout,
Outcome::Ignored,
Outcome::NotBuilt,
Outcome::NoCoverage,
Outcome::OutOfMemory,
Outcome::Flaky,
Outcome::Pending,
] {
assert_eq!(tier_of(refused), None, "{refused:?} would have been carried");
}
}
}