Skip to main content

testing_conventions/
mutation.rs

1//! Mutation testing for Rust (`unit mutation --language rust`, #201) — 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/// #226 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 #226 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/// #226 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 (#239). 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 #226 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 (#239): 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`] (#239).
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 #226 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 (#239) — 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 #226 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 (#226), 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 (#202), 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 (#239), 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 (#226). `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 (#239) — 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 (#203 / #248), 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 (#239)
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
551/// (#226).
552pub fn measure_python(
553    root: &Path,
554    exempt: &[String],
555    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
556    base: Option<&str>,
557) -> Result<Vec<Survivor>> {
558    let changed = match base {
559        Some(base) => Some(crate::patch_coverage::changed_lines(root, base)?),
560        None => None,
561    };
562    let modules: Vec<String> = match &changed {
563        None => Vec::new(),
564        Some(changed) => {
565            let modules: Vec<String> = changed
566                .keys()
567                .filter(|file| is_mutatable_py(file))
568                .cloned()
569                .collect();
570            // Nothing mutatable changed on the diff: no run, no survivors.
571            if modules.is_empty() {
572                return Ok(Vec::new());
573            }
574            modules
575        }
576    };
577    let json = run_py_adapter(root, &modules)?;
578    let mut mutants = parse_normalized_results(&json)?;
579    if let Some(changed) = &changed {
580        // Diff-scoping v1 (#248): keep only mutants on the changed lines.
581        mutants.retain(|mutant| {
582            changed
583                .get(&mutant.file)
584                .is_some_and(|lines| lines.contains(&u64::from(mutant.line)))
585        });
586    }
587    evaluate_normalized(&mutants, exempt, exempt_lines)
588}
589
590/// Run the bundled Python mutation adapter over `root` and return the normalized-results JSON
591/// it writes. The rust binary spawns `python3 -m testing_conventions.mutation.main --out <tmp>
592/// [--module <path> ...]`; the adapter drives cosmic-ray in-process (#248) and emits a
593/// [`NormalizedMutant`] array (#239). `modules`, when non-empty, scopes the run to those source
594/// files (the `<base>...HEAD` changed ones); empty runs the whole project. Results are written
595/// to a temp file the adapter names via `--out`, then read back. `PYTHONDONTWRITEBYTECODE` keeps
596/// `__pycache__` out of the scanned tree; a non-zero adapter exit surfaces its captured output.
597fn run_py_adapter(root: &Path, modules: &[String]) -> Result<String> {
598    let out = AdapterOut::new();
599    std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
600    let results = out.0.join("results.json");
601
602    let mut command = Command::new("python3");
603    command
604        .current_dir(root)
605        .args(["-m", "testing_conventions.mutation.main", "--out"])
606        .arg(&results)
607        .env("PYTHONDONTWRITEBYTECODE", "1");
608    for module in modules {
609        command.arg("--module").arg(module);
610    }
611    let output = command
612        .output()
613        .context("running the Python mutation adapter (is `python3` installed?)")?;
614    if !output.status.success() {
615        bail!(
616            "the Python mutation adapter failed in `{}`:\n{}{}",
617            root.display(),
618            String::from_utf8_lossy(&output.stdout),
619            String::from_utf8_lossy(&output.stderr),
620        );
621    }
622    std::fs::read_to_string(&results).with_context(|| {
623        format!(
624            "reading the Python mutation adapter's results from `{}`",
625            results.display()
626        )
627    })
628}
629
630/// Whether a changed file is a mutatable Python *source* — a `.py` that is not a test
631/// (`*_test.py` / `test_*.py`) or `conftest.py`.
632fn is_mutatable_py(file: &str) -> bool {
633    if !file.ends_with(".py") {
634        return false;
635    }
636    let base = file.rsplit('/').next().unwrap_or(file);
637    !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
638}
639
640/// A unique temp dir for one cargo-mutants run's `--output`, removed on drop so the
641/// scanned crate stays pristine and parallel runs don't collide.
642struct MutantsOut(PathBuf);
643
644impl MutantsOut {
645    fn new() -> Self {
646        static COUNTER: AtomicU64 = AtomicU64::new(0);
647        let name = format!(
648            "testing-conventions-mutants-{}-{}",
649            std::process::id(),
650            COUNTER.fetch_add(1, Ordering::Relaxed),
651        );
652        MutantsOut(std::env::temp_dir().join(name))
653    }
654}
655
656impl Drop for MutantsOut {
657    fn drop(&mut self) {
658        let _ = std::fs::remove_dir_all(&self.0);
659    }
660}
661
662/// Write the `<base>...HEAD` diff cargo-mutants' `--in-diff` scopes to, returning its
663/// path — or `None` when the diff is empty (no changed lines under the crate).
664///
665/// `--relative` restricts the diff to changes under `root` (the crate dir) and makes
666/// the paths relative to it. cargo-mutants runs *in* the crate dir and matches its
667/// `--in-diff` paths crate-relative, so without `--relative` the diff is repo-relative
668/// and matches nothing whenever the crate is a subdirectory of the git repo (the common
669/// case). Scoping also means a PR that doesn't touch the crate yields an empty diff.
670fn write_base_diff(root: &Path, base: &str, out: &MutantsOut) -> Result<Option<PathBuf>> {
671    let range = format!("{base}...HEAD");
672    let output = Command::new("git")
673        .current_dir(root)
674        .args(["diff", "--relative", &range])
675        .output()
676        .context("running `git diff` for `--base` (is git installed?)")?;
677    if !output.status.success() {
678        bail!(
679            "git diff {range} failed: {}",
680            String::from_utf8_lossy(&output.stderr)
681        );
682    }
683    if output.stdout.is_empty() {
684        return Ok(None);
685    }
686    std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
687    let path = out.0.join("base.diff");
688    std::fs::write(&path, &output.stdout).context("writing the base diff")?;
689    Ok(Some(path))
690}
691
692/// The cargo-mutants version the Rust arm provisions and pins to. Bumping this points the
693/// cache at a fresh version-scoped directory, so the next run installs the new release.
694const CARGO_MUTANTS_VERSION: &str = "27.1.0";
695
696/// Ensure the pinned cargo-mutants is available and return the absolute path to its binary,
697/// provisioning it on first use.
698///
699/// The consumer installs nothing and never names the engine (the #242 / #239 contract):
700/// cargo ships no library form of cargo-mutants, so — unlike the in-process TS/Python
701/// adapters — the tool runs a pinned `cargo install cargo-mutants` into its own cache
702/// directory and drives the installed binary from there. A cached binary is reused; only a
703/// cargo toolchain need be present. This is the one deliberate asymmetry from the other
704/// arms, called out per the cross-language-parity rule.
705fn ensure_cargo_mutants() -> Result<PathBuf> {
706    let root = cargo_mutants_cache_root();
707    let bin = root.join("bin").join(cargo_mutants_bin_name());
708    let lock_path = root.join(".install.lock");
709    provision(&bin, &lock_path, || {
710        run_install(&root, |command| command.output())
711    })
712}
713
714/// The cargo-mutants binary's file name (`.exe` on Windows), as `cargo install --root`
715/// lays it out under `<root>/bin/`.
716fn cargo_mutants_bin_name() -> &'static str {
717    if cfg!(windows) {
718        "cargo-mutants.exe"
719    } else {
720        "cargo-mutants"
721    }
722}
723
724/// The tool-owned, version-scoped cache directory cargo-mutants is installed under, so a
725/// version bump provisions cleanly beside the old one and never clobbers a user's own
726/// `~/.cargo/bin`.
727fn cargo_mutants_cache_root() -> PathBuf {
728    cache_base()
729        .join("testing-conventions")
730        .join(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}"))
731}
732
733/// The base cache directory, read from OS-owned config. Split from [`resolve_cache_base`]
734/// so the resolution logic is unit-tested without touching the process environment.
735fn cache_base() -> PathBuf {
736    resolve_cache_base(std::env::var_os("XDG_CACHE_HOME"), std::env::var_os("HOME"))
737}
738
739/// Resolve the base cache dir: `XDG_CACHE_HOME` when set and non-empty, else `$HOME/.cache`,
740/// else the temp dir. Pure over its inputs.
741fn resolve_cache_base(xdg: Option<OsString>, home: Option<OsString>) -> PathBuf {
742    if let Some(dir) = xdg.filter(|value| !value.is_empty()) {
743        return PathBuf::from(dir);
744    }
745    if let Some(dir) = home.filter(|value| !value.is_empty()) {
746        return PathBuf::from(dir).join(".cache");
747    }
748    std::env::temp_dir()
749}
750
751/// Return `bin` if it already exists, otherwise take an exclusive advisory lock at
752/// `lock_path`, re-check (another caller may have installed while this one waited for the
753/// lock), and run `install` if still absent.
754///
755/// The lock closes a race #370 exposed: a bare check-then-install with no locking let N
756/// concurrent callers that all observed an absent binary each launch a full `cargo install`
757/// — correct (no corrupted output) but ruinously slow, since a from-source cargo-mutants
758/// compile is duplicated N times instead of once. Concurrent callers now wait ~one install and
759/// find the binary, instead of each running their own; a cold cache costs one serial install
760/// regardless of how many callers race for it (#385).
761///
762/// Pure over the filesystem plus the injected installer, so a test drives every branch with
763/// a temp path and a fake installer (no from-source compile). An installer that reports
764/// success but produces no binary is an error.
765fn provision(
766    bin: &Path,
767    lock_path: &Path,
768    install: impl FnOnce() -> Result<()>,
769) -> Result<PathBuf> {
770    if bin.exists() {
771        return Ok(bin.to_path_buf());
772    }
773    if let Some(parent) = lock_path.parent() {
774        std::fs::create_dir_all(parent).context("creating the provisioning lock's parent dir")?;
775    }
776    let lock_file = std::fs::OpenOptions::new()
777        .create(true)
778        .truncate(false)
779        .write(true)
780        .open(lock_path)
781        .context("opening the provisioning lock file")?;
782    lock_file
783        .lock()
784        .context("acquiring the provisioning lock")?;
785    // Re-check: another caller may have installed while this one waited for the lock.
786    if bin.exists() {
787        return Ok(bin.to_path_buf());
788    }
789    install()?;
790    if !bin.exists() {
791        bail!(
792            "provisioning reported success but cargo-mutants is not at `{}`",
793            bin.display()
794        );
795    }
796    Ok(bin.to_path_buf())
797}
798
799/// The argv provisioning the pinned cargo-mutants into `root` (`cargo install cargo-mutants
800/// --locked --version <X> --root <root>`). Split from execution so a test asserts the pin
801/// and the isolated `--root` without a real install.
802fn install_argv(root: &Path) -> Vec<OsString> {
803    vec![
804        OsString::from("install"),
805        OsString::from("cargo-mutants"),
806        OsString::from("--locked"),
807        OsString::from("--version"),
808        OsString::from(CARGO_MUTANTS_VERSION),
809        OsString::from("--root"),
810        root.as_os_str().to_os_string(),
811    ]
812}
813
814/// Provision cargo-mutants into `root`, executing the built `cargo install` command with
815/// `run`. `run` is injected so a test drives the success and failure branches with a fake
816/// (no from-source compile). The coverage-instrumentation env is stripped so the compile
817/// doesn't re-enter a `cargo llvm-cov` rustc wrapper.
818fn run_install(
819    root: &Path,
820    run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
821) -> Result<()> {
822    let mut command = Command::new("cargo");
823    command.args(install_argv(root));
824    strip_llvm_cov_env(&mut command);
825    let output = run(&mut command)
826        .context("provisioning cargo-mutants via `cargo install` (is cargo installed?)")?;
827    if !output.status.success() {
828        bail!(
829            "failed to provision cargo-mutants {CARGO_MUTANTS_VERSION}:\n{}{}",
830            String::from_utf8_lossy(&output.stdout),
831            String::from_utf8_lossy(&output.stderr),
832        );
833    }
834    Ok(())
835}
836
837/// Strip the outer coverage-instrumentation env from a nested cargo invocation (the
838/// cargo-mutants run, or the `cargo install` that provisions it) so it doesn't re-enter the
839/// `cargo llvm-cov` rustc wrapper and hang, as when this rule's own tests run under coverage.
840fn strip_llvm_cov_env(command: &mut Command) {
841    for var in [
842        "RUSTFLAGS",
843        "CARGO_ENCODED_RUSTFLAGS",
844        "RUSTDOCFLAGS",
845        "CARGO_ENCODED_RUSTDOCFLAGS",
846        "LLVM_PROFILE_FILE",
847        "CARGO_LLVM_COV",
848        "CARGO_LLVM_COV_SHOW_ENV",
849        "CARGO_LLVM_COV_TARGET_DIR",
850        "CARGO_LLVM_COV_BUILD_DIR",
851        "RUSTC_WRAPPER",
852        "RUSTC_WORKSPACE_WRAPPER",
853        "__CARGO_LLVM_COV_RUSTC_WRAPPER",
854        "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
855        "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
856    ] {
857        command.env_remove(var);
858    }
859}
860
861/// Run `<engine> mutants --output <out> [--in-diff <diff>] [-- --features <list>]` in `root`,
862/// where `engine` is the provisioned cargo-mutants binary ([`ensure_cargo_mutants`]) invoked
863/// by absolute path. The `[rust] features` list rides after cargo-mutants' `--` separator,
864/// which forwards it to the cargo build/test runs — so `#[cfg(feature = ...)]` code is
865/// compiled and its mutants exercised (#266).
866///
867/// cargo-mutants exits `0` when every mutant is caught and `2` when some survive (or
868/// time out / are unviable) — both are normal here, since survivors are the rule's
869/// *output*, not an error. Any other code (usage error, or a baseline that didn't
870/// build/pass) is fatal. The outer instrumentation env is stripped so a nested run (this
871/// rule's own tests under `cargo llvm-cov`) doesn't re-enter the rustc wrapper and hang.
872fn run_cargo_mutants(
873    engine: &Path,
874    root: &Path,
875    out: &Path,
876    in_diff: Option<&Path>,
877    features: &[String],
878) -> Result<()> {
879    let mut command = Command::new(engine);
880    command
881        .current_dir(root)
882        .arg("mutants")
883        .arg("--output")
884        .arg(out);
885    if let Some(diff) = in_diff {
886        command.arg("--in-diff").arg(diff);
887    }
888    if !features.is_empty() {
889        command.args(["--", "--features"]).arg(features.join(","));
890    }
891    strip_llvm_cov_env(&mut command);
892    let output = command.output().context("running cargo-mutants")?;
893    match output.status.code() {
894        // 0 = all caught, 2 = some survived/timed out: both produce a report to read.
895        Some(0) | Some(2) => Ok(()),
896        _ => bail!(
897            "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
898            root.display(),
899            String::from_utf8_lossy(&output.stdout),
900            String::from_utf8_lossy(&output.stderr),
901        ),
902    }
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908
909    // --- normalized results (#239): the engine-agnostic schema + core gate ---
910
911    // A normalized result set covering every status: two survivors (Survived + NoCoverage),
912    // a caught Killed, an inconclusive-but-viable Timeout, and two unviable mutants
913    // (CompileError / RuntimeError). `snake_case` on the wire; an extra field is ignored.
914    const NORMALIZED: &str = r#"[
915        {"file": "src/a.ts", "line": 2, "status": "survived",
916         "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
917        {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
918        {"file": "src/a.ts", "line": 9, "status": "killed",
919         "mutator": "BooleanLiteral", "replacement": "false"},
920        {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
921        {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
922        {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
923    ]"#;
924
925    #[test]
926    fn parses_the_normalized_schema() {
927        let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
928        assert_eq!(mutants.len(), 6);
929        assert_eq!(mutants[0].status, MutantStatus::Survived);
930        assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
931        assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
932        assert_eq!(mutants[1].replacement, None);
933    }
934
935    #[test]
936    fn normalized_survivors_are_survived_and_nocoverage_only() {
937        let mutants = parse_normalized_results(NORMALIZED).unwrap();
938        let survivors = normalized_survivors(&mutants);
939        // Survived (2) + NoCoverage (5); not killed/timeout/compile/runtime.
940        assert_eq!(survivors.len(), 2);
941        assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
942        // Replacement is folded into the description when present, omitted otherwise.
943        assert!(survivors[0].description.contains("ConditionalExpression"));
944        assert!(survivors[0].description.contains("-> true"));
945        assert_eq!(survivors[1].description, "ArithmeticOperator");
946    }
947
948    #[test]
949    fn normalized_mutated_lines_collects_only_viable_mutants() {
950        let mutants = parse_normalized_results(NORMALIZED).unwrap();
951        // Survived/Killed/NoCoverage/Timeout ran; CompileError/RuntimeError never produced
952        // a viable mutant.
953        assert_eq!(
954            normalized_mutated_lines(&mutants),
955            [2u32, 5, 9, 12]
956                .into_iter()
957                .map(|line| ("src/a.ts".to_string(), line))
958                .collect()
959        );
960    }
961
962    #[test]
963    fn evaluate_normalized_reports_unexempted_survivors() {
964        let mutants = parse_normalized_results(NORMALIZED).unwrap();
965        let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
966        assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
967    }
968
969    #[test]
970    fn evaluate_normalized_drops_a_whole_file_exemption() {
971        let mutants = parse_normalized_results(NORMALIZED).unwrap();
972        let kept =
973            evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
974        assert!(
975            kept.is_empty(),
976            "the whole-file exemption lifts both survivors"
977        );
978    }
979
980    #[test]
981    fn evaluate_normalized_drops_a_line_scoped_exemption() {
982        let mutants = parse_normalized_results(NORMALIZED).unwrap();
983        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
984        let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
985        // Line 2's survivor is lifted; line 5's still stands.
986        assert_eq!(kept.len(), 1);
987        assert_eq!(kept[0].line, 5);
988    }
989
990    #[test]
991    fn evaluate_normalized_rejects_exempting_a_caught_line() {
992        // Line 9 had only a Killed mutant (viable, no survivor) — over-exemption is an error,
993        // via the shared #226 determinism guard.
994        let mutants = parse_normalized_results(NORMALIZED).unwrap();
995        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
996        let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
997        assert!(
998            err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
999            "got: {err}"
1000        );
1001    }
1002
1003    #[test]
1004    fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
1005        // Line 15 had only a CompileError (no viable mutant) — neither an error nor a drop;
1006        // the real survivors still stand.
1007        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1008        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
1009        let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1010        assert_eq!(kept.len(), 2);
1011    }
1012
1013    // A pared `outcomes.json`: the baseline, one missed mutant, and one caught — the
1014    // real shape (externally-tagged `scenario`, extra fields the rule ignores).
1015    const SAMPLE: &str = r#"{
1016        "outcomes": [
1017            {"scenario": "Baseline", "summary": "Success",
1018             "phase_results": []},
1019            {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
1020                "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
1021                "function": {"function_name": "is_positive"},
1022                "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
1023             "summary": "MissedMutant"},
1024            {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
1025                "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
1026                "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
1027             "summary": "CaughtMutant"}
1028        ],
1029        "total_mutants": 2
1030    }"#;
1031
1032    #[test]
1033    fn parses_the_outcomes_export() {
1034        let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
1035        assert_eq!(report.outcomes.len(), 3);
1036        assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
1037    }
1038
1039    #[test]
1040    fn collects_only_missed_mutants_as_survivors() {
1041        let report = parse_mutants_report(SAMPLE).unwrap();
1042        let survivors = unexplained_survivors(&report, &[]);
1043        // Only the MissedMutant — the baseline and the CaughtMutant are not survivors.
1044        assert_eq!(survivors.len(), 1);
1045        assert_eq!(survivors[0].file, "src/lib.rs");
1046        assert_eq!(survivors[0].line, 7);
1047        assert!(survivors[0].description.contains("replace > with =="));
1048    }
1049
1050    #[test]
1051    fn an_exemption_drops_a_survivor_in_that_file() {
1052        let report = parse_mutants_report(SAMPLE).unwrap();
1053        let exempt = vec!["src/lib.rs".to_string()];
1054        assert!(unexplained_survivors(&report, &exempt).is_empty());
1055    }
1056
1057    #[test]
1058    fn an_exemption_on_another_file_leaves_the_survivor() {
1059        let report = parse_mutants_report(SAMPLE).unwrap();
1060        let exempt = vec!["src/elsewhere.rs".to_string()];
1061        assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1062    }
1063
1064    #[test]
1065    fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
1066        assert!(is_mutatable_ts("src/index.ts"));
1067        assert!(is_mutatable_ts("src/util.tsx"));
1068        assert!(is_mutatable_ts("src/util.js"));
1069        assert!(!is_mutatable_ts("src/index.test.ts"));
1070        assert!(!is_mutatable_ts("src/index.spec.ts"));
1071        assert!(!is_mutatable_ts("src/types.d.ts"));
1072        assert!(!is_mutatable_ts("README.md"));
1073    }
1074
1075    #[test]
1076    fn contiguous_runs_collapses_adjacent_lines() {
1077        let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
1078        assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
1079        assert!(contiguous_runs(&BTreeSet::new()).is_empty());
1080    }
1081
1082    #[test]
1083    fn one_line_flattens_and_caps() {
1084        assert_eq!(one_line("a -\n  b"), "a - b");
1085        let long = "x".repeat(80);
1086        let capped = one_line(&long);
1087        assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
1088    }
1089
1090    #[test]
1091    fn is_mutatable_py_keeps_sources_and_drops_tests() {
1092        assert!(is_mutatable_py("calc.py"));
1093        assert!(is_mutatable_py("pkg/util.py"));
1094        assert!(!is_mutatable_py("calc_test.py"));
1095        assert!(!is_mutatable_py("test_calc.py"));
1096        assert!(!is_mutatable_py("pkg/conftest.py"));
1097        assert!(!is_mutatable_py("README.md"));
1098    }
1099
1100    // --- line-scoped exemptions (#226) ---
1101
1102    #[test]
1103    fn mutated_lines_collects_caught_and_missed() {
1104        // The MissedMutant (src/lib.rs:7) and the CaughtMutant (src/other.rs:3) are both
1105        // viable, conclusive mutants; the Baseline is not.
1106        let report = parse_mutants_report(SAMPLE).unwrap();
1107        assert_eq!(
1108            mutated_lines(&report),
1109            [
1110                ("src/lib.rs".to_string(), 7),
1111                ("src/other.rs".to_string(), 3)
1112            ]
1113            .into_iter()
1114            .collect()
1115        );
1116    }
1117
1118    #[test]
1119    fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1120        let report = parse_mutants_report(SAMPLE).unwrap();
1121        let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1122        let kept = evaluate_scoped(
1123            cargo_mutants_survivors(&report),
1124            &mutated_lines(&report),
1125            &[],
1126            &line_scoped,
1127        )
1128        .unwrap();
1129        assert!(
1130            kept.is_empty(),
1131            "the src/lib.rs:7 survivor should be lifted"
1132        );
1133    }
1134
1135    #[test]
1136    fn evaluate_scoped_rejects_exempting_a_caught_line() {
1137        // src/other.rs:3 had only a caught mutant (no survivor) — over-exemption.
1138        let report = parse_mutants_report(SAMPLE).unwrap();
1139        let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
1140        let err = evaluate_scoped(
1141            cargo_mutants_survivors(&report),
1142            &mutated_lines(&report),
1143            &[],
1144            &line_scoped,
1145        )
1146        .unwrap_err();
1147        assert!(
1148            err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
1149            "got: {err}"
1150        );
1151    }
1152
1153    #[test]
1154    fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
1155        // Line 99 has no mutant at all (e.g. outside a `--base` diff) — neither an error
1156        // nor a drop; the real survivor on line 7 still stands.
1157        let report = parse_mutants_report(SAMPLE).unwrap();
1158        let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
1159        let kept = evaluate_scoped(
1160            cargo_mutants_survivors(&report),
1161            &mutated_lines(&report),
1162            &[],
1163            &line_scoped,
1164        )
1165        .unwrap();
1166        assert_eq!(kept.len(), 1);
1167        assert_eq!(kept[0].line, 7);
1168    }
1169
1170    #[test]
1171    fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1172        let report = parse_mutants_report(SAMPLE).unwrap();
1173        let kept = evaluate_scoped(
1174            cargo_mutants_survivors(&report),
1175            &mutated_lines(&report),
1176            &["src/lib.rs".to_string()],
1177            &BTreeMap::new(),
1178        )
1179        .unwrap();
1180        assert!(kept.is_empty());
1181    }
1182
1183    // --- engine provisioning (#242) ---
1184
1185    fn unique_tmp() -> PathBuf {
1186        static COUNTER: AtomicU64 = AtomicU64::new(0);
1187        let dir = std::env::temp_dir().join(format!(
1188            "tc-provision-test-{}-{}",
1189            std::process::id(),
1190            COUNTER.fetch_add(1, Ordering::Relaxed)
1191        ));
1192        std::fs::create_dir_all(&dir).unwrap();
1193        dir
1194    }
1195
1196    #[test]
1197    fn provision_returns_an_existing_binary_without_installing() {
1198        let tmp = unique_tmp();
1199        let bin = tmp.join("bin").join("cargo-mutants");
1200        let lock = tmp.join(".install.lock");
1201        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1202        std::fs::write(&bin, b"binary").unwrap();
1203        let mut installed = false;
1204        let got = provision(&bin, &lock, || {
1205            installed = true;
1206            Ok(())
1207        })
1208        .unwrap();
1209        assert_eq!(got, bin);
1210        assert!(!installed, "a present binary must not be reinstalled");
1211        std::fs::remove_dir_all(&tmp).unwrap();
1212    }
1213
1214    #[test]
1215    fn provision_installs_when_the_binary_is_absent() {
1216        let tmp = unique_tmp();
1217        let bin = tmp.join("bin").join("cargo-mutants");
1218        let lock = tmp.join(".install.lock");
1219        let mut installed = false;
1220        let got = provision(&bin, &lock, || {
1221            installed = true;
1222            std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1223            std::fs::write(&bin, b"binary").unwrap();
1224            Ok(())
1225        })
1226        .unwrap();
1227        assert!(installed, "an absent binary must be installed");
1228        assert_eq!(got, bin);
1229        std::fs::remove_dir_all(&tmp).unwrap();
1230    }
1231
1232    #[test]
1233    fn provision_errors_when_install_produces_no_binary() {
1234        let tmp = unique_tmp();
1235        let bin = tmp.join("bin").join("cargo-mutants");
1236        let lock = tmp.join(".install.lock");
1237        let err = provision(&bin, &lock, || Ok(())).unwrap_err();
1238        assert!(
1239            err.to_string().contains("cargo-mutants is not at"),
1240            "got: {err}"
1241        );
1242        std::fs::remove_dir_all(&tmp).unwrap();
1243    }
1244
1245    #[test]
1246    fn provision_propagates_an_install_failure() {
1247        let tmp = unique_tmp();
1248        let bin = tmp.join("bin").join("cargo-mutants");
1249        let lock = tmp.join(".install.lock");
1250        let err = provision(&bin, &lock, || bail!("install blew up")).unwrap_err();
1251        assert!(err.to_string().contains("install blew up"), "got: {err}");
1252        std::fs::remove_dir_all(&tmp).unwrap();
1253    }
1254
1255    #[test]
1256    fn provision_does_not_duplicate_the_install_under_concurrent_callers() {
1257        // #385: on a cold cache, N concurrent callers must share one install, not each run
1258        // their own — cargo-mutants' from-source compile duplicated N times (instead of once)
1259        // is what turned a ~1-minute cold-cache cost into ~7 minutes under nextest (#370). A
1260        // barrier forces both threads to observe the absent binary together, and the install
1261        // closure sleeps briefly to widen the race window so this reproduces deterministically
1262        // rather than flakily.
1263        use std::sync::{Arc, Barrier};
1264        use std::thread;
1265        use std::time::Duration;
1266
1267        let tmp = unique_tmp();
1268        let bin = tmp.join("bin").join("cargo-mutants");
1269        let lock = tmp.join(".install.lock");
1270        let install_count = Arc::new(AtomicU64::new(0));
1271        let barrier = Arc::new(Barrier::new(2));
1272
1273        let handles: Vec<_> = (0..2)
1274            .map(|_| {
1275                let bin = bin.clone();
1276                let lock = lock.clone();
1277                let install_count = Arc::clone(&install_count);
1278                let barrier = Arc::clone(&barrier);
1279                thread::spawn(move || {
1280                    barrier.wait();
1281                    provision(&bin, &lock, || {
1282                        install_count.fetch_add(1, Ordering::SeqCst);
1283                        thread::sleep(Duration::from_millis(50));
1284                        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1285                        std::fs::write(&bin, b"binary").unwrap();
1286                        Ok(())
1287                    })
1288                })
1289            })
1290            .collect();
1291
1292        for h in handles {
1293            h.join()
1294                .expect("provisioning thread must not panic")
1295                .unwrap();
1296        }
1297
1298        assert_eq!(
1299            install_count.load(Ordering::SeqCst),
1300            1,
1301            "two concurrent callers on a cold cache must share one install, not each run their own"
1302        );
1303        std::fs::remove_dir_all(&tmp).unwrap();
1304    }
1305
1306    #[test]
1307    fn resolve_cache_base_prefers_xdg_then_home_then_temp() {
1308        let xdg = |s: &str| Some(OsString::from(s));
1309        // XDG wins when set and non-empty.
1310        assert_eq!(
1311            resolve_cache_base(xdg("/xdg"), xdg("/home")),
1312            PathBuf::from("/xdg")
1313        );
1314        // An empty XDG falls through to $HOME/.cache.
1315        assert_eq!(
1316            resolve_cache_base(xdg(""), xdg("/home")),
1317            PathBuf::from("/home/.cache")
1318        );
1319        // A missing XDG likewise uses $HOME/.cache.
1320        assert_eq!(
1321            resolve_cache_base(None, xdg("/home")),
1322            PathBuf::from("/home/.cache")
1323        );
1324        // Neither set → the temp dir.
1325        assert_eq!(resolve_cache_base(None, None), std::env::temp_dir());
1326        assert_eq!(
1327            resolve_cache_base(xdg(""), Some(OsString::new())),
1328            std::env::temp_dir()
1329        );
1330    }
1331
1332    #[test]
1333    fn cache_root_is_absolute_and_version_scoped() {
1334        let root = cargo_mutants_cache_root();
1335        assert!(
1336            root.ends_with(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}")),
1337            "version-scoped; got {root:?}"
1338        );
1339        assert!(
1340            root.to_string_lossy().contains("testing-conventions"),
1341            "tool-namespaced; got {root:?}"
1342        );
1343        // A real base dir (HOME/XDG in the test env) makes it absolute — not an empty path.
1344        assert!(
1345            root.is_absolute(),
1346            "expected an absolute path; got {root:?}"
1347        );
1348    }
1349
1350    #[test]
1351    fn install_argv_pins_the_version_and_isolates_the_root() {
1352        let argv: Vec<String> = install_argv(Path::new("/cache/cargo-mutants-27"))
1353            .iter()
1354            .map(|arg| arg.to_string_lossy().into_owned())
1355            .collect();
1356        assert_eq!(
1357            argv,
1358            vec![
1359                "install",
1360                "cargo-mutants",
1361                "--locked",
1362                "--version",
1363                CARGO_MUTANTS_VERSION,
1364                "--root",
1365                "/cache/cargo-mutants-27",
1366            ]
1367        );
1368    }
1369
1370    #[cfg(unix)]
1371    fn fake_output(code: i32, stderr: &str) -> Output {
1372        use std::os::unix::process::ExitStatusExt;
1373        Output {
1374            status: std::process::ExitStatus::from_raw(code << 8),
1375            stdout: Vec::new(),
1376            stderr: stderr.as_bytes().to_vec(),
1377        }
1378    }
1379
1380    #[cfg(unix)]
1381    #[test]
1382    fn run_install_succeeds_on_a_zero_exit() {
1383        let mut ran = false;
1384        run_install(Path::new("/cache/root"), |command| {
1385            ran = true;
1386            // The pinned argv reaches the runner.
1387            let argv: Vec<String> = command
1388                .get_args()
1389                .map(|arg| arg.to_string_lossy().into_owned())
1390                .collect();
1391            assert!(argv.contains(&CARGO_MUTANTS_VERSION.to_string()));
1392            Ok(fake_output(0, ""))
1393        })
1394        .unwrap();
1395        assert!(ran);
1396    }
1397
1398    #[cfg(unix)]
1399    #[test]
1400    fn run_install_reports_a_nonzero_exit_with_the_engine_output() {
1401        let err = run_install(Path::new("/cache/root"), |_| {
1402            Ok(fake_output(1, "error: could not compile cargo-mutants"))
1403        })
1404        .unwrap_err();
1405        assert!(
1406            err.to_string()
1407                .contains("failed to provision cargo-mutants")
1408                && err.to_string().contains("could not compile"),
1409            "got: {err}"
1410        );
1411    }
1412
1413    #[cfg(unix)]
1414    #[test]
1415    fn run_install_propagates_a_spawn_failure() {
1416        let err = run_install(Path::new("/cache/root"), |_| {
1417            Err(std::io::Error::new(
1418                std::io::ErrorKind::NotFound,
1419                "no cargo",
1420            ))
1421        })
1422        .unwrap_err();
1423        assert!(
1424            err.to_string().contains("is cargo installed?"),
1425            "got: {err}"
1426        );
1427    }
1428
1429    #[test]
1430    fn cargo_mutants_bin_name_matches_the_platform() {
1431        let name = cargo_mutants_bin_name();
1432        if cfg!(windows) {
1433            assert_eq!(name, "cargo-mutants.exe");
1434        } else {
1435            assert_eq!(name, "cargo-mutants");
1436        }
1437    }
1438}