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