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    provision(&bin, || run_install(&root, |command| command.output()))
709}
710
711/// The cargo-mutants binary's file name (`.exe` on Windows), as `cargo install --root`
712/// lays it out under `<root>/bin/`.
713fn cargo_mutants_bin_name() -> &'static str {
714    if cfg!(windows) {
715        "cargo-mutants.exe"
716    } else {
717        "cargo-mutants"
718    }
719}
720
721/// The tool-owned, version-scoped cache directory cargo-mutants is installed under, so a
722/// version bump provisions cleanly beside the old one and never clobbers a user's own
723/// `~/.cargo/bin`.
724fn cargo_mutants_cache_root() -> PathBuf {
725    cache_base()
726        .join("testing-conventions")
727        .join(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}"))
728}
729
730/// The base cache directory, read from OS-owned config. Split from [`resolve_cache_base`]
731/// so the resolution logic is unit-tested without touching the process environment.
732fn cache_base() -> PathBuf {
733    resolve_cache_base(std::env::var_os("XDG_CACHE_HOME"), std::env::var_os("HOME"))
734}
735
736/// Resolve the base cache dir: `XDG_CACHE_HOME` when set and non-empty, else `$HOME/.cache`,
737/// else the temp dir. Pure over its inputs.
738fn resolve_cache_base(xdg: Option<OsString>, home: Option<OsString>) -> PathBuf {
739    if let Some(dir) = xdg.filter(|value| !value.is_empty()) {
740        return PathBuf::from(dir);
741    }
742    if let Some(dir) = home.filter(|value| !value.is_empty()) {
743        return PathBuf::from(dir).join(".cache");
744    }
745    std::env::temp_dir()
746}
747
748/// Return `bin` if it already exists, otherwise run `install` and return `bin` once it does.
749/// Pure over the filesystem plus the injected installer, so a test drives every branch with
750/// a temp path and a fake installer (no from-source compile). An installer that reports
751/// success but produces no binary is an error.
752fn provision(bin: &Path, install: impl FnOnce() -> Result<()>) -> Result<PathBuf> {
753    if bin.exists() {
754        return Ok(bin.to_path_buf());
755    }
756    install()?;
757    if !bin.exists() {
758        bail!(
759            "provisioning reported success but cargo-mutants is not at `{}`",
760            bin.display()
761        );
762    }
763    Ok(bin.to_path_buf())
764}
765
766/// The argv provisioning the pinned cargo-mutants into `root` (`cargo install cargo-mutants
767/// --locked --version <X> --root <root>`). Split from execution so a test asserts the pin
768/// and the isolated `--root` without a real install.
769fn install_argv(root: &Path) -> Vec<OsString> {
770    vec![
771        OsString::from("install"),
772        OsString::from("cargo-mutants"),
773        OsString::from("--locked"),
774        OsString::from("--version"),
775        OsString::from(CARGO_MUTANTS_VERSION),
776        OsString::from("--root"),
777        root.as_os_str().to_os_string(),
778    ]
779}
780
781/// Provision cargo-mutants into `root`, executing the built `cargo install` command with
782/// `run`. `run` is injected so a test drives the success and failure branches with a fake
783/// (no from-source compile). The coverage-instrumentation env is stripped so the compile
784/// doesn't re-enter a `cargo llvm-cov` rustc wrapper.
785fn run_install(
786    root: &Path,
787    run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
788) -> Result<()> {
789    let mut command = Command::new("cargo");
790    command.args(install_argv(root));
791    strip_llvm_cov_env(&mut command);
792    let output = run(&mut command)
793        .context("provisioning cargo-mutants via `cargo install` (is cargo installed?)")?;
794    if !output.status.success() {
795        bail!(
796            "failed to provision cargo-mutants {CARGO_MUTANTS_VERSION}:\n{}{}",
797            String::from_utf8_lossy(&output.stdout),
798            String::from_utf8_lossy(&output.stderr),
799        );
800    }
801    Ok(())
802}
803
804/// Strip the outer coverage-instrumentation env from a nested cargo invocation (the
805/// cargo-mutants run, or the `cargo install` that provisions it) so it doesn't re-enter the
806/// `cargo llvm-cov` rustc wrapper and hang, as when this rule's own tests run under coverage.
807fn strip_llvm_cov_env(command: &mut Command) {
808    for var in [
809        "RUSTFLAGS",
810        "CARGO_ENCODED_RUSTFLAGS",
811        "RUSTDOCFLAGS",
812        "CARGO_ENCODED_RUSTDOCFLAGS",
813        "LLVM_PROFILE_FILE",
814        "CARGO_LLVM_COV",
815        "CARGO_LLVM_COV_SHOW_ENV",
816        "CARGO_LLVM_COV_TARGET_DIR",
817        "CARGO_LLVM_COV_BUILD_DIR",
818        "RUSTC_WRAPPER",
819        "RUSTC_WORKSPACE_WRAPPER",
820        "__CARGO_LLVM_COV_RUSTC_WRAPPER",
821        "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
822        "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
823    ] {
824        command.env_remove(var);
825    }
826}
827
828/// Run `<engine> mutants --output <out> [--in-diff <diff>] [-- --features <list>]` in `root`,
829/// where `engine` is the provisioned cargo-mutants binary ([`ensure_cargo_mutants`]) invoked
830/// by absolute path. The `[rust] features` list rides after cargo-mutants' `--` separator,
831/// which forwards it to the cargo build/test runs — so `#[cfg(feature = ...)]` code is
832/// compiled and its mutants exercised (#266).
833///
834/// cargo-mutants exits `0` when every mutant is caught and `2` when some survive (or
835/// time out / are unviable) — both are normal here, since survivors are the rule's
836/// *output*, not an error. Any other code (usage error, or a baseline that didn't
837/// build/pass) is fatal. The outer instrumentation env is stripped so a nested run (this
838/// rule's own tests under `cargo llvm-cov`) doesn't re-enter the rustc wrapper and hang.
839fn run_cargo_mutants(
840    engine: &Path,
841    root: &Path,
842    out: &Path,
843    in_diff: Option<&Path>,
844    features: &[String],
845) -> Result<()> {
846    let mut command = Command::new(engine);
847    command
848        .current_dir(root)
849        .arg("mutants")
850        .arg("--output")
851        .arg(out);
852    if let Some(diff) = in_diff {
853        command.arg("--in-diff").arg(diff);
854    }
855    if !features.is_empty() {
856        command.args(["--", "--features"]).arg(features.join(","));
857    }
858    strip_llvm_cov_env(&mut command);
859    let output = command.output().context("running cargo-mutants")?;
860    match output.status.code() {
861        // 0 = all caught, 2 = some survived/timed out: both produce a report to read.
862        Some(0) | Some(2) => Ok(()),
863        _ => bail!(
864            "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
865            root.display(),
866            String::from_utf8_lossy(&output.stdout),
867            String::from_utf8_lossy(&output.stderr),
868        ),
869    }
870}
871
872#[cfg(test)]
873mod tests {
874    use super::*;
875
876    // --- normalized results (#239): the engine-agnostic schema + core gate ---
877
878    // A normalized result set covering every status: two survivors (Survived + NoCoverage),
879    // a caught Killed, an inconclusive-but-viable Timeout, and two unviable mutants
880    // (CompileError / RuntimeError). `snake_case` on the wire; an extra field is ignored.
881    const NORMALIZED: &str = r#"[
882        {"file": "src/a.ts", "line": 2, "status": "survived",
883         "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
884        {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
885        {"file": "src/a.ts", "line": 9, "status": "killed",
886         "mutator": "BooleanLiteral", "replacement": "false"},
887        {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
888        {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
889        {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
890    ]"#;
891
892    #[test]
893    fn parses_the_normalized_schema() {
894        let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
895        assert_eq!(mutants.len(), 6);
896        assert_eq!(mutants[0].status, MutantStatus::Survived);
897        assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
898        assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
899        assert_eq!(mutants[1].replacement, None);
900    }
901
902    #[test]
903    fn normalized_survivors_are_survived_and_nocoverage_only() {
904        let mutants = parse_normalized_results(NORMALIZED).unwrap();
905        let survivors = normalized_survivors(&mutants);
906        // Survived (2) + NoCoverage (5); not killed/timeout/compile/runtime.
907        assert_eq!(survivors.len(), 2);
908        assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
909        // Replacement is folded into the description when present, omitted otherwise.
910        assert!(survivors[0].description.contains("ConditionalExpression"));
911        assert!(survivors[0].description.contains("-> true"));
912        assert_eq!(survivors[1].description, "ArithmeticOperator");
913    }
914
915    #[test]
916    fn normalized_mutated_lines_collects_only_viable_mutants() {
917        let mutants = parse_normalized_results(NORMALIZED).unwrap();
918        // Survived/Killed/NoCoverage/Timeout ran; CompileError/RuntimeError never produced
919        // a viable mutant.
920        assert_eq!(
921            normalized_mutated_lines(&mutants),
922            [2u32, 5, 9, 12]
923                .into_iter()
924                .map(|line| ("src/a.ts".to_string(), line))
925                .collect()
926        );
927    }
928
929    #[test]
930    fn evaluate_normalized_reports_unexempted_survivors() {
931        let mutants = parse_normalized_results(NORMALIZED).unwrap();
932        let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
933        assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
934    }
935
936    #[test]
937    fn evaluate_normalized_drops_a_whole_file_exemption() {
938        let mutants = parse_normalized_results(NORMALIZED).unwrap();
939        let kept =
940            evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
941        assert!(
942            kept.is_empty(),
943            "the whole-file exemption lifts both survivors"
944        );
945    }
946
947    #[test]
948    fn evaluate_normalized_drops_a_line_scoped_exemption() {
949        let mutants = parse_normalized_results(NORMALIZED).unwrap();
950        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
951        let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
952        // Line 2's survivor is lifted; line 5's still stands.
953        assert_eq!(kept.len(), 1);
954        assert_eq!(kept[0].line, 5);
955    }
956
957    #[test]
958    fn evaluate_normalized_rejects_exempting_a_caught_line() {
959        // Line 9 had only a Killed mutant (viable, no survivor) — over-exemption is an error,
960        // via the shared #226 determinism guard.
961        let mutants = parse_normalized_results(NORMALIZED).unwrap();
962        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
963        let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
964        assert!(
965            err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
966            "got: {err}"
967        );
968    }
969
970    #[test]
971    fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
972        // Line 15 had only a CompileError (no viable mutant) — neither an error nor a drop;
973        // the real survivors still stand.
974        let mutants = parse_normalized_results(NORMALIZED).unwrap();
975        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
976        let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
977        assert_eq!(kept.len(), 2);
978    }
979
980    // A pared `outcomes.json`: the baseline, one missed mutant, and one caught — the
981    // real shape (externally-tagged `scenario`, extra fields the rule ignores).
982    const SAMPLE: &str = r#"{
983        "outcomes": [
984            {"scenario": "Baseline", "summary": "Success",
985             "phase_results": []},
986            {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
987                "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
988                "function": {"function_name": "is_positive"},
989                "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
990             "summary": "MissedMutant"},
991            {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
992                "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
993                "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
994             "summary": "CaughtMutant"}
995        ],
996        "total_mutants": 2
997    }"#;
998
999    #[test]
1000    fn parses_the_outcomes_export() {
1001        let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
1002        assert_eq!(report.outcomes.len(), 3);
1003        assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
1004    }
1005
1006    #[test]
1007    fn collects_only_missed_mutants_as_survivors() {
1008        let report = parse_mutants_report(SAMPLE).unwrap();
1009        let survivors = unexplained_survivors(&report, &[]);
1010        // Only the MissedMutant — the baseline and the CaughtMutant are not survivors.
1011        assert_eq!(survivors.len(), 1);
1012        assert_eq!(survivors[0].file, "src/lib.rs");
1013        assert_eq!(survivors[0].line, 7);
1014        assert!(survivors[0].description.contains("replace > with =="));
1015    }
1016
1017    #[test]
1018    fn an_exemption_drops_a_survivor_in_that_file() {
1019        let report = parse_mutants_report(SAMPLE).unwrap();
1020        let exempt = vec!["src/lib.rs".to_string()];
1021        assert!(unexplained_survivors(&report, &exempt).is_empty());
1022    }
1023
1024    #[test]
1025    fn an_exemption_on_another_file_leaves_the_survivor() {
1026        let report = parse_mutants_report(SAMPLE).unwrap();
1027        let exempt = vec!["src/elsewhere.rs".to_string()];
1028        assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1029    }
1030
1031    #[test]
1032    fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
1033        assert!(is_mutatable_ts("src/index.ts"));
1034        assert!(is_mutatable_ts("src/util.tsx"));
1035        assert!(is_mutatable_ts("src/util.js"));
1036        assert!(!is_mutatable_ts("src/index.test.ts"));
1037        assert!(!is_mutatable_ts("src/index.spec.ts"));
1038        assert!(!is_mutatable_ts("src/types.d.ts"));
1039        assert!(!is_mutatable_ts("README.md"));
1040    }
1041
1042    #[test]
1043    fn contiguous_runs_collapses_adjacent_lines() {
1044        let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
1045        assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
1046        assert!(contiguous_runs(&BTreeSet::new()).is_empty());
1047    }
1048
1049    #[test]
1050    fn one_line_flattens_and_caps() {
1051        assert_eq!(one_line("a -\n  b"), "a - b");
1052        let long = "x".repeat(80);
1053        let capped = one_line(&long);
1054        assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
1055    }
1056
1057    #[test]
1058    fn is_mutatable_py_keeps_sources_and_drops_tests() {
1059        assert!(is_mutatable_py("calc.py"));
1060        assert!(is_mutatable_py("pkg/util.py"));
1061        assert!(!is_mutatable_py("calc_test.py"));
1062        assert!(!is_mutatable_py("test_calc.py"));
1063        assert!(!is_mutatable_py("pkg/conftest.py"));
1064        assert!(!is_mutatable_py("README.md"));
1065    }
1066
1067    // --- line-scoped exemptions (#226) ---
1068
1069    #[test]
1070    fn mutated_lines_collects_caught_and_missed() {
1071        // The MissedMutant (src/lib.rs:7) and the CaughtMutant (src/other.rs:3) are both
1072        // viable, conclusive mutants; the Baseline is not.
1073        let report = parse_mutants_report(SAMPLE).unwrap();
1074        assert_eq!(
1075            mutated_lines(&report),
1076            [
1077                ("src/lib.rs".to_string(), 7),
1078                ("src/other.rs".to_string(), 3)
1079            ]
1080            .into_iter()
1081            .collect()
1082        );
1083    }
1084
1085    #[test]
1086    fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1087        let report = parse_mutants_report(SAMPLE).unwrap();
1088        let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1089        let kept = evaluate_scoped(
1090            cargo_mutants_survivors(&report),
1091            &mutated_lines(&report),
1092            &[],
1093            &line_scoped,
1094        )
1095        .unwrap();
1096        assert!(
1097            kept.is_empty(),
1098            "the src/lib.rs:7 survivor should be lifted"
1099        );
1100    }
1101
1102    #[test]
1103    fn evaluate_scoped_rejects_exempting_a_caught_line() {
1104        // src/other.rs:3 had only a caught mutant (no survivor) — over-exemption.
1105        let report = parse_mutants_report(SAMPLE).unwrap();
1106        let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
1107        let err = evaluate_scoped(
1108            cargo_mutants_survivors(&report),
1109            &mutated_lines(&report),
1110            &[],
1111            &line_scoped,
1112        )
1113        .unwrap_err();
1114        assert!(
1115            err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
1116            "got: {err}"
1117        );
1118    }
1119
1120    #[test]
1121    fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
1122        // Line 99 has no mutant at all (e.g. outside a `--base` diff) — neither an error
1123        // nor a drop; the real survivor on line 7 still stands.
1124        let report = parse_mutants_report(SAMPLE).unwrap();
1125        let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
1126        let kept = evaluate_scoped(
1127            cargo_mutants_survivors(&report),
1128            &mutated_lines(&report),
1129            &[],
1130            &line_scoped,
1131        )
1132        .unwrap();
1133        assert_eq!(kept.len(), 1);
1134        assert_eq!(kept[0].line, 7);
1135    }
1136
1137    #[test]
1138    fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1139        let report = parse_mutants_report(SAMPLE).unwrap();
1140        let kept = evaluate_scoped(
1141            cargo_mutants_survivors(&report),
1142            &mutated_lines(&report),
1143            &["src/lib.rs".to_string()],
1144            &BTreeMap::new(),
1145        )
1146        .unwrap();
1147        assert!(kept.is_empty());
1148    }
1149
1150    // --- engine provisioning (#242) ---
1151
1152    fn unique_tmp() -> PathBuf {
1153        static COUNTER: AtomicU64 = AtomicU64::new(0);
1154        let dir = std::env::temp_dir().join(format!(
1155            "tc-provision-test-{}-{}",
1156            std::process::id(),
1157            COUNTER.fetch_add(1, Ordering::Relaxed)
1158        ));
1159        std::fs::create_dir_all(&dir).unwrap();
1160        dir
1161    }
1162
1163    #[test]
1164    fn provision_returns_an_existing_binary_without_installing() {
1165        let tmp = unique_tmp();
1166        let bin = tmp.join("bin").join("cargo-mutants");
1167        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1168        std::fs::write(&bin, b"binary").unwrap();
1169        let mut installed = false;
1170        let got = provision(&bin, || {
1171            installed = true;
1172            Ok(())
1173        })
1174        .unwrap();
1175        assert_eq!(got, bin);
1176        assert!(!installed, "a present binary must not be reinstalled");
1177        std::fs::remove_dir_all(&tmp).unwrap();
1178    }
1179
1180    #[test]
1181    fn provision_installs_when_the_binary_is_absent() {
1182        let tmp = unique_tmp();
1183        let bin = tmp.join("bin").join("cargo-mutants");
1184        let mut installed = false;
1185        let got = provision(&bin, || {
1186            installed = true;
1187            std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1188            std::fs::write(&bin, b"binary").unwrap();
1189            Ok(())
1190        })
1191        .unwrap();
1192        assert!(installed, "an absent binary must be installed");
1193        assert_eq!(got, bin);
1194        std::fs::remove_dir_all(&tmp).unwrap();
1195    }
1196
1197    #[test]
1198    fn provision_errors_when_install_produces_no_binary() {
1199        let tmp = unique_tmp();
1200        let bin = tmp.join("bin").join("cargo-mutants");
1201        let err = provision(&bin, || Ok(())).unwrap_err();
1202        assert!(
1203            err.to_string().contains("cargo-mutants is not at"),
1204            "got: {err}"
1205        );
1206        std::fs::remove_dir_all(&tmp).unwrap();
1207    }
1208
1209    #[test]
1210    fn provision_propagates_an_install_failure() {
1211        let tmp = unique_tmp();
1212        let bin = tmp.join("bin").join("cargo-mutants");
1213        let err = provision(&bin, || bail!("install blew up")).unwrap_err();
1214        assert!(err.to_string().contains("install blew up"), "got: {err}");
1215        std::fs::remove_dir_all(&tmp).unwrap();
1216    }
1217
1218    #[test]
1219    fn resolve_cache_base_prefers_xdg_then_home_then_temp() {
1220        let xdg = |s: &str| Some(OsString::from(s));
1221        // XDG wins when set and non-empty.
1222        assert_eq!(
1223            resolve_cache_base(xdg("/xdg"), xdg("/home")),
1224            PathBuf::from("/xdg")
1225        );
1226        // An empty XDG falls through to $HOME/.cache.
1227        assert_eq!(
1228            resolve_cache_base(xdg(""), xdg("/home")),
1229            PathBuf::from("/home/.cache")
1230        );
1231        // A missing XDG likewise uses $HOME/.cache.
1232        assert_eq!(
1233            resolve_cache_base(None, xdg("/home")),
1234            PathBuf::from("/home/.cache")
1235        );
1236        // Neither set → the temp dir.
1237        assert_eq!(resolve_cache_base(None, None), std::env::temp_dir());
1238        assert_eq!(
1239            resolve_cache_base(xdg(""), Some(OsString::new())),
1240            std::env::temp_dir()
1241        );
1242    }
1243
1244    #[test]
1245    fn cache_root_is_absolute_and_version_scoped() {
1246        let root = cargo_mutants_cache_root();
1247        assert!(
1248            root.ends_with(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}")),
1249            "version-scoped; got {root:?}"
1250        );
1251        assert!(
1252            root.to_string_lossy().contains("testing-conventions"),
1253            "tool-namespaced; got {root:?}"
1254        );
1255        // A real base dir (HOME/XDG in the test env) makes it absolute — not an empty path.
1256        assert!(
1257            root.is_absolute(),
1258            "expected an absolute path; got {root:?}"
1259        );
1260    }
1261
1262    #[test]
1263    fn install_argv_pins_the_version_and_isolates_the_root() {
1264        let argv: Vec<String> = install_argv(Path::new("/cache/cargo-mutants-27"))
1265            .iter()
1266            .map(|arg| arg.to_string_lossy().into_owned())
1267            .collect();
1268        assert_eq!(
1269            argv,
1270            vec![
1271                "install",
1272                "cargo-mutants",
1273                "--locked",
1274                "--version",
1275                CARGO_MUTANTS_VERSION,
1276                "--root",
1277                "/cache/cargo-mutants-27",
1278            ]
1279        );
1280    }
1281
1282    #[cfg(unix)]
1283    fn fake_output(code: i32, stderr: &str) -> Output {
1284        use std::os::unix::process::ExitStatusExt;
1285        Output {
1286            status: std::process::ExitStatus::from_raw(code << 8),
1287            stdout: Vec::new(),
1288            stderr: stderr.as_bytes().to_vec(),
1289        }
1290    }
1291
1292    #[cfg(unix)]
1293    #[test]
1294    fn run_install_succeeds_on_a_zero_exit() {
1295        let mut ran = false;
1296        run_install(Path::new("/cache/root"), |command| {
1297            ran = true;
1298            // The pinned argv reaches the runner.
1299            let argv: Vec<String> = command
1300                .get_args()
1301                .map(|arg| arg.to_string_lossy().into_owned())
1302                .collect();
1303            assert!(argv.contains(&CARGO_MUTANTS_VERSION.to_string()));
1304            Ok(fake_output(0, ""))
1305        })
1306        .unwrap();
1307        assert!(ran);
1308    }
1309
1310    #[cfg(unix)]
1311    #[test]
1312    fn run_install_reports_a_nonzero_exit_with_the_engine_output() {
1313        let err = run_install(Path::new("/cache/root"), |_| {
1314            Ok(fake_output(1, "error: could not compile cargo-mutants"))
1315        })
1316        .unwrap_err();
1317        assert!(
1318            err.to_string()
1319                .contains("failed to provision cargo-mutants")
1320                && err.to_string().contains("could not compile"),
1321            "got: {err}"
1322        );
1323    }
1324
1325    #[cfg(unix)]
1326    #[test]
1327    fn run_install_propagates_a_spawn_failure() {
1328        let err = run_install(Path::new("/cache/root"), |_| {
1329            Err(std::io::Error::new(
1330                std::io::ErrorKind::NotFound,
1331                "no cargo",
1332            ))
1333        })
1334        .unwrap_err();
1335        assert!(
1336            err.to_string().contains("is cargo installed?"),
1337            "got: {err}"
1338        );
1339    }
1340
1341    #[test]
1342    fn cargo_mutants_bin_name_matches_the_platform() {
1343        let name = cargo_mutants_bin_name();
1344        if cfg!(windows) {
1345            assert_eq!(name, "cargo-mutants.exe");
1346        } else {
1347            assert_eq!(name, "cargo-mutants");
1348        }
1349    }
1350}