Skip to main content

testing_conventions/
mutation.rs

1//! Mutation testing for Rust (`unit mutation --language rust`) — the rung
2//! above coverage. A test that *runs* a line still passes if you delete its
3//! assertions; a surviving mutant proves it. This module wraps
4//! [cargo-mutants](https://github.com/sourcefrog/cargo-mutants): it runs the engine,
5//! reads its `outcomes.json`, and reports the **surviving** mutants the suite failed
6//! to catch.
7//!
8//! The gate is **binary, not a percentage** (equivalent mutants make a fixed score
9//! unreachable, and a score isn't comparable across engines) and on by default: any
10//! *un-exempted* surviving mutant is a finding. This module stays a pure measurement —
11//! [`measure_rust`] returns the survivors and [`unexplained_survivors`] is the pure
12//! core over a parsed report; the CLI layer turns a non-empty result into the failure.
13//!
14//! Diff-scoping (`--base`) is delegated to cargo-mutants' own `--in-diff`: the
15//! `<base>...HEAD` diff is written out and passed through, so only mutants on changed
16//! lines are tested ("no unexplained surviving mutant on the lines you touched").
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::ffi::OsString;
20use std::path::{Path, PathBuf};
21use std::process::{Command, Output};
22use std::sync::atomic::{AtomicU64, Ordering};
23
24use anyhow::{bail, Context, Result};
25use serde::Deserialize;
26
27/// A surviving mutant — a mutation the unit suite ran but failed to catch.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Survivor {
30    /// The mutated file, as cargo-mutants reports it (crate-root-relative, `/`-separated).
31    pub file: String,
32    /// The 1-based line the mutation starts on.
33    pub line: u32,
34    /// cargo-mutants' human description (e.g. `replace > with == in is_positive`).
35    pub description: String,
36}
37
38/// The `(file, line)` locations an engine produced a viable mutant for — the input the
39/// line-scoped guard reads to tell an over-exemption (a listed line whose mutants
40/// were all caught) from an out-of-scope line (no mutant there).
41pub type MutatedLines = BTreeSet<(String, u32)>;
42
43/// A cargo-mutants `outcomes.json` export, pared to what the rule reads. Unmodeled
44/// fields (`total_mutants`, `caught`, timings, …) are ignored.
45#[derive(Debug, Clone, Deserialize)]
46pub struct MutantsReport {
47    pub outcomes: Vec<MutantOutcome>,
48}
49
50/// One scenario's outcome. `summary` is cargo-mutants' result word — `Success` for the
51/// unmutated baseline, `CaughtMutant` / `MissedMutant` (and `Timeout` / `Unviable`)
52/// for each mutant.
53#[derive(Debug, Clone, Deserialize)]
54pub struct MutantOutcome {
55    pub summary: String,
56    pub scenario: Scenario,
57}
58
59/// The scenario a result came from: the unmutated baseline, or one mutant. Matches
60/// cargo-mutants' externally-tagged JSON (`"Baseline"` vs `{"Mutant": {…}}`).
61#[derive(Debug, Clone, Deserialize)]
62pub enum Scenario {
63    Baseline,
64    Mutant(MutantInfo),
65}
66
67/// The mutant a scenario describes, pared to the location + description the report
68/// needs. cargo-mutants also carries `function`, `genre`, `package`, `replacement`;
69/// those are ignored.
70#[derive(Debug, Clone, Deserialize)]
71pub struct MutantInfo {
72    pub file: String,
73    pub span: Span,
74    pub name: String,
75}
76
77/// A source span; only the start line is read.
78#[derive(Debug, Clone, Deserialize)]
79pub struct Span {
80    pub start: LineCol,
81}
82
83/// A line/column position; only the line is read.
84#[derive(Debug, Clone, Deserialize)]
85pub struct LineCol {
86    pub line: u32,
87}
88
89/// Parse a cargo-mutants `outcomes.json` export.
90pub fn parse_mutants_report(json: &str) -> Result<MutantsReport> {
91    serde_json::from_str(json).context("parsing cargo-mutants outcomes.json")
92}
93
94/// The surviving mutants not lifted by a `mutation` exemption — the rule's findings.
95///
96/// A survivor is a `MissedMutant` outcome (the suite ran the mutated code but no test
97/// failed). `exempt` is the resolved set of `mutation`-rule exempt paths (crate-root
98/// relative); a survivor in an exempt file is dropped (an equivalent or deliberately
99/// defensive mutation, lifted with a reason). `Timeout` / `Unviable` are *not*
100/// survivors — a timeout is inconclusive, not a pass, and an unviable mutant never
101/// compiled.
102pub fn unexplained_survivors(report: &MutantsReport, exempt: &[String]) -> Vec<Survivor> {
103    evaluate(cargo_mutants_survivors(report), exempt)
104}
105
106/// The surviving mutants in a cargo-mutants report — the raw list before exemptions.
107/// A survivor is a `MissedMutant` outcome (the suite ran the mutated code but no test
108/// failed). `Timeout` / `Unviable` are not survivors.
109fn cargo_mutants_survivors(report: &MutantsReport) -> Vec<Survivor> {
110    report
111        .outcomes
112        .iter()
113        .filter_map(|outcome| {
114            if outcome.summary != "MissedMutant" {
115                return None;
116            }
117            let Scenario::Mutant(mutant) = &outcome.scenario else {
118                return None;
119            };
120            Some(Survivor {
121                file: mutant.file.clone(),
122                line: mutant.span.start.line,
123                description: mutant.name.clone(),
124            })
125        })
126        .collect()
127}
128
129/// The `(file, line)` locations cargo-mutants produced a **viable, conclusive** mutant
130/// for — caught or missed (`CaughtMutant` / `MissedMutant`), not the inconclusive
131/// `Timeout` / `Unviable`. The line-scoped guard reads this to tell an
132/// over-exemption (a listed line whose mutants were all *caught*, no survivor) from an
133/// out-of-scope line (no mutant there at all — e.g. outside a `--base` diff).
134pub fn mutated_lines(report: &MutantsReport) -> MutatedLines {
135    report
136        .outcomes
137        .iter()
138        .filter_map(|outcome| {
139            if outcome.summary != "CaughtMutant" && outcome.summary != "MissedMutant" {
140                return None;
141            }
142            let Scenario::Mutant(mutant) = &outcome.scenario else {
143                return None;
144            };
145            Some((mutant.file.clone(), mutant.span.start.line))
146        })
147        .collect()
148}
149
150/// The shared whole-file evaluation core: drop the survivors lifted by a file-level
151/// `mutation` exemption (a file-path match), leaving the rule's findings. The
152/// line-scoped path ([`evaluate_scoped`]) generalizes this to per-line exemptions with
153/// a determinism guard.
154pub fn evaluate(survivors: Vec<Survivor>, exempt: &[String]) -> Vec<Survivor> {
155    survivors
156        .into_iter()
157        .filter(|survivor| !exempt.iter().any(|path| path == &survivor.file))
158        .collect()
159}
160
161/// Apply file- and line-scoped `mutation` exemptions to the raw `survivors`, with the
162/// determinism guard. `mutated` is the set of `(file, line)` that produced a
163/// viable mutant (caught or survived); `whole_file` is the file-level exemptions and
164/// `line_scoped` the per-line ones.
165///
166/// Guard: a line-scoped exemption that names a line whose mutants were **all caught**
167/// (in `mutated`, but with no survivor) is over-exemption — a hard error, the
168/// counterpart to the stale-path rule. A listed line with **no** mutant at all is left
169/// alone (it may simply be outside a `--base` diff), neither an error nor a drop. Then
170/// every survivor whose file is whole-file-exempt, or whose `(file, line)` is
171/// line-exempt, is dropped; an unlisted survivor still fails the gate.
172pub fn evaluate_scoped(
173    survivors: Vec<Survivor>,
174    mutated: &MutatedLines,
175    whole_file: &[String],
176    line_scoped: &BTreeMap<String, BTreeSet<u32>>,
177) -> Result<Vec<Survivor>> {
178    let mut over: Vec<String> = Vec::new();
179    for (file, lines) in line_scoped {
180        for &line in lines {
181            let has_survivor = survivors
182                .iter()
183                .any(|survivor| survivor.file == *file && survivor.line == line);
184            if has_survivor {
185                continue;
186            }
187            if mutated.contains(&(file.clone(), line)) {
188                over.push(format!("\n  {file}:{line}"));
189            }
190        }
191    }
192    if !over.is_empty() {
193        bail!(
194            "a line-scoped mutation exemption may only list a line with a surviving mutant, but \
195             these had mutants that were all caught:{}",
196            over.concat()
197        );
198    }
199    Ok(survivors
200        .into_iter()
201        .filter(|survivor| {
202            let whole = whole_file.iter().any(|path| path == &survivor.file);
203            let line = line_scoped
204                .get(&survivor.file)
205                .is_some_and(|lines| lines.contains(&survivor.line));
206            !(whole || line)
207        })
208        .collect())
209}
210
211/// A mutant's outcome, normalized across the engines (Stryker / cosmic-ray / cargo-mutants)
212/// — the union of their result vocabularies reduced to what the gate needs. Each
213/// language adapter maps its native outcomes onto this so the Rust core gates on one
214/// representation instead of three per-engine report formats. The serialized form is
215/// `snake_case` (`no_coverage`, `compile_error`, …) — the wire contract adapters emit.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
217#[serde(rename_all = "snake_case")]
218pub enum MutantStatus {
219    /// A test ran the mutated code but none failed — a survivor.
220    Survived,
221    /// A test failed on the mutant — caught.
222    Killed,
223    /// No test exercised the mutant at all — a survivor (worse than `Survived`).
224    NoCoverage,
225    /// The mutant ran but the suite timed out — inconclusive, not a survivor (but viable).
226    Timeout,
227    /// The mutant never compiled — not a viable mutant.
228    CompileError,
229    /// The mutant errored at runtime before a test could judge it — not viable.
230    RuntimeError,
231}
232
233impl MutantStatus {
234    /// Whether this outcome is a **survivor** — a mutant the suite failed to catch
235    /// (`Survived` or `NoCoverage`). Mirrors the per-engine survivor rules.
236    fn is_survivor(self) -> bool {
237        matches!(self, MutantStatus::Survived | MutantStatus::NoCoverage)
238    }
239
240    /// Whether this came from a **viable, conclusive** mutant — one that actually ran
241    /// (`Survived` / `Killed` / `NoCoverage` / `Timeout`), not one that never compiled or
242    /// errored out. The determinism guard reads this to tell an over-exemption (a
243    /// listed line whose mutants were all caught) from an out-of-scope line (no mutant there).
244    fn is_viable(self) -> bool {
245        matches!(
246            self,
247            MutantStatus::Survived
248                | MutantStatus::Killed
249                | MutantStatus::NoCoverage
250                | MutantStatus::Timeout
251        )
252    }
253}
254
255/// One mutant in the normalized result set: the engine-agnostic shape every language
256/// adapter emits. Extra fields an adapter includes are ignored.
257#[derive(Debug, Clone, Deserialize)]
258pub struct NormalizedMutant {
259    /// Project-relative, `/`-separated path of the mutated file.
260    pub file: String,
261    /// The 1-based line the mutant starts on.
262    pub line: u32,
263    /// The outcome, normalized across engines.
264    pub status: MutantStatus,
265    /// The engine's mutator/operator name (e.g. `ConditionalExpression`).
266    pub mutator: String,
267    /// The replacement text, when the engine reports one — used for a readable description.
268    #[serde(default)]
269    pub replacement: Option<String>,
270}
271
272/// Parse the normalized results an engine adapter emits — a flat JSON array of
273/// [`NormalizedMutant`].
274pub fn parse_normalized_results(json: &str) -> Result<Vec<NormalizedMutant>> {
275    serde_json::from_str(json).context("parsing normalized mutation results")
276}
277
278/// Gate a normalized result set: drop the survivors lifted by a file- or line-scoped
279/// `mutation` exemption (with the determinism guard), leaving the rule's findings.
280///
281/// This is the engine-agnostic core each language arm feeds once its adapter has produced
282/// [`NormalizedMutant`]s — the replacement for the per-engine `*_survivors` /
283/// `*_mutated_lines` + [`evaluate_scoped`] wiring. Survivors are `Survived` / `NoCoverage`
284/// mutants; the guard reads every *viable* mutant's `(file, line)`.
285pub fn evaluate_normalized(
286    mutants: &[NormalizedMutant],
287    whole_file: &[String],
288    line_scoped: &BTreeMap<String, BTreeSet<u32>>,
289) -> Result<Vec<Survivor>> {
290    evaluate_scoped(
291        normalized_survivors(mutants),
292        &normalized_mutated_lines(mutants),
293        whole_file,
294        line_scoped,
295    )
296}
297
298/// The surviving mutants in a normalized result set — the raw list before exemptions.
299fn normalized_survivors(mutants: &[NormalizedMutant]) -> Vec<Survivor> {
300    mutants
301        .iter()
302        .filter(|mutant| mutant.status.is_survivor())
303        .map(|mutant| Survivor {
304            file: mutant.file.clone(),
305            line: mutant.line,
306            description: describe_normalized(mutant),
307        })
308        .collect()
309}
310
311/// The `(file, line)` of every viable, conclusive mutant in a normalized result set — the
312/// input the line-scoped guard in [`evaluate_scoped`] reads.
313fn normalized_mutated_lines(mutants: &[NormalizedMutant]) -> MutatedLines {
314    mutants
315        .iter()
316        .filter(|mutant| mutant.status.is_viable())
317        .map(|mutant| (mutant.file.clone(), mutant.line))
318        .collect()
319}
320
321/// A one-line description for a normalized mutant: the mutator name, plus the replacement
322/// (flattened + capped via [`one_line`]) when the engine reported one.
323fn describe_normalized(mutant: &NormalizedMutant) -> String {
324    match &mutant.replacement {
325        Some(replacement) => format!("{} (-> {})", mutant.mutator, one_line(replacement)),
326        None => mutant.mutator.clone(),
327    }
328}
329
330/// Run cargo-mutants over the crate at `root` and return its un-exempted survivors.
331///
332/// With `base` set, only mutants on the `<base>...HEAD` changed lines are tested (via
333/// cargo-mutants' `--in-diff`); without it, the whole crate. `exempt` is the file-level
334/// `mutation` exempt paths and `exempt_lines` the line-scoped ones, applied with
335/// the determinism guard in [`evaluate_scoped`]. The tool provisions cargo-mutants itself
336/// on first use ([`ensure_cargo_mutants`]) — only a cargo toolchain need be present.
337pub fn measure_rust(
338    root: &Path,
339    exempt: &[String],
340    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
341    base: Option<&str>,
342    features: &[String],
343) -> Result<Vec<Survivor>> {
344    let out = MutantsOut::new();
345    let diff = match base {
346        // An empty diff (no changed lines under the crate — a PR that doesn't touch it)
347        // means nothing to mutate: no survivors, no cargo-mutants run.
348        Some(base) => match write_base_diff(root, base, &out)? {
349            None => return Ok(Vec::new()),
350            Some(path) => Some(path),
351        },
352        None => None,
353    };
354    let engine = ensure_cargo_mutants()?;
355    run_cargo_mutants(&engine, root, &out.0, diff.as_deref(), features)?;
356    let outcomes = out.0.join("mutants.out").join("outcomes.json");
357    // cargo-mutants writes no `outcomes.json` when a run produces no mutants (e.g. an
358    // `--in-diff` that matches none of the crate's lines). `run_cargo_mutants` already
359    // bailed on a fatal exit, so a missing report here is "no mutants" → no survivors.
360    let json = match std::fs::read_to_string(&outcomes) {
361        Ok(json) => json,
362        Err(_) => return Ok(Vec::new()),
363    };
364    let report = parse_mutants_report(&json)?;
365    evaluate_scoped(
366        cargo_mutants_survivors(&report),
367        &mutated_lines(&report),
368        exempt,
369        exempt_lines,
370    )
371}
372
373/// Collapse a (possibly multi-line) replacement to a single trimmed line, capped, so a
374/// survivor's one-line description stays readable.
375fn one_line(replacement: &str) -> String {
376    let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
377    const MAX: usize = 60;
378    if flat.chars().count() > MAX {
379        format!("{}…", flat.chars().take(MAX).collect::<String>())
380    } else {
381        flat
382    }
383}
384
385/// Run the bundled TypeScript mutation adapter over the project at `root` and return its
386/// un-exempted survivors — the TS arm of the mutation rule, parity with
387/// [`measure_rust`].
388///
389/// The consumer installs **nothing** Stryker-related: the npm package ships a Node
390/// adapter that drives Stryker through its own Node API and emits the engine-agnostic
391/// [`NormalizedMutant`] schema, which this gates over via [`evaluate_normalized`]
392/// — the same core the Rust and Python arms feed. Only the project's own test runner
393/// (vitest) needs to be present, exactly as cargo-mutants needs a buildable crate and
394/// cosmic-ray needs pytest.
395///
396/// With `base` set, only mutants on the `<base>...HEAD` changed lines are tested —
397/// Stryker has no native git-diff mode, so the changed lines become `--mutate
398/// <file>:<line>-<line>` ranges (line granularity, matching cargo-mutants' `--in-diff`).
399/// Without it, the project's configured `mutate` set runs. `exempt` is the file-level
400/// exempt paths and `exempt_lines` the line-scoped ones. `adapter` is the path to
401/// the bundled Node adapter (`dist/mutation/main.js`) — the CLI receives it from the npm
402/// launcher's `--ts-mutation-adapter` argument and hands it down explicitly.
403pub fn measure_typescript(
404    root: &Path,
405    exempt: &[String],
406    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
407    base: Option<&str>,
408    adapter: &Path,
409) -> Result<Vec<Survivor>> {
410    let mutate = match base {
411        Some(base) => {
412            let ranges = mutate_ranges(root, base)?;
413            // Nothing mutatable changed on the diff: no run, no survivors.
414            if ranges.is_empty() {
415                return Ok(Vec::new());
416            }
417            Some(ranges)
418        }
419        None => None,
420    };
421    let json = run_ts_adapter(root, adapter, mutate.as_deref())?;
422    let mutants = parse_normalized_results(&json)?;
423    evaluate_normalized(&mutants, exempt, exempt_lines)
424}
425
426/// Run the bundled TS mutation `adapter` over `root` and return the normalized-results JSON
427/// it writes. The adapter (a Node entry shipped with the npm package) drives Stryker via
428/// its Node API and emits a [`NormalizedMutant`] array — so the consumer drives the
429/// engine through this CLI alone; the npm package resolves `@stryker-mutator/*` from the
430/// tool's own tree.
431///
432/// `mutate`, when set, scopes the run to `--mutate` line ranges. Results are written to a
433/// temp file the adapter names via `--out` (so Stryker's own stdout logging can't corrupt
434/// them), then read back. `node` and the project's own test runner must be available; a
435/// non-zero adapter exit surfaces its captured output.
436fn run_ts_adapter(root: &Path, adapter: &Path, mutate: Option<&[String]>) -> Result<String> {
437    let out = AdapterOut::new();
438    std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
439    let results = out.0.join("results.json");
440
441    let mut command = Command::new("node");
442    command
443        .current_dir(root)
444        .arg(adapter)
445        .arg("--out")
446        .arg(&results);
447    if let Some(specs) = mutate {
448        command.arg("--mutate").arg(specs.join(","));
449    }
450    let output = command
451        .output()
452        .context("running the TypeScript mutation adapter (is `node` installed?)")?;
453    if !output.status.success() {
454        bail!(
455            "the TypeScript mutation adapter failed in `{}`:\n{}{}",
456            root.display(),
457            String::from_utf8_lossy(&output.stdout),
458            String::from_utf8_lossy(&output.stderr),
459        );
460    }
461    std::fs::read_to_string(&results).with_context(|| {
462        format!(
463            "reading the TypeScript mutation adapter's results from `{}`",
464            results.display()
465        )
466    })
467}
468
469/// A unique temp dir for one TS mutation adapter run's `--out` JSON, removed on drop so
470/// the scanned project stays pristine and parallel runs don't collide.
471struct AdapterOut(PathBuf);
472
473impl AdapterOut {
474    fn new() -> Self {
475        static COUNTER: AtomicU64 = AtomicU64::new(0);
476        let name = format!(
477            "testing-conventions-ts-adapter-{}-{}",
478            std::process::id(),
479            COUNTER.fetch_add(1, Ordering::Relaxed),
480        );
481        AdapterOut(std::env::temp_dir().join(name))
482    }
483}
484
485impl Drop for AdapterOut {
486    fn drop(&mut self) {
487        let _ = std::fs::remove_dir_all(&self.0);
488    }
489}
490
491/// Build the Stryker `--mutate` specs scoping a run to the `<base>...HEAD` changed
492/// lines: each mutatable source file's contiguous runs of changed lines become a
493/// `<file>:<start>-<end>` range (Stryker's line-range form). Reuses the patch-coverage
494/// diff parser. Test and declaration files are filtered out — Stryker's configured
495/// `mutate` set normally excludes them, but passing `--mutate` replaces that set.
496fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
497    let changed = crate::patch_coverage::changed_lines(root, base)?;
498    let mut specs = Vec::new();
499    for (file, lines) in changed {
500        if !is_mutatable_ts(&file) {
501            continue;
502        }
503        for (start, end) in contiguous_runs(&lines) {
504            specs.push(format!("{file}:{start}-{end}"));
505        }
506    }
507    Ok(specs)
508}
509
510/// Whether a changed file is a TypeScript/JavaScript *source* Stryker should mutate — a
511/// `.ts`/`.tsx`/`.mts`/`.cts`/`.js`/`.jsx`/`.mjs`/`.cjs` file that is not a declaration
512/// (`.d.ts`) or a test (`.test.` / `.spec.`).
513fn is_mutatable_ts(file: &str) -> bool {
514    let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
515        .iter()
516        .any(|ext| file.ends_with(ext));
517    let is_decl = file.ends_with(".d.ts");
518    let is_test = file.contains(".test.") || file.contains(".spec.");
519    is_source && !is_decl && !is_test
520}
521
522/// Fold a sorted set of line numbers into inclusive `(start, end)` contiguous runs.
523fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
524    let mut runs: Vec<(u64, u64)> = Vec::new();
525    for &line in lines {
526        match runs.last_mut() {
527            Some(run) if run.1 + 1 == line => run.1 = line,
528            _ => runs.push((line, line)),
529        }
530    }
531    runs
532}
533
534/// Run the bundled Python mutation adapter over the project at `root` and return its
535/// un-exempted survivors — the Python arm of the mutation rule, parity with
536/// [`measure_rust`] and [`measure_typescript`].
537///
538/// The tool drives the engine: the wheel ships a Python adapter that runs cosmic-ray through
539/// its own library API (`WorkDB`) and emits the normalized [`NormalizedMutant`] schema
540/// the gate consumes. maturin (`bindings = "bin"`) ships the rust binary directly as the wheel's
541/// script — with no Python launcher to inject a path, unlike the TS arm — so the binary invokes
542/// the adapter as an installed module (`python3 -m testing_conventions.mutation.main`), resolved
543/// from the wheel's environment alongside cosmic-ray. The project supplies its own test runner
544/// (pytest), exactly as cargo-mutants needs a buildable crate and Stryker needs vitest.
545///
546/// With `base` set, only mutants on the `<base>...HEAD` changed lines are reported: cosmic-ray
547/// has no native git-diff mode, so the run is scoped to the changed `.py` files (passed as
548/// `--module`) and the survivors are filtered to the changed lines in the core — line
549/// granularity, matching the other arms. Without it, the whole project's sources run (tests
550/// excluded). `exempt` is the file-level exempt paths and `exempt_lines` the line-scoped ones.
551pub fn measure_python(
552    root: &Path,
553    exempt: &[String],
554    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
555    base: Option<&str>,
556) -> Result<Vec<Survivor>> {
557    let changed = match base {
558        Some(base) => Some(crate::patch_coverage::changed_lines(root, base)?),
559        None => None,
560    };
561    let modules: Vec<String> = match &changed {
562        None => Vec::new(),
563        Some(changed) => {
564            let modules: Vec<String> = changed
565                .keys()
566                .filter(|file| is_mutatable_py(file))
567                .cloned()
568                .collect();
569            // Nothing mutatable changed on the diff: no run, no survivors.
570            if modules.is_empty() {
571                return Ok(Vec::new());
572            }
573            modules
574        }
575    };
576    let json = run_py_adapter(root, &modules)?;
577    let mut mutants = parse_normalized_results(&json)?;
578    if let Some(changed) = &changed {
579        // Diff-scoping v1: keep only mutants on the changed lines.
580        mutants.retain(|mutant| {
581            changed
582                .get(&mutant.file)
583                .is_some_and(|lines| lines.contains(&u64::from(mutant.line)))
584        });
585    }
586    evaluate_normalized(&mutants, exempt, exempt_lines)
587}
588
589/// Run the bundled Python mutation adapter over `root` and return the normalized-results JSON
590/// it writes. The rust binary spawns `python3 -m testing_conventions.mutation.main --out <tmp>
591/// [--module <path> ...]`; the adapter drives cosmic-ray in-process and emits a
592/// [`NormalizedMutant`] array. `modules`, when non-empty, scopes the run to those source
593/// files (the `<base>...HEAD` changed ones); empty runs the whole project. Results are written
594/// to a temp file the adapter names via `--out`, then read back. `PYTHONDONTWRITEBYTECODE` keeps
595/// `__pycache__` out of the scanned tree; a non-zero adapter exit surfaces its captured output.
596fn run_py_adapter(root: &Path, modules: &[String]) -> Result<String> {
597    let out = AdapterOut::new();
598    std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
599    let results = out.0.join("results.json");
600
601    let mut command = Command::new("python3");
602    command
603        .current_dir(root)
604        .args(["-m", "testing_conventions.mutation.main", "--out"])
605        .arg(&results)
606        .env("PYTHONDONTWRITEBYTECODE", "1");
607    for module in modules {
608        command.arg("--module").arg(module);
609    }
610    let output = command
611        .output()
612        .context("running the Python mutation adapter (is `python3` installed?)")?;
613    if !output.status.success() {
614        bail!(
615            "the Python mutation adapter failed in `{}`:\n{}{}",
616            root.display(),
617            String::from_utf8_lossy(&output.stdout),
618            String::from_utf8_lossy(&output.stderr),
619        );
620    }
621    std::fs::read_to_string(&results).with_context(|| {
622        format!(
623            "reading the Python mutation adapter's results from `{}`",
624            results.display()
625        )
626    })
627}
628
629/// Whether a changed file is a mutatable Python *source* — a `.py` that is not a test
630/// (`*_test.py` / `test_*.py`) or `conftest.py`.
631fn is_mutatable_py(file: &str) -> bool {
632    if !file.ends_with(".py") {
633        return false;
634    }
635    let base = file.rsplit('/').next().unwrap_or(file);
636    !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
637}
638
639/// A unique temp dir for one cargo-mutants run's `--output`, removed on drop so the
640/// scanned crate stays pristine and parallel runs don't collide.
641struct MutantsOut(PathBuf);
642
643impl MutantsOut {
644    fn new() -> Self {
645        static COUNTER: AtomicU64 = AtomicU64::new(0);
646        let name = format!(
647            "testing-conventions-mutants-{}-{}",
648            std::process::id(),
649            COUNTER.fetch_add(1, Ordering::Relaxed),
650        );
651        MutantsOut(std::env::temp_dir().join(name))
652    }
653}
654
655impl Drop for MutantsOut {
656    fn drop(&mut self) {
657        let _ = std::fs::remove_dir_all(&self.0);
658    }
659}
660
661/// Write the `<base>...HEAD` diff cargo-mutants' `--in-diff` scopes to, returning its
662/// path — or `None` when the diff is empty (no changed lines under the crate).
663///
664/// `--relative` restricts the diff to changes under `root` (the crate dir) and makes
665/// the paths relative to it. cargo-mutants runs *in* the crate dir and matches its
666/// `--in-diff` paths crate-relative, so without `--relative` the diff is repo-relative
667/// and matches nothing whenever the crate is a subdirectory of the git repo (the common
668/// case). Scoping also means a PR that doesn't touch the crate yields an empty diff.
669fn write_base_diff(root: &Path, base: &str, out: &MutantsOut) -> Result<Option<PathBuf>> {
670    let range = format!("{base}...HEAD");
671    let output = Command::new("git")
672        .current_dir(root)
673        .args(["diff", "--relative", &range])
674        .output()
675        .context("running `git diff` for `--base` (is git installed?)")?;
676    if !output.status.success() {
677        bail!(
678            "git diff {range} failed: {}",
679            String::from_utf8_lossy(&output.stderr)
680        );
681    }
682    if output.stdout.is_empty() {
683        return Ok(None);
684    }
685    std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
686    let path = out.0.join("base.diff");
687    std::fs::write(&path, &output.stdout).context("writing the base diff")?;
688    Ok(Some(path))
689}
690
691/// The cargo-mutants version the Rust arm provisions and pins to. Bumping this points the
692/// cache at a fresh version-scoped directory, so the next run installs the new release.
693const CARGO_MUTANTS_VERSION: &str = "27.1.0";
694
695/// Ensure the pinned cargo-mutants is available and return the absolute path to its binary,
696/// provisioning it on first use.
697///
698/// The consumer installs nothing and never names the engine:
699/// cargo ships no library form of cargo-mutants, so — unlike the in-process TS/Python
700/// adapters — the tool runs a pinned `cargo install cargo-mutants` into its own cache
701/// directory and drives the installed binary from there. A cached binary is reused; only a
702/// cargo toolchain need be present. This is the one deliberate asymmetry from the other
703/// arms, called out per the cross-language-parity rule.
704fn ensure_cargo_mutants() -> Result<PathBuf> {
705    let root = cargo_mutants_cache_root();
706    let bin = root.join("bin").join(cargo_mutants_bin_name());
707    let lock_path = root.join(".install.lock");
708    provision(&bin, &lock_path, || {
709        run_install(&root, |command| command.output())
710    })
711}
712
713/// The cargo-mutants binary's file name (`.exe` on Windows), as `cargo install --root`
714/// lays it out under `<root>/bin/`.
715fn cargo_mutants_bin_name() -> &'static str {
716    if cfg!(windows) {
717        "cargo-mutants.exe"
718    } else {
719        "cargo-mutants"
720    }
721}
722
723/// The tool-owned, version-scoped cache directory cargo-mutants is installed under, so a
724/// version bump provisions cleanly beside the old one and never clobbers a user's own
725/// `~/.cargo/bin`.
726fn cargo_mutants_cache_root() -> PathBuf {
727    cache_base()
728        .join("testing-conventions")
729        .join(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}"))
730}
731
732/// The base cache directory, read from OS-owned config. Split from [`resolve_cache_base`]
733/// so the resolution logic is unit-tested without touching the process environment.
734fn cache_base() -> PathBuf {
735    resolve_cache_base(std::env::var_os("XDG_CACHE_HOME"), std::env::var_os("HOME"))
736}
737
738/// Resolve the base cache dir: `XDG_CACHE_HOME` when set and non-empty, else `$HOME/.cache`,
739/// else the temp dir. Pure over its inputs.
740fn resolve_cache_base(xdg: Option<OsString>, home: Option<OsString>) -> PathBuf {
741    if let Some(dir) = xdg.filter(|value| !value.is_empty()) {
742        return PathBuf::from(dir);
743    }
744    if let Some(dir) = home.filter(|value| !value.is_empty()) {
745        return PathBuf::from(dir).join(".cache");
746    }
747    std::env::temp_dir()
748}
749
750/// Return `bin` if it already exists, otherwise take an exclusive advisory lock at
751/// `lock_path`, re-check (another caller may have installed while this one waited for the
752/// lock), and run `install` if still absent.
753///
754/// The lock closes a race: a bare check-then-install with no locking let N
755/// concurrent callers that all observed an absent binary each launch a full `cargo install`
756/// — correct (no corrupted output) but ruinously slow, since a from-source cargo-mutants
757/// compile is duplicated N times instead of once. Concurrent callers now wait ~one install and
758/// find the binary, instead of each running their own; a cold cache costs one serial install
759/// regardless of how many callers race for it.
760///
761/// Pure over the filesystem plus the injected installer, so a test drives every branch with
762/// a temp path and a fake installer (no from-source compile). An installer that reports
763/// success but produces no binary is an error.
764fn provision(
765    bin: &Path,
766    lock_path: &Path,
767    install: impl FnOnce() -> Result<()>,
768) -> Result<PathBuf> {
769    if bin.exists() {
770        return Ok(bin.to_path_buf());
771    }
772    if let Some(parent) = lock_path.parent() {
773        std::fs::create_dir_all(parent).context("creating the provisioning lock's parent dir")?;
774    }
775    let lock_file = std::fs::OpenOptions::new()
776        .create(true)
777        .truncate(false)
778        .write(true)
779        .open(lock_path)
780        .context("opening the provisioning lock file")?;
781    lock_file
782        .lock()
783        .context("acquiring the provisioning lock")?;
784    // Re-check: another caller may have installed while this one waited for the lock.
785    if bin.exists() {
786        return Ok(bin.to_path_buf());
787    }
788    install()?;
789    if !bin.exists() {
790        bail!(
791            "provisioning reported success but cargo-mutants is not at `{}`",
792            bin.display()
793        );
794    }
795    Ok(bin.to_path_buf())
796}
797
798/// The argv provisioning the pinned cargo-mutants into `root` (`cargo install cargo-mutants
799/// --locked --version <X> --root <root>`). Split from execution so a test asserts the pin
800/// and the isolated `--root` without a real install.
801fn install_argv(root: &Path) -> Vec<OsString> {
802    vec![
803        OsString::from("install"),
804        OsString::from("cargo-mutants"),
805        OsString::from("--locked"),
806        OsString::from("--version"),
807        OsString::from(CARGO_MUTANTS_VERSION),
808        OsString::from("--root"),
809        root.as_os_str().to_os_string(),
810    ]
811}
812
813/// Provision cargo-mutants into `root`, executing the built `cargo install` command with
814/// `run`. `run` is injected so a test drives the success and failure branches with a fake
815/// (no from-source compile). The coverage-instrumentation env is stripped so the compile
816/// doesn't re-enter a `cargo llvm-cov` rustc wrapper.
817fn run_install(
818    root: &Path,
819    run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
820) -> Result<()> {
821    let mut command = Command::new("cargo");
822    command.args(install_argv(root));
823    strip_llvm_cov_env(&mut command);
824    let output = run(&mut command)
825        .context("provisioning cargo-mutants via `cargo install` (is cargo installed?)")?;
826    if !output.status.success() {
827        bail!(
828            "failed to provision cargo-mutants {CARGO_MUTANTS_VERSION}:\n{}{}",
829            String::from_utf8_lossy(&output.stdout),
830            String::from_utf8_lossy(&output.stderr),
831        );
832    }
833    Ok(())
834}
835
836/// Strip the outer coverage-instrumentation env from a nested cargo invocation (the
837/// cargo-mutants run, or the `cargo install` that provisions it) so it doesn't re-enter the
838/// `cargo llvm-cov` rustc wrapper and hang, as when this rule's own tests run under coverage.
839fn strip_llvm_cov_env(command: &mut Command) {
840    for var in [
841        "RUSTFLAGS",
842        "CARGO_ENCODED_RUSTFLAGS",
843        "RUSTDOCFLAGS",
844        "CARGO_ENCODED_RUSTDOCFLAGS",
845        "LLVM_PROFILE_FILE",
846        "CARGO_LLVM_COV",
847        "CARGO_LLVM_COV_SHOW_ENV",
848        "CARGO_LLVM_COV_TARGET_DIR",
849        "CARGO_LLVM_COV_BUILD_DIR",
850        "RUSTC_WRAPPER",
851        "RUSTC_WORKSPACE_WRAPPER",
852        "__CARGO_LLVM_COV_RUSTC_WRAPPER",
853        "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
854        "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
855    ] {
856        command.env_remove(var);
857    }
858}
859
860/// Run `<engine> mutants --output <out> [--in-diff <diff>] [-- --features <list>]` in `root`,
861/// where `engine` is the provisioned cargo-mutants binary ([`ensure_cargo_mutants`]) invoked
862/// by absolute path. The `[rust] features` list rides after cargo-mutants' `--` separator,
863/// which forwards it to the cargo build/test runs — so `#[cfg(feature = ...)]` code is
864/// compiled and its mutants exercised.
865///
866/// The exit code is classified by [`classify_mutants_exit`] (`0`/`2`/`3` normal, else fatal).
867/// The outer instrumentation env is stripped so a nested run (this rule's own tests under
868/// `cargo llvm-cov`) doesn't re-enter the rustc wrapper and hang.
869fn run_cargo_mutants(
870    engine: &Path,
871    root: &Path,
872    out: &Path,
873    in_diff: Option<&Path>,
874    features: &[String],
875) -> Result<()> {
876    let mut command = Command::new(engine);
877    command
878        .current_dir(root)
879        .arg("mutants")
880        .arg("--output")
881        .arg(out);
882    if let Some(diff) = in_diff {
883        command.arg("--in-diff").arg(diff);
884    }
885    if !features.is_empty() {
886        command.args(["--", "--features"]).arg(features.join(","));
887    }
888    strip_llvm_cov_env(&mut command);
889    let output = command.output().context("running cargo-mutants")?;
890    classify_mutants_exit(root, &output)
891}
892
893/// Classify a finished cargo-mutants run's exit code as a normal outcome or a fatal error.
894/// Split from [`run_cargo_mutants`] so the exit-code handling is unit-tested with an injected
895/// [`Output`] rather than a real (and, for a timeout, a genuinely slow) engine run.
896///
897/// cargo-mutants exits `0` when every mutant is caught, `2` when some are missed (survivors),
898/// and `3` when some mutants **timed out** and none were missed — all three write an
899/// `outcomes.json` the gate reads, and a timeout is inconclusive (this module's own `Timeout`
900/// semantics), not a survivor. Any other code — a usage error, or a baseline that didn't
901/// build/pass (exit 4) — is fatal.
902fn classify_mutants_exit(root: &Path, output: &Output) -> Result<()> {
903    match output.status.code() {
904        // 0 = all caught, 2 = some missed (survivors), 3 = some timed out with none missed:
905        // all three produce an outcomes.json to read, and a timeout is inconclusive.
906        Some(0) | Some(2) | Some(3) => Ok(()),
907        _ => bail!(
908            "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
909            root.display(),
910            String::from_utf8_lossy(&output.stdout),
911            String::from_utf8_lossy(&output.stderr),
912        ),
913    }
914}
915
916#[cfg(test)]
917mod tests {
918    use super::*;
919
920    // A normalized result set covering every status: two survivors (Survived + NoCoverage),
921    // a caught Killed, an inconclusive-but-viable Timeout, and two unviable mutants
922    // (CompileError / RuntimeError). `snake_case` on the wire; an extra field is ignored.
923    const NORMALIZED: &str = r#"[
924        {"file": "src/a.ts", "line": 2, "status": "survived",
925         "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
926        {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
927        {"file": "src/a.ts", "line": 9, "status": "killed",
928         "mutator": "BooleanLiteral", "replacement": "false"},
929        {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
930        {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
931        {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
932    ]"#;
933
934    #[test]
935    fn parses_the_normalized_schema() {
936        let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
937        assert_eq!(mutants.len(), 6);
938        assert_eq!(mutants[0].status, MutantStatus::Survived);
939        assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
940        assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
941        assert_eq!(mutants[1].replacement, None);
942    }
943
944    #[test]
945    fn normalized_survivors_are_survived_and_nocoverage_only() {
946        let mutants = parse_normalized_results(NORMALIZED).unwrap();
947        let survivors = normalized_survivors(&mutants);
948        // Survived (2) + NoCoverage (5); not killed/timeout/compile/runtime.
949        assert_eq!(survivors.len(), 2);
950        assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
951        // Replacement is folded into the description when present, omitted otherwise.
952        assert!(survivors[0].description.contains("ConditionalExpression"));
953        assert!(survivors[0].description.contains("-> true"));
954        assert_eq!(survivors[1].description, "ArithmeticOperator");
955    }
956
957    #[test]
958    fn normalized_mutated_lines_collects_only_viable_mutants() {
959        let mutants = parse_normalized_results(NORMALIZED).unwrap();
960        // Survived/Killed/NoCoverage/Timeout ran; CompileError/RuntimeError never produced
961        // a viable mutant.
962        assert_eq!(
963            normalized_mutated_lines(&mutants),
964            [2u32, 5, 9, 12]
965                .into_iter()
966                .map(|line| ("src/a.ts".to_string(), line))
967                .collect()
968        );
969    }
970
971    #[test]
972    fn evaluate_normalized_reports_unexempted_survivors() {
973        let mutants = parse_normalized_results(NORMALIZED).unwrap();
974        let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
975        assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
976    }
977
978    #[test]
979    fn evaluate_normalized_drops_a_whole_file_exemption() {
980        let mutants = parse_normalized_results(NORMALIZED).unwrap();
981        let kept =
982            evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
983        assert!(
984            kept.is_empty(),
985            "the whole-file exemption lifts both survivors"
986        );
987    }
988
989    #[test]
990    fn evaluate_normalized_drops_a_line_scoped_exemption() {
991        let mutants = parse_normalized_results(NORMALIZED).unwrap();
992        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
993        let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
994        // Line 2's survivor is lifted; line 5's still stands.
995        assert_eq!(kept.len(), 1);
996        assert_eq!(kept[0].line, 5);
997    }
998
999    #[test]
1000    fn evaluate_normalized_rejects_exempting_a_caught_line() {
1001        // Line 9 had only a Killed mutant (viable, no survivor) — over-exemption is an error,
1002        // via the shared determinism guard.
1003        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1004        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
1005        let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
1006        assert!(
1007            err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
1008            "got: {err}"
1009        );
1010    }
1011
1012    #[test]
1013    fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
1014        // Line 15 had only a CompileError (no viable mutant) — neither an error nor a drop;
1015        // the real survivors still stand.
1016        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1017        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
1018        let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1019        assert_eq!(kept.len(), 2);
1020    }
1021
1022    // A pared `outcomes.json`: the baseline, one missed mutant, and one caught — the
1023    // real shape (externally-tagged `scenario`, extra fields the rule ignores).
1024    const SAMPLE: &str = r#"{
1025        "outcomes": [
1026            {"scenario": "Baseline", "summary": "Success",
1027             "phase_results": []},
1028            {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
1029                "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
1030                "function": {"function_name": "is_positive"},
1031                "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
1032             "summary": "MissedMutant"},
1033            {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
1034                "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
1035                "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
1036             "summary": "CaughtMutant"}
1037        ],
1038        "total_mutants": 2
1039    }"#;
1040
1041    #[test]
1042    fn parses_the_outcomes_export() {
1043        let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
1044        assert_eq!(report.outcomes.len(), 3);
1045        assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
1046    }
1047
1048    #[test]
1049    fn collects_only_missed_mutants_as_survivors() {
1050        let report = parse_mutants_report(SAMPLE).unwrap();
1051        let survivors = unexplained_survivors(&report, &[]);
1052        // Only the MissedMutant — the baseline and the CaughtMutant are not survivors.
1053        assert_eq!(survivors.len(), 1);
1054        assert_eq!(survivors[0].file, "src/lib.rs");
1055        assert_eq!(survivors[0].line, 7);
1056        assert!(survivors[0].description.contains("replace > with =="));
1057    }
1058
1059    #[test]
1060    fn an_exemption_drops_a_survivor_in_that_file() {
1061        let report = parse_mutants_report(SAMPLE).unwrap();
1062        let exempt = vec!["src/lib.rs".to_string()];
1063        assert!(unexplained_survivors(&report, &exempt).is_empty());
1064    }
1065
1066    #[test]
1067    fn an_exemption_on_another_file_leaves_the_survivor() {
1068        let report = parse_mutants_report(SAMPLE).unwrap();
1069        let exempt = vec!["src/elsewhere.rs".to_string()];
1070        assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1071    }
1072
1073    #[test]
1074    fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
1075        assert!(is_mutatable_ts("src/index.ts"));
1076        assert!(is_mutatable_ts("src/util.tsx"));
1077        assert!(is_mutatable_ts("src/util.js"));
1078        assert!(!is_mutatable_ts("src/index.test.ts"));
1079        assert!(!is_mutatable_ts("src/index.spec.ts"));
1080        assert!(!is_mutatable_ts("src/types.d.ts"));
1081        assert!(!is_mutatable_ts("README.md"));
1082    }
1083
1084    #[test]
1085    fn contiguous_runs_collapses_adjacent_lines() {
1086        let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
1087        assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
1088        assert!(contiguous_runs(&BTreeSet::new()).is_empty());
1089    }
1090
1091    #[test]
1092    fn one_line_flattens_and_caps() {
1093        assert_eq!(one_line("a -\n  b"), "a - b");
1094        let long = "x".repeat(80);
1095        let capped = one_line(&long);
1096        assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
1097    }
1098
1099    #[test]
1100    fn is_mutatable_py_keeps_sources_and_drops_tests() {
1101        assert!(is_mutatable_py("calc.py"));
1102        assert!(is_mutatable_py("pkg/util.py"));
1103        assert!(!is_mutatable_py("calc_test.py"));
1104        assert!(!is_mutatable_py("test_calc.py"));
1105        assert!(!is_mutatable_py("pkg/conftest.py"));
1106        assert!(!is_mutatable_py("README.md"));
1107    }
1108
1109    #[test]
1110    fn mutated_lines_collects_caught_and_missed() {
1111        // The MissedMutant (src/lib.rs:7) and the CaughtMutant (src/other.rs:3) are both
1112        // viable, conclusive mutants; the Baseline is not.
1113        let report = parse_mutants_report(SAMPLE).unwrap();
1114        assert_eq!(
1115            mutated_lines(&report),
1116            [
1117                ("src/lib.rs".to_string(), 7),
1118                ("src/other.rs".to_string(), 3)
1119            ]
1120            .into_iter()
1121            .collect()
1122        );
1123    }
1124
1125    #[test]
1126    fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1127        let report = parse_mutants_report(SAMPLE).unwrap();
1128        let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1129        let kept = evaluate_scoped(
1130            cargo_mutants_survivors(&report),
1131            &mutated_lines(&report),
1132            &[],
1133            &line_scoped,
1134        )
1135        .unwrap();
1136        assert!(
1137            kept.is_empty(),
1138            "the src/lib.rs:7 survivor should be lifted"
1139        );
1140    }
1141
1142    #[test]
1143    fn evaluate_scoped_rejects_exempting_a_caught_line() {
1144        // src/other.rs:3 had only a caught mutant (no survivor) — over-exemption.
1145        let report = parse_mutants_report(SAMPLE).unwrap();
1146        let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
1147        let err = evaluate_scoped(
1148            cargo_mutants_survivors(&report),
1149            &mutated_lines(&report),
1150            &[],
1151            &line_scoped,
1152        )
1153        .unwrap_err();
1154        assert!(
1155            err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
1156            "got: {err}"
1157        );
1158    }
1159
1160    #[test]
1161    fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
1162        // Line 99 has no mutant at all (e.g. outside a `--base` diff) — neither an error
1163        // nor a drop; the real survivor on line 7 still stands.
1164        let report = parse_mutants_report(SAMPLE).unwrap();
1165        let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
1166        let kept = evaluate_scoped(
1167            cargo_mutants_survivors(&report),
1168            &mutated_lines(&report),
1169            &[],
1170            &line_scoped,
1171        )
1172        .unwrap();
1173        assert_eq!(kept.len(), 1);
1174        assert_eq!(kept[0].line, 7);
1175    }
1176
1177    #[test]
1178    fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1179        let report = parse_mutants_report(SAMPLE).unwrap();
1180        let kept = evaluate_scoped(
1181            cargo_mutants_survivors(&report),
1182            &mutated_lines(&report),
1183            &["src/lib.rs".to_string()],
1184            &BTreeMap::new(),
1185        )
1186        .unwrap();
1187        assert!(kept.is_empty());
1188    }
1189
1190    fn unique_tmp() -> PathBuf {
1191        static COUNTER: AtomicU64 = AtomicU64::new(0);
1192        let dir = std::env::temp_dir().join(format!(
1193            "tc-provision-test-{}-{}",
1194            std::process::id(),
1195            COUNTER.fetch_add(1, Ordering::Relaxed)
1196        ));
1197        std::fs::create_dir_all(&dir).unwrap();
1198        dir
1199    }
1200
1201    #[test]
1202    fn provision_returns_an_existing_binary_without_installing() {
1203        let tmp = unique_tmp();
1204        let bin = tmp.join("bin").join("cargo-mutants");
1205        let lock = tmp.join(".install.lock");
1206        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1207        std::fs::write(&bin, b"binary").unwrap();
1208        let mut installed = false;
1209        let got = provision(&bin, &lock, || {
1210            installed = true;
1211            Ok(())
1212        })
1213        .unwrap();
1214        assert_eq!(got, bin);
1215        assert!(!installed, "a present binary must not be reinstalled");
1216        std::fs::remove_dir_all(&tmp).unwrap();
1217    }
1218
1219    #[test]
1220    fn provision_installs_when_the_binary_is_absent() {
1221        let tmp = unique_tmp();
1222        let bin = tmp.join("bin").join("cargo-mutants");
1223        let lock = tmp.join(".install.lock");
1224        let mut installed = false;
1225        let got = provision(&bin, &lock, || {
1226            installed = true;
1227            std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1228            std::fs::write(&bin, b"binary").unwrap();
1229            Ok(())
1230        })
1231        .unwrap();
1232        assert!(installed, "an absent binary must be installed");
1233        assert_eq!(got, bin);
1234        std::fs::remove_dir_all(&tmp).unwrap();
1235    }
1236
1237    #[test]
1238    fn provision_errors_when_install_produces_no_binary() {
1239        let tmp = unique_tmp();
1240        let bin = tmp.join("bin").join("cargo-mutants");
1241        let lock = tmp.join(".install.lock");
1242        let err = provision(&bin, &lock, || Ok(())).unwrap_err();
1243        assert!(
1244            err.to_string().contains("cargo-mutants is not at"),
1245            "got: {err}"
1246        );
1247        std::fs::remove_dir_all(&tmp).unwrap();
1248    }
1249
1250    #[test]
1251    fn provision_propagates_an_install_failure() {
1252        let tmp = unique_tmp();
1253        let bin = tmp.join("bin").join("cargo-mutants");
1254        let lock = tmp.join(".install.lock");
1255        let err = provision(&bin, &lock, || bail!("install blew up")).unwrap_err();
1256        assert!(err.to_string().contains("install blew up"), "got: {err}");
1257        std::fs::remove_dir_all(&tmp).unwrap();
1258    }
1259
1260    #[test]
1261    fn provision_does_not_duplicate_the_install_under_concurrent_callers() {
1262        // On a cold cache, N concurrent callers must share one install, not each run
1263        // their own — cargo-mutants' from-source compile duplicated N times (instead of once)
1264        // is what turned a ~1-minute cold-cache cost into ~7 minutes under nextest. A
1265        // barrier forces both threads to observe the absent binary together, and the install
1266        // closure sleeps briefly to widen the race window so this reproduces deterministically
1267        // rather than flakily.
1268        use std::sync::{Arc, Barrier};
1269        use std::thread;
1270        use std::time::Duration;
1271
1272        let tmp = unique_tmp();
1273        let bin = tmp.join("bin").join("cargo-mutants");
1274        let lock = tmp.join(".install.lock");
1275        let install_count = Arc::new(AtomicU64::new(0));
1276        let barrier = Arc::new(Barrier::new(2));
1277
1278        let handles: Vec<_> = (0..2)
1279            .map(|_| {
1280                let bin = bin.clone();
1281                let lock = lock.clone();
1282                let install_count = Arc::clone(&install_count);
1283                let barrier = Arc::clone(&barrier);
1284                thread::spawn(move || {
1285                    barrier.wait();
1286                    provision(&bin, &lock, || {
1287                        install_count.fetch_add(1, Ordering::SeqCst);
1288                        thread::sleep(Duration::from_millis(50));
1289                        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1290                        std::fs::write(&bin, b"binary").unwrap();
1291                        Ok(())
1292                    })
1293                })
1294            })
1295            .collect();
1296
1297        for h in handles {
1298            h.join()
1299                .expect("provisioning thread must not panic")
1300                .unwrap();
1301        }
1302
1303        assert_eq!(
1304            install_count.load(Ordering::SeqCst),
1305            1,
1306            "two concurrent callers on a cold cache must share one install, not each run their own"
1307        );
1308        std::fs::remove_dir_all(&tmp).unwrap();
1309    }
1310
1311    #[test]
1312    fn resolve_cache_base_prefers_xdg_then_home_then_temp() {
1313        let xdg = |s: &str| Some(OsString::from(s));
1314        // XDG wins when set and non-empty.
1315        assert_eq!(
1316            resolve_cache_base(xdg("/xdg"), xdg("/home")),
1317            PathBuf::from("/xdg")
1318        );
1319        // An empty XDG falls through to $HOME/.cache.
1320        assert_eq!(
1321            resolve_cache_base(xdg(""), xdg("/home")),
1322            PathBuf::from("/home/.cache")
1323        );
1324        // A missing XDG likewise uses $HOME/.cache.
1325        assert_eq!(
1326            resolve_cache_base(None, xdg("/home")),
1327            PathBuf::from("/home/.cache")
1328        );
1329        // Neither set → the temp dir.
1330        assert_eq!(resolve_cache_base(None, None), std::env::temp_dir());
1331        assert_eq!(
1332            resolve_cache_base(xdg(""), Some(OsString::new())),
1333            std::env::temp_dir()
1334        );
1335    }
1336
1337    #[test]
1338    fn cache_root_is_absolute_and_version_scoped() {
1339        let root = cargo_mutants_cache_root();
1340        assert!(
1341            root.ends_with(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}")),
1342            "version-scoped; got {root:?}"
1343        );
1344        assert!(
1345            root.to_string_lossy().contains("testing-conventions"),
1346            "tool-namespaced; got {root:?}"
1347        );
1348        // A real base dir (HOME/XDG in the test env) makes it absolute — not an empty path.
1349        assert!(
1350            root.is_absolute(),
1351            "expected an absolute path; got {root:?}"
1352        );
1353    }
1354
1355    #[test]
1356    fn install_argv_pins_the_version_and_isolates_the_root() {
1357        let argv: Vec<String> = install_argv(Path::new("/cache/cargo-mutants-27"))
1358            .iter()
1359            .map(|arg| arg.to_string_lossy().into_owned())
1360            .collect();
1361        assert_eq!(
1362            argv,
1363            vec![
1364                "install",
1365                "cargo-mutants",
1366                "--locked",
1367                "--version",
1368                CARGO_MUTANTS_VERSION,
1369                "--root",
1370                "/cache/cargo-mutants-27",
1371            ]
1372        );
1373    }
1374
1375    #[cfg(unix)]
1376    fn fake_output(code: i32, stderr: &str) -> Output {
1377        use std::os::unix::process::ExitStatusExt;
1378        Output {
1379            status: std::process::ExitStatus::from_raw(code << 8),
1380            stdout: Vec::new(),
1381            stderr: stderr.as_bytes().to_vec(),
1382        }
1383    }
1384
1385    #[cfg(unix)]
1386    #[test]
1387    fn run_install_succeeds_on_a_zero_exit() {
1388        let mut ran = false;
1389        run_install(Path::new("/cache/root"), |command| {
1390            ran = true;
1391            // The pinned argv reaches the runner.
1392            let argv: Vec<String> = command
1393                .get_args()
1394                .map(|arg| arg.to_string_lossy().into_owned())
1395                .collect();
1396            assert!(argv.contains(&CARGO_MUTANTS_VERSION.to_string()));
1397            Ok(fake_output(0, ""))
1398        })
1399        .unwrap();
1400        assert!(ran);
1401    }
1402
1403    #[cfg(unix)]
1404    #[test]
1405    fn run_install_reports_a_nonzero_exit_with_the_engine_output() {
1406        let err = run_install(Path::new("/cache/root"), |_| {
1407            Ok(fake_output(1, "error: could not compile cargo-mutants"))
1408        })
1409        .unwrap_err();
1410        assert!(
1411            err.to_string()
1412                .contains("failed to provision cargo-mutants")
1413                && err.to_string().contains("could not compile"),
1414            "got: {err}"
1415        );
1416    }
1417
1418    #[cfg(unix)]
1419    #[test]
1420    fn run_install_propagates_a_spawn_failure() {
1421        let err = run_install(Path::new("/cache/root"), |_| {
1422            Err(std::io::Error::new(
1423                std::io::ErrorKind::NotFound,
1424                "no cargo",
1425            ))
1426        })
1427        .unwrap_err();
1428        assert!(
1429            err.to_string().contains("is cargo installed?"),
1430            "got: {err}"
1431        );
1432    }
1433
1434    #[cfg(unix)]
1435    #[test]
1436    fn classify_mutants_exit_accepts_the_caught_and_survivor_exits() {
1437        // 0 (all caught) and 2 (some missed/survived) both leave an outcomes.json to read.
1438        classify_mutants_exit(Path::new("/crate"), &fake_output(0, "")).unwrap();
1439        classify_mutants_exit(Path::new("/crate"), &fake_output(2, "")).unwrap();
1440    }
1441
1442    #[cfg(unix)]
1443    #[test]
1444    fn classify_mutants_exit_accepts_a_timeout_exit_3() {
1445        // cargo-mutants exits 3 when mutants timed out and none were missed — an
1446        // inconclusive-not-fatal outcome (this module's own `Timeout` semantics). It still
1447        // wrote an outcomes.json, so the run is a pass, not the "baseline failure" bail.
1448        classify_mutants_exit(Path::new("/crate"), &fake_output(3, ""))
1449            .expect("a timeout (exit 3) is inconclusive, not fatal");
1450    }
1451
1452    #[cfg(unix)]
1453    #[test]
1454    fn classify_mutants_exit_is_fatal_on_a_baseline_failure() {
1455        // Exit 4 (the clean/baseline build or test failed) — and any other code — stays fatal.
1456        let err = classify_mutants_exit(Path::new("/crate"), &fake_output(4, "baseline broke"))
1457            .unwrap_err();
1458        assert!(
1459            err.to_string().contains("did not run cleanly")
1460                && err.to_string().contains("baseline broke"),
1461            "got: {err}"
1462        );
1463    }
1464
1465    #[test]
1466    fn cargo_mutants_bin_name_matches_the_platform() {
1467        let name = cargo_mutants_bin_name();
1468        if cfg!(windows) {
1469            assert_eq!(name, "cargo-mutants.exe");
1470        } else {
1471            assert_eq!(name, "cargo-mutants");
1472        }
1473    }
1474}