Skip to main content

testing_conventions/
mutation.rs

1//! Mutation testing for Rust (`unit mutation --language rust`, #201) — the rung
2//! above coverage. A test that *runs* a line still passes if you delete its
3//! assertions; a surviving mutant proves it. This module wraps
4//! [cargo-mutants](https://github.com/sourcefrog/cargo-mutants): it runs the engine,
5//! reads its `outcomes.json`, and reports the **surviving** mutants the suite failed
6//! to catch.
7//!
8//! The gate is **binary, not a percentage** (equivalent mutants make a fixed score
9//! unreachable, and a score isn't comparable across engines) and on by default: any
10//! *un-exempted* surviving mutant is a finding. This module stays a pure measurement —
11//! [`measure_rust`] returns the survivors and [`unexplained_survivors`] is the pure
12//! core over a parsed report; the CLI layer turns a non-empty result into the failure.
13//!
14//! Diff-scoping (`--base`) is delegated to cargo-mutants' own `--in-diff`: the
15//! `<base>...HEAD` diff is written out and passed through, so only mutants on changed
16//! lines are tested ("no unexplained surviving mutant on the lines you touched").
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::path::{Path, PathBuf};
20use std::process::Command;
21use std::sync::atomic::{AtomicU64, Ordering};
22
23use anyhow::{bail, Context, Result};
24use serde::Deserialize;
25
26/// A surviving mutant — a mutation the unit suite ran but failed to catch.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Survivor {
29    /// The mutated file, as cargo-mutants reports it (crate-root-relative, `/`-separated).
30    pub file: String,
31    /// The 1-based line the mutation starts on.
32    pub line: u32,
33    /// cargo-mutants' human description (e.g. `replace > with == in is_positive`).
34    pub description: String,
35}
36
37/// The `(file, line)` locations an engine produced a viable mutant for — the input the
38/// #226 line-scoped guard reads to tell an over-exemption (a listed line whose mutants
39/// were all caught) from an out-of-scope line (no mutant there).
40pub type MutatedLines = BTreeSet<(String, u32)>;
41
42/// A cargo-mutants `outcomes.json` export, pared to what the rule reads. Unmodeled
43/// fields (`total_mutants`, `caught`, timings, …) are ignored.
44#[derive(Debug, Clone, Deserialize)]
45pub struct MutantsReport {
46    pub outcomes: Vec<MutantOutcome>,
47}
48
49/// One scenario's outcome. `summary` is cargo-mutants' result word — `Success` for the
50/// unmutated baseline, `CaughtMutant` / `MissedMutant` (and `Timeout` / `Unviable`)
51/// for each mutant.
52#[derive(Debug, Clone, Deserialize)]
53pub struct MutantOutcome {
54    pub summary: String,
55    pub scenario: Scenario,
56}
57
58/// The scenario a result came from: the unmutated baseline, or one mutant. Matches
59/// cargo-mutants' externally-tagged JSON (`"Baseline"` vs `{"Mutant": {…}}`).
60#[derive(Debug, Clone, Deserialize)]
61pub enum Scenario {
62    Baseline,
63    Mutant(MutantInfo),
64}
65
66/// The mutant a scenario describes, pared to the location + description the report
67/// needs. cargo-mutants also carries `function`, `genre`, `package`, `replacement`;
68/// those are ignored.
69#[derive(Debug, Clone, Deserialize)]
70pub struct MutantInfo {
71    pub file: String,
72    pub span: Span,
73    pub name: String,
74}
75
76/// A source span; only the start line is read.
77#[derive(Debug, Clone, Deserialize)]
78pub struct Span {
79    pub start: LineCol,
80}
81
82/// A line/column position; only the line is read.
83#[derive(Debug, Clone, Deserialize)]
84pub struct LineCol {
85    pub line: u32,
86}
87
88/// Parse a cargo-mutants `outcomes.json` export.
89pub fn parse_mutants_report(json: &str) -> Result<MutantsReport> {
90    serde_json::from_str(json).context("parsing cargo-mutants outcomes.json")
91}
92
93/// The surviving mutants not lifted by a `mutation` exemption — the rule's findings.
94///
95/// A survivor is a `MissedMutant` outcome (the suite ran the mutated code but no test
96/// failed). `exempt` is the resolved set of `mutation`-rule exempt paths (crate-root
97/// relative); a survivor in an exempt file is dropped (an equivalent or deliberately
98/// defensive mutation, lifted with a reason). `Timeout` / `Unviable` are *not*
99/// survivors — a timeout is inconclusive, not a pass, and an unviable mutant never
100/// compiled.
101pub fn unexplained_survivors(report: &MutantsReport, exempt: &[String]) -> Vec<Survivor> {
102    evaluate(cargo_mutants_survivors(report), exempt)
103}
104
105/// The surviving mutants in a cargo-mutants report — the raw list before exemptions.
106/// A survivor is a `MissedMutant` outcome (the suite ran the mutated code but no test
107/// failed). `Timeout` / `Unviable` are not survivors.
108fn cargo_mutants_survivors(report: &MutantsReport) -> Vec<Survivor> {
109    report
110        .outcomes
111        .iter()
112        .filter_map(|outcome| {
113            if outcome.summary != "MissedMutant" {
114                return None;
115            }
116            let Scenario::Mutant(mutant) = &outcome.scenario else {
117                return None;
118            };
119            Some(Survivor {
120                file: mutant.file.clone(),
121                line: mutant.span.start.line,
122                description: mutant.name.clone(),
123            })
124        })
125        .collect()
126}
127
128/// The `(file, line)` locations cargo-mutants produced a **viable, conclusive** mutant
129/// for — caught or missed (`CaughtMutant` / `MissedMutant`), not the inconclusive
130/// `Timeout` / `Unviable`. The #226 line-scoped guard reads this to tell an
131/// over-exemption (a listed line whose mutants were all *caught*, no survivor) from an
132/// out-of-scope line (no mutant there at all — e.g. outside a `--base` diff).
133pub fn mutated_lines(report: &MutantsReport) -> MutatedLines {
134    report
135        .outcomes
136        .iter()
137        .filter_map(|outcome| {
138            if outcome.summary != "CaughtMutant" && outcome.summary != "MissedMutant" {
139                return None;
140            }
141            let Scenario::Mutant(mutant) = &outcome.scenario else {
142                return None;
143            };
144            Some((mutant.file.clone(), mutant.span.start.line))
145        })
146        .collect()
147}
148
149/// The shared whole-file evaluation core: drop the survivors lifted by a file-level
150/// `mutation` exemption (a file-path match), leaving the rule's findings. The
151/// line-scoped path ([`evaluate_scoped`]) generalizes this to per-line exemptions with
152/// a determinism guard.
153pub fn evaluate(survivors: Vec<Survivor>, exempt: &[String]) -> Vec<Survivor> {
154    survivors
155        .into_iter()
156        .filter(|survivor| !exempt.iter().any(|path| path == &survivor.file))
157        .collect()
158}
159
160/// Apply file- and line-scoped `mutation` exemptions to the raw `survivors`, with the
161/// #226 determinism guard. `mutated` is the set of `(file, line)` that produced a
162/// viable mutant (caught or survived); `whole_file` is the file-level exemptions and
163/// `line_scoped` the per-line ones.
164///
165/// Guard: a line-scoped exemption that names a line whose mutants were **all caught**
166/// (in `mutated`, but with no survivor) is over-exemption — a hard error, the
167/// counterpart to the stale-path rule. A listed line with **no** mutant at all is left
168/// alone (it may simply be outside a `--base` diff), neither an error nor a drop. Then
169/// every survivor whose file is whole-file-exempt, or whose `(file, line)` is
170/// line-exempt, is dropped; an unlisted survivor still fails the gate.
171pub fn evaluate_scoped(
172    survivors: Vec<Survivor>,
173    mutated: &MutatedLines,
174    whole_file: &[String],
175    line_scoped: &BTreeMap<String, BTreeSet<u32>>,
176) -> Result<Vec<Survivor>> {
177    let mut over: Vec<String> = Vec::new();
178    for (file, lines) in line_scoped {
179        for &line in lines {
180            let has_survivor = survivors
181                .iter()
182                .any(|survivor| survivor.file == *file && survivor.line == line);
183            if has_survivor {
184                continue;
185            }
186            if mutated.contains(&(file.clone(), line)) {
187                over.push(format!("\n  {file}:{line}"));
188            }
189        }
190    }
191    if !over.is_empty() {
192        bail!(
193            "a line-scoped mutation exemption may only list a line with a surviving mutant, but \
194             these had mutants that were all caught:{}",
195            over.concat()
196        );
197    }
198    Ok(survivors
199        .into_iter()
200        .filter(|survivor| {
201            let whole = whole_file.iter().any(|path| path == &survivor.file);
202            let line = line_scoped
203                .get(&survivor.file)
204                .is_some_and(|lines| lines.contains(&survivor.line));
205            !(whole || line)
206        })
207        .collect())
208}
209
210/// A mutant's outcome, normalized across the engines (Stryker / cosmic-ray / cargo-mutants)
211/// — the union of their result vocabularies reduced to what the gate needs (#239). Each
212/// language adapter maps its native outcomes onto this so the Rust core gates on one
213/// representation instead of three per-engine report formats. The serialized form is
214/// `snake_case` (`no_coverage`, `compile_error`, …) — the wire contract adapters emit.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
216#[serde(rename_all = "snake_case")]
217pub enum MutantStatus {
218    /// A test ran the mutated code but none failed — a survivor.
219    Survived,
220    /// A test failed on the mutant — caught.
221    Killed,
222    /// No test exercised the mutant at all — a survivor (worse than `Survived`).
223    NoCoverage,
224    /// The mutant ran but the suite timed out — inconclusive, not a survivor (but viable).
225    Timeout,
226    /// The mutant never compiled — not a viable mutant.
227    CompileError,
228    /// The mutant errored at runtime before a test could judge it — not viable.
229    RuntimeError,
230}
231
232impl MutantStatus {
233    /// Whether this outcome is a **survivor** — a mutant the suite failed to catch
234    /// (`Survived` or `NoCoverage`). Mirrors the per-engine survivor rules.
235    fn is_survivor(self) -> bool {
236        matches!(self, MutantStatus::Survived | MutantStatus::NoCoverage)
237    }
238
239    /// Whether this came from a **viable, conclusive** mutant — one that actually ran
240    /// (`Survived` / `Killed` / `NoCoverage` / `Timeout`), not one that never compiled or
241    /// errored out. The #226 determinism guard reads this to tell an over-exemption (a
242    /// listed line whose mutants were all caught) from an out-of-scope line (no mutant there).
243    fn is_viable(self) -> bool {
244        matches!(
245            self,
246            MutantStatus::Survived
247                | MutantStatus::Killed
248                | MutantStatus::NoCoverage
249                | MutantStatus::Timeout
250        )
251    }
252}
253
254/// One mutant in the normalized result set (#239): the engine-agnostic shape every language
255/// adapter emits. Extra fields an adapter includes are ignored.
256#[derive(Debug, Clone, Deserialize)]
257pub struct NormalizedMutant {
258    /// Project-relative, `/`-separated path of the mutated file.
259    pub file: String,
260    /// The 1-based line the mutant starts on.
261    pub line: u32,
262    /// The outcome, normalized across engines.
263    pub status: MutantStatus,
264    /// The engine's mutator/operator name (e.g. `ConditionalExpression`).
265    pub mutator: String,
266    /// The replacement text, when the engine reports one — used for a readable description.
267    #[serde(default)]
268    pub replacement: Option<String>,
269}
270
271/// Parse the normalized results an engine adapter emits — a flat JSON array of
272/// [`NormalizedMutant`] (#239).
273pub fn parse_normalized_results(json: &str) -> Result<Vec<NormalizedMutant>> {
274    serde_json::from_str(json).context("parsing normalized mutation results")
275}
276
277/// Gate a normalized result set: drop the survivors lifted by a file- or line-scoped
278/// `mutation` exemption (with the #226 determinism guard), leaving the rule's findings.
279///
280/// This is the engine-agnostic core each language arm feeds once its adapter has produced
281/// [`NormalizedMutant`]s (#239) — the replacement for the per-engine `*_survivors` /
282/// `*_mutated_lines` + [`evaluate_scoped`] wiring. Survivors are `Survived` / `NoCoverage`
283/// mutants; the guard reads every *viable* mutant's `(file, line)`.
284pub fn evaluate_normalized(
285    mutants: &[NormalizedMutant],
286    whole_file: &[String],
287    line_scoped: &BTreeMap<String, BTreeSet<u32>>,
288) -> Result<Vec<Survivor>> {
289    evaluate_scoped(
290        normalized_survivors(mutants),
291        &normalized_mutated_lines(mutants),
292        whole_file,
293        line_scoped,
294    )
295}
296
297/// The surviving mutants in a normalized result set — the raw list before exemptions.
298fn normalized_survivors(mutants: &[NormalizedMutant]) -> Vec<Survivor> {
299    mutants
300        .iter()
301        .filter(|mutant| mutant.status.is_survivor())
302        .map(|mutant| Survivor {
303            file: mutant.file.clone(),
304            line: mutant.line,
305            description: describe_normalized(mutant),
306        })
307        .collect()
308}
309
310/// The `(file, line)` of every viable, conclusive mutant in a normalized result set — the
311/// input the #226 line-scoped guard in [`evaluate_scoped`] reads.
312fn normalized_mutated_lines(mutants: &[NormalizedMutant]) -> MutatedLines {
313    mutants
314        .iter()
315        .filter(|mutant| mutant.status.is_viable())
316        .map(|mutant| (mutant.file.clone(), mutant.line))
317        .collect()
318}
319
320/// A one-line description for a normalized mutant: the mutator name, plus the replacement
321/// (flattened + capped via [`one_line`]) when the engine reported one.
322fn describe_normalized(mutant: &NormalizedMutant) -> String {
323    match &mutant.replacement {
324        Some(replacement) => format!("{} (-> {})", mutant.mutator, one_line(replacement)),
325        None => mutant.mutator.clone(),
326    }
327}
328
329/// Run cargo-mutants over the crate at `root` and return its un-exempted survivors.
330///
331/// With `base` set, only mutants on the `<base>...HEAD` changed lines are tested (via
332/// cargo-mutants' `--in-diff`); without it, the whole crate. `exempt` is the file-level
333/// `mutation` exempt paths and `exempt_lines` the line-scoped ones (#226), applied with
334/// the determinism guard in [`evaluate_scoped`]. `cargo-mutants` must be installed.
335pub fn measure_rust(
336    root: &Path,
337    exempt: &[String],
338    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
339    base: Option<&str>,
340) -> Result<Vec<Survivor>> {
341    let out = MutantsOut::new();
342    let diff = match base {
343        // An empty diff (no changed lines under the crate — a PR that doesn't touch it)
344        // means nothing to mutate: no survivors, no cargo-mutants run.
345        Some(base) => match write_base_diff(root, base, &out)? {
346            None => return Ok(Vec::new()),
347            Some(path) => Some(path),
348        },
349        None => None,
350    };
351    run_cargo_mutants(root, &out.0, diff.as_deref())?;
352    let outcomes = out.0.join("mutants.out").join("outcomes.json");
353    // cargo-mutants writes no `outcomes.json` when a run produces no mutants (e.g. an
354    // `--in-diff` that matches none of the crate's lines). `run_cargo_mutants` already
355    // bailed on a fatal exit, so a missing report here is "no mutants" → no survivors.
356    let json = match std::fs::read_to_string(&outcomes) {
357        Ok(json) => json,
358        Err(_) => return Ok(Vec::new()),
359    };
360    let report = parse_mutants_report(&json)?;
361    evaluate_scoped(
362        cargo_mutants_survivors(&report),
363        &mutated_lines(&report),
364        exempt,
365        exempt_lines,
366    )
367}
368
369/// Collapse a (possibly multi-line) replacement to a single trimmed line, capped, so a
370/// survivor's one-line description stays readable.
371fn one_line(replacement: &str) -> String {
372    let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
373    const MAX: usize = 60;
374    if flat.chars().count() > MAX {
375        format!("{}…", flat.chars().take(MAX).collect::<String>())
376    } else {
377        flat
378    }
379}
380
381/// Run the bundled TypeScript mutation adapter over the project at `root` and return its
382/// un-exempted survivors — the TS arm of the mutation rule (#202), parity with
383/// [`measure_rust`].
384///
385/// The consumer installs **nothing** Stryker-related: the npm package ships a Node
386/// adapter that drives Stryker through its own Node API and emits the engine-agnostic
387/// [`NormalizedMutant`] schema (#239), which this gates over via [`evaluate_normalized`]
388/// — the same core the Rust and Python arms feed. Only the project's own test runner
389/// (vitest) needs to be present, exactly as cargo-mutants needs a buildable crate and
390/// cosmic-ray needs pytest.
391///
392/// With `base` set, only mutants on the `<base>...HEAD` changed lines are tested —
393/// Stryker has no native git-diff mode, so the changed lines become `--mutate
394/// <file>:<line>-<line>` ranges (line granularity, matching cargo-mutants' `--in-diff`).
395/// Without it, the project's configured `mutate` set runs. `exempt` is the file-level
396/// exempt paths and `exempt_lines` the line-scoped ones (#226). `adapter` is the path to
397/// the bundled Node adapter (`dist/mutation/main.js`) — the CLI receives it from the npm
398/// launcher's `--ts-mutation-adapter` argument and hands it down explicitly.
399pub fn measure_typescript(
400    root: &Path,
401    exempt: &[String],
402    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
403    base: Option<&str>,
404    adapter: &Path,
405) -> Result<Vec<Survivor>> {
406    let mutate = match base {
407        Some(base) => {
408            let ranges = mutate_ranges(root, base)?;
409            // Nothing mutatable changed on the diff: no run, no survivors.
410            if ranges.is_empty() {
411                return Ok(Vec::new());
412            }
413            Some(ranges)
414        }
415        None => None,
416    };
417    let json = run_ts_adapter(root, adapter, mutate.as_deref())?;
418    let mutants = parse_normalized_results(&json)?;
419    evaluate_normalized(&mutants, exempt, exempt_lines)
420}
421
422/// Run the bundled TS mutation `adapter` over `root` and return the normalized-results JSON
423/// it writes. The adapter (a Node entry shipped with the npm package) drives Stryker via
424/// its Node API and emits a [`NormalizedMutant`] array (#239) — so the consumer drives the
425/// engine through this CLI alone; the npm package resolves `@stryker-mutator/*` from the
426/// tool's own tree.
427///
428/// `mutate`, when set, scopes the run to `--mutate` line ranges. Results are written to a
429/// temp file the adapter names via `--out` (so Stryker's own stdout logging can't corrupt
430/// them), then read back. `node` and the project's own test runner must be available; a
431/// non-zero adapter exit surfaces its captured output.
432fn run_ts_adapter(root: &Path, adapter: &Path, mutate: Option<&[String]>) -> Result<String> {
433    let out = AdapterOut::new();
434    std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
435    let results = out.0.join("results.json");
436
437    let mut command = Command::new("node");
438    command
439        .current_dir(root)
440        .arg(adapter)
441        .arg("--out")
442        .arg(&results);
443    if let Some(specs) = mutate {
444        command.arg("--mutate").arg(specs.join(","));
445    }
446    let output = command
447        .output()
448        .context("running the TypeScript mutation adapter (is `node` installed?)")?;
449    if !output.status.success() {
450        bail!(
451            "the TypeScript mutation adapter failed in `{}`:\n{}{}",
452            root.display(),
453            String::from_utf8_lossy(&output.stdout),
454            String::from_utf8_lossy(&output.stderr),
455        );
456    }
457    std::fs::read_to_string(&results).with_context(|| {
458        format!(
459            "reading the TypeScript mutation adapter's results from `{}`",
460            results.display()
461        )
462    })
463}
464
465/// A unique temp dir for one TS mutation adapter run's `--out` JSON, removed on drop so
466/// the scanned project stays pristine and parallel runs don't collide.
467struct AdapterOut(PathBuf);
468
469impl AdapterOut {
470    fn new() -> Self {
471        static COUNTER: AtomicU64 = AtomicU64::new(0);
472        let name = format!(
473            "testing-conventions-ts-adapter-{}-{}",
474            std::process::id(),
475            COUNTER.fetch_add(1, Ordering::Relaxed),
476        );
477        AdapterOut(std::env::temp_dir().join(name))
478    }
479}
480
481impl Drop for AdapterOut {
482    fn drop(&mut self) {
483        let _ = std::fs::remove_dir_all(&self.0);
484    }
485}
486
487/// Build the Stryker `--mutate` specs scoping a run to the `<base>...HEAD` changed
488/// lines: each mutatable source file's contiguous runs of changed lines become a
489/// `<file>:<start>-<end>` range (Stryker's line-range form). Reuses the patch-coverage
490/// diff parser. Test and declaration files are filtered out — Stryker's configured
491/// `mutate` set normally excludes them, but passing `--mutate` replaces that set.
492fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
493    let changed = crate::patch_coverage::changed_lines(root, base)?;
494    let mut specs = Vec::new();
495    for (file, lines) in changed {
496        if !is_mutatable_ts(&file) {
497            continue;
498        }
499        for (start, end) in contiguous_runs(&lines) {
500            specs.push(format!("{file}:{start}-{end}"));
501        }
502    }
503    Ok(specs)
504}
505
506/// Whether a changed file is a TypeScript/JavaScript *source* Stryker should mutate — a
507/// `.ts`/`.tsx`/`.mts`/`.cts`/`.js`/`.jsx`/`.mjs`/`.cjs` file that is not a declaration
508/// (`.d.ts`) or a test (`.test.` / `.spec.`).
509fn is_mutatable_ts(file: &str) -> bool {
510    let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
511        .iter()
512        .any(|ext| file.ends_with(ext));
513    let is_decl = file.ends_with(".d.ts");
514    let is_test = file.contains(".test.") || file.contains(".spec.");
515    is_source && !is_decl && !is_test
516}
517
518/// Fold a sorted set of line numbers into inclusive `(start, end)` contiguous runs.
519fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
520    let mut runs: Vec<(u64, u64)> = Vec::new();
521    for &line in lines {
522        match runs.last_mut() {
523            Some(run) if run.1 + 1 == line => run.1 = line,
524            _ => runs.push((line, line)),
525        }
526    }
527    runs
528}
529
530/// Run the bundled Python mutation adapter over the project at `root` and return its
531/// un-exempted survivors — the Python arm of the mutation rule (#203 / #248), parity with
532/// [`measure_rust`] and [`measure_typescript`].
533///
534/// The tool drives the engine: the wheel ships a Python adapter that runs cosmic-ray through
535/// its own library API (`WorkDB`) and emits the normalized [`NormalizedMutant`] schema (#239)
536/// the gate consumes. maturin (`bindings = "bin"`) ships the rust binary directly as the wheel's
537/// script — with no Python launcher to inject a path, unlike the TS arm — so the binary invokes
538/// the adapter as an installed module (`python3 -m testing_conventions.mutation.main`), resolved
539/// from the wheel's environment alongside cosmic-ray. The project supplies its own test runner
540/// (pytest), exactly as cargo-mutants needs a buildable crate and Stryker needs vitest.
541///
542/// With `base` set, only mutants on the `<base>...HEAD` changed lines are reported: cosmic-ray
543/// has no native git-diff mode, so the run is scoped to the changed `.py` files (passed as
544/// `--module`) and the survivors are filtered to the changed lines in the core — line
545/// granularity, matching the other arms. Without it, the whole project's sources run (tests
546/// excluded). `exempt` is the file-level exempt paths and `exempt_lines` the line-scoped ones
547/// (#226).
548pub fn measure_python(
549    root: &Path,
550    exempt: &[String],
551    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
552    base: Option<&str>,
553) -> Result<Vec<Survivor>> {
554    let changed = match base {
555        Some(base) => Some(crate::patch_coverage::changed_lines(root, base)?),
556        None => None,
557    };
558    let modules: Vec<String> = match &changed {
559        None => Vec::new(),
560        Some(changed) => {
561            let modules: Vec<String> = changed
562                .keys()
563                .filter(|file| is_mutatable_py(file))
564                .cloned()
565                .collect();
566            // Nothing mutatable changed on the diff: no run, no survivors.
567            if modules.is_empty() {
568                return Ok(Vec::new());
569            }
570            modules
571        }
572    };
573    let json = run_py_adapter(root, &modules)?;
574    let mut mutants = parse_normalized_results(&json)?;
575    if let Some(changed) = &changed {
576        // Diff-scoping v1 (#248): keep only mutants on the changed lines.
577        mutants.retain(|mutant| {
578            changed
579                .get(&mutant.file)
580                .is_some_and(|lines| lines.contains(&u64::from(mutant.line)))
581        });
582    }
583    evaluate_normalized(&mutants, exempt, exempt_lines)
584}
585
586/// Run the bundled Python mutation adapter over `root` and return the normalized-results JSON
587/// it writes. The rust binary spawns `python3 -m testing_conventions.mutation.main --out <tmp>
588/// [--module <path> ...]`; the adapter drives cosmic-ray in-process (#248) and emits a
589/// [`NormalizedMutant`] array (#239). `modules`, when non-empty, scopes the run to those source
590/// files (the `<base>...HEAD` changed ones); empty runs the whole project. Results are written
591/// to a temp file the adapter names via `--out`, then read back. `PYTHONDONTWRITEBYTECODE` keeps
592/// `__pycache__` out of the scanned tree; a non-zero adapter exit surfaces its captured output.
593fn run_py_adapter(root: &Path, modules: &[String]) -> Result<String> {
594    let out = AdapterOut::new();
595    std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
596    let results = out.0.join("results.json");
597
598    let mut command = Command::new("python3");
599    command
600        .current_dir(root)
601        .args(["-m", "testing_conventions.mutation.main", "--out"])
602        .arg(&results)
603        .env("PYTHONDONTWRITEBYTECODE", "1");
604    for module in modules {
605        command.arg("--module").arg(module);
606    }
607    let output = command
608        .output()
609        .context("running the Python mutation adapter (is `python3` installed?)")?;
610    if !output.status.success() {
611        bail!(
612            "the Python mutation adapter failed in `{}`:\n{}{}",
613            root.display(),
614            String::from_utf8_lossy(&output.stdout),
615            String::from_utf8_lossy(&output.stderr),
616        );
617    }
618    std::fs::read_to_string(&results).with_context(|| {
619        format!(
620            "reading the Python mutation adapter's results from `{}`",
621            results.display()
622        )
623    })
624}
625
626/// Whether a changed file is a mutatable Python *source* — a `.py` that is not a test
627/// (`*_test.py` / `test_*.py`) or `conftest.py`.
628fn is_mutatable_py(file: &str) -> bool {
629    if !file.ends_with(".py") {
630        return false;
631    }
632    let base = file.rsplit('/').next().unwrap_or(file);
633    !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
634}
635
636/// A unique temp dir for one cargo-mutants run's `--output`, removed on drop so the
637/// scanned crate stays pristine and parallel runs don't collide.
638struct MutantsOut(PathBuf);
639
640impl MutantsOut {
641    fn new() -> Self {
642        static COUNTER: AtomicU64 = AtomicU64::new(0);
643        let name = format!(
644            "testing-conventions-mutants-{}-{}",
645            std::process::id(),
646            COUNTER.fetch_add(1, Ordering::Relaxed),
647        );
648        MutantsOut(std::env::temp_dir().join(name))
649    }
650}
651
652impl Drop for MutantsOut {
653    fn drop(&mut self) {
654        let _ = std::fs::remove_dir_all(&self.0);
655    }
656}
657
658/// Write the `<base>...HEAD` diff cargo-mutants' `--in-diff` scopes to, returning its
659/// path — or `None` when the diff is empty (no changed lines under the crate).
660///
661/// `--relative` restricts the diff to changes under `root` (the crate dir) and makes
662/// the paths relative to it. cargo-mutants runs *in* the crate dir and matches its
663/// `--in-diff` paths crate-relative, so without `--relative` the diff is repo-relative
664/// and matches nothing whenever the crate is a subdirectory of the git repo (the common
665/// case). Scoping also means a PR that doesn't touch the crate yields an empty diff.
666fn write_base_diff(root: &Path, base: &str, out: &MutantsOut) -> Result<Option<PathBuf>> {
667    let range = format!("{base}...HEAD");
668    let output = Command::new("git")
669        .current_dir(root)
670        .args(["diff", "--relative", &range])
671        .output()
672        .context("running `git diff` for `--base` (is git installed?)")?;
673    if !output.status.success() {
674        bail!(
675            "git diff {range} failed: {}",
676            String::from_utf8_lossy(&output.stderr)
677        );
678    }
679    if output.stdout.is_empty() {
680        return Ok(None);
681    }
682    std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
683    let path = out.0.join("base.diff");
684    std::fs::write(&path, &output.stdout).context("writing the base diff")?;
685    Ok(Some(path))
686}
687
688/// Run `cargo mutants --output <out> [--in-diff <diff>]` in `root`.
689///
690/// cargo-mutants exits `0` when every mutant is caught and `2` when some survive (or
691/// time out / are unviable) — both are normal here, since survivors are the rule's
692/// *output*, not an error. Any other code (usage error, or a baseline that didn't
693/// build/pass) is fatal. As with the coverage run, the outer instrumentation env is
694/// stripped so a nested run (this rule's own tests under `cargo llvm-cov`) doesn't
695/// re-enter the rustc wrapper and hang.
696fn run_cargo_mutants(root: &Path, out: &Path, in_diff: Option<&Path>) -> Result<()> {
697    let mut command = Command::new("cargo");
698    command
699        .current_dir(root)
700        .arg("mutants")
701        .arg("--output")
702        .arg(out);
703    if let Some(diff) = in_diff {
704        command.arg("--in-diff").arg(diff);
705    }
706    for var in [
707        "RUSTFLAGS",
708        "CARGO_ENCODED_RUSTFLAGS",
709        "RUSTDOCFLAGS",
710        "CARGO_ENCODED_RUSTDOCFLAGS",
711        "LLVM_PROFILE_FILE",
712        "CARGO_LLVM_COV",
713        "CARGO_LLVM_COV_SHOW_ENV",
714        "CARGO_LLVM_COV_TARGET_DIR",
715        "CARGO_LLVM_COV_BUILD_DIR",
716        "RUSTC_WRAPPER",
717        "RUSTC_WORKSPACE_WRAPPER",
718        "__CARGO_LLVM_COV_RUSTC_WRAPPER",
719        "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
720        "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
721    ] {
722        command.env_remove(var);
723    }
724    let output = command
725        .output()
726        .context("running `cargo mutants` (is cargo-mutants installed?)")?;
727    match output.status.code() {
728        // 0 = all caught, 2 = some survived/timed out: both produce a report to read.
729        Some(0) | Some(2) => Ok(()),
730        _ => bail!(
731            "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
732            root.display(),
733            String::from_utf8_lossy(&output.stdout),
734            String::from_utf8_lossy(&output.stderr),
735        ),
736    }
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742
743    // --- normalized results (#239): the engine-agnostic schema + core gate ---
744
745    // A normalized result set covering every status: two survivors (Survived + NoCoverage),
746    // a caught Killed, an inconclusive-but-viable Timeout, and two unviable mutants
747    // (CompileError / RuntimeError). `snake_case` on the wire; an extra field is ignored.
748    const NORMALIZED: &str = r#"[
749        {"file": "src/a.ts", "line": 2, "status": "survived",
750         "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
751        {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
752        {"file": "src/a.ts", "line": 9, "status": "killed",
753         "mutator": "BooleanLiteral", "replacement": "false"},
754        {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
755        {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
756        {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
757    ]"#;
758
759    #[test]
760    fn parses_the_normalized_schema() {
761        let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
762        assert_eq!(mutants.len(), 6);
763        assert_eq!(mutants[0].status, MutantStatus::Survived);
764        assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
765        assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
766        assert_eq!(mutants[1].replacement, None);
767    }
768
769    #[test]
770    fn normalized_survivors_are_survived_and_nocoverage_only() {
771        let mutants = parse_normalized_results(NORMALIZED).unwrap();
772        let survivors = normalized_survivors(&mutants);
773        // Survived (2) + NoCoverage (5); not killed/timeout/compile/runtime.
774        assert_eq!(survivors.len(), 2);
775        assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
776        // Replacement is folded into the description when present, omitted otherwise.
777        assert!(survivors[0].description.contains("ConditionalExpression"));
778        assert!(survivors[0].description.contains("-> true"));
779        assert_eq!(survivors[1].description, "ArithmeticOperator");
780    }
781
782    #[test]
783    fn normalized_mutated_lines_collects_only_viable_mutants() {
784        let mutants = parse_normalized_results(NORMALIZED).unwrap();
785        // Survived/Killed/NoCoverage/Timeout ran; CompileError/RuntimeError never produced
786        // a viable mutant.
787        assert_eq!(
788            normalized_mutated_lines(&mutants),
789            [2u32, 5, 9, 12]
790                .into_iter()
791                .map(|line| ("src/a.ts".to_string(), line))
792                .collect()
793        );
794    }
795
796    #[test]
797    fn evaluate_normalized_reports_unexempted_survivors() {
798        let mutants = parse_normalized_results(NORMALIZED).unwrap();
799        let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
800        assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
801    }
802
803    #[test]
804    fn evaluate_normalized_drops_a_whole_file_exemption() {
805        let mutants = parse_normalized_results(NORMALIZED).unwrap();
806        let kept =
807            evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
808        assert!(
809            kept.is_empty(),
810            "the whole-file exemption lifts both survivors"
811        );
812    }
813
814    #[test]
815    fn evaluate_normalized_drops_a_line_scoped_exemption() {
816        let mutants = parse_normalized_results(NORMALIZED).unwrap();
817        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
818        let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
819        // Line 2's survivor is lifted; line 5's still stands.
820        assert_eq!(kept.len(), 1);
821        assert_eq!(kept[0].line, 5);
822    }
823
824    #[test]
825    fn evaluate_normalized_rejects_exempting_a_caught_line() {
826        // Line 9 had only a Killed mutant (viable, no survivor) — over-exemption is an error,
827        // via the shared #226 determinism guard.
828        let mutants = parse_normalized_results(NORMALIZED).unwrap();
829        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
830        let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
831        assert!(
832            err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
833            "got: {err}"
834        );
835    }
836
837    #[test]
838    fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
839        // Line 15 had only a CompileError (no viable mutant) — neither an error nor a drop;
840        // the real survivors still stand.
841        let mutants = parse_normalized_results(NORMALIZED).unwrap();
842        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
843        let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
844        assert_eq!(kept.len(), 2);
845    }
846
847    // A pared `outcomes.json`: the baseline, one missed mutant, and one caught — the
848    // real shape (externally-tagged `scenario`, extra fields the rule ignores).
849    const SAMPLE: &str = r#"{
850        "outcomes": [
851            {"scenario": "Baseline", "summary": "Success",
852             "phase_results": []},
853            {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
854                "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
855                "function": {"function_name": "is_positive"},
856                "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
857             "summary": "MissedMutant"},
858            {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
859                "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
860                "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
861             "summary": "CaughtMutant"}
862        ],
863        "total_mutants": 2
864    }"#;
865
866    #[test]
867    fn parses_the_outcomes_export() {
868        let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
869        assert_eq!(report.outcomes.len(), 3);
870        assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
871    }
872
873    #[test]
874    fn collects_only_missed_mutants_as_survivors() {
875        let report = parse_mutants_report(SAMPLE).unwrap();
876        let survivors = unexplained_survivors(&report, &[]);
877        // Only the MissedMutant — the baseline and the CaughtMutant are not survivors.
878        assert_eq!(survivors.len(), 1);
879        assert_eq!(survivors[0].file, "src/lib.rs");
880        assert_eq!(survivors[0].line, 7);
881        assert!(survivors[0].description.contains("replace > with =="));
882    }
883
884    #[test]
885    fn an_exemption_drops_a_survivor_in_that_file() {
886        let report = parse_mutants_report(SAMPLE).unwrap();
887        let exempt = vec!["src/lib.rs".to_string()];
888        assert!(unexplained_survivors(&report, &exempt).is_empty());
889    }
890
891    #[test]
892    fn an_exemption_on_another_file_leaves_the_survivor() {
893        let report = parse_mutants_report(SAMPLE).unwrap();
894        let exempt = vec!["src/elsewhere.rs".to_string()];
895        assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
896    }
897
898    #[test]
899    fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
900        assert!(is_mutatable_ts("src/index.ts"));
901        assert!(is_mutatable_ts("src/util.tsx"));
902        assert!(is_mutatable_ts("src/util.js"));
903        assert!(!is_mutatable_ts("src/index.test.ts"));
904        assert!(!is_mutatable_ts("src/index.spec.ts"));
905        assert!(!is_mutatable_ts("src/types.d.ts"));
906        assert!(!is_mutatable_ts("README.md"));
907    }
908
909    #[test]
910    fn contiguous_runs_collapses_adjacent_lines() {
911        let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
912        assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
913        assert!(contiguous_runs(&BTreeSet::new()).is_empty());
914    }
915
916    #[test]
917    fn one_line_flattens_and_caps() {
918        assert_eq!(one_line("a -\n  b"), "a - b");
919        let long = "x".repeat(80);
920        let capped = one_line(&long);
921        assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
922    }
923
924    #[test]
925    fn is_mutatable_py_keeps_sources_and_drops_tests() {
926        assert!(is_mutatable_py("calc.py"));
927        assert!(is_mutatable_py("pkg/util.py"));
928        assert!(!is_mutatable_py("calc_test.py"));
929        assert!(!is_mutatable_py("test_calc.py"));
930        assert!(!is_mutatable_py("pkg/conftest.py"));
931        assert!(!is_mutatable_py("README.md"));
932    }
933
934    // --- line-scoped exemptions (#226) ---
935
936    #[test]
937    fn mutated_lines_collects_caught_and_missed() {
938        // The MissedMutant (src/lib.rs:7) and the CaughtMutant (src/other.rs:3) are both
939        // viable, conclusive mutants; the Baseline is not.
940        let report = parse_mutants_report(SAMPLE).unwrap();
941        assert_eq!(
942            mutated_lines(&report),
943            [
944                ("src/lib.rs".to_string(), 7),
945                ("src/other.rs".to_string(), 3)
946            ]
947            .into_iter()
948            .collect()
949        );
950    }
951
952    #[test]
953    fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
954        let report = parse_mutants_report(SAMPLE).unwrap();
955        let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
956        let kept = evaluate_scoped(
957            cargo_mutants_survivors(&report),
958            &mutated_lines(&report),
959            &[],
960            &line_scoped,
961        )
962        .unwrap();
963        assert!(
964            kept.is_empty(),
965            "the src/lib.rs:7 survivor should be lifted"
966        );
967    }
968
969    #[test]
970    fn evaluate_scoped_rejects_exempting_a_caught_line() {
971        // src/other.rs:3 had only a caught mutant (no survivor) — over-exemption.
972        let report = parse_mutants_report(SAMPLE).unwrap();
973        let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
974        let err = evaluate_scoped(
975            cargo_mutants_survivors(&report),
976            &mutated_lines(&report),
977            &[],
978            &line_scoped,
979        )
980        .unwrap_err();
981        assert!(
982            err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
983            "got: {err}"
984        );
985    }
986
987    #[test]
988    fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
989        // Line 99 has no mutant at all (e.g. outside a `--base` diff) — neither an error
990        // nor a drop; the real survivor on line 7 still stands.
991        let report = parse_mutants_report(SAMPLE).unwrap();
992        let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
993        let kept = evaluate_scoped(
994            cargo_mutants_survivors(&report),
995            &mutated_lines(&report),
996            &[],
997            &line_scoped,
998        )
999        .unwrap();
1000        assert_eq!(kept.len(), 1);
1001        assert_eq!(kept[0].line, 7);
1002    }
1003
1004    #[test]
1005    fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1006        let report = parse_mutants_report(SAMPLE).unwrap();
1007        let kept = evaluate_scoped(
1008            cargo_mutants_survivors(&report),
1009            &mutated_lines(&report),
1010            &["src/lib.rs".to_string()],
1011            &BTreeMap::new(),
1012        )
1013        .unwrap();
1014        assert!(kept.is_empty());
1015    }
1016}