Skip to main content

testing_conventions/
mutation.rs

1//! Mutation testing for Rust (`unit mutation --language rust`) — the rung
2//! above coverage. A test that *runs* a line still passes if you delete its
3//! assertions; a surviving mutant proves it. This module wraps
4//! [cargo-mutants](https://github.com/sourcefrog/cargo-mutants): it runs the engine,
5//! reads its `outcomes.json`, and reports the **surviving** mutants the suite failed
6//! to catch.
7//!
8//! The gate is **binary, not a percentage** (equivalent mutants make a fixed score
9//! unreachable, and a score isn't comparable across engines) and on by default: any
10//! *un-exempted* surviving mutant is a finding. This module stays a pure measurement —
11//! [`measure_rust`] returns the survivors and [`unexplained_survivors`] is the pure
12//! core over a parsed report; the CLI layer turns a non-empty result into the failure.
13//!
14//! Diff-scoping (`--base`) is delegated to cargo-mutants' own `--in-diff`: the
15//! `<base>...HEAD` diff is written out and passed through, so only mutants on changed
16//! lines are tested ("no unexplained surviving mutant on the lines you touched").
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::ffi::OsString;
20use std::path::{Path, PathBuf};
21use std::process::{Command, Output};
22use std::sync::atomic::{AtomicU64, Ordering};
23
24use anyhow::{bail, Context, Result};
25use serde::Deserialize;
26
27/// A surviving mutant — a mutation the unit suite ran but failed to catch.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Survivor {
30    /// The mutated file, scan-path-relative and `/`-separated — cargo-mutants reports
31    /// workspace-root-relative paths, rebased onto the scan path before gating.
32    pub file: String,
33    /// The 1-based line the mutation starts on.
34    pub line: u32,
35    /// cargo-mutants' human description (e.g. `replace > with == in is_positive`).
36    pub description: String,
37}
38
39/// One mutation measurement: whether the engine ran, and what it found. Telling
40/// [`Measurement::EngineNotRun`] from an all-killed [`Measurement::Tested`] keeps a
41/// vacuous pass visible — a diff-scoped run that never built a mutant reads differently
42/// from one that tested mutants and caught every one, and a counted pass carries its
43/// own evidence.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum Measurement {
46    /// The `--base` diff carried no mutatable changed lines; the engine never ran.
47    EngineNotRun,
48    /// The engine ran: `count` viable, conclusive mutants judged (caught or missed),
49    /// `survivors` the un-exempted surviving ones.
50    Tested {
51        count: usize,
52        survivors: Vec<Survivor>,
53    },
54}
55
56/// The `(file, line)` locations an engine produced a viable mutant for — the input the
57/// line-scoped guard reads to tell an over-exemption (a listed line whose mutants
58/// were all caught) from an out-of-scope line (no mutant there).
59pub type MutatedLines = BTreeSet<(String, u32)>;
60
61/// A cargo-mutants `outcomes.json` export, pared to what the rule reads. Unmodeled
62/// fields (`total_mutants`, `caught`, timings, …) are ignored.
63#[derive(Debug, Clone, Deserialize)]
64pub struct MutantsReport {
65    pub outcomes: Vec<MutantOutcome>,
66}
67
68/// One scenario's outcome. `summary` is cargo-mutants' result word — `Success` for the
69/// unmutated baseline, `CaughtMutant` / `MissedMutant` (and `Timeout` / `Unviable`)
70/// for each mutant.
71#[derive(Debug, Clone, Deserialize)]
72pub struct MutantOutcome {
73    pub summary: String,
74    pub scenario: Scenario,
75}
76
77/// The scenario a result came from: the unmutated baseline, or one mutant. Matches
78/// cargo-mutants' externally-tagged JSON (`"Baseline"` vs `{"Mutant": {…}}`).
79#[derive(Debug, Clone, Deserialize)]
80pub enum Scenario {
81    Baseline,
82    Mutant(MutantInfo),
83}
84
85/// The mutant a scenario describes, pared to the location + description the report
86/// needs. cargo-mutants also carries `function`, `genre`, `package`, `replacement`;
87/// those are ignored.
88#[derive(Debug, Clone, Deserialize)]
89pub struct MutantInfo {
90    pub file: String,
91    pub span: Span,
92    pub name: String,
93}
94
95/// A source span; the start and end lines are read.
96#[derive(Debug, Clone, Deserialize)]
97pub struct Span {
98    pub start: LineCol,
99    pub end: LineCol,
100}
101
102/// A line/column position; only the line is read.
103#[derive(Debug, Clone, Deserialize)]
104pub struct LineCol {
105    pub line: u32,
106}
107
108/// Parse a cargo-mutants `outcomes.json` export.
109pub fn parse_mutants_report(json: &str) -> Result<MutantsReport> {
110    serde_json::from_str(json).context("parsing cargo-mutants outcomes.json")
111}
112
113/// Parse a `cargo mutants --list --json` export: the crate's discoverable mutants, each
114/// with its workspace-root-relative file and span.
115fn parse_mutants_list(json: &str) -> Result<Vec<MutantInfo>> {
116    serde_json::from_str(json).context("parsing the cargo-mutants mutant list")
117}
118
119/// The surviving mutants not lifted by a `mutation` exemption — the rule's findings.
120///
121/// A survivor is a `MissedMutant` outcome (the suite ran the mutated code but no test
122/// failed). `exempt` is the resolved set of `mutation`-rule exempt paths (crate-root
123/// relative); a survivor in an exempt file is dropped (an equivalent or deliberately
124/// defensive mutation, lifted with a reason). `Timeout` / `Unviable` are *not*
125/// survivors — a timeout is inconclusive, not a pass, and an unviable mutant never
126/// compiled.
127pub fn unexplained_survivors(report: &MutantsReport, exempt: &[String]) -> Vec<Survivor> {
128    evaluate(cargo_mutants_survivors(report), exempt)
129}
130
131/// The surviving mutants in a cargo-mutants report — the raw list before exemptions.
132/// A survivor is a `MissedMutant` outcome (the suite ran the mutated code but no test
133/// failed). `Timeout` / `Unviable` are not survivors.
134fn cargo_mutants_survivors(report: &MutantsReport) -> Vec<Survivor> {
135    report
136        .outcomes
137        .iter()
138        .filter_map(|outcome| {
139            if outcome.summary != "MissedMutant" {
140                return None;
141            }
142            let Scenario::Mutant(mutant) = &outcome.scenario else {
143                return None;
144            };
145            Some(Survivor {
146                file: mutant.file.clone(),
147                line: mutant.span.start.line,
148                description: mutant.name.clone(),
149            })
150        })
151        .collect()
152}
153
154/// The `(file, line)` locations cargo-mutants produced a **viable, conclusive** mutant
155/// for — caught or missed (`CaughtMutant` / `MissedMutant`), not the inconclusive
156/// `Timeout` / `Unviable`. The line-scoped guard reads this to tell an
157/// over-exemption (a listed line whose mutants were all *caught*, no survivor) from an
158/// out-of-scope line (no mutant there at all — e.g. outside a `--base` diff).
159pub fn mutated_lines(report: &MutantsReport) -> MutatedLines {
160    report
161        .outcomes
162        .iter()
163        .filter_map(|outcome| {
164            if outcome.summary != "CaughtMutant" && outcome.summary != "MissedMutant" {
165                return None;
166            }
167            let Scenario::Mutant(mutant) = &outcome.scenario else {
168                return None;
169            };
170            Some((mutant.file.clone(), mutant.span.start.line))
171        })
172        .collect()
173}
174
175/// The number of viable, conclusive mutants in a cargo-mutants report — `CaughtMutant`
176/// plus `MissedMutant`, the same set [`mutated_lines`] reads. A passing run states this
177/// count as its evidence.
178fn conclusive_count(report: &MutantsReport) -> usize {
179    report
180        .outcomes
181        .iter()
182        .filter(|outcome| outcome.summary == "CaughtMutant" || outcome.summary == "MissedMutant")
183        .count()
184}
185
186/// The shared whole-file evaluation core: drop the survivors lifted by a file-level
187/// `mutation` exemption (a file-path match), leaving the rule's findings. The
188/// line-scoped path ([`evaluate_scoped`]) generalizes this to per-line exemptions with
189/// a determinism guard.
190pub fn evaluate(survivors: Vec<Survivor>, exempt: &[String]) -> Vec<Survivor> {
191    survivors
192        .into_iter()
193        .filter(|survivor| !exempt.iter().any(|path| path == &survivor.file))
194        .collect()
195}
196
197/// Apply file- and line-scoped `mutation` exemptions to the raw `survivors`, with the
198/// determinism guard. `mutated` is the set of `(file, line)` that produced a
199/// viable mutant (caught or survived); `whole_file` is the file-level exemptions and
200/// `line_scoped` the per-line ones.
201///
202/// Guard: a line-scoped exemption that names a line whose mutants were **all caught**
203/// (in `mutated`, but with no survivor) is over-exemption — a hard error, the
204/// counterpart to the stale-path rule. A listed line with **no** mutant at all is left
205/// alone (it may simply be outside a `--base` diff), neither an error nor a drop. Then
206/// every survivor whose file is whole-file-exempt, or whose `(file, line)` is
207/// line-exempt, is dropped; an unlisted survivor still fails the gate.
208pub fn evaluate_scoped(
209    survivors: Vec<Survivor>,
210    mutated: &MutatedLines,
211    whole_file: &[String],
212    line_scoped: &BTreeMap<String, BTreeSet<u32>>,
213) -> Result<Vec<Survivor>> {
214    let mut over: Vec<String> = Vec::new();
215    for (file, lines) in line_scoped {
216        for &line in lines {
217            let has_survivor = survivors
218                .iter()
219                .any(|survivor| survivor.file == *file && survivor.line == line);
220            if has_survivor {
221                continue;
222            }
223            if mutated.contains(&(file.clone(), line)) {
224                over.push(format!("\n  {file}:{line}"));
225            }
226        }
227    }
228    if !over.is_empty() {
229        bail!(
230            "a line-scoped mutation exemption may only list a line with a surviving mutant, but \
231             these had mutants that were all caught:{}",
232            over.concat()
233        );
234    }
235    Ok(survivors
236        .into_iter()
237        .filter(|survivor| {
238            let whole = whole_file.iter().any(|path| path == &survivor.file);
239            let line = line_scoped
240                .get(&survivor.file)
241                .is_some_and(|lines| lines.contains(&survivor.line));
242            !(whole || line)
243        })
244        .collect())
245}
246
247/// A mutant's outcome, normalized across the engines (Stryker / cosmic-ray / cargo-mutants)
248/// — the union of their result vocabularies reduced to what the gate needs. Each
249/// language adapter maps its native outcomes onto this so the Rust core gates on one
250/// representation instead of three per-engine report formats. The serialized form is
251/// `snake_case` (`no_coverage`, `compile_error`, …) — the wire contract adapters emit.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
253#[serde(rename_all = "snake_case")]
254pub enum MutantStatus {
255    /// A test ran the mutated code but none failed — a survivor.
256    Survived,
257    /// A test failed on the mutant — caught.
258    Killed,
259    /// No test exercised the mutant at all — a survivor (worse than `Survived`).
260    NoCoverage,
261    /// The mutant ran but the suite timed out — inconclusive, not a survivor (but viable).
262    Timeout,
263    /// The mutant never compiled — not a viable mutant.
264    CompileError,
265    /// The mutant errored at runtime before a test could judge it — not viable.
266    RuntimeError,
267}
268
269impl MutantStatus {
270    /// Whether this outcome is a **survivor** — a mutant the suite failed to catch
271    /// (`Survived` or `NoCoverage`). Mirrors the per-engine survivor rules.
272    fn is_survivor(self) -> bool {
273        matches!(self, MutantStatus::Survived | MutantStatus::NoCoverage)
274    }
275
276    /// Whether this came from a **viable, conclusive** mutant — one that actually ran
277    /// (`Survived` / `Killed` / `NoCoverage` / `Timeout`), not one that never compiled or
278    /// errored out. The determinism guard reads this to tell an over-exemption (a
279    /// listed line whose mutants were all caught) from an out-of-scope line (no mutant there).
280    fn is_viable(self) -> bool {
281        matches!(
282            self,
283            MutantStatus::Survived
284                | MutantStatus::Killed
285                | MutantStatus::NoCoverage
286                | MutantStatus::Timeout
287        )
288    }
289
290    /// Whether the suite **judged** this mutant (`Survived` / `Killed` / `NoCoverage`) —
291    /// the conclusive set a passing run counts as its evidence. A `Timeout` ran but
292    /// judged nothing; `CompileError` / `RuntimeError` never produced a viable mutant.
293    fn is_conclusive(self) -> bool {
294        matches!(
295            self,
296            MutantStatus::Survived | MutantStatus::Killed | MutantStatus::NoCoverage
297        )
298    }
299}
300
301/// One mutant in the normalized result set: the engine-agnostic shape every language
302/// adapter emits. Extra fields an adapter includes are ignored.
303#[derive(Debug, Clone, Deserialize)]
304pub struct NormalizedMutant {
305    /// Project-relative, `/`-separated path of the mutated file.
306    pub file: String,
307    /// The 1-based line the mutant starts on.
308    pub line: u32,
309    /// The outcome, normalized across engines.
310    pub status: MutantStatus,
311    /// The engine's mutator/operator name (e.g. `ConditionalExpression`).
312    pub mutator: String,
313    /// The replacement text, when the engine reports one — used for a readable description.
314    #[serde(default)]
315    pub replacement: Option<String>,
316}
317
318/// Parse the normalized results an engine adapter emits — a flat JSON array of
319/// [`NormalizedMutant`].
320pub fn parse_normalized_results(json: &str) -> Result<Vec<NormalizedMutant>> {
321    serde_json::from_str(json).context("parsing normalized mutation results")
322}
323
324/// Gate a normalized result set: drop the survivors lifted by a file- or line-scoped
325/// `mutation` exemption (with the determinism guard), leaving the rule's findings.
326///
327/// This is the engine-agnostic core each language arm feeds once its adapter has produced
328/// [`NormalizedMutant`]s — the replacement for the per-engine `*_survivors` /
329/// `*_mutated_lines` + [`evaluate_scoped`] wiring. Survivors are `Survived` / `NoCoverage`
330/// mutants; the guard reads every *viable* mutant's `(file, line)`.
331pub fn evaluate_normalized(
332    mutants: &[NormalizedMutant],
333    whole_file: &[String],
334    line_scoped: &BTreeMap<String, BTreeSet<u32>>,
335) -> Result<Vec<Survivor>> {
336    evaluate_scoped(
337        normalized_survivors(mutants),
338        &normalized_mutated_lines(mutants),
339        whole_file,
340        line_scoped,
341    )
342}
343
344/// The surviving mutants in a normalized result set — the raw list before exemptions.
345fn normalized_survivors(mutants: &[NormalizedMutant]) -> Vec<Survivor> {
346    mutants
347        .iter()
348        .filter(|mutant| mutant.status.is_survivor())
349        .map(|mutant| Survivor {
350            file: mutant.file.clone(),
351            line: mutant.line,
352            description: describe_normalized(mutant),
353        })
354        .collect()
355}
356
357/// The `(file, line)` of every viable, conclusive mutant in a normalized result set — the
358/// input the line-scoped guard in [`evaluate_scoped`] reads.
359fn normalized_mutated_lines(mutants: &[NormalizedMutant]) -> MutatedLines {
360    mutants
361        .iter()
362        .filter(|mutant| mutant.status.is_viable())
363        .map(|mutant| (mutant.file.clone(), mutant.line))
364        .collect()
365}
366
367/// The number of conclusive mutants in a normalized result set — the count a passing
368/// run states as its evidence, parity with [`conclusive_count`].
369fn normalized_conclusive_count(mutants: &[NormalizedMutant]) -> usize {
370    mutants
371        .iter()
372        .filter(|mutant| mutant.status.is_conclusive())
373        .count()
374}
375
376/// A one-line description for a normalized mutant: the mutator name, plus the replacement
377/// (flattened + capped via [`one_line`]) when the engine reported one.
378fn describe_normalized(mutant: &NormalizedMutant) -> String {
379    match &mutant.replacement {
380        Some(replacement) => format!("{} (-> {})", mutant.mutator, one_line(replacement)),
381        None => mutant.mutator.clone(),
382    }
383}
384
385/// Run cargo-mutants over the crate at `root` and return the [`Measurement`]: the
386/// un-exempted survivors plus the conclusive-mutant count, or
387/// [`Measurement::EngineNotRun`] for a `base` diff that changes no lines — or no Rust
388/// source — under the crate.
389///
390/// With `base` set, only mutants on the `<base>...HEAD` changed lines are tested (via
391/// cargo-mutants' `--in-diff`); without it, the whole crate. `exempt` is the file-level
392/// `mutation` exempt paths and `exempt_lines` the line-scoped ones, applied with
393/// the determinism guard in [`evaluate_scoped`]. The tool provisions cargo-mutants itself
394/// on first use ([`ensure_cargo_mutants`]) — only a cargo toolchain need be present.
395pub fn measure_rust(
396    root: &Path,
397    exempt: &[String],
398    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
399    base: Option<&str>,
400    features: &[String],
401) -> Result<Measurement> {
402    let out = MutantsOut::new();
403    // cargo-mutants addresses files relative to the crate's cargo workspace root, so
404    // both the `--in-diff` diff it consumes and the report paths it emits carry the
405    // scan path's workspace-relative prefix whenever the crate is a member of a
406    // workspace rooted above it. A standalone crate is its own workspace root: no prefix.
407    let workspace_root = cargo_workspace_root(root)?;
408    let prefix = canonical_scan_prefix(root, &workspace_root);
409    let mut base_diff = None;
410    let diff = match base {
411        // An empty diff (no changed lines under the crate — a PR that doesn't touch it)
412        // means nothing to mutate: the engine is skipped, and the caller reports it.
413        Some(base) => {
414            match write_base_diff(root, &workspace_root, prefix.as_deref(), base, &out)? {
415                None => return Ok(Measurement::EngineNotRun),
416                Some(path) => {
417                    let parsed =
418                        parse_base_diff(&std::fs::read_to_string(&path).with_context(|| {
419                            format!("reading the written base diff `{}`", path.display())
420                        })?);
421                    // A diff that touches the crate but changes no Rust source (a README
422                    // edit) holds nothing the engine could judge: skip it up front, the
423                    // same pre-filter the TypeScript and Python arms apply.
424                    if !parsed.files.iter().any(|file| file.ends_with(".rs")) {
425                        return Ok(Measurement::EngineNotRun);
426                    }
427                    base_diff = Some(parsed);
428                    Some(path)
429                }
430            }
431        }
432        None => None,
433    };
434    let engine = ensure_cargo_mutants()?;
435    let run = run_cargo_mutants(&engine, root, &out.0, diff.as_deref(), features)?;
436    let outcomes = out.0.join("mutants.out").join("outcomes.json");
437    // cargo-mutants writes no `outcomes.json` when a run produces no mutants (e.g. an
438    // `--in-diff` that matches none of the crate's lines). `run_cargo_mutants` already
439    // bailed on a fatal exit, so a missing report here is an engine run that judged
440    // zero mutants — legitimate only if none of the crate's mutants sits on the diff's
441    // inserted lines, which [`zero_mutant_verdict`] proves against the engine's own
442    // mutant list before the zero is allowed to stand.
443    let json = match std::fs::read_to_string(&outcomes) {
444        Ok(json) => json,
445        Err(_) => {
446            if let Some(diff) = &base_diff {
447                let listed =
448                    list_cargo_mutants(&engine, root, features, |command| command.output())?;
449                zero_mutant_verdict(&listed, diff, &run)?;
450            }
451            return Ok(Measurement::Tested {
452                count: 0,
453                survivors: Vec::new(),
454            });
455        }
456    };
457    let report = rebase_report_paths(parse_mutants_report(&json)?, prefix.as_deref());
458    let survivors = evaluate_scoped(
459        cargo_mutants_survivors(&report),
460        &mutated_lines(&report),
461        exempt,
462        exempt_lines,
463    )?;
464    Ok(Measurement::Tested {
465        count: conclusive_count(&report),
466        survivors,
467    })
468}
469
470/// Collapse a (possibly multi-line) replacement to a single trimmed line, capped, so a
471/// survivor's one-line description stays readable.
472fn one_line(replacement: &str) -> String {
473    let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
474    const MAX: usize = 60;
475    if flat.chars().count() > MAX {
476        format!("{}…", flat.chars().take(MAX).collect::<String>())
477    } else {
478        flat
479    }
480}
481
482/// Run the bundled TypeScript mutation adapter over the scan path at `root` and return
483/// the [`Measurement`] — the TS arm of the mutation rule, parity with [`measure_rust`].
484///
485/// The consumer installs **nothing** Stryker-related: the npm package ships a Node
486/// adapter that drives Stryker through its own Node API and emits the engine-agnostic
487/// [`NormalizedMutant`] schema, which this gates over via [`evaluate_normalized`]
488/// — the same core the Rust and Python arms feed. Only the project's own test runner
489/// (vitest) needs to be present, exactly as cargo-mutants needs a buildable crate and
490/// cosmic-ray needs pytest.
491///
492/// The adapter runs at the **package root** — the nearest directory at or above `root`
493/// holding a `package.json` ([`crate::tiers::package_root`]) — and Stryker runs **in
494/// place** there: mutants are applied to the package's real tree (backed up under
495/// `.stryker-tmp`, restored at run end), so the manifest, the package's `tsconfig.json`,
496/// and every reference a source legitimately makes above the scan path
497/// (`import pkg from '../package.json'`, a shared `../tsconfig`) resolve exactly as they
498/// do in the tree — including for the engine's own tooling, which a sandbox copy would
499/// resolve from the isolated install's tree instead. The run stays scan-path-scoped end
500/// to end: mutate patterns address the
501/// scan path within the package, the scan path's colocated suite judges the mutants (the
502/// adapter's `--vitest-dir`), and the results are rebased scan-path-relative before
503/// gating, so exemption paths match every other check. A scan path that is itself the
504/// package root — or a loose tree with no manifest — runs at the scan path, unprefixed.
505///
506/// With `base` set, only mutants on the `<base>...HEAD` changed lines are tested —
507/// Stryker has no native git-diff mode, so the changed lines become `--mutate
508/// <file>:<line>-<line>` ranges (line granularity, matching cargo-mutants' `--in-diff`).
509/// Without it, the scan path's sources run ([`scan_scoped_mutate_globs`]). `exempt` is the
510/// file-level exempt paths and `exempt_lines` the line-scoped ones. `adapter` is the path
511/// to the bundled Node adapter (`dist/mutation/main.js`) — the CLI receives it from the
512/// npm launcher's `--ts-mutation-adapter` argument and hands it down explicitly.
513pub fn measure_typescript(
514    root: &Path,
515    exempt: &[String],
516    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
517    base: Option<&str>,
518    adapter: &Path,
519) -> Result<Measurement> {
520    let package_root =
521        crate::tiers::package_root(root, "package.json").unwrap_or_else(|| root.to_path_buf());
522    let prefix = scan_prefix(root, &package_root);
523    let mutate = match base {
524        Some(base) => {
525            let ranges = mutate_ranges(root, base)?;
526            // Nothing mutatable changed on the diff: the engine is skipped, and the
527            // caller reports it.
528            if ranges.is_empty() {
529                return Ok(Measurement::EngineNotRun);
530            }
531            Some(prefix_mutate_specs(ranges, prefix.as_deref()))
532        }
533        None => prefix.as_deref().map(scan_scoped_mutate_globs),
534    };
535    let json = run_ts_adapter(&package_root, adapter, mutate.as_deref(), prefix.as_deref())?;
536    let mutants = to_scan_relative(parse_normalized_results(&json)?, prefix.as_deref());
537    let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
538    Ok(Measurement::Tested {
539        count: normalized_conclusive_count(&mutants),
540        survivors,
541    })
542}
543
544/// The scan path relative to its package root, as a `/`-joined string — the prefix every
545/// package-root-relative path carries for a scan path below the root. `None` when the scan
546/// path *is* the package root (nothing to prefix), which also covers a loose tree whose
547/// "package root" fell back to the scan path itself.
548fn scan_prefix(root: &Path, package_root: &Path) -> Option<String> {
549    let rel = root.strip_prefix(package_root).ok()?;
550    let parts: Vec<String> = rel
551        .components()
552        .map(|part| part.as_os_str().to_string_lossy().into_owned())
553        .collect();
554    if parts.is_empty() {
555        None
556    } else {
557        Some(parts.join("/"))
558    }
559}
560
561/// Prefix diff-scoped mutate specs (`<file>:<start>-<end>`, scan-path-relative) with the
562/// scan prefix, so they address the same files from the package root the adapter runs at.
563fn prefix_mutate_specs(specs: Vec<String>, prefix: Option<&str>) -> Vec<String> {
564    match prefix {
565        None => specs,
566        Some(prefix) => specs
567            .into_iter()
568            .map(|spec| format!("{prefix}/{spec}"))
569            .collect(),
570    }
571}
572
573/// Stryker's default `mutate` set re-rooted at the scan path: every source under it except
574/// test files and `__tests__` trees — the same shape Stryker itself defaults to for
575/// `{src,lib}`, addressed from the package root the adapter runs at.
576fn scan_scoped_mutate_globs(prefix: &str) -> Vec<String> {
577    const EXTENSIONS: &str = "+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)";
578    vec![
579        format!("{prefix}/**/!(*.+(s|S)pec|*.+(t|T)est).{EXTENSIONS}"),
580        format!("!{prefix}/**/__tests__/**/*.{EXTENSIONS}"),
581    ]
582}
583
584/// Rebase package-root-relative mutant paths onto the scan path: strip the scan prefix so
585/// exemption matching and the reported survivors address scan-path-relative files, as every
586/// other check does. A mutant outside the scan path is outside the gate's scope and dropped.
587fn to_scan_relative(mutants: Vec<NormalizedMutant>, prefix: Option<&str>) -> Vec<NormalizedMutant> {
588    let Some(prefix) = prefix else {
589        return mutants;
590    };
591    let prefix = format!("{prefix}/");
592    mutants
593        .into_iter()
594        .filter_map(|mut mutant| {
595            mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
596            Some(mutant)
597        })
598        .collect()
599}
600
601/// The checked working directory for an adapter run rooted at `root`, for the named
602/// `engine` ("TypeScript" / "Python").
603///
604/// [`crate::tiers::package_root`] walks `scan_root.ancestors()`, which ends at `""`
605/// for a **relative** scan path like `src` — and `Path::new("").join("package.json")`
606/// resolves against the cwd, so the walk stops there and hands back an empty path.
607/// That is the right answer for the callers that join onto it, but
608/// `Command::current_dir("")` fails with ENOENT. Normalise it to `.`.
609///
610/// The directory is then checked, because `Command::output()` reports a missing working
611/// directory and a missing interpreter as the same ENOENT (#478/#493) — so without the
612/// check a directory that isn't there reads as an interpreter that isn't installed.
613fn adapter_cwd<'a>(root: &'a Path, engine: &str) -> Result<&'a Path> {
614    let cwd = if root.as_os_str().is_empty() {
615        Path::new(".")
616    } else {
617        root
618    };
619    if !cwd.is_dir() {
620        bail!(
621            "the {engine} mutation adapter's working directory `{}` is not a directory",
622            cwd.display()
623        );
624    }
625    Ok(cwd)
626}
627
628/// The context a failed adapter spawn carries. `Command::output()` surfaces the bare OS
629/// error, whose ENOENT names nothing at all — so the message names every path the spawn
630/// used: the interpreter, the entry point it was handed, and the directory it ran in
631/// (#493). Naming none of them is what made #478 read as a missing `node`.
632fn spawn_context(interpreter: &str, entry: &str, cwd: &Path) -> String {
633    format!(
634        "spawning `{interpreter} {entry}` in `{}` (is `{interpreter}` on PATH?)",
635        cwd.display()
636    )
637}
638
639/// Run the bundled TS mutation `adapter` at `package_root` and return the
640/// normalized-results JSON it writes. The adapter (a Node entry shipped with the npm
641/// package) drives Stryker via its Node API and emits a [`NormalizedMutant`] array — so
642/// the consumer drives the engine through this CLI alone; the npm package resolves
643/// `@stryker-mutator/*` from the tool's own tree. The adapter's working directory is
644/// Stryker's project root: the in-place run mutates and resolves against it.
645///
646/// `mutate`, when set, scopes the run to `--mutate` patterns; `vitest_dir`, when set,
647/// scopes vitest's test discovery to that directory within the project (the scan path).
648/// Results are written to a temp file the adapter names via `--out` (so Stryker's own
649/// stdout logging can't corrupt them), then read back. `node` and the project's own test
650/// runner must be available; a non-zero adapter exit surfaces its captured output.
651fn run_ts_adapter(
652    package_root: &Path,
653    adapter: &Path,
654    mutate: Option<&[String]>,
655    vitest_dir: Option<&str>,
656) -> Result<String> {
657    let out = AdapterOut::new();
658    std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
659    let results = out.0.join("results.json");
660
661    let cwd = adapter_cwd(package_root, "TypeScript")?;
662
663    let mut command = Command::new("node");
664    command
665        .current_dir(cwd)
666        .arg(adapter)
667        .arg("--out")
668        .arg(&results);
669    if let Some(specs) = mutate {
670        command.arg("--mutate").arg(specs.join(","));
671    }
672    if let Some(dir) = vitest_dir {
673        command.arg("--vitest-dir").arg(dir);
674    }
675    let output = command
676        .output()
677        .with_context(|| spawn_context("node", &adapter.display().to_string(), cwd))?;
678    if !output.status.success() {
679        bail!(
680            "the TypeScript mutation adapter failed in `{}`:\n{}{}",
681            cwd.display(),
682            String::from_utf8_lossy(&output.stdout),
683            String::from_utf8_lossy(&output.stderr),
684        );
685    }
686    std::fs::read_to_string(&results).with_context(|| {
687        format!(
688            "reading the TypeScript mutation adapter's results from `{}`",
689            results.display()
690        )
691    })
692}
693
694/// A unique temp dir for one TS mutation adapter run's `--out` JSON, removed on drop so
695/// the scanned project stays pristine and parallel runs don't collide.
696struct AdapterOut(PathBuf);
697
698impl AdapterOut {
699    fn new() -> Self {
700        static COUNTER: AtomicU64 = AtomicU64::new(0);
701        let name = format!(
702            "testing-conventions-ts-adapter-{}-{}",
703            std::process::id(),
704            COUNTER.fetch_add(1, Ordering::Relaxed),
705        );
706        AdapterOut(std::env::temp_dir().join(name))
707    }
708}
709
710impl Drop for AdapterOut {
711    fn drop(&mut self) {
712        let _ = std::fs::remove_dir_all(&self.0);
713    }
714}
715
716/// Build the Stryker `--mutate` specs scoping a run to the `<base>...HEAD` changed
717/// lines: each mutatable source file's contiguous runs of changed lines become a
718/// `<file>:<start>-<end>` range (Stryker's line-range form). Reuses the patch-coverage
719/// diff parser. Test and declaration files are filtered out — Stryker's configured
720/// `mutate` set normally excludes them, but passing `--mutate` replaces that set.
721fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
722    let changed = crate::patch_coverage::changed_lines(root, base)?;
723    let mut specs = Vec::new();
724    for (file, lines) in changed {
725        if !is_mutatable_ts(&file) {
726            continue;
727        }
728        for (start, end) in contiguous_runs(&lines) {
729            specs.push(format!("{file}:{start}-{end}"));
730        }
731    }
732    Ok(specs)
733}
734
735/// Whether a changed file is a TypeScript/JavaScript *source* Stryker should mutate — a
736/// `.ts`/`.tsx`/`.mts`/`.cts`/`.js`/`.jsx`/`.mjs`/`.cjs` file that is not a declaration
737/// (`.d.ts`) or a test (`.test.` / `.spec.`).
738fn is_mutatable_ts(file: &str) -> bool {
739    let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
740        .iter()
741        .any(|ext| file.ends_with(ext));
742    let is_decl = file.ends_with(".d.ts");
743    let is_test = file.contains(".test.") || file.contains(".spec.");
744    is_source && !is_decl && !is_test
745}
746
747/// Fold a sorted set of line numbers into inclusive `(start, end)` contiguous runs.
748fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
749    let mut runs: Vec<(u64, u64)> = Vec::new();
750    for &line in lines {
751        match runs.last_mut() {
752            Some(run) if run.1 + 1 == line => run.1 = line,
753            _ => runs.push((line, line)),
754        }
755    }
756    runs
757}
758
759/// Run the bundled Python mutation adapter over the project at `root` and return the
760/// [`Measurement`] — the Python arm of the mutation rule, parity with
761/// [`measure_rust`] and [`measure_typescript`].
762///
763/// The tool drives the engine: the wheel ships a Python adapter that runs cosmic-ray through
764/// its own library API (`WorkDB`) and emits the normalized [`NormalizedMutant`] schema
765/// the gate consumes. maturin (`bindings = "bin"`) ships the rust binary directly as the wheel's
766/// script — with no Python launcher to inject a path, unlike the TS arm — so the binary invokes
767/// the adapter as an installed module (`python3 -m testing_conventions.mutation.main`), resolved
768/// from the wheel's environment alongside cosmic-ray. The project supplies its own test runner
769/// (pytest), exactly as cargo-mutants needs a buildable crate and Stryker needs vitest.
770///
771/// With `base` set, only mutants on the `<base>...HEAD` changed lines are reported: cosmic-ray
772/// has no native git-diff mode, so the run is scoped to the changed `.py` files (passed as
773/// `--module`) and the survivors are filtered to the changed lines in the core — line
774/// granularity, matching the other arms. Without it, the whole project's sources run (tests
775/// excluded). `exempt` is the file-level exempt paths and `exempt_lines` the line-scoped ones.
776pub fn measure_python(
777    root: &Path,
778    exempt: &[String],
779    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
780    base: Option<&str>,
781) -> Result<Measurement> {
782    let changed = match base {
783        Some(base) => Some(crate::patch_coverage::changed_lines(root, base)?),
784        None => None,
785    };
786    let modules: Vec<String> = match &changed {
787        None => Vec::new(),
788        Some(changed) => {
789            let modules: Vec<String> = changed
790                .keys()
791                .filter(|file| is_mutatable_py(file))
792                .cloned()
793                .collect();
794            // Nothing mutatable changed on the diff: the engine is skipped, and the
795            // caller reports it.
796            if modules.is_empty() {
797                return Ok(Measurement::EngineNotRun);
798            }
799            modules
800        }
801    };
802    let json = run_py_adapter(root, &modules)?;
803    let mut mutants = parse_normalized_results(&json)?;
804    if let Some(changed) = &changed {
805        // Diff-scoping v1: keep only mutants on the changed lines.
806        mutants.retain(|mutant| {
807            changed
808                .get(&mutant.file)
809                .is_some_and(|lines| lines.contains(&u64::from(mutant.line)))
810        });
811    }
812    let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
813    Ok(Measurement::Tested {
814        count: normalized_conclusive_count(&mutants),
815        survivors,
816    })
817}
818
819/// Run the bundled Python mutation adapter over `root` and return the normalized-results JSON
820/// it writes. The rust binary spawns `python3 -m testing_conventions.mutation.main --out <tmp>
821/// [--module <path> ...]`; the adapter drives cosmic-ray in-process and emits a
822/// [`NormalizedMutant`] array. `modules`, when non-empty, scopes the run to those source
823/// files (the `<base>...HEAD` changed ones); empty runs the whole project. Results are written
824/// to a temp file the adapter names via `--out`, then read back. `PYTHONDONTWRITEBYTECODE` keeps
825/// `__pycache__` out of the scanned tree; a non-zero adapter exit surfaces its captured output.
826fn run_py_adapter(root: &Path, modules: &[String]) -> Result<String> {
827    let out = AdapterOut::new();
828    std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
829    let results = out.0.join("results.json");
830
831    let cwd = adapter_cwd(root, "Python")?;
832
833    const ENTRY: &str = "-m testing_conventions.mutation.main";
834    let mut command = Command::new("python3");
835    command
836        .current_dir(cwd)
837        .args(["-m", "testing_conventions.mutation.main", "--out"])
838        .arg(&results)
839        .env("PYTHONDONTWRITEBYTECODE", "1");
840    for module in modules {
841        command.arg("--module").arg(module);
842    }
843    let output = command
844        .output()
845        .with_context(|| spawn_context("python3", ENTRY, cwd))?;
846    if !output.status.success() {
847        bail!(
848            "the Python mutation adapter failed in `{}`:\n{}{}",
849            cwd.display(),
850            String::from_utf8_lossy(&output.stdout),
851            String::from_utf8_lossy(&output.stderr),
852        );
853    }
854    std::fs::read_to_string(&results).with_context(|| {
855        format!(
856            "reading the Python mutation adapter's results from `{}`",
857            results.display()
858        )
859    })
860}
861
862/// Whether a changed file is a mutatable Python *source* — a `.py` that is not a test
863/// (`*_test.py` / `test_*.py`) or `conftest.py`.
864fn is_mutatable_py(file: &str) -> bool {
865    if !file.ends_with(".py") {
866        return false;
867    }
868    let base = file.rsplit('/').next().unwrap_or(file);
869    !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
870}
871
872/// A unique temp dir for one cargo-mutants run's `--output`, removed on drop so the
873/// scanned crate stays pristine and parallel runs don't collide.
874struct MutantsOut(PathBuf);
875
876impl MutantsOut {
877    fn new() -> Self {
878        static COUNTER: AtomicU64 = AtomicU64::new(0);
879        let name = format!(
880            "testing-conventions-mutants-{}-{}",
881            std::process::id(),
882            COUNTER.fetch_add(1, Ordering::Relaxed),
883        );
884        MutantsOut(std::env::temp_dir().join(name))
885    }
886}
887
888impl Drop for MutantsOut {
889    fn drop(&mut self) {
890        let _ = std::fs::remove_dir_all(&self.0);
891    }
892}
893
894/// The directory of the cargo workspace `root` belongs to — the source-tree root
895/// cargo-mutants addresses its diff and report paths from. A standalone crate is its
896/// own workspace root. `cargo locate-project --workspace` is the authoritative lookup:
897/// membership involves member globs and `exclude` lists a manifest walk can't settle.
898fn cargo_workspace_root(root: &Path) -> Result<PathBuf> {
899    let output = Command::new("cargo")
900        .current_dir(root)
901        .args(["locate-project", "--workspace", "--message-format", "plain"])
902        .output()
903        .context("running `cargo locate-project` (is cargo installed?)")?;
904    if !output.status.success() {
905        bail!(
906            "cargo locate-project failed in `{}`: {}",
907            root.display(),
908            String::from_utf8_lossy(&output.stderr)
909        );
910    }
911    let manifest = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
912    manifest.parent().map(Path::to_path_buf).with_context(|| {
913        format!(
914            "no parent dir for the workspace manifest `{}`",
915            manifest.display()
916        )
917    })
918}
919
920/// The scan path's prefix relative to the workspace root ([`scan_prefix`]), over
921/// canonicalized paths so a relative CLI scan path resolves against the absolute path
922/// `cargo locate-project` reports. `None` when the scan path *is* the workspace root.
923fn canonical_scan_prefix(root: &Path, workspace_root: &Path) -> Option<String> {
924    let root = root.canonicalize().ok()?;
925    let workspace_root = workspace_root.canonicalize().ok()?;
926    scan_prefix(&root, &workspace_root)
927}
928
929/// Write the `<base>...HEAD` diff cargo-mutants' `--in-diff` scopes to, returning its
930/// path — or `None` when the diff is empty (no changed lines under the crate).
931///
932/// cargo-mutants matches `--in-diff` paths relative to the crate's cargo workspace
933/// root, so the diff is generated there: `--relative` makes the paths workspace-root-
934/// relative, and for a workspace-member crate a pathspec (`prefix`) restricts the diff
935/// to changes under the scan path. A standalone crate is its own workspace root, so
936/// the same invocation runs in the crate dir with no pathspec. Scoping means a PR that
937/// doesn't touch the crate yields an empty diff either way.
938fn write_base_diff(
939    root: &Path,
940    workspace_root: &Path,
941    prefix: Option<&str>,
942    base: &str,
943    out: &MutantsOut,
944) -> Result<Option<PathBuf>> {
945    let range = format!("{base}...HEAD");
946    let (dir, args) = match prefix {
947        None => (root, vec!["diff", "--relative", &range]),
948        Some(prefix) => (
949            workspace_root,
950            vec!["diff", "--relative", &range, "--", prefix],
951        ),
952    };
953    let output = Command::new("git")
954        .current_dir(dir)
955        .args(&args)
956        .output()
957        .context("running `git diff` for `--base` (is git installed?)")?;
958    if !output.status.success() {
959        bail!(
960            "git diff {range} failed: {}",
961            String::from_utf8_lossy(&output.stderr)
962        );
963    }
964    if output.stdout.is_empty() {
965        return Ok(None);
966    }
967    std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
968    let path = out.0.join("base.diff");
969    std::fs::write(&path, &output.stdout).context("writing the base diff")?;
970    Ok(Some(path))
971}
972
973/// The tool's own reading of a base diff: the changed files (new-side paths, `b/`
974/// convention stripped) and the inserted line numbers per file, in new-file numbering.
975/// Paths stay on the diff's own basis — workspace-root-relative, the same basis
976/// cargo-mutants addresses mutants on.
977struct BaseDiff {
978    files: Vec<String>,
979    inserted: BTreeMap<String, BTreeSet<u32>>,
980}
981
982/// Parse a unified diff into a [`BaseDiff`]. Each hunk body is consumed by the counts
983/// its `@@` header declares, so a content line that begins with `+++` or `---` never
984/// reads as a file header. A deleted file (`+++ /dev/null`) has no lines in `HEAD`, so
985/// it carries neither a changed file nor inserted lines.
986fn parse_base_diff(diff: &str) -> BaseDiff {
987    let mut files = Vec::new();
988    let mut inserted: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
989    let mut current: Option<String> = None;
990    let mut lines = diff.lines();
991    while let Some(line) = lines.next() {
992        if let Some(path) = line.strip_prefix("+++ ") {
993            current = (path != "/dev/null").then(|| {
994                let path = path.strip_prefix("b/").unwrap_or(path).to_string();
995                files.push(path.clone());
996                path
997            });
998        } else if let Some(header) = line.strip_prefix("@@ ") {
999            let Some((new_start, old_count, new_count)) = parse_hunk_header(header) else {
1000                continue;
1001            };
1002            let mut new_line = new_start;
1003            let (mut old_left, mut new_left) = (old_count, new_count);
1004            while old_left > 0 || new_left > 0 {
1005                let Some(line) = lines.next() else { break };
1006                if line.starts_with('\\') {
1007                    // "\ No newline at end of file" annotates the previous line and
1008                    // counts against neither side.
1009                } else if line.starts_with('+') {
1010                    if let Some(file) = &current {
1011                        inserted.entry(file.clone()).or_default().insert(new_line);
1012                    }
1013                    new_line += 1;
1014                    new_left = new_left.saturating_sub(1);
1015                } else if line.starts_with('-') {
1016                    old_left = old_left.saturating_sub(1);
1017                } else {
1018                    new_line += 1;
1019                    old_left = old_left.saturating_sub(1);
1020                    new_left = new_left.saturating_sub(1);
1021                }
1022            }
1023        }
1024    }
1025    BaseDiff { files, inserted }
1026}
1027
1028/// The `(new_start, old_count, new_count)` of a hunk header's `-a[,b] +c[,d]` part.
1029fn parse_hunk_header(header: &str) -> Option<(u32, u32, u32)> {
1030    let mut parts = header.split(' ');
1031    let (_, old_count) = parse_range(parts.next()?.strip_prefix('-')?)?;
1032    let (new_start, new_count) = parse_range(parts.next()?.strip_prefix('+')?)?;
1033    Some((new_start, old_count, new_count))
1034}
1035
1036/// A hunk range `start[,count]`; the count defaults to 1.
1037fn parse_range(range: &str) -> Option<(u32, u32)> {
1038    match range.split_once(',') {
1039        Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
1040        None => Some((range.parse().ok()?, 1)),
1041    }
1042}
1043
1044/// Rebase a cargo-mutants report's workspace-root-relative mutant paths onto the scan
1045/// path: strip the scan prefix so survivor reporting and `mutation` exemption matching
1046/// address scan-path-relative files, as every other check does. Baseline outcomes carry
1047/// no path and pass through; a mutant outside the scan path is outside the gate's scope
1048/// and dropped (parity with the TS arm's [`to_scan_relative`]). `None` is the standalone
1049/// crate: the report already addresses the scan path.
1050fn rebase_report_paths(report: MutantsReport, prefix: Option<&str>) -> MutantsReport {
1051    let Some(prefix) = prefix else {
1052        return report;
1053    };
1054    let prefix = format!("{prefix}/");
1055    MutantsReport {
1056        outcomes: report
1057            .outcomes
1058            .into_iter()
1059            .filter_map(|mut outcome| {
1060                if let Scenario::Mutant(mutant) = &mut outcome.scenario {
1061                    mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
1062                }
1063                Some(outcome)
1064            })
1065            .collect(),
1066    }
1067}
1068
1069/// The cargo-mutants version the Rust arm provisions and pins to. Bumping this points the
1070/// cache at a fresh version-scoped directory, so the next run installs the new release.
1071const CARGO_MUTANTS_VERSION: &str = "27.1.0";
1072
1073/// Ensure the pinned cargo-mutants is available and return the absolute path to its binary,
1074/// provisioning it on first use.
1075///
1076/// The consumer installs nothing and never names the engine:
1077/// cargo ships no library form of cargo-mutants, so — unlike the in-process TS/Python
1078/// adapters — the tool runs a pinned `cargo install cargo-mutants` into its own cache
1079/// directory and drives the installed binary from there. A cached binary is reused; only a
1080/// cargo toolchain need be present. This is the one deliberate asymmetry from the other
1081/// arms, called out per the cross-language-parity rule.
1082fn ensure_cargo_mutants() -> Result<PathBuf> {
1083    let root = cargo_mutants_cache_root();
1084    let bin = root.join("bin").join(cargo_mutants_bin_name());
1085    let lock_path = root.join(".install.lock");
1086    provision(&bin, &lock_path, || {
1087        run_install(&root, |command| command.output())
1088    })
1089}
1090
1091/// The cargo-mutants binary's file name (`.exe` on Windows), as `cargo install --root`
1092/// lays it out under `<root>/bin/`.
1093fn cargo_mutants_bin_name() -> &'static str {
1094    if cfg!(windows) {
1095        "cargo-mutants.exe"
1096    } else {
1097        "cargo-mutants"
1098    }
1099}
1100
1101/// The tool-owned, version-scoped cache directory cargo-mutants is installed under, so a
1102/// version bump provisions cleanly beside the old one and never clobbers a user's own
1103/// `~/.cargo/bin`.
1104fn cargo_mutants_cache_root() -> PathBuf {
1105    cache_base()
1106        .join("testing-conventions")
1107        .join(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}"))
1108}
1109
1110/// The base cache directory, read from OS-owned config. Split from [`resolve_cache_base`]
1111/// so the resolution logic is unit-tested without touching the process environment.
1112fn cache_base() -> PathBuf {
1113    resolve_cache_base(std::env::var_os("XDG_CACHE_HOME"), std::env::var_os("HOME"))
1114}
1115
1116/// Resolve the base cache dir: `XDG_CACHE_HOME` when set and non-empty, else `$HOME/.cache`,
1117/// else the temp dir. Pure over its inputs.
1118fn resolve_cache_base(xdg: Option<OsString>, home: Option<OsString>) -> PathBuf {
1119    if let Some(dir) = xdg.filter(|value| !value.is_empty()) {
1120        return PathBuf::from(dir);
1121    }
1122    if let Some(dir) = home.filter(|value| !value.is_empty()) {
1123        return PathBuf::from(dir).join(".cache");
1124    }
1125    std::env::temp_dir()
1126}
1127
1128/// Return `bin` if it already exists, otherwise take an exclusive advisory lock at
1129/// `lock_path`, re-check (another caller may have installed while this one waited for the
1130/// lock), and run `install` if still absent.
1131///
1132/// The lock closes a race: a bare check-then-install with no locking let N
1133/// concurrent callers that all observed an absent binary each launch a full `cargo install`
1134/// — correct (no corrupted output) but ruinously slow, since a from-source cargo-mutants
1135/// compile is duplicated N times instead of once. Concurrent callers now wait ~one install and
1136/// find the binary, instead of each running their own; a cold cache costs one serial install
1137/// regardless of how many callers race for it.
1138///
1139/// Pure over the filesystem plus the injected installer, so a test drives every branch with
1140/// a temp path and a fake installer (no from-source compile). An installer that reports
1141/// success but produces no binary is an error.
1142fn provision(
1143    bin: &Path,
1144    lock_path: &Path,
1145    install: impl FnOnce() -> Result<()>,
1146) -> Result<PathBuf> {
1147    if bin.exists() {
1148        return Ok(bin.to_path_buf());
1149    }
1150    if let Some(parent) = lock_path.parent() {
1151        std::fs::create_dir_all(parent).context("creating the provisioning lock's parent dir")?;
1152    }
1153    let lock_file = std::fs::OpenOptions::new()
1154        .create(true)
1155        .truncate(false)
1156        .write(true)
1157        .open(lock_path)
1158        .context("opening the provisioning lock file")?;
1159    lock_file
1160        .lock()
1161        .context("acquiring the provisioning lock")?;
1162    // Re-check: another caller may have installed while this one waited for the lock.
1163    if bin.exists() {
1164        return Ok(bin.to_path_buf());
1165    }
1166    install()?;
1167    if !bin.exists() {
1168        bail!(
1169            "provisioning reported success but cargo-mutants is not at `{}`",
1170            bin.display()
1171        );
1172    }
1173    Ok(bin.to_path_buf())
1174}
1175
1176/// The argv provisioning the pinned cargo-mutants into `root` (`cargo install cargo-mutants
1177/// --locked --version <X> --root <root>`). Split from execution so a test asserts the pin
1178/// and the isolated `--root` without a real install.
1179fn install_argv(root: &Path) -> Vec<OsString> {
1180    vec![
1181        OsString::from("install"),
1182        OsString::from("cargo-mutants"),
1183        OsString::from("--locked"),
1184        OsString::from("--version"),
1185        OsString::from(CARGO_MUTANTS_VERSION),
1186        OsString::from("--root"),
1187        root.as_os_str().to_os_string(),
1188    ]
1189}
1190
1191/// Provision cargo-mutants into `root`, executing the built `cargo install` command with
1192/// `run`. `run` is injected so a test drives the success and failure branches with a fake
1193/// (no from-source compile). The coverage-instrumentation env is stripped so the compile
1194/// doesn't re-enter a `cargo llvm-cov` rustc wrapper.
1195fn run_install(
1196    root: &Path,
1197    run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1198) -> Result<()> {
1199    let mut command = Command::new("cargo");
1200    command.args(install_argv(root));
1201    strip_llvm_cov_env(&mut command);
1202    let output = run(&mut command)
1203        .context("provisioning cargo-mutants via `cargo install` (is cargo installed?)")?;
1204    if !output.status.success() {
1205        bail!(
1206            "failed to provision cargo-mutants {CARGO_MUTANTS_VERSION}:\n{}{}",
1207            String::from_utf8_lossy(&output.stdout),
1208            String::from_utf8_lossy(&output.stderr),
1209        );
1210    }
1211    Ok(())
1212}
1213
1214/// Strip the outer coverage-instrumentation env from a nested cargo invocation (the
1215/// cargo-mutants run, or the `cargo install` that provisions it) so it doesn't re-enter the
1216/// `cargo llvm-cov` rustc wrapper and hang, as when this rule's own tests run under coverage.
1217fn strip_llvm_cov_env(command: &mut Command) {
1218    for var in [
1219        "RUSTFLAGS",
1220        "CARGO_ENCODED_RUSTFLAGS",
1221        "RUSTDOCFLAGS",
1222        "CARGO_ENCODED_RUSTDOCFLAGS",
1223        "LLVM_PROFILE_FILE",
1224        "CARGO_LLVM_COV",
1225        "CARGO_LLVM_COV_SHOW_ENV",
1226        "CARGO_LLVM_COV_TARGET_DIR",
1227        "CARGO_LLVM_COV_BUILD_DIR",
1228        "RUSTC_WRAPPER",
1229        "RUSTC_WORKSPACE_WRAPPER",
1230        "__CARGO_LLVM_COV_RUSTC_WRAPPER",
1231        "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
1232        "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
1233    ] {
1234        command.env_remove(var);
1235    }
1236}
1237
1238/// Run the cargo-mutants argv ([`mutants_argv`]) in `root`, where `engine` is the provisioned
1239/// cargo-mutants binary ([`ensure_cargo_mutants`]) invoked by absolute path, and return the
1240/// engine's [`Output`] for the caller's diagnostics.
1241///
1242/// The exit code is classified by [`classify_mutants_exit`] (`0`/`2`/`3` normal, else fatal).
1243/// The outer instrumentation env is stripped so a nested run (this rule's own tests under
1244/// `cargo llvm-cov`) doesn't re-enter the rustc wrapper and hang.
1245fn run_cargo_mutants(
1246    engine: &Path,
1247    root: &Path,
1248    out: &Path,
1249    in_diff: Option<&Path>,
1250    features: &[String],
1251) -> Result<Output> {
1252    let mut command = Command::new(engine);
1253    command
1254        .current_dir(root)
1255        .args(mutants_argv(out, in_diff, features));
1256    strip_llvm_cov_env(&mut command);
1257    let output = command.output().context("running cargo-mutants")?;
1258    classify_mutants_exit(root, &output)?;
1259    Ok(output)
1260}
1261
1262/// Decide whether an engine run that judged zero mutants is legitimate: `listed` is the
1263/// crate's full mutant list and `diff` the tool's own reading of the very diff the engine
1264/// filtered by. A listed mutant whose span touches an inserted line proves the filter
1265/// dropped real mutants — a fatal error naming the sites and the engine's output — while
1266/// no overlap confirms an honest zero. The inserted lines are a subset of the engine's
1267/// affected-lines rule (insertions plus deletion-adjacent lines), so a legitimate zero
1268/// never trips this.
1269fn zero_mutant_verdict(listed: &[MutantInfo], diff: &BaseDiff, run: &Output) -> Result<()> {
1270    let dropped: Vec<&MutantInfo> = listed
1271        .iter()
1272        .filter(|mutant| {
1273            diff.inserted.get(&mutant.file).is_some_and(|lines| {
1274                lines
1275                    .range(mutant.span.start.line..=mutant.span.end.line)
1276                    .next()
1277                    .is_some()
1278            })
1279        })
1280        .collect();
1281    if dropped.is_empty() {
1282        return Ok(());
1283    }
1284    let sites: Vec<String> = dropped
1285        .iter()
1286        .map(|mutant| {
1287            format!(
1288                "  {}:{}: {}",
1289                mutant.file, mutant.span.start.line, mutant.name
1290            )
1291        })
1292        .collect();
1293    bail!(
1294        "cargo-mutants tested no mutants, but {} of the crate's {} mutant site(s) sit on the diff's inserted lines — the changed-line filter dropped real mutants:\n{}\nengine output:\n{}{}",
1295        dropped.len(),
1296        listed.len(),
1297        sites.join("\n"),
1298        String::from_utf8_lossy(&run.stdout),
1299        String::from_utf8_lossy(&run.stderr),
1300    )
1301}
1302
1303/// The argv for one cargo-mutants mutant listing: `mutants --list --json
1304/// [--features <list>]`, mirroring the run's own feature selection so both see the same
1305/// mutant set.
1306fn list_argv(features: &[String]) -> Vec<OsString> {
1307    let mut argv = vec![
1308        OsString::from("mutants"),
1309        OsString::from("--list"),
1310        OsString::from("--json"),
1311    ];
1312    if !features.is_empty() {
1313        argv.push(OsString::from("--features"));
1314        argv.push(OsString::from(features.join(",")));
1315    }
1316    argv
1317}
1318
1319/// List the crate's discoverable mutants via `cargo mutants --list --json`, executing the
1320/// built command with `run`. `run` is injected so a test drives the success and failure
1321/// branches with a fake (no real engine).
1322fn list_cargo_mutants(
1323    engine: &Path,
1324    root: &Path,
1325    features: &[String],
1326    run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1327) -> Result<Vec<MutantInfo>> {
1328    let mut command = Command::new(engine);
1329    command.current_dir(root).args(list_argv(features));
1330    strip_llvm_cov_env(&mut command);
1331    let output = run(&mut command).context("listing the crate's mutants with cargo-mutants")?;
1332    if !output.status.success() {
1333        bail!(
1334            "cargo-mutants --list failed in `{}`:\n{}{}",
1335            root.display(),
1336            String::from_utf8_lossy(&output.stdout),
1337            String::from_utf8_lossy(&output.stderr),
1338        );
1339    }
1340    parse_mutants_list(&String::from_utf8_lossy(&output.stdout))
1341}
1342
1343/// The argv for one cargo-mutants run: `mutants --output <out> [--in-diff <diff>]
1344/// [--features <list>]`. Split from execution so the shape is unit-tested without a real
1345/// engine run.
1346///
1347/// The `[rust] features` list rides on cargo-mutants' **own** `--features` option, which it
1348/// applies to every cargo invocation the run makes. Cargo builds a crate's test targets
1349/// before running them, and a list passed after the `--` separator reaches `cargo test`
1350/// alone — so a crate whose test target names a `#[cfg(feature = ...)]` item fails to compile
1351/// in the unmutated tree, and the run judges nothing. On the engine's option the targets
1352/// build, the gated code is mutated, and the tests covering it judge those mutants.
1353fn mutants_argv(out: &Path, in_diff: Option<&Path>, features: &[String]) -> Vec<OsString> {
1354    let mut argv = vec![
1355        OsString::from("mutants"),
1356        OsString::from("--output"),
1357        out.as_os_str().to_os_string(),
1358    ];
1359    if let Some(diff) = in_diff {
1360        argv.push(OsString::from("--in-diff"));
1361        argv.push(diff.as_os_str().to_os_string());
1362    }
1363    if !features.is_empty() {
1364        argv.push(OsString::from("--features"));
1365        argv.push(OsString::from(features.join(",")));
1366    }
1367    argv
1368}
1369
1370/// Classify a finished cargo-mutants run's exit code as a normal outcome or a fatal error.
1371/// Split from [`run_cargo_mutants`] so the exit-code handling is unit-tested with an injected
1372/// [`Output`] rather than a real (and, for a timeout, a genuinely slow) engine run.
1373///
1374/// cargo-mutants exits `0` when every mutant is caught, `2` when some are missed (survivors),
1375/// and `3` when some mutants **timed out** and none were missed — all three write an
1376/// `outcomes.json` the gate reads, and a timeout is inconclusive (this module's own `Timeout`
1377/// semantics), not a survivor. Any other code — a usage error, or a baseline that didn't
1378/// build/pass (exit 4) — is fatal.
1379fn classify_mutants_exit(root: &Path, output: &Output) -> Result<()> {
1380    match output.status.code() {
1381        // 0 = all caught, 2 = some missed (survivors), 3 = some timed out with none missed:
1382        // all three produce an outcomes.json to read, and a timeout is inconclusive.
1383        Some(0) | Some(2) | Some(3) => Ok(()),
1384        _ => bail!(
1385            "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
1386            root.display(),
1387            String::from_utf8_lossy(&output.stdout),
1388            String::from_utf8_lossy(&output.stderr),
1389        ),
1390    }
1391}
1392
1393#[cfg(test)]
1394mod tests {
1395    use super::*;
1396
1397    // A normalized result set covering every status: two survivors (Survived + NoCoverage),
1398    // a caught Killed, an inconclusive-but-viable Timeout, and two unviable mutants
1399    // (CompileError / RuntimeError). `snake_case` on the wire; an extra field is ignored.
1400    const NORMALIZED: &str = r#"[
1401        {"file": "src/a.ts", "line": 2, "status": "survived",
1402         "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
1403        {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
1404        {"file": "src/a.ts", "line": 9, "status": "killed",
1405         "mutator": "BooleanLiteral", "replacement": "false"},
1406        {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
1407        {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
1408        {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
1409    ]"#;
1410
1411    #[test]
1412    fn parses_the_normalized_schema() {
1413        let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
1414        assert_eq!(mutants.len(), 6);
1415        assert_eq!(mutants[0].status, MutantStatus::Survived);
1416        assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
1417        assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
1418        assert_eq!(mutants[1].replacement, None);
1419    }
1420
1421    #[test]
1422    fn normalized_survivors_are_survived_and_nocoverage_only() {
1423        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1424        let survivors = normalized_survivors(&mutants);
1425        // Survived (2) + NoCoverage (5); not killed/timeout/compile/runtime.
1426        assert_eq!(survivors.len(), 2);
1427        assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
1428        // Replacement is folded into the description when present, omitted otherwise.
1429        assert!(survivors[0].description.contains("ConditionalExpression"));
1430        assert!(survivors[0].description.contains("-> true"));
1431        assert_eq!(survivors[1].description, "ArithmeticOperator");
1432    }
1433
1434    #[test]
1435    fn normalized_mutated_lines_collects_only_viable_mutants() {
1436        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1437        // Survived/Killed/NoCoverage/Timeout ran; CompileError/RuntimeError never produced
1438        // a viable mutant.
1439        assert_eq!(
1440            normalized_mutated_lines(&mutants),
1441            [2u32, 5, 9, 12]
1442                .into_iter()
1443                .map(|line| ("src/a.ts".to_string(), line))
1444                .collect()
1445        );
1446    }
1447
1448    #[test]
1449    fn normalized_conclusive_count_is_survived_killed_and_nocoverage() {
1450        // Survived (2) + NoCoverage (5) + Killed (9) were judged; Timeout ran but judged
1451        // nothing, and CompileError / RuntimeError never produced a viable mutant.
1452        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1453        assert_eq!(normalized_conclusive_count(&mutants), 3);
1454        assert_eq!(normalized_conclusive_count(&[]), 0);
1455    }
1456
1457    #[test]
1458    fn evaluate_normalized_reports_unexempted_survivors() {
1459        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1460        let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
1461        assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
1462    }
1463
1464    #[test]
1465    fn evaluate_normalized_drops_a_whole_file_exemption() {
1466        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1467        let kept =
1468            evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
1469        assert!(
1470            kept.is_empty(),
1471            "the whole-file exemption lifts both survivors"
1472        );
1473    }
1474
1475    #[test]
1476    fn evaluate_normalized_drops_a_line_scoped_exemption() {
1477        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1478        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
1479        let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1480        // Line 2's survivor is lifted; line 5's still stands.
1481        assert_eq!(kept.len(), 1);
1482        assert_eq!(kept[0].line, 5);
1483    }
1484
1485    #[test]
1486    fn evaluate_normalized_rejects_exempting_a_caught_line() {
1487        // Line 9 had only a Killed mutant (viable, no survivor) — over-exemption is an error,
1488        // via the shared determinism guard.
1489        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1490        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
1491        let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
1492        assert!(
1493            err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
1494            "got: {err}"
1495        );
1496    }
1497
1498    #[test]
1499    fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
1500        // Line 15 had only a CompileError (no viable mutant) — neither an error nor a drop;
1501        // the real survivors still stand.
1502        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1503        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
1504        let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1505        assert_eq!(kept.len(), 2);
1506    }
1507
1508    // A pared `outcomes.json`: the baseline, one missed mutant, and one caught — the
1509    // real shape (externally-tagged `scenario`, extra fields the rule ignores).
1510    const SAMPLE: &str = r#"{
1511        "outcomes": [
1512            {"scenario": "Baseline", "summary": "Success",
1513             "phase_results": []},
1514            {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
1515                "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
1516                "function": {"function_name": "is_positive"},
1517                "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
1518             "summary": "MissedMutant"},
1519            {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
1520                "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
1521                "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
1522             "summary": "CaughtMutant"}
1523        ],
1524        "total_mutants": 2
1525    }"#;
1526
1527    #[test]
1528    fn parses_the_outcomes_export() {
1529        let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
1530        assert_eq!(report.outcomes.len(), 3);
1531        assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
1532    }
1533
1534    #[test]
1535    fn collects_only_missed_mutants_as_survivors() {
1536        let report = parse_mutants_report(SAMPLE).unwrap();
1537        let survivors = unexplained_survivors(&report, &[]);
1538        // Only the MissedMutant — the baseline and the CaughtMutant are not survivors.
1539        assert_eq!(survivors.len(), 1);
1540        assert_eq!(survivors[0].file, "src/lib.rs");
1541        assert_eq!(survivors[0].line, 7);
1542        assert!(survivors[0].description.contains("replace > with =="));
1543    }
1544
1545    #[test]
1546    fn conclusive_count_is_caught_plus_missed() {
1547        // The MissedMutant and the CaughtMutant were judged; the Baseline is not a mutant.
1548        let report = parse_mutants_report(SAMPLE).unwrap();
1549        assert_eq!(conclusive_count(&report), 2);
1550        assert_eq!(conclusive_count(&MutantsReport { outcomes: vec![] }), 0);
1551    }
1552
1553    #[test]
1554    fn an_exemption_drops_a_survivor_in_that_file() {
1555        let report = parse_mutants_report(SAMPLE).unwrap();
1556        let exempt = vec!["src/lib.rs".to_string()];
1557        assert!(unexplained_survivors(&report, &exempt).is_empty());
1558    }
1559
1560    #[test]
1561    fn an_exemption_on_another_file_leaves_the_survivor() {
1562        let report = parse_mutants_report(SAMPLE).unwrap();
1563        let exempt = vec!["src/elsewhere.rs".to_string()];
1564        assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1565    }
1566
1567    #[test]
1568    fn rebase_report_paths_strips_the_workspace_prefix() {
1569        // A workspace-member crate's report addresses files workspace-root-relative
1570        // (`member/src/lib.rs`); gating addresses them scan-path-relative (`src/lib.rs`).
1571        let report = parse_mutants_report(SAMPLE).unwrap();
1572        let prefixed = MutantsReport {
1573            outcomes: report
1574                .outcomes
1575                .iter()
1576                .cloned()
1577                .map(|mut outcome| {
1578                    if let Scenario::Mutant(mutant) = &mut outcome.scenario {
1579                        mutant.file = format!("member/{}", mutant.file);
1580                    }
1581                    outcome
1582                })
1583                .collect(),
1584        };
1585        let rebased = rebase_report_paths(prefixed, Some("member"));
1586        let survivors = unexplained_survivors(&rebased, &[]);
1587        assert_eq!(survivors.len(), 1);
1588        assert_eq!(survivors[0].file, "src/lib.rs");
1589        // The baseline outcome carries no path and passes through.
1590        assert_eq!(rebased.outcomes.len(), 3);
1591    }
1592
1593    #[test]
1594    fn rebase_report_paths_drops_an_out_of_scope_mutant_and_keeps_none_identity() {
1595        let report = parse_mutants_report(SAMPLE).unwrap();
1596        // `src/lib.rs` / `src/other.rs` don't carry the `member/` prefix: out of scope.
1597        let rebased = rebase_report_paths(report.clone(), Some("member"));
1598        assert_eq!(
1599            rebased.outcomes.len(),
1600            1,
1601            "only the pathless baseline outcome remains"
1602        );
1603        // No prefix (a standalone crate): the report passes through untouched.
1604        let unchanged = rebase_report_paths(report, None);
1605        assert_eq!(unchanged.outcomes.len(), 3);
1606        assert_eq!(unexplained_survivors(&unchanged, &[])[0].file, "src/lib.rs");
1607    }
1608
1609    #[test]
1610    fn adapter_cwd_normalises_the_empty_package_root_to_the_current_dir() {
1611        // `tiers::package_root` yields `""` for a relative scan path such as `src`,
1612        // and `Command::current_dir("")` fails with ENOENT — which the adapter's
1613        // error context mislabelled as a missing `node`. Every TypeScript consumer
1614        // of the mutation gate hit this, since the reusable workflow scans `src`.
1615        // Cargo runs a unit test from the crate root, so `.` and `src` are both real.
1616        assert_eq!(
1617            adapter_cwd(Path::new(""), "TypeScript").unwrap(),
1618            Path::new(".")
1619        );
1620        assert_eq!(
1621            adapter_cwd(Path::new("src"), "TypeScript").unwrap(),
1622            Path::new("src")
1623        );
1624    }
1625
1626    #[test]
1627    fn adapter_cwd_rejects_a_directory_that_is_not_there() {
1628        // The check is the point: `Command::output()` reports a missing working directory
1629        // with the same ENOENT as a missing interpreter, so an unchecked spawn tells a
1630        // consumer whose scan path is wrong that the interpreter is missing instead.
1631        let err = adapter_cwd(Path::new("no/such/dir"), "Python")
1632            .expect_err("a directory that is not there is an error");
1633        assert_eq!(
1634            err.to_string(),
1635            "the Python mutation adapter's working directory `no/such/dir` is not a directory"
1636        );
1637    }
1638
1639    #[test]
1640    fn spawn_context_names_the_interpreter_the_entry_and_the_working_directory() {
1641        // Every path the spawn used, so an ENOENT is diagnosable from the message alone.
1642        // The #478 message named none of them, and cost hours to a wrong first guess.
1643        assert_eq!(
1644            spawn_context("node", "/pkg/dist/mutation/main.js", Path::new("/pkg")),
1645            "spawning `node /pkg/dist/mutation/main.js` in `/pkg` (is `node` on PATH?)"
1646        );
1647    }
1648
1649    #[test]
1650    fn scan_prefix_is_the_scan_path_relative_to_the_package_root() {
1651        assert_eq!(
1652            scan_prefix(Path::new("/repo/pkg/src"), Path::new("/repo/pkg")),
1653            Some("src".to_string())
1654        );
1655        assert_eq!(
1656            scan_prefix(Path::new("/repo/pkg/src/nested"), Path::new("/repo/pkg")),
1657            Some("src/nested".to_string())
1658        );
1659        // The scan path is the package root itself: nothing to prefix.
1660        assert_eq!(
1661            scan_prefix(Path::new("/repo/pkg"), Path::new("/repo/pkg")),
1662            None
1663        );
1664        // Relative scan paths resolve the same way.
1665        assert_eq!(
1666            scan_prefix(Path::new("pkg/src"), Path::new("pkg")),
1667            Some("src".to_string())
1668        );
1669    }
1670
1671    #[test]
1672    fn prefix_mutate_specs_rebases_diff_ranges_onto_the_package_root() {
1673        let specs = vec!["index.ts:8-11".to_string(), "a/b.ts:2-2".to_string()];
1674        assert_eq!(
1675            prefix_mutate_specs(specs.clone(), Some("src")),
1676            vec![
1677                "src/index.ts:8-11".to_string(),
1678                "src/a/b.ts:2-2".to_string()
1679            ]
1680        );
1681        assert_eq!(prefix_mutate_specs(specs.clone(), None), specs);
1682    }
1683
1684    #[test]
1685    fn scan_scoped_mutate_globs_mirror_strykers_default_under_the_scan_path() {
1686        assert_eq!(
1687            scan_scoped_mutate_globs("src"),
1688            vec![
1689                "src/**/!(*.+(s|S)pec|*.+(t|T)est).+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
1690                    .to_string(),
1691                "!src/**/__tests__/**/*.+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
1692                    .to_string(),
1693            ]
1694        );
1695    }
1696
1697    #[test]
1698    fn to_scan_relative_strips_the_prefix_and_drops_out_of_scope_mutants() {
1699        let mutants = parse_normalized_results(
1700            r#"[
1701                {"file": "src/a.ts", "line": 2, "status": "survived", "mutator": "X"},
1702                {"file": "tests/e2e/t.ts", "line": 9, "status": "survived", "mutator": "X"}
1703            ]"#,
1704        )
1705        .unwrap();
1706        let rebased = to_scan_relative(mutants.clone(), Some("src"));
1707        assert_eq!(rebased.len(), 1, "the out-of-scan-path mutant is dropped");
1708        assert_eq!(rebased[0].file, "a.ts");
1709        // No prefix (the scan path is the package root): paths pass through untouched.
1710        let unchanged = to_scan_relative(mutants, None);
1711        assert_eq!(unchanged.len(), 2);
1712        assert_eq!(unchanged[0].file, "src/a.ts");
1713    }
1714
1715    #[test]
1716    fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
1717        assert!(is_mutatable_ts("src/index.ts"));
1718        assert!(is_mutatable_ts("src/util.tsx"));
1719        assert!(is_mutatable_ts("src/util.js"));
1720        assert!(!is_mutatable_ts("src/index.test.ts"));
1721        assert!(!is_mutatable_ts("src/index.spec.ts"));
1722        assert!(!is_mutatable_ts("src/types.d.ts"));
1723        assert!(!is_mutatable_ts("README.md"));
1724    }
1725
1726    #[test]
1727    fn contiguous_runs_collapses_adjacent_lines() {
1728        let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
1729        assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
1730        assert!(contiguous_runs(&BTreeSet::new()).is_empty());
1731    }
1732
1733    #[test]
1734    fn one_line_flattens_and_caps() {
1735        assert_eq!(one_line("a -\n  b"), "a - b");
1736        let long = "x".repeat(80);
1737        let capped = one_line(&long);
1738        assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
1739    }
1740
1741    #[test]
1742    fn is_mutatable_py_keeps_sources_and_drops_tests() {
1743        assert!(is_mutatable_py("calc.py"));
1744        assert!(is_mutatable_py("pkg/util.py"));
1745        assert!(!is_mutatable_py("calc_test.py"));
1746        assert!(!is_mutatable_py("test_calc.py"));
1747        assert!(!is_mutatable_py("pkg/conftest.py"));
1748        assert!(!is_mutatable_py("README.md"));
1749    }
1750
1751    #[test]
1752    fn mutated_lines_collects_caught_and_missed() {
1753        // The MissedMutant (src/lib.rs:7) and the CaughtMutant (src/other.rs:3) are both
1754        // viable, conclusive mutants; the Baseline is not.
1755        let report = parse_mutants_report(SAMPLE).unwrap();
1756        assert_eq!(
1757            mutated_lines(&report),
1758            [
1759                ("src/lib.rs".to_string(), 7),
1760                ("src/other.rs".to_string(), 3)
1761            ]
1762            .into_iter()
1763            .collect()
1764        );
1765    }
1766
1767    #[test]
1768    fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1769        let report = parse_mutants_report(SAMPLE).unwrap();
1770        let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1771        let kept = evaluate_scoped(
1772            cargo_mutants_survivors(&report),
1773            &mutated_lines(&report),
1774            &[],
1775            &line_scoped,
1776        )
1777        .unwrap();
1778        assert!(
1779            kept.is_empty(),
1780            "the src/lib.rs:7 survivor should be lifted"
1781        );
1782    }
1783
1784    #[test]
1785    fn evaluate_scoped_rejects_exempting_a_caught_line() {
1786        // src/other.rs:3 had only a caught mutant (no survivor) — over-exemption.
1787        let report = parse_mutants_report(SAMPLE).unwrap();
1788        let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
1789        let err = evaluate_scoped(
1790            cargo_mutants_survivors(&report),
1791            &mutated_lines(&report),
1792            &[],
1793            &line_scoped,
1794        )
1795        .unwrap_err();
1796        assert!(
1797            err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
1798            "got: {err}"
1799        );
1800    }
1801
1802    #[test]
1803    fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
1804        // Line 99 has no mutant at all (e.g. outside a `--base` diff) — neither an error
1805        // nor a drop; the real survivor on line 7 still stands.
1806        let report = parse_mutants_report(SAMPLE).unwrap();
1807        let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
1808        let kept = evaluate_scoped(
1809            cargo_mutants_survivors(&report),
1810            &mutated_lines(&report),
1811            &[],
1812            &line_scoped,
1813        )
1814        .unwrap();
1815        assert_eq!(kept.len(), 1);
1816        assert_eq!(kept[0].line, 7);
1817    }
1818
1819    #[test]
1820    fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1821        let report = parse_mutants_report(SAMPLE).unwrap();
1822        let kept = evaluate_scoped(
1823            cargo_mutants_survivors(&report),
1824            &mutated_lines(&report),
1825            &["src/lib.rs".to_string()],
1826            &BTreeMap::new(),
1827        )
1828        .unwrap();
1829        assert!(kept.is_empty());
1830    }
1831
1832    fn unique_tmp() -> PathBuf {
1833        static COUNTER: AtomicU64 = AtomicU64::new(0);
1834        let dir = std::env::temp_dir().join(format!(
1835            "tc-provision-test-{}-{}",
1836            std::process::id(),
1837            COUNTER.fetch_add(1, Ordering::Relaxed)
1838        ));
1839        std::fs::create_dir_all(&dir).unwrap();
1840        dir
1841    }
1842
1843    #[test]
1844    fn provision_returns_an_existing_binary_without_installing() {
1845        let tmp = unique_tmp();
1846        let bin = tmp.join("bin").join("cargo-mutants");
1847        let lock = tmp.join(".install.lock");
1848        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1849        std::fs::write(&bin, b"binary").unwrap();
1850        let mut installed = false;
1851        let got = provision(&bin, &lock, || {
1852            installed = true;
1853            Ok(())
1854        })
1855        .unwrap();
1856        assert_eq!(got, bin);
1857        assert!(!installed, "a present binary must not be reinstalled");
1858        std::fs::remove_dir_all(&tmp).unwrap();
1859    }
1860
1861    #[test]
1862    fn provision_installs_when_the_binary_is_absent() {
1863        let tmp = unique_tmp();
1864        let bin = tmp.join("bin").join("cargo-mutants");
1865        let lock = tmp.join(".install.lock");
1866        let mut installed = false;
1867        let got = provision(&bin, &lock, || {
1868            installed = true;
1869            std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1870            std::fs::write(&bin, b"binary").unwrap();
1871            Ok(())
1872        })
1873        .unwrap();
1874        assert!(installed, "an absent binary must be installed");
1875        assert_eq!(got, bin);
1876        std::fs::remove_dir_all(&tmp).unwrap();
1877    }
1878
1879    #[test]
1880    fn provision_errors_when_install_produces_no_binary() {
1881        let tmp = unique_tmp();
1882        let bin = tmp.join("bin").join("cargo-mutants");
1883        let lock = tmp.join(".install.lock");
1884        let err = provision(&bin, &lock, || Ok(())).unwrap_err();
1885        assert!(
1886            err.to_string().contains("cargo-mutants is not at"),
1887            "got: {err}"
1888        );
1889        std::fs::remove_dir_all(&tmp).unwrap();
1890    }
1891
1892    #[test]
1893    fn provision_propagates_an_install_failure() {
1894        let tmp = unique_tmp();
1895        let bin = tmp.join("bin").join("cargo-mutants");
1896        let lock = tmp.join(".install.lock");
1897        let err = provision(&bin, &lock, || bail!("install blew up")).unwrap_err();
1898        assert!(err.to_string().contains("install blew up"), "got: {err}");
1899        std::fs::remove_dir_all(&tmp).unwrap();
1900    }
1901
1902    #[test]
1903    fn provision_does_not_duplicate_the_install_under_concurrent_callers() {
1904        // On a cold cache, N concurrent callers must share one install, not each run
1905        // their own — cargo-mutants' from-source compile duplicated N times (instead of once)
1906        // is what turned a ~1-minute cold-cache cost into ~7 minutes under nextest. A
1907        // barrier forces both threads to observe the absent binary together, and the install
1908        // closure sleeps briefly to widen the race window so this reproduces deterministically
1909        // rather than flakily.
1910        use std::sync::{Arc, Barrier};
1911        use std::thread;
1912        use std::time::Duration;
1913
1914        let tmp = unique_tmp();
1915        let bin = tmp.join("bin").join("cargo-mutants");
1916        let lock = tmp.join(".install.lock");
1917        let install_count = Arc::new(AtomicU64::new(0));
1918        let barrier = Arc::new(Barrier::new(2));
1919
1920        let handles: Vec<_> = (0..2)
1921            .map(|_| {
1922                let bin = bin.clone();
1923                let lock = lock.clone();
1924                let install_count = Arc::clone(&install_count);
1925                let barrier = Arc::clone(&barrier);
1926                thread::spawn(move || {
1927                    barrier.wait();
1928                    provision(&bin, &lock, || {
1929                        install_count.fetch_add(1, Ordering::SeqCst);
1930                        thread::sleep(Duration::from_millis(50));
1931                        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1932                        std::fs::write(&bin, b"binary").unwrap();
1933                        Ok(())
1934                    })
1935                })
1936            })
1937            .collect();
1938
1939        for h in handles {
1940            h.join()
1941                .expect("provisioning thread must not panic")
1942                .unwrap();
1943        }
1944
1945        assert_eq!(
1946            install_count.load(Ordering::SeqCst),
1947            1,
1948            "two concurrent callers on a cold cache must share one install, not each run their own"
1949        );
1950        std::fs::remove_dir_all(&tmp).unwrap();
1951    }
1952
1953    #[test]
1954    fn resolve_cache_base_prefers_xdg_then_home_then_temp() {
1955        let xdg = |s: &str| Some(OsString::from(s));
1956        // XDG wins when set and non-empty.
1957        assert_eq!(
1958            resolve_cache_base(xdg("/xdg"), xdg("/home")),
1959            PathBuf::from("/xdg")
1960        );
1961        // An empty XDG falls through to $HOME/.cache.
1962        assert_eq!(
1963            resolve_cache_base(xdg(""), xdg("/home")),
1964            PathBuf::from("/home/.cache")
1965        );
1966        // A missing XDG likewise uses $HOME/.cache.
1967        assert_eq!(
1968            resolve_cache_base(None, xdg("/home")),
1969            PathBuf::from("/home/.cache")
1970        );
1971        // Neither set → the temp dir.
1972        assert_eq!(resolve_cache_base(None, None), std::env::temp_dir());
1973        assert_eq!(
1974            resolve_cache_base(xdg(""), Some(OsString::new())),
1975            std::env::temp_dir()
1976        );
1977    }
1978
1979    #[test]
1980    fn cache_root_is_absolute_and_version_scoped() {
1981        let root = cargo_mutants_cache_root();
1982        assert!(
1983            root.ends_with(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}")),
1984            "version-scoped; got {root:?}"
1985        );
1986        assert!(
1987            root.to_string_lossy().contains("testing-conventions"),
1988            "tool-namespaced; got {root:?}"
1989        );
1990        // A real base dir (HOME/XDG in the test env) makes it absolute — not an empty path.
1991        assert!(
1992            root.is_absolute(),
1993            "expected an absolute path; got {root:?}"
1994        );
1995    }
1996
1997    #[test]
1998    fn install_argv_pins_the_version_and_isolates_the_root() {
1999        let argv: Vec<String> = install_argv(Path::new("/cache/cargo-mutants-27"))
2000            .iter()
2001            .map(|arg| arg.to_string_lossy().into_owned())
2002            .collect();
2003        assert_eq!(
2004            argv,
2005            vec![
2006                "install",
2007                "cargo-mutants",
2008                "--locked",
2009                "--version",
2010                CARGO_MUTANTS_VERSION,
2011                "--root",
2012                "/cache/cargo-mutants-27",
2013            ]
2014        );
2015    }
2016
2017    #[test]
2018    fn mutants_argv_enables_features_on_the_engine_itself() {
2019        let argv = |diff, features: &[&str]| -> Vec<String> {
2020            mutants_argv(
2021                Path::new("/out"),
2022                diff,
2023                &features.iter().map(|f| f.to_string()).collect::<Vec<_>>(),
2024            )
2025            .iter()
2026            .map(|arg| arg.to_string_lossy().into_owned())
2027            .collect()
2028        };
2029        // The features land on cargo-mutants' own `--features`, which reaches every cargo
2030        // invocation — after a `--` separator they would reach `cargo test` alone, and a test
2031        // target needing the feature would break the unmutated baseline build.
2032        assert_eq!(
2033            argv(None, &["cli", "boost"]),
2034            vec!["mutants", "--output", "/out", "--features", "cli,boost"]
2035        );
2036        assert_eq!(
2037            argv(Some(Path::new("/out/base.diff")), &["cli"]),
2038            vec![
2039                "mutants",
2040                "--output",
2041                "/out",
2042                "--in-diff",
2043                "/out/base.diff",
2044                "--features",
2045                "cli",
2046            ]
2047        );
2048        // No features configured: the engine runs on the crate's default features.
2049        assert_eq!(argv(None, &[]), vec!["mutants", "--output", "/out"]);
2050    }
2051
2052    #[test]
2053    fn list_argv_mirrors_the_run_feature_selection() {
2054        let argv = |features: &[&str]| -> Vec<String> {
2055            list_argv(&features.iter().map(|f| f.to_string()).collect::<Vec<_>>())
2056                .iter()
2057                .map(|arg| arg.to_string_lossy().into_owned())
2058                .collect()
2059        };
2060        assert_eq!(argv(&[]), vec!["mutants", "--list", "--json"]);
2061        assert_eq!(
2062            argv(&["cli", "boost"]),
2063            vec!["mutants", "--list", "--json", "--features", "cli,boost"]
2064        );
2065    }
2066
2067    #[test]
2068    fn parse_base_diff_maps_inserted_lines_per_hunk() {
2069        let diff = "\
2070diff --git a/src/lib.rs b/src/lib.rs
2071--- a/src/lib.rs
2072+++ b/src/lib.rs
2073@@ -1,4 +1,5 @@
2074 fn a() {}
2075+fn b() {}
2076 fn c() {}
2077-fn d() {}
2078+fn e() {}
2079 fn f() {}
2080@@ -10,2 +11,4 @@
2081 tail
2082+one
2083+two
2084 more
2085";
2086        let parsed = parse_base_diff(diff);
2087        assert_eq!(parsed.files, vec!["src/lib.rs"]);
2088        assert_eq!(
2089            parsed.inserted.get("src/lib.rs"),
2090            Some(&BTreeSet::from([2, 4, 12, 13]))
2091        );
2092    }
2093
2094    #[test]
2095    fn parse_base_diff_leaves_a_deletion_only_file_without_inserted_lines() {
2096        let diff = "\
2097--- a/src/gone.rs
2098+++ b/src/gone.rs
2099@@ -5,2 +4,0 @@
2100-x
2101-y
2102";
2103        let parsed = parse_base_diff(diff);
2104        assert_eq!(parsed.files, vec!["src/gone.rs"]);
2105        assert!(parsed.inserted.is_empty());
2106    }
2107
2108    #[test]
2109    fn parse_base_diff_skips_a_deleted_file() {
2110        // A deleted file has no lines in HEAD: `+++ /dev/null` carries neither a changed
2111        // file nor inserted lines.
2112        let diff = "\
2113--- a/src/dead.rs
2114+++ /dev/null
2115@@ -1,2 +0,0 @@
2116-a
2117-b
2118";
2119        let parsed = parse_base_diff(diff);
2120        assert!(parsed.files.is_empty());
2121        assert!(parsed.inserted.is_empty());
2122    }
2123
2124    #[test]
2125    fn parse_base_diff_consumes_hunk_bodies_by_count_so_content_never_reads_as_a_header() {
2126        // The inserted content line begins with `+++`; consuming the hunk by its declared
2127        // counts keeps it a body line, not a second file header.
2128        let diff = "\
2129+++ b/notes.txt
2130@@ -1,1 +1,2 @@
2131 keep
2132++++ not a header
2133";
2134        let parsed = parse_base_diff(diff);
2135        assert_eq!(parsed.files, vec!["notes.txt"]);
2136        assert_eq!(parsed.inserted.get("notes.txt"), Some(&BTreeSet::from([2])));
2137    }
2138
2139    #[test]
2140    fn parse_base_diff_defaults_an_elided_hunk_count_to_one() {
2141        let diff = "\
2142+++ b/one.txt
2143@@ -1 +1 @@
2144-old
2145+new
2146";
2147        let parsed = parse_base_diff(diff);
2148        assert_eq!(parsed.inserted.get("one.txt"), Some(&BTreeSet::from([1])));
2149    }
2150
2151    #[test]
2152    fn parse_base_diff_skips_no_newline_annotations_mid_hunk() {
2153        // "\ No newline at end of file" annotates the line before it and counts against
2154        // neither side of the hunk.
2155        let diff = "\
2156+++ b/n.txt
2157@@ -1 +1 @@
2158-old
2159\\ No newline at end of file
2160+new
2161\\ No newline at end of file
2162";
2163        let parsed = parse_base_diff(diff);
2164        assert_eq!(parsed.inserted.get("n.txt"), Some(&BTreeSet::from([1])));
2165    }
2166
2167    #[cfg(unix)]
2168    fn fake_output(code: i32, stderr: &str) -> Output {
2169        use std::os::unix::process::ExitStatusExt;
2170        Output {
2171            status: std::process::ExitStatus::from_raw(code << 8),
2172            stdout: Vec::new(),
2173            stderr: stderr.as_bytes().to_vec(),
2174        }
2175    }
2176
2177    #[cfg(unix)]
2178    #[test]
2179    fn run_install_succeeds_on_a_zero_exit() {
2180        let mut ran = false;
2181        run_install(Path::new("/cache/root"), |command| {
2182            ran = true;
2183            // The pinned argv reaches the runner.
2184            let argv: Vec<String> = command
2185                .get_args()
2186                .map(|arg| arg.to_string_lossy().into_owned())
2187                .collect();
2188            assert!(argv.contains(&CARGO_MUTANTS_VERSION.to_string()));
2189            Ok(fake_output(0, ""))
2190        })
2191        .unwrap();
2192        assert!(ran);
2193    }
2194
2195    #[cfg(unix)]
2196    #[test]
2197    fn run_install_reports_a_nonzero_exit_with_the_engine_output() {
2198        let err = run_install(Path::new("/cache/root"), |_| {
2199            Ok(fake_output(1, "error: could not compile cargo-mutants"))
2200        })
2201        .unwrap_err();
2202        assert!(
2203            err.to_string()
2204                .contains("failed to provision cargo-mutants")
2205                && err.to_string().contains("could not compile"),
2206            "got: {err}"
2207        );
2208    }
2209
2210    #[cfg(unix)]
2211    #[test]
2212    fn run_install_propagates_a_spawn_failure() {
2213        let err = run_install(Path::new("/cache/root"), |_| {
2214            Err(std::io::Error::new(
2215                std::io::ErrorKind::NotFound,
2216                "no cargo",
2217            ))
2218        })
2219        .unwrap_err();
2220        assert!(
2221            err.to_string().contains("is cargo installed?"),
2222            "got: {err}"
2223        );
2224    }
2225
2226    #[cfg(unix)]
2227    fn fake_stdout(code: i32, stdout: &str) -> Output {
2228        use std::os::unix::process::ExitStatusExt;
2229        Output {
2230            status: std::process::ExitStatus::from_raw(code << 8),
2231            stdout: stdout.as_bytes().to_vec(),
2232            stderr: Vec::new(),
2233        }
2234    }
2235
2236    #[cfg(unix)]
2237    #[test]
2238    fn list_cargo_mutants_parses_the_listing_from_a_clean_run() {
2239        let json = r#"[{"file": "src/lib.rs", "name": "replace add -> 0",
2240            "span": {"start": {"line": 3, "column": 1}, "end": {"line": 5, "column": 2}}}]"#;
2241        let listed = list_cargo_mutants(
2242            Path::new("/cache/bin/cargo-mutants"),
2243            Path::new("/crate"),
2244            &["cli".to_string()],
2245            |command| {
2246                let argv: Vec<String> = command
2247                    .get_args()
2248                    .map(|arg| arg.to_string_lossy().into_owned())
2249                    .collect();
2250                assert_eq!(
2251                    argv,
2252                    vec!["mutants", "--list", "--json", "--features", "cli"]
2253                );
2254                assert_eq!(command.get_current_dir(), Some(Path::new("/crate")));
2255                Ok(fake_stdout(0, json))
2256            },
2257        )
2258        .unwrap();
2259        assert_eq!(listed.len(), 1);
2260        assert_eq!(listed[0].file, "src/lib.rs");
2261        assert_eq!(listed[0].span.start.line, 3);
2262        assert_eq!(listed[0].span.end.line, 5);
2263        assert_eq!(listed[0].name, "replace add -> 0");
2264    }
2265
2266    #[cfg(unix)]
2267    #[test]
2268    fn list_cargo_mutants_reports_a_nonzero_exit_with_the_engine_output() {
2269        let err = list_cargo_mutants(
2270            Path::new("/cache/bin/cargo-mutants"),
2271            Path::new("/crate"),
2272            &[],
2273            |_| Ok(fake_output(1, "error: no such option")),
2274        )
2275        .unwrap_err();
2276        assert!(
2277            err.to_string().contains("cargo-mutants --list failed")
2278                && err.to_string().contains("no such option"),
2279            "got: {err}"
2280        );
2281    }
2282
2283    #[cfg(unix)]
2284    #[test]
2285    fn list_cargo_mutants_propagates_a_spawn_failure() {
2286        let err = list_cargo_mutants(
2287            Path::new("/cache/bin/cargo-mutants"),
2288            Path::new("/crate"),
2289            &[],
2290            |_| {
2291                Err(std::io::Error::new(
2292                    std::io::ErrorKind::NotFound,
2293                    "no engine",
2294                ))
2295            },
2296        )
2297        .unwrap_err();
2298        assert!(
2299            err.to_string()
2300                .contains("listing the crate's mutants with cargo-mutants"),
2301            "got: {err}"
2302        );
2303    }
2304
2305    #[cfg(unix)]
2306    fn listed_mutant(file: &str, start: u32, end: u32, name: &str) -> MutantInfo {
2307        MutantInfo {
2308            file: file.to_string(),
2309            span: Span {
2310                start: LineCol { line: start },
2311                end: LineCol { line: end },
2312            },
2313            name: name.to_string(),
2314        }
2315    }
2316
2317    #[cfg(unix)]
2318    fn diff_with_inserted(file: &str, lines: &[u32]) -> BaseDiff {
2319        BaseDiff {
2320            files: vec![file.to_string()],
2321            inserted: BTreeMap::from([(file.to_string(), lines.iter().copied().collect())]),
2322        }
2323    }
2324
2325    #[cfg(unix)]
2326    #[test]
2327    fn zero_mutant_verdict_accepts_a_zero_with_no_mutant_on_the_inserted_lines() {
2328        let run = fake_output(0, "");
2329        // No mutants listed at all.
2330        zero_mutant_verdict(&[], &diff_with_inserted("src/lib.rs", &[5]), &run).unwrap();
2331        // Inserted lines sit just outside the span on either side.
2332        let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2333        zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[4, 9]), &run).unwrap();
2334        // A mutant in a different file never matches.
2335        zero_mutant_verdict(&listed, &diff_with_inserted("src/other.rs", &[6]), &run).unwrap();
2336    }
2337
2338    #[cfg(unix)]
2339    #[test]
2340    fn zero_mutant_verdict_is_fatal_on_a_mutant_at_either_span_boundary() {
2341        let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2342        let run = fake_stdout(0, "0 mutants tested");
2343        for line in [5, 8] {
2344            let err =
2345                zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[line]), &run)
2346                    .unwrap_err();
2347            let message = err.to_string();
2348            assert!(
2349                message.contains("1 of the crate's 1 mutant site(s)")
2350                    && message.contains("src/lib.rs:5: replace add -> 0")
2351                    && message.contains("0 mutants tested"),
2352                "got: {message}"
2353            );
2354        }
2355    }
2356
2357    #[cfg(unix)]
2358    #[test]
2359    fn classify_mutants_exit_accepts_the_caught_and_survivor_exits() {
2360        // 0 (all caught) and 2 (some missed/survived) both leave an outcomes.json to read.
2361        classify_mutants_exit(Path::new("/crate"), &fake_output(0, "")).unwrap();
2362        classify_mutants_exit(Path::new("/crate"), &fake_output(2, "")).unwrap();
2363    }
2364
2365    #[cfg(unix)]
2366    #[test]
2367    fn classify_mutants_exit_accepts_a_timeout_exit_3() {
2368        // cargo-mutants exits 3 when mutants timed out and none were missed — an
2369        // inconclusive-not-fatal outcome (this module's own `Timeout` semantics). It still
2370        // wrote an outcomes.json, so the run is a pass, not the "baseline failure" bail.
2371        classify_mutants_exit(Path::new("/crate"), &fake_output(3, ""))
2372            .expect("a timeout (exit 3) is inconclusive, not fatal");
2373    }
2374
2375    #[cfg(unix)]
2376    #[test]
2377    fn classify_mutants_exit_is_fatal_on_a_baseline_failure() {
2378        // Exit 4 (the clean/baseline build or test failed) — and any other code — stays fatal.
2379        let err = classify_mutants_exit(Path::new("/crate"), &fake_output(4, "baseline broke"))
2380            .unwrap_err();
2381        assert!(
2382            err.to_string().contains("did not run cleanly")
2383                && err.to_string().contains("baseline broke"),
2384            "got: {err}"
2385        );
2386    }
2387
2388    #[test]
2389    fn cargo_mutants_bin_name_matches_the_platform() {
2390        let name = cargo_mutants_bin_name();
2391        if cfg!(windows) {
2392            assert_eq!(name, "cargo-mutants.exe");
2393        } else {
2394            assert_eq!(name, "cargo-mutants");
2395        }
2396    }
2397}