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) -> Result<Vec<Survivor>> {
343    let out = MutantsOut::new();
344    let diff = match base {
345        // An empty diff (no changed lines under the crate — a PR that doesn't touch it)
346        // means nothing to mutate: no survivors, no cargo-mutants run.
347        Some(base) => match write_base_diff(root, base, &out)? {
348            None => return Ok(Vec::new()),
349            Some(path) => Some(path),
350        },
351        None => None,
352    };
353    let engine = ensure_cargo_mutants()?;
354    run_cargo_mutants(&engine, root, &out.0, diff.as_deref())?;
355    let outcomes = out.0.join("mutants.out").join("outcomes.json");
356    // cargo-mutants writes no `outcomes.json` when a run produces no mutants (e.g. an
357    // `--in-diff` that matches none of the crate's lines). `run_cargo_mutants` already
358    // bailed on a fatal exit, so a missing report here is "no mutants" → no survivors.
359    let json = match std::fs::read_to_string(&outcomes) {
360        Ok(json) => json,
361        Err(_) => return Ok(Vec::new()),
362    };
363    let report = parse_mutants_report(&json)?;
364    evaluate_scoped(
365        cargo_mutants_survivors(&report),
366        &mutated_lines(&report),
367        exempt,
368        exempt_lines,
369    )
370}
371
372/// Collapse a (possibly multi-line) replacement to a single trimmed line, capped, so a
373/// survivor's one-line description stays readable.
374fn one_line(replacement: &str) -> String {
375    let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
376    const MAX: usize = 60;
377    if flat.chars().count() > MAX {
378        format!("{}…", flat.chars().take(MAX).collect::<String>())
379    } else {
380        flat
381    }
382}
383
384/// Run the bundled TypeScript mutation adapter over the project at `root` and return its
385/// un-exempted survivors — the TS arm of the mutation rule (#202), parity with
386/// [`measure_rust`].
387///
388/// The consumer installs **nothing** Stryker-related: the npm package ships a Node
389/// adapter that drives Stryker through its own Node API and emits the engine-agnostic
390/// [`NormalizedMutant`] schema (#239), which this gates over via [`evaluate_normalized`]
391/// — the same core the Rust and Python arms feed. Only the project's own test runner
392/// (vitest) needs to be present, exactly as cargo-mutants needs a buildable crate and
393/// cosmic-ray needs pytest.
394///
395/// With `base` set, only mutants on the `<base>...HEAD` changed lines are tested —
396/// Stryker has no native git-diff mode, so the changed lines become `--mutate
397/// <file>:<line>-<line>` ranges (line granularity, matching cargo-mutants' `--in-diff`).
398/// Without it, the project's configured `mutate` set runs. `exempt` is the file-level
399/// exempt paths and `exempt_lines` the line-scoped ones (#226). `adapter` is the path to
400/// the bundled Node adapter (`dist/mutation/main.js`) — the CLI receives it from the npm
401/// launcher's `--ts-mutation-adapter` argument and hands it down explicitly.
402pub fn measure_typescript(
403    root: &Path,
404    exempt: &[String],
405    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
406    base: Option<&str>,
407    adapter: &Path,
408) -> Result<Vec<Survivor>> {
409    let mutate = match base {
410        Some(base) => {
411            let ranges = mutate_ranges(root, base)?;
412            // Nothing mutatable changed on the diff: no run, no survivors.
413            if ranges.is_empty() {
414                return Ok(Vec::new());
415            }
416            Some(ranges)
417        }
418        None => None,
419    };
420    let json = run_ts_adapter(root, adapter, mutate.as_deref())?;
421    let mutants = parse_normalized_results(&json)?;
422    evaluate_normalized(&mutants, exempt, exempt_lines)
423}
424
425/// Run the bundled TS mutation `adapter` over `root` and return the normalized-results JSON
426/// it writes. The adapter (a Node entry shipped with the npm package) drives Stryker via
427/// its Node API and emits a [`NormalizedMutant`] array (#239) — so the consumer drives the
428/// engine through this CLI alone; the npm package resolves `@stryker-mutator/*` from the
429/// tool's own tree.
430///
431/// `mutate`, when set, scopes the run to `--mutate` line ranges. Results are written to a
432/// temp file the adapter names via `--out` (so Stryker's own stdout logging can't corrupt
433/// them), then read back. `node` and the project's own test runner must be available; a
434/// non-zero adapter exit surfaces its captured output.
435fn run_ts_adapter(root: &Path, adapter: &Path, mutate: Option<&[String]>) -> Result<String> {
436    let out = AdapterOut::new();
437    std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
438    let results = out.0.join("results.json");
439
440    let mut command = Command::new("node");
441    command
442        .current_dir(root)
443        .arg(adapter)
444        .arg("--out")
445        .arg(&results);
446    if let Some(specs) = mutate {
447        command.arg("--mutate").arg(specs.join(","));
448    }
449    let output = command
450        .output()
451        .context("running the TypeScript mutation adapter (is `node` installed?)")?;
452    if !output.status.success() {
453        bail!(
454            "the TypeScript mutation adapter failed in `{}`:\n{}{}",
455            root.display(),
456            String::from_utf8_lossy(&output.stdout),
457            String::from_utf8_lossy(&output.stderr),
458        );
459    }
460    std::fs::read_to_string(&results).with_context(|| {
461        format!(
462            "reading the TypeScript mutation adapter's results from `{}`",
463            results.display()
464        )
465    })
466}
467
468/// A unique temp dir for one TS mutation adapter run's `--out` JSON, removed on drop so
469/// the scanned project stays pristine and parallel runs don't collide.
470struct AdapterOut(PathBuf);
471
472impl AdapterOut {
473    fn new() -> Self {
474        static COUNTER: AtomicU64 = AtomicU64::new(0);
475        let name = format!(
476            "testing-conventions-ts-adapter-{}-{}",
477            std::process::id(),
478            COUNTER.fetch_add(1, Ordering::Relaxed),
479        );
480        AdapterOut(std::env::temp_dir().join(name))
481    }
482}
483
484impl Drop for AdapterOut {
485    fn drop(&mut self) {
486        let _ = std::fs::remove_dir_all(&self.0);
487    }
488}
489
490/// Build the Stryker `--mutate` specs scoping a run to the `<base>...HEAD` changed
491/// lines: each mutatable source file's contiguous runs of changed lines become a
492/// `<file>:<start>-<end>` range (Stryker's line-range form). Reuses the patch-coverage
493/// diff parser. Test and declaration files are filtered out — Stryker's configured
494/// `mutate` set normally excludes them, but passing `--mutate` replaces that set.
495fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
496    let changed = crate::patch_coverage::changed_lines(root, base)?;
497    let mut specs = Vec::new();
498    for (file, lines) in changed {
499        if !is_mutatable_ts(&file) {
500            continue;
501        }
502        for (start, end) in contiguous_runs(&lines) {
503            specs.push(format!("{file}:{start}-{end}"));
504        }
505    }
506    Ok(specs)
507}
508
509/// Whether a changed file is a TypeScript/JavaScript *source* Stryker should mutate — a
510/// `.ts`/`.tsx`/`.mts`/`.cts`/`.js`/`.jsx`/`.mjs`/`.cjs` file that is not a declaration
511/// (`.d.ts`) or a test (`.test.` / `.spec.`).
512fn is_mutatable_ts(file: &str) -> bool {
513    let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
514        .iter()
515        .any(|ext| file.ends_with(ext));
516    let is_decl = file.ends_with(".d.ts");
517    let is_test = file.contains(".test.") || file.contains(".spec.");
518    is_source && !is_decl && !is_test
519}
520
521/// Fold a sorted set of line numbers into inclusive `(start, end)` contiguous runs.
522fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
523    let mut runs: Vec<(u64, u64)> = Vec::new();
524    for &line in lines {
525        match runs.last_mut() {
526            Some(run) if run.1 + 1 == line => run.1 = line,
527            _ => runs.push((line, line)),
528        }
529    }
530    runs
531}
532
533/// Run the bundled Python mutation adapter over the project at `root` and return its
534/// un-exempted survivors — the Python arm of the mutation rule (#203 / #248), parity with
535/// [`measure_rust`] and [`measure_typescript`].
536///
537/// The tool drives the engine: the wheel ships a Python adapter that runs cosmic-ray through
538/// its own library API (`WorkDB`) and emits the normalized [`NormalizedMutant`] schema (#239)
539/// the gate consumes. maturin (`bindings = "bin"`) ships the rust binary directly as the wheel's
540/// script — with no Python launcher to inject a path, unlike the TS arm — so the binary invokes
541/// the adapter as an installed module (`python3 -m testing_conventions.mutation.main`), resolved
542/// from the wheel's environment alongside cosmic-ray. The project supplies its own test runner
543/// (pytest), exactly as cargo-mutants needs a buildable crate and Stryker needs vitest.
544///
545/// With `base` set, only mutants on the `<base>...HEAD` changed lines are reported: cosmic-ray
546/// has no native git-diff mode, so the run is scoped to the changed `.py` files (passed as
547/// `--module`) and the survivors are filtered to the changed lines in the core — line
548/// granularity, matching the other arms. Without it, the whole project's sources run (tests
549/// excluded). `exempt` is the file-level exempt paths and `exempt_lines` the line-scoped ones
550/// (#226).
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 (#248): 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 (#248) and emits a
592/// [`NormalizedMutant`] array (#239). `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 (the #242 / #239 contract):
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    provision(&bin, || run_install(&root, |command| command.output()))
708}
709
710/// The cargo-mutants binary's file name (`.exe` on Windows), as `cargo install --root`
711/// lays it out under `<root>/bin/`.
712fn cargo_mutants_bin_name() -> &'static str {
713    if cfg!(windows) {
714        "cargo-mutants.exe"
715    } else {
716        "cargo-mutants"
717    }
718}
719
720/// The tool-owned, version-scoped cache directory cargo-mutants is installed under, so a
721/// version bump provisions cleanly beside the old one and never clobbers a user's own
722/// `~/.cargo/bin`.
723fn cargo_mutants_cache_root() -> PathBuf {
724    cache_base()
725        .join("testing-conventions")
726        .join(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}"))
727}
728
729/// The base cache directory, read from OS-owned config. Split from [`resolve_cache_base`]
730/// so the resolution logic is unit-tested without touching the process environment.
731fn cache_base() -> PathBuf {
732    resolve_cache_base(std::env::var_os("XDG_CACHE_HOME"), std::env::var_os("HOME"))
733}
734
735/// Resolve the base cache dir: `XDG_CACHE_HOME` when set and non-empty, else `$HOME/.cache`,
736/// else the temp dir. Pure over its inputs.
737fn resolve_cache_base(xdg: Option<OsString>, home: Option<OsString>) -> PathBuf {
738    if let Some(dir) = xdg.filter(|value| !value.is_empty()) {
739        return PathBuf::from(dir);
740    }
741    if let Some(dir) = home.filter(|value| !value.is_empty()) {
742        return PathBuf::from(dir).join(".cache");
743    }
744    std::env::temp_dir()
745}
746
747/// Return `bin` if it already exists, otherwise run `install` and return `bin` once it does.
748/// Pure over the filesystem plus the injected installer, so a test drives every branch with
749/// a temp path and a fake installer (no from-source compile). An installer that reports
750/// success but produces no binary is an error.
751fn provision(bin: &Path, install: impl FnOnce() -> Result<()>) -> Result<PathBuf> {
752    if bin.exists() {
753        return Ok(bin.to_path_buf());
754    }
755    install()?;
756    if !bin.exists() {
757        bail!(
758            "provisioning reported success but cargo-mutants is not at `{}`",
759            bin.display()
760        );
761    }
762    Ok(bin.to_path_buf())
763}
764
765/// The argv provisioning the pinned cargo-mutants into `root` (`cargo install cargo-mutants
766/// --locked --version <X> --root <root>`). Split from execution so a test asserts the pin
767/// and the isolated `--root` without a real install.
768fn install_argv(root: &Path) -> Vec<OsString> {
769    vec![
770        OsString::from("install"),
771        OsString::from("cargo-mutants"),
772        OsString::from("--locked"),
773        OsString::from("--version"),
774        OsString::from(CARGO_MUTANTS_VERSION),
775        OsString::from("--root"),
776        root.as_os_str().to_os_string(),
777    ]
778}
779
780/// Provision cargo-mutants into `root`, executing the built `cargo install` command with
781/// `run`. `run` is injected so a test drives the success and failure branches with a fake
782/// (no from-source compile). The coverage-instrumentation env is stripped so the compile
783/// doesn't re-enter a `cargo llvm-cov` rustc wrapper.
784fn run_install(
785    root: &Path,
786    run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
787) -> Result<()> {
788    let mut command = Command::new("cargo");
789    command.args(install_argv(root));
790    strip_llvm_cov_env(&mut command);
791    let output = run(&mut command)
792        .context("provisioning cargo-mutants via `cargo install` (is cargo installed?)")?;
793    if !output.status.success() {
794        bail!(
795            "failed to provision cargo-mutants {CARGO_MUTANTS_VERSION}:\n{}{}",
796            String::from_utf8_lossy(&output.stdout),
797            String::from_utf8_lossy(&output.stderr),
798        );
799    }
800    Ok(())
801}
802
803/// Strip the outer coverage-instrumentation env from a nested cargo invocation (the
804/// cargo-mutants run, or the `cargo install` that provisions it) so it doesn't re-enter the
805/// `cargo llvm-cov` rustc wrapper and hang, as when this rule's own tests run under coverage.
806fn strip_llvm_cov_env(command: &mut Command) {
807    for var in [
808        "RUSTFLAGS",
809        "CARGO_ENCODED_RUSTFLAGS",
810        "RUSTDOCFLAGS",
811        "CARGO_ENCODED_RUSTDOCFLAGS",
812        "LLVM_PROFILE_FILE",
813        "CARGO_LLVM_COV",
814        "CARGO_LLVM_COV_SHOW_ENV",
815        "CARGO_LLVM_COV_TARGET_DIR",
816        "CARGO_LLVM_COV_BUILD_DIR",
817        "RUSTC_WRAPPER",
818        "RUSTC_WORKSPACE_WRAPPER",
819        "__CARGO_LLVM_COV_RUSTC_WRAPPER",
820        "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
821        "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
822    ] {
823        command.env_remove(var);
824    }
825}
826
827/// Run `<engine> mutants --output <out> [--in-diff <diff>]` in `root`, where `engine` is the
828/// provisioned cargo-mutants binary ([`ensure_cargo_mutants`]) invoked by absolute path.
829///
830/// cargo-mutants exits `0` when every mutant is caught and `2` when some survive (or
831/// time out / are unviable) — both are normal here, since survivors are the rule's
832/// *output*, not an error. Any other code (usage error, or a baseline that didn't
833/// build/pass) is fatal. The outer instrumentation env is stripped so a nested run (this
834/// rule's own tests under `cargo llvm-cov`) doesn't re-enter the rustc wrapper and hang.
835fn run_cargo_mutants(engine: &Path, root: &Path, out: &Path, in_diff: Option<&Path>) -> Result<()> {
836    let mut command = Command::new(engine);
837    command
838        .current_dir(root)
839        .arg("mutants")
840        .arg("--output")
841        .arg(out);
842    if let Some(diff) = in_diff {
843        command.arg("--in-diff").arg(diff);
844    }
845    strip_llvm_cov_env(&mut command);
846    let output = command.output().context("running cargo-mutants")?;
847    match output.status.code() {
848        // 0 = all caught, 2 = some survived/timed out: both produce a report to read.
849        Some(0) | Some(2) => Ok(()),
850        _ => bail!(
851            "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
852            root.display(),
853            String::from_utf8_lossy(&output.stdout),
854            String::from_utf8_lossy(&output.stderr),
855        ),
856    }
857}
858
859#[cfg(test)]
860mod tests {
861    use super::*;
862
863    // --- normalized results (#239): the engine-agnostic schema + core gate ---
864
865    // A normalized result set covering every status: two survivors (Survived + NoCoverage),
866    // a caught Killed, an inconclusive-but-viable Timeout, and two unviable mutants
867    // (CompileError / RuntimeError). `snake_case` on the wire; an extra field is ignored.
868    const NORMALIZED: &str = r#"[
869        {"file": "src/a.ts", "line": 2, "status": "survived",
870         "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
871        {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
872        {"file": "src/a.ts", "line": 9, "status": "killed",
873         "mutator": "BooleanLiteral", "replacement": "false"},
874        {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
875        {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
876        {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
877    ]"#;
878
879    #[test]
880    fn parses_the_normalized_schema() {
881        let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
882        assert_eq!(mutants.len(), 6);
883        assert_eq!(mutants[0].status, MutantStatus::Survived);
884        assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
885        assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
886        assert_eq!(mutants[1].replacement, None);
887    }
888
889    #[test]
890    fn normalized_survivors_are_survived_and_nocoverage_only() {
891        let mutants = parse_normalized_results(NORMALIZED).unwrap();
892        let survivors = normalized_survivors(&mutants);
893        // Survived (2) + NoCoverage (5); not killed/timeout/compile/runtime.
894        assert_eq!(survivors.len(), 2);
895        assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
896        // Replacement is folded into the description when present, omitted otherwise.
897        assert!(survivors[0].description.contains("ConditionalExpression"));
898        assert!(survivors[0].description.contains("-> true"));
899        assert_eq!(survivors[1].description, "ArithmeticOperator");
900    }
901
902    #[test]
903    fn normalized_mutated_lines_collects_only_viable_mutants() {
904        let mutants = parse_normalized_results(NORMALIZED).unwrap();
905        // Survived/Killed/NoCoverage/Timeout ran; CompileError/RuntimeError never produced
906        // a viable mutant.
907        assert_eq!(
908            normalized_mutated_lines(&mutants),
909            [2u32, 5, 9, 12]
910                .into_iter()
911                .map(|line| ("src/a.ts".to_string(), line))
912                .collect()
913        );
914    }
915
916    #[test]
917    fn evaluate_normalized_reports_unexempted_survivors() {
918        let mutants = parse_normalized_results(NORMALIZED).unwrap();
919        let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
920        assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
921    }
922
923    #[test]
924    fn evaluate_normalized_drops_a_whole_file_exemption() {
925        let mutants = parse_normalized_results(NORMALIZED).unwrap();
926        let kept =
927            evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
928        assert!(
929            kept.is_empty(),
930            "the whole-file exemption lifts both survivors"
931        );
932    }
933
934    #[test]
935    fn evaluate_normalized_drops_a_line_scoped_exemption() {
936        let mutants = parse_normalized_results(NORMALIZED).unwrap();
937        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
938        let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
939        // Line 2's survivor is lifted; line 5's still stands.
940        assert_eq!(kept.len(), 1);
941        assert_eq!(kept[0].line, 5);
942    }
943
944    #[test]
945    fn evaluate_normalized_rejects_exempting_a_caught_line() {
946        // Line 9 had only a Killed mutant (viable, no survivor) — over-exemption is an error,
947        // via the shared #226 determinism guard.
948        let mutants = parse_normalized_results(NORMALIZED).unwrap();
949        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
950        let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
951        assert!(
952            err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
953            "got: {err}"
954        );
955    }
956
957    #[test]
958    fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
959        // Line 15 had only a CompileError (no viable mutant) — neither an error nor a drop;
960        // the real survivors still stand.
961        let mutants = parse_normalized_results(NORMALIZED).unwrap();
962        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
963        let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
964        assert_eq!(kept.len(), 2);
965    }
966
967    // A pared `outcomes.json`: the baseline, one missed mutant, and one caught — the
968    // real shape (externally-tagged `scenario`, extra fields the rule ignores).
969    const SAMPLE: &str = r#"{
970        "outcomes": [
971            {"scenario": "Baseline", "summary": "Success",
972             "phase_results": []},
973            {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
974                "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
975                "function": {"function_name": "is_positive"},
976                "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
977             "summary": "MissedMutant"},
978            {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
979                "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
980                "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
981             "summary": "CaughtMutant"}
982        ],
983        "total_mutants": 2
984    }"#;
985
986    #[test]
987    fn parses_the_outcomes_export() {
988        let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
989        assert_eq!(report.outcomes.len(), 3);
990        assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
991    }
992
993    #[test]
994    fn collects_only_missed_mutants_as_survivors() {
995        let report = parse_mutants_report(SAMPLE).unwrap();
996        let survivors = unexplained_survivors(&report, &[]);
997        // Only the MissedMutant — the baseline and the CaughtMutant are not survivors.
998        assert_eq!(survivors.len(), 1);
999        assert_eq!(survivors[0].file, "src/lib.rs");
1000        assert_eq!(survivors[0].line, 7);
1001        assert!(survivors[0].description.contains("replace > with =="));
1002    }
1003
1004    #[test]
1005    fn an_exemption_drops_a_survivor_in_that_file() {
1006        let report = parse_mutants_report(SAMPLE).unwrap();
1007        let exempt = vec!["src/lib.rs".to_string()];
1008        assert!(unexplained_survivors(&report, &exempt).is_empty());
1009    }
1010
1011    #[test]
1012    fn an_exemption_on_another_file_leaves_the_survivor() {
1013        let report = parse_mutants_report(SAMPLE).unwrap();
1014        let exempt = vec!["src/elsewhere.rs".to_string()];
1015        assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1016    }
1017
1018    #[test]
1019    fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
1020        assert!(is_mutatable_ts("src/index.ts"));
1021        assert!(is_mutatable_ts("src/util.tsx"));
1022        assert!(is_mutatable_ts("src/util.js"));
1023        assert!(!is_mutatable_ts("src/index.test.ts"));
1024        assert!(!is_mutatable_ts("src/index.spec.ts"));
1025        assert!(!is_mutatable_ts("src/types.d.ts"));
1026        assert!(!is_mutatable_ts("README.md"));
1027    }
1028
1029    #[test]
1030    fn contiguous_runs_collapses_adjacent_lines() {
1031        let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
1032        assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
1033        assert!(contiguous_runs(&BTreeSet::new()).is_empty());
1034    }
1035
1036    #[test]
1037    fn one_line_flattens_and_caps() {
1038        assert_eq!(one_line("a -\n  b"), "a - b");
1039        let long = "x".repeat(80);
1040        let capped = one_line(&long);
1041        assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
1042    }
1043
1044    #[test]
1045    fn is_mutatable_py_keeps_sources_and_drops_tests() {
1046        assert!(is_mutatable_py("calc.py"));
1047        assert!(is_mutatable_py("pkg/util.py"));
1048        assert!(!is_mutatable_py("calc_test.py"));
1049        assert!(!is_mutatable_py("test_calc.py"));
1050        assert!(!is_mutatable_py("pkg/conftest.py"));
1051        assert!(!is_mutatable_py("README.md"));
1052    }
1053
1054    // --- line-scoped exemptions (#226) ---
1055
1056    #[test]
1057    fn mutated_lines_collects_caught_and_missed() {
1058        // The MissedMutant (src/lib.rs:7) and the CaughtMutant (src/other.rs:3) are both
1059        // viable, conclusive mutants; the Baseline is not.
1060        let report = parse_mutants_report(SAMPLE).unwrap();
1061        assert_eq!(
1062            mutated_lines(&report),
1063            [
1064                ("src/lib.rs".to_string(), 7),
1065                ("src/other.rs".to_string(), 3)
1066            ]
1067            .into_iter()
1068            .collect()
1069        );
1070    }
1071
1072    #[test]
1073    fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1074        let report = parse_mutants_report(SAMPLE).unwrap();
1075        let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1076        let kept = evaluate_scoped(
1077            cargo_mutants_survivors(&report),
1078            &mutated_lines(&report),
1079            &[],
1080            &line_scoped,
1081        )
1082        .unwrap();
1083        assert!(
1084            kept.is_empty(),
1085            "the src/lib.rs:7 survivor should be lifted"
1086        );
1087    }
1088
1089    #[test]
1090    fn evaluate_scoped_rejects_exempting_a_caught_line() {
1091        // src/other.rs:3 had only a caught mutant (no survivor) — over-exemption.
1092        let report = parse_mutants_report(SAMPLE).unwrap();
1093        let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
1094        let err = evaluate_scoped(
1095            cargo_mutants_survivors(&report),
1096            &mutated_lines(&report),
1097            &[],
1098            &line_scoped,
1099        )
1100        .unwrap_err();
1101        assert!(
1102            err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
1103            "got: {err}"
1104        );
1105    }
1106
1107    #[test]
1108    fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
1109        // Line 99 has no mutant at all (e.g. outside a `--base` diff) — neither an error
1110        // nor a drop; the real survivor on line 7 still stands.
1111        let report = parse_mutants_report(SAMPLE).unwrap();
1112        let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
1113        let kept = evaluate_scoped(
1114            cargo_mutants_survivors(&report),
1115            &mutated_lines(&report),
1116            &[],
1117            &line_scoped,
1118        )
1119        .unwrap();
1120        assert_eq!(kept.len(), 1);
1121        assert_eq!(kept[0].line, 7);
1122    }
1123
1124    #[test]
1125    fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1126        let report = parse_mutants_report(SAMPLE).unwrap();
1127        let kept = evaluate_scoped(
1128            cargo_mutants_survivors(&report),
1129            &mutated_lines(&report),
1130            &["src/lib.rs".to_string()],
1131            &BTreeMap::new(),
1132        )
1133        .unwrap();
1134        assert!(kept.is_empty());
1135    }
1136
1137    // --- engine provisioning (#242) ---
1138
1139    fn unique_tmp() -> PathBuf {
1140        static COUNTER: AtomicU64 = AtomicU64::new(0);
1141        let dir = std::env::temp_dir().join(format!(
1142            "tc-provision-test-{}-{}",
1143            std::process::id(),
1144            COUNTER.fetch_add(1, Ordering::Relaxed)
1145        ));
1146        std::fs::create_dir_all(&dir).unwrap();
1147        dir
1148    }
1149
1150    #[test]
1151    fn provision_returns_an_existing_binary_without_installing() {
1152        let tmp = unique_tmp();
1153        let bin = tmp.join("bin").join("cargo-mutants");
1154        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1155        std::fs::write(&bin, b"binary").unwrap();
1156        let mut installed = false;
1157        let got = provision(&bin, || {
1158            installed = true;
1159            Ok(())
1160        })
1161        .unwrap();
1162        assert_eq!(got, bin);
1163        assert!(!installed, "a present binary must not be reinstalled");
1164        std::fs::remove_dir_all(&tmp).unwrap();
1165    }
1166
1167    #[test]
1168    fn provision_installs_when_the_binary_is_absent() {
1169        let tmp = unique_tmp();
1170        let bin = tmp.join("bin").join("cargo-mutants");
1171        let mut installed = false;
1172        let got = provision(&bin, || {
1173            installed = true;
1174            std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1175            std::fs::write(&bin, b"binary").unwrap();
1176            Ok(())
1177        })
1178        .unwrap();
1179        assert!(installed, "an absent binary must be installed");
1180        assert_eq!(got, bin);
1181        std::fs::remove_dir_all(&tmp).unwrap();
1182    }
1183
1184    #[test]
1185    fn provision_errors_when_install_produces_no_binary() {
1186        let tmp = unique_tmp();
1187        let bin = tmp.join("bin").join("cargo-mutants");
1188        let err = provision(&bin, || Ok(())).unwrap_err();
1189        assert!(
1190            err.to_string().contains("cargo-mutants is not at"),
1191            "got: {err}"
1192        );
1193        std::fs::remove_dir_all(&tmp).unwrap();
1194    }
1195
1196    #[test]
1197    fn provision_propagates_an_install_failure() {
1198        let tmp = unique_tmp();
1199        let bin = tmp.join("bin").join("cargo-mutants");
1200        let err = provision(&bin, || bail!("install blew up")).unwrap_err();
1201        assert!(err.to_string().contains("install blew up"), "got: {err}");
1202        std::fs::remove_dir_all(&tmp).unwrap();
1203    }
1204
1205    #[test]
1206    fn resolve_cache_base_prefers_xdg_then_home_then_temp() {
1207        let xdg = |s: &str| Some(OsString::from(s));
1208        // XDG wins when set and non-empty.
1209        assert_eq!(
1210            resolve_cache_base(xdg("/xdg"), xdg("/home")),
1211            PathBuf::from("/xdg")
1212        );
1213        // An empty XDG falls through to $HOME/.cache.
1214        assert_eq!(
1215            resolve_cache_base(xdg(""), xdg("/home")),
1216            PathBuf::from("/home/.cache")
1217        );
1218        // A missing XDG likewise uses $HOME/.cache.
1219        assert_eq!(
1220            resolve_cache_base(None, xdg("/home")),
1221            PathBuf::from("/home/.cache")
1222        );
1223        // Neither set → the temp dir.
1224        assert_eq!(resolve_cache_base(None, None), std::env::temp_dir());
1225        assert_eq!(
1226            resolve_cache_base(xdg(""), Some(OsString::new())),
1227            std::env::temp_dir()
1228        );
1229    }
1230
1231    #[test]
1232    fn cache_root_is_absolute_and_version_scoped() {
1233        let root = cargo_mutants_cache_root();
1234        assert!(
1235            root.ends_with(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}")),
1236            "version-scoped; got {root:?}"
1237        );
1238        assert!(
1239            root.to_string_lossy().contains("testing-conventions"),
1240            "tool-namespaced; got {root:?}"
1241        );
1242        // A real base dir (HOME/XDG in the test env) makes it absolute — not an empty path.
1243        assert!(
1244            root.is_absolute(),
1245            "expected an absolute path; got {root:?}"
1246        );
1247    }
1248
1249    #[test]
1250    fn install_argv_pins_the_version_and_isolates_the_root() {
1251        let argv: Vec<String> = install_argv(Path::new("/cache/cargo-mutants-27"))
1252            .iter()
1253            .map(|arg| arg.to_string_lossy().into_owned())
1254            .collect();
1255        assert_eq!(
1256            argv,
1257            vec![
1258                "install",
1259                "cargo-mutants",
1260                "--locked",
1261                "--version",
1262                CARGO_MUTANTS_VERSION,
1263                "--root",
1264                "/cache/cargo-mutants-27",
1265            ]
1266        );
1267    }
1268
1269    #[cfg(unix)]
1270    fn fake_output(code: i32, stderr: &str) -> Output {
1271        use std::os::unix::process::ExitStatusExt;
1272        Output {
1273            status: std::process::ExitStatus::from_raw(code << 8),
1274            stdout: Vec::new(),
1275            stderr: stderr.as_bytes().to_vec(),
1276        }
1277    }
1278
1279    #[cfg(unix)]
1280    #[test]
1281    fn run_install_succeeds_on_a_zero_exit() {
1282        let mut ran = false;
1283        run_install(Path::new("/cache/root"), |command| {
1284            ran = true;
1285            // The pinned argv reaches the runner.
1286            let argv: Vec<String> = command
1287                .get_args()
1288                .map(|arg| arg.to_string_lossy().into_owned())
1289                .collect();
1290            assert!(argv.contains(&CARGO_MUTANTS_VERSION.to_string()));
1291            Ok(fake_output(0, ""))
1292        })
1293        .unwrap();
1294        assert!(ran);
1295    }
1296
1297    #[cfg(unix)]
1298    #[test]
1299    fn run_install_reports_a_nonzero_exit_with_the_engine_output() {
1300        let err = run_install(Path::new("/cache/root"), |_| {
1301            Ok(fake_output(1, "error: could not compile cargo-mutants"))
1302        })
1303        .unwrap_err();
1304        assert!(
1305            err.to_string()
1306                .contains("failed to provision cargo-mutants")
1307                && err.to_string().contains("could not compile"),
1308            "got: {err}"
1309        );
1310    }
1311
1312    #[cfg(unix)]
1313    #[test]
1314    fn run_install_propagates_a_spawn_failure() {
1315        let err = run_install(Path::new("/cache/root"), |_| {
1316            Err(std::io::Error::new(
1317                std::io::ErrorKind::NotFound,
1318                "no cargo",
1319            ))
1320        })
1321        .unwrap_err();
1322        assert!(
1323            err.to_string().contains("is cargo installed?"),
1324            "got: {err}"
1325        );
1326    }
1327
1328    #[test]
1329    fn cargo_mutants_bin_name_matches_the_platform() {
1330        let name = cargo_mutants_bin_name();
1331        if cfg!(windows) {
1332            assert_eq!(name, "cargo-mutants.exe");
1333        } else {
1334            assert_eq!(name, "cargo-mutants");
1335        }
1336    }
1337}