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/// A cargo-mutants `outcomes.json` export, pared to what the rule reads. Unmodeled
38/// fields (`total_mutants`, `caught`, timings, …) are ignored.
39#[derive(Debug, Clone, Deserialize)]
40pub struct MutantsReport {
41    pub outcomes: Vec<MutantOutcome>,
42}
43
44/// One scenario's outcome. `summary` is cargo-mutants' result word — `Success` for the
45/// unmutated baseline, `CaughtMutant` / `MissedMutant` (and `Timeout` / `Unviable`)
46/// for each mutant.
47#[derive(Debug, Clone, Deserialize)]
48pub struct MutantOutcome {
49    pub summary: String,
50    pub scenario: Scenario,
51}
52
53/// The scenario a result came from: the unmutated baseline, or one mutant. Matches
54/// cargo-mutants' externally-tagged JSON (`"Baseline"` vs `{"Mutant": {…}}`).
55#[derive(Debug, Clone, Deserialize)]
56pub enum Scenario {
57    Baseline,
58    Mutant(MutantInfo),
59}
60
61/// The mutant a scenario describes, pared to the location + description the report
62/// needs. cargo-mutants also carries `function`, `genre`, `package`, `replacement`;
63/// those are ignored.
64#[derive(Debug, Clone, Deserialize)]
65pub struct MutantInfo {
66    pub file: String,
67    pub span: Span,
68    pub name: String,
69}
70
71/// A source span; only the start line is read.
72#[derive(Debug, Clone, Deserialize)]
73pub struct Span {
74    pub start: LineCol,
75}
76
77/// A line/column position; only the line is read.
78#[derive(Debug, Clone, Deserialize)]
79pub struct LineCol {
80    pub line: u32,
81}
82
83/// Parse a cargo-mutants `outcomes.json` export.
84pub fn parse_mutants_report(json: &str) -> Result<MutantsReport> {
85    serde_json::from_str(json).context("parsing cargo-mutants outcomes.json")
86}
87
88/// The surviving mutants not lifted by a `mutation` exemption — the rule's findings.
89///
90/// A survivor is a `MissedMutant` outcome (the suite ran the mutated code but no test
91/// failed). `exempt` is the resolved set of `mutation`-rule exempt paths (crate-root
92/// relative); a survivor in an exempt file is dropped (an equivalent or deliberately
93/// defensive mutation, lifted with a reason). `Timeout` / `Unviable` are *not*
94/// survivors — a timeout is inconclusive, not a pass, and an unviable mutant never
95/// compiled.
96pub fn unexplained_survivors(report: &MutantsReport, exempt: &[String]) -> Vec<Survivor> {
97    let survivors = report
98        .outcomes
99        .iter()
100        .filter_map(|outcome| {
101            if outcome.summary != "MissedMutant" {
102                return None;
103            }
104            let Scenario::Mutant(mutant) = &outcome.scenario else {
105                return None;
106            };
107            Some(Survivor {
108                file: mutant.file.clone(),
109                line: mutant.span.start.line,
110                description: mutant.name.clone(),
111            })
112        })
113        .collect();
114    evaluate(survivors, exempt)
115}
116
117/// The shared evaluation core both engines feed: drop the survivors lifted by a
118/// `mutation` exemption (a file-path match), leaving the rule's findings. Each engine
119/// produces the raw survivor list its own way ([`unexplained_survivors`] from a
120/// cargo-mutants report, [`stryker_survivors`] from a Stryker report); this applies
121/// the reason-required exemptions identically across languages.
122pub fn evaluate(survivors: Vec<Survivor>, exempt: &[String]) -> Vec<Survivor> {
123    survivors
124        .into_iter()
125        .filter(|survivor| !exempt.iter().any(|path| path == &survivor.file))
126        .collect()
127}
128
129/// Run cargo-mutants over the crate at `root` and return its un-exempted survivors.
130///
131/// With `base` set, only mutants on the `<base>...HEAD` changed lines are tested (via
132/// cargo-mutants' `--in-diff`); without it, the whole crate. `exempt` is the resolved
133/// `mutation`-rule exempt paths. `cargo-mutants` must be installed.
134pub fn measure_rust(root: &Path, exempt: &[String], base: Option<&str>) -> Result<Vec<Survivor>> {
135    let out = MutantsOut::new();
136    let diff = match base {
137        // An empty diff (no changed lines under the crate — a PR that doesn't touch it)
138        // means nothing to mutate: no survivors, no cargo-mutants run.
139        Some(base) => match write_base_diff(root, base, &out)? {
140            None => return Ok(Vec::new()),
141            Some(path) => Some(path),
142        },
143        None => None,
144    };
145    run_cargo_mutants(root, &out.0, diff.as_deref())?;
146    let outcomes = out.0.join("mutants.out").join("outcomes.json");
147    // cargo-mutants writes no `outcomes.json` when a run produces no mutants (e.g. an
148    // `--in-diff` that matches none of the crate's lines). `run_cargo_mutants` already
149    // bailed on a fatal exit, so a missing report here is "no mutants" → no survivors.
150    let json = match std::fs::read_to_string(&outcomes) {
151        Ok(json) => json,
152        Err(_) => return Ok(Vec::new()),
153    };
154    let report = parse_mutants_report(&json)?;
155    Ok(unexplained_survivors(&report, exempt))
156}
157
158/// A Stryker `mutation.json` report (the mutation-testing-elements schema), pared to
159/// the fields the rule reads. Unmodeled keys (`schemaVersion`, `thresholds`, `source`,
160/// `testFiles`, `projectRoot`, `config`, …) are ignored.
161#[derive(Debug, Clone, Deserialize)]
162pub struct StrykerReport {
163    /// Per-file mutants, keyed by project-relative, `/`-separated path.
164    pub files: BTreeMap<String, StrykerFile>,
165}
166
167/// One file's mutants in a Stryker report.
168#[derive(Debug, Clone, Deserialize)]
169pub struct StrykerFile {
170    #[serde(default)]
171    pub mutants: Vec<StrykerMutant>,
172}
173
174/// One mutant, pared to the location + status + description the rule needs. Stryker
175/// also carries `id`, `coveredBy`, `static`, `testsCompleted`, …; those are ignored.
176#[derive(Debug, Clone, Deserialize)]
177#[serde(rename_all = "camelCase")]
178pub struct StrykerMutant {
179    pub mutator_name: String,
180    #[serde(default)]
181    pub replacement: Option<String>,
182    pub status: String,
183    pub location: StrykerLocation,
184}
185
186/// A mutant's source location; only the start line is read (reusing [`LineCol`], whose
187/// extra `column` field Stryker also provides and serde ignores).
188#[derive(Debug, Clone, Deserialize)]
189pub struct StrykerLocation {
190    pub start: LineCol,
191}
192
193/// Parse a Stryker `mutation.json` report.
194pub fn parse_stryker_report(json: &str) -> Result<StrykerReport> {
195    serde_json::from_str(json).context("parsing Stryker mutation.json")
196}
197
198/// The surviving mutants in a Stryker report — the raw list before exemptions.
199///
200/// A survivor is a `Survived` mutant (a test ran the mutated code but none failed) or a
201/// `NoCoverage` one (no test exercised it at all — worse). `Killed` / `Timeout` are
202/// caught; `CompileError` / `RuntimeError` never produced a viable mutant; `Ignored` /
203/// `Pending` are out of scope. (Mirrors the cargo-mutants `MissedMutant`-only rule.)
204pub fn stryker_survivors(report: &StrykerReport) -> Vec<Survivor> {
205    let mut survivors = Vec::new();
206    for (file, contents) in &report.files {
207        for mutant in &contents.mutants {
208            if mutant.status != "Survived" && mutant.status != "NoCoverage" {
209                continue;
210            }
211            let description = match &mutant.replacement {
212                Some(replacement) => {
213                    format!("{} (-> {})", mutant.mutator_name, one_line(replacement))
214                }
215                None => mutant.mutator_name.clone(),
216            };
217            survivors.push(Survivor {
218                file: file.clone(),
219                line: mutant.location.start.line,
220                description,
221            });
222        }
223    }
224    survivors
225}
226
227/// Collapse a (possibly multi-line) replacement to a single trimmed line, capped, so a
228/// survivor's one-line description stays readable.
229fn one_line(replacement: &str) -> String {
230    let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
231    const MAX: usize = 60;
232    if flat.chars().count() > MAX {
233        format!("{}…", flat.chars().take(MAX).collect::<String>())
234    } else {
235        flat
236    }
237}
238
239/// Run Stryker over the TypeScript project at `root` and return its un-exempted
240/// survivors — the TS arm of the mutation rule (#202), parity with [`measure_rust`].
241///
242/// With `base` set, only mutants on the `<base>...HEAD` changed lines are tested —
243/// Stryker has no native git-diff mode, so the changed lines become `--mutate
244/// <file>:<line>-<line>` ranges (line granularity, matching cargo-mutants' `--in-diff`).
245/// Without it, the project's configured `mutate` set runs. `exempt` is the resolved
246/// `mutation`-rule exempt paths. Stryker must be installed / resolvable.
247pub fn measure_typescript(
248    root: &Path,
249    exempt: &[String],
250    base: Option<&str>,
251) -> Result<Vec<Survivor>> {
252    let mutate = match base {
253        Some(base) => {
254            let ranges = mutate_ranges(root, base)?;
255            // Nothing mutatable changed on the diff: no run, no survivors.
256            if ranges.is_empty() {
257                return Ok(Vec::new());
258            }
259            Some(ranges)
260        }
261        None => None,
262    };
263    let json = run_stryker(root, mutate.as_deref())?;
264    let report = parse_stryker_report(&json)?;
265    Ok(evaluate(stryker_survivors(&report), exempt))
266}
267
268/// Build the Stryker `--mutate` specs scoping a run to the `<base>...HEAD` changed
269/// lines: each mutatable source file's contiguous runs of changed lines become a
270/// `<file>:<start>-<end>` range (Stryker's line-range form). Reuses the patch-coverage
271/// diff parser. Test and declaration files are filtered out — Stryker's configured
272/// `mutate` set normally excludes them, but passing `--mutate` replaces that set.
273fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
274    let changed = crate::patch_coverage::changed_lines(root, base)?;
275    let mut specs = Vec::new();
276    for (file, lines) in changed {
277        if !is_mutatable_ts(&file) {
278            continue;
279        }
280        for (start, end) in contiguous_runs(&lines) {
281            specs.push(format!("{file}:{start}-{end}"));
282        }
283    }
284    Ok(specs)
285}
286
287/// Whether a changed file is a TypeScript/JavaScript *source* Stryker should mutate — a
288/// `.ts`/`.tsx`/`.mts`/`.cts`/`.js`/`.jsx`/`.mjs`/`.cjs` file that is not a declaration
289/// (`.d.ts`) or a test (`.test.` / `.spec.`).
290fn is_mutatable_ts(file: &str) -> bool {
291    let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
292        .iter()
293        .any(|ext| file.ends_with(ext));
294    let is_decl = file.ends_with(".d.ts");
295    let is_test = file.contains(".test.") || file.contains(".spec.");
296    is_source && !is_decl && !is_test
297}
298
299/// Fold a sorted set of line numbers into inclusive `(start, end)` contiguous runs.
300fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
301    let mut runs: Vec<(u64, u64)> = Vec::new();
302    for &line in lines {
303        match runs.last_mut() {
304            Some(run) if run.1 + 1 == line => run.1 = line,
305            _ => runs.push((line, line)),
306        }
307    }
308    runs
309}
310
311/// Run Stryker over `root` (resolving the project's own config) with the json reporter,
312/// returning the contents of its `mutation.json`. `mutate`, when set, scopes the run to
313/// `--mutate` line ranges. The report goes to Stryker's default
314/// `reports/mutation/mutation.json`; it's read and then pruned (the file and any empty
315/// parents) so the scanned tree stays pristine and a populated `reports/` is untouched.
316///
317/// Stryker's exit code is *not* trusted to mean "no survivors": a configured
318/// `thresholds.break` makes it exit non-zero on a low score, which is exactly the
319/// survivor case the rule reports on. So the report is read whenever it exists; only a
320/// missing report (a real run failure) is fatal.
321fn run_stryker(root: &Path, mutate: Option<&[String]>) -> Result<String> {
322    let report_path = root.join("reports").join("mutation").join("mutation.json");
323    let _cleanup = ReportCleanup(report_path.clone());
324    // Drop any stale report so a previous run's output is never mistaken for this one's.
325    let _ = std::fs::remove_file(&report_path);
326
327    let mut command = Command::new("npx");
328    command
329        .current_dir(root)
330        .args(["--yes", "stryker", "run", "--reporters", "json"]);
331    if let Some(specs) = mutate {
332        command.arg("--mutate").arg(specs.join(","));
333    }
334    let output = command
335        .env("CI", "1")
336        .output()
337        .context("running `npx stryker run` (is @stryker-mutator/core installed?)")?;
338
339    std::fs::read_to_string(&report_path).map_err(|_| {
340        anyhow::anyhow!(
341            "Stryker produced no report in `{}` (did it run cleanly?):\n{}{}",
342            root.display(),
343            String::from_utf8_lossy(&output.stdout),
344            String::from_utf8_lossy(&output.stderr),
345        )
346    })
347}
348
349/// Removes the Stryker json report on drop, pruning the `mutation/` and `reports/`
350/// parents only if they're left empty — so a user's own populated `reports/` survives.
351struct ReportCleanup(PathBuf);
352
353impl Drop for ReportCleanup {
354    fn drop(&mut self) {
355        let _ = std::fs::remove_file(&self.0);
356        if let Some(mutation_dir) = self.0.parent() {
357            // `remove_dir` only succeeds on an empty dir, so populated trees are safe.
358            let _ = std::fs::remove_dir(mutation_dir);
359            if let Some(reports_dir) = mutation_dir.parent() {
360                let _ = std::fs::remove_dir(reports_dir);
361            }
362        }
363    }
364}
365
366/// One line of `cosmic-ray dump` output: a `[work_item, result]` pair. The result is
367/// absent (`null`) for an un-executed work item.
368#[derive(Debug, Clone, Deserialize)]
369pub struct CosmicRayLine(pub CrWorkItem, pub Option<CrResult>);
370
371/// A cosmic-ray work item, pared to its one mutation's location. (cosmic-ray models a
372/// list of mutations per item, but the operators here produce one apiece.)
373#[derive(Debug, Clone, Deserialize)]
374pub struct CrWorkItem {
375    pub mutations: Vec<CrMutation>,
376}
377
378/// One mutation, pared to the location + operator the rule reads. cosmic-ray also
379/// carries `occurrence`, `end_pos`, `operator_args`; those are ignored.
380#[derive(Debug, Clone, Deserialize)]
381pub struct CrMutation {
382    pub module_path: String,
383    pub operator_name: String,
384    /// `[line, column]`, 1-based line.
385    pub start_pos: (u32, u32),
386    #[serde(default)]
387    pub definition_name: Option<String>,
388}
389
390/// A work item's result; only the test outcome is read (`survived` / `killed` /
391/// `incompetent`).
392#[derive(Debug, Clone, Deserialize)]
393pub struct CrResult {
394    #[serde(default)]
395    pub test_outcome: Option<String>,
396}
397
398/// Parse `cosmic-ray dump` output (JSON Lines) into the surviving mutants — the raw
399/// list before exemptions.
400///
401/// A survivor is a work item whose result is `survived` (the suite ran the mutated code
402/// but no test failed). `killed` / `incompetent` (the mutant didn't run — e.g. a syntax
403/// error) are not survivors. (Mirrors the cargo-mutants `MissedMutant` / Stryker
404/// `Survived` rules.)
405pub fn parse_cosmic_ray_dump(dump: &str) -> Result<Vec<Survivor>> {
406    let mut survivors = Vec::new();
407    for line in dump.lines() {
408        if line.trim().is_empty() {
409            continue;
410        }
411        let CosmicRayLine(item, result) =
412            serde_json::from_str(line).context("parsing a cosmic-ray dump line")?;
413        let survived = matches!(result, Some(CrResult { test_outcome: Some(outcome) }) if outcome == "survived");
414        if !survived {
415            continue;
416        }
417        let Some(mutation) = item.mutations.first() else {
418            continue;
419        };
420        let definition = mutation.definition_name.as_deref().unwrap_or("<module>");
421        survivors.push(Survivor {
422            file: mutation.module_path.clone(),
423            line: mutation.start_pos.0,
424            description: format!("{} in {}", mutation.operator_name, definition),
425        });
426    }
427    Ok(survivors)
428}
429
430/// Run cosmic-ray over the Python project at `root` and return its un-exempted
431/// survivors — the Python arm of the mutation rule (#203), parity with [`measure_rust`]
432/// and [`measure_typescript`].
433///
434/// With `base` set, only mutants on the `<base>...HEAD` changed lines are reported:
435/// cosmic-ray has no native git-diff mode, so the run is scoped to the changed `.py`
436/// files (one session each) and the survivors are then filtered to the changed lines —
437/// line granularity, matching the other arms. Without it, the whole project's sources
438/// run (tests excluded). `exempt` is the resolved `mutation`-rule exempt paths.
439/// cosmic-ray + pytest must be installed.
440pub fn measure_python(root: &Path, exempt: &[String], base: Option<&str>) -> Result<Vec<Survivor>> {
441    let survivors = match base {
442        None => run_cosmic_ray(root, ".", &PY_TEST_EXCLUDES)?,
443        Some(base) => {
444            let changed = crate::patch_coverage::changed_lines(root, base)?;
445            let mut all = Vec::new();
446            for (file, lines) in &changed {
447                if !is_mutatable_py(file) {
448                    continue;
449                }
450                // The file is a single non-test module, so no test exclusions are needed.
451                for survivor in run_cosmic_ray(root, file, &[])? {
452                    if lines.contains(&(survivor.line as u64)) {
453                        all.push(survivor);
454                    }
455                }
456            }
457            all
458        }
459    };
460    Ok(evaluate(survivors, exempt))
461}
462
463/// The test/conftest globs excluded from whole-project mutation (cosmic-ray would
464/// otherwise mutate the suite itself).
465const PY_TEST_EXCLUDES: [&str; 3] = ["*_test.py", "test_*.py", "conftest.py"];
466
467/// Whether a changed file is a mutatable Python *source* — a `.py` that is not a test
468/// (`*_test.py` / `test_*.py`) or `conftest.py`.
469fn is_mutatable_py(file: &str) -> bool {
470    if !file.ends_with(".py") {
471        return false;
472    }
473    let base = file.rsplit('/').next().unwrap_or(file);
474    !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
475}
476
477/// Run one cosmic-ray session over `module_path` (relative to `root`) and return its
478/// surviving mutants. A baseline check runs first so a suite that fails *unmutated*
479/// errors rather than reporting a false "all killed". The config + session DB live in
480/// an out-of-tree temp dir; cosmic-ray mutates each file in place and reverts it, so
481/// the scanned tree is left as it was.
482fn run_cosmic_ray(
483    root: &Path,
484    module_path: &str,
485    excluded_modules: &[&str],
486) -> Result<Vec<Survivor>> {
487    let dir = CosmicRayDir::new();
488    std::fs::create_dir_all(&dir.0).context("creating the cosmic-ray temp dir")?;
489    let config = dir.0.join("cr.toml");
490    let session = dir.0.join("session.sqlite");
491
492    let excludes = excluded_modules
493        .iter()
494        .map(|glob| format!("\"{glob}\""))
495        .collect::<Vec<_>>()
496        .join(", ");
497    std::fs::write(
498        &config,
499        format!(
500            "[cosmic-ray]\n\
501             module-path = \"{module_path}\"\n\
502             timeout = 30.0\n\
503             excluded-modules = [{excludes}]\n\
504             test-command = \"python3 -m pytest -q -p no:cacheprovider\"\n\
505             \n\
506             [cosmic-ray.distributor]\n\
507             name = \"local\"\n"
508        ),
509    )
510    .context("writing the cosmic-ray config")?;
511
512    // Baseline: the suite must pass unmutated, or every mutant would "die" on the
513    // already-failing tests and we'd report a false pass.
514    let baseline = cosmic_ray(root, &["baseline", path_str(&config)])?;
515    if !baseline.status.success() {
516        bail!(
517            "the Python unit suite did not pass unmutated in `{}` (cosmic-ray baseline failed):\n{}{}",
518            root.display(),
519            String::from_utf8_lossy(&baseline.stdout),
520            String::from_utf8_lossy(&baseline.stderr),
521        );
522    }
523
524    let init = cosmic_ray(root, &["init", path_str(&config), path_str(&session)])?;
525    if !init.status.success() {
526        bail!(
527            "cosmic-ray init failed in `{}`:\n{}{}",
528            root.display(),
529            String::from_utf8_lossy(&init.stdout),
530            String::from_utf8_lossy(&init.stderr),
531        );
532    }
533    let exec = cosmic_ray(root, &["exec", path_str(&config), path_str(&session)])?;
534    if !exec.status.success() {
535        bail!(
536            "cosmic-ray exec failed in `{}`:\n{}{}",
537            root.display(),
538            String::from_utf8_lossy(&exec.stdout),
539            String::from_utf8_lossy(&exec.stderr),
540        );
541    }
542    let dump = cosmic_ray(root, &["dump", path_str(&session)])?;
543    if !dump.status.success() {
544        bail!(
545            "cosmic-ray dump failed in `{}`:\n{}",
546            root.display(),
547            String::from_utf8_lossy(&dump.stderr),
548        );
549    }
550    parse_cosmic_ray_dump(&String::from_utf8_lossy(&dump.stdout))
551}
552
553/// Run a `cosmic-ray` subcommand in `root`, capturing its output. `PYTHONDONTWRITEBYTECODE`
554/// keeps `__pycache__` out of the scanned tree.
555fn cosmic_ray(root: &Path, args: &[&str]) -> Result<std::process::Output> {
556    Command::new("cosmic-ray")
557        .current_dir(root)
558        .args(args)
559        .env("PYTHONDONTWRITEBYTECODE", "1")
560        .output()
561        .context("running `cosmic-ray` (is it installed?)")
562}
563
564fn path_str(path: &Path) -> &str {
565    path.to_str().expect("temp path is valid UTF-8")
566}
567
568/// A unique temp dir for one cosmic-ray session's config + SQLite, removed on drop so
569/// the scanned tree stays pristine and parallel runs don't collide.
570struct CosmicRayDir(PathBuf);
571
572impl CosmicRayDir {
573    fn new() -> Self {
574        static COUNTER: AtomicU64 = AtomicU64::new(0);
575        let name = format!(
576            "testing-conventions-cosmic-ray-{}-{}",
577            std::process::id(),
578            COUNTER.fetch_add(1, Ordering::Relaxed),
579        );
580        CosmicRayDir(std::env::temp_dir().join(name))
581    }
582}
583
584impl Drop for CosmicRayDir {
585    fn drop(&mut self) {
586        let _ = std::fs::remove_dir_all(&self.0);
587    }
588}
589
590/// A unique temp dir for one cargo-mutants run's `--output`, removed on drop so the
591/// scanned crate stays pristine and parallel runs don't collide.
592struct MutantsOut(PathBuf);
593
594impl MutantsOut {
595    fn new() -> Self {
596        static COUNTER: AtomicU64 = AtomicU64::new(0);
597        let name = format!(
598            "testing-conventions-mutants-{}-{}",
599            std::process::id(),
600            COUNTER.fetch_add(1, Ordering::Relaxed),
601        );
602        MutantsOut(std::env::temp_dir().join(name))
603    }
604}
605
606impl Drop for MutantsOut {
607    fn drop(&mut self) {
608        let _ = std::fs::remove_dir_all(&self.0);
609    }
610}
611
612/// Write the `<base>...HEAD` diff cargo-mutants' `--in-diff` scopes to, returning its
613/// path — or `None` when the diff is empty (no changed lines under the crate).
614///
615/// `--relative` restricts the diff to changes under `root` (the crate dir) and makes
616/// the paths relative to it. cargo-mutants runs *in* the crate dir and matches its
617/// `--in-diff` paths crate-relative, so without `--relative` the diff is repo-relative
618/// and matches nothing whenever the crate is a subdirectory of the git repo (the common
619/// case). Scoping also means a PR that doesn't touch the crate yields an empty diff.
620fn write_base_diff(root: &Path, base: &str, out: &MutantsOut) -> Result<Option<PathBuf>> {
621    let range = format!("{base}...HEAD");
622    let output = Command::new("git")
623        .current_dir(root)
624        .args(["diff", "--relative", &range])
625        .output()
626        .context("running `git diff` for `--base` (is git installed?)")?;
627    if !output.status.success() {
628        bail!(
629            "git diff {range} failed: {}",
630            String::from_utf8_lossy(&output.stderr)
631        );
632    }
633    if output.stdout.is_empty() {
634        return Ok(None);
635    }
636    std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
637    let path = out.0.join("base.diff");
638    std::fs::write(&path, &output.stdout).context("writing the base diff")?;
639    Ok(Some(path))
640}
641
642/// Run `cargo mutants --output <out> [--in-diff <diff>]` in `root`.
643///
644/// cargo-mutants exits `0` when every mutant is caught and `2` when some survive (or
645/// time out / are unviable) — both are normal here, since survivors are the rule's
646/// *output*, not an error. Any other code (usage error, or a baseline that didn't
647/// build/pass) is fatal. As with the coverage run, the outer instrumentation env is
648/// stripped so a nested run (this rule's own tests under `cargo llvm-cov`) doesn't
649/// re-enter the rustc wrapper and hang.
650fn run_cargo_mutants(root: &Path, out: &Path, in_diff: Option<&Path>) -> Result<()> {
651    let mut command = Command::new("cargo");
652    command
653        .current_dir(root)
654        .arg("mutants")
655        .arg("--output")
656        .arg(out);
657    if let Some(diff) = in_diff {
658        command.arg("--in-diff").arg(diff);
659    }
660    for var in [
661        "RUSTFLAGS",
662        "CARGO_ENCODED_RUSTFLAGS",
663        "RUSTDOCFLAGS",
664        "CARGO_ENCODED_RUSTDOCFLAGS",
665        "LLVM_PROFILE_FILE",
666        "CARGO_LLVM_COV",
667        "CARGO_LLVM_COV_SHOW_ENV",
668        "CARGO_LLVM_COV_TARGET_DIR",
669        "CARGO_LLVM_COV_BUILD_DIR",
670        "RUSTC_WRAPPER",
671        "RUSTC_WORKSPACE_WRAPPER",
672        "__CARGO_LLVM_COV_RUSTC_WRAPPER",
673        "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
674        "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
675    ] {
676        command.env_remove(var);
677    }
678    let output = command
679        .output()
680        .context("running `cargo mutants` (is cargo-mutants installed?)")?;
681    match output.status.code() {
682        // 0 = all caught, 2 = some survived/timed out: both produce a report to read.
683        Some(0) | Some(2) => Ok(()),
684        _ => bail!(
685            "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
686            root.display(),
687            String::from_utf8_lossy(&output.stdout),
688            String::from_utf8_lossy(&output.stderr),
689        ),
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    // A pared `outcomes.json`: the baseline, one missed mutant, and one caught — the
698    // real shape (externally-tagged `scenario`, extra fields the rule ignores).
699    const SAMPLE: &str = r#"{
700        "outcomes": [
701            {"scenario": "Baseline", "summary": "Success",
702             "phase_results": []},
703            {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
704                "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
705                "function": {"function_name": "is_positive"},
706                "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
707             "summary": "MissedMutant"},
708            {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
709                "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
710                "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
711             "summary": "CaughtMutant"}
712        ],
713        "total_mutants": 2
714    }"#;
715
716    #[test]
717    fn parses_the_outcomes_export() {
718        let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
719        assert_eq!(report.outcomes.len(), 3);
720        assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
721    }
722
723    #[test]
724    fn collects_only_missed_mutants_as_survivors() {
725        let report = parse_mutants_report(SAMPLE).unwrap();
726        let survivors = unexplained_survivors(&report, &[]);
727        // Only the MissedMutant — the baseline and the CaughtMutant are not survivors.
728        assert_eq!(survivors.len(), 1);
729        assert_eq!(survivors[0].file, "src/lib.rs");
730        assert_eq!(survivors[0].line, 7);
731        assert!(survivors[0].description.contains("replace > with =="));
732    }
733
734    #[test]
735    fn an_exemption_drops_a_survivor_in_that_file() {
736        let report = parse_mutants_report(SAMPLE).unwrap();
737        let exempt = vec!["src/lib.rs".to_string()];
738        assert!(unexplained_survivors(&report, &exempt).is_empty());
739    }
740
741    #[test]
742    fn an_exemption_on_another_file_leaves_the_survivor() {
743        let report = parse_mutants_report(SAMPLE).unwrap();
744        let exempt = vec!["src/elsewhere.rs".to_string()];
745        assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
746    }
747
748    // A pared Stryker `mutation.json`: one Survived, one NoCoverage, one Killed — the
749    // real shape (per-file `files` map, extra fields the rule ignores).
750    const STRYKER_SAMPLE: &str = r#"{
751        "schemaVersion": "1.0",
752        "files": {
753            "src/index.ts": {
754                "language": "typescript",
755                "source": "...",
756                "mutants": [
757                    {"id": "0", "mutatorName": "ConditionalExpression", "replacement": "true",
758                     "status": "Survived", "coveredBy": ["t0"],
759                     "location": {"start": {"line": 2, "column": 10}, "end": {"line": 2, "column": 15}}},
760                    {"id": "1", "mutatorName": "ArithmeticOperator", "replacement": "a - b",
761                     "status": "NoCoverage",
762                     "location": {"start": {"line": 5, "column": 3}, "end": {"line": 5, "column": 8}}},
763                    {"id": "2", "mutatorName": "BooleanLiteral", "replacement": "false",
764                     "status": "Killed",
765                     "location": {"start": {"line": 9, "column": 1}, "end": {"line": 9, "column": 6}}}
766                ]
767            }
768        }
769    }"#;
770
771    #[test]
772    fn parses_a_stryker_report() {
773        let report = parse_stryker_report(STRYKER_SAMPLE).expect("valid mutation.json");
774        assert_eq!(report.files["src/index.ts"].mutants.len(), 3);
775    }
776
777    #[test]
778    fn collects_survived_and_nocoverage_as_survivors() {
779        let report = parse_stryker_report(STRYKER_SAMPLE).unwrap();
780        let survivors = stryker_survivors(&report);
781        // Survived + NoCoverage are survivors; the Killed mutant is not.
782        assert_eq!(survivors.len(), 2);
783        assert!(survivors.iter().all(|s| s.file == "src/index.ts"));
784        assert_eq!(survivors[0].line, 2);
785        assert!(survivors[0].description.contains("ConditionalExpression"));
786        assert!(survivors[0].description.contains("true"));
787        assert_eq!(survivors[1].line, 5);
788    }
789
790    #[test]
791    fn evaluate_drops_exempt_files_for_either_engine() {
792        let report = parse_stryker_report(STRYKER_SAMPLE).unwrap();
793        let survivors = stryker_survivors(&report);
794        let exempt = vec!["src/index.ts".to_string()];
795        assert!(evaluate(survivors, &exempt).is_empty());
796    }
797
798    #[test]
799    fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
800        assert!(is_mutatable_ts("src/index.ts"));
801        assert!(is_mutatable_ts("src/util.tsx"));
802        assert!(is_mutatable_ts("src/util.js"));
803        assert!(!is_mutatable_ts("src/index.test.ts"));
804        assert!(!is_mutatable_ts("src/index.spec.ts"));
805        assert!(!is_mutatable_ts("src/types.d.ts"));
806        assert!(!is_mutatable_ts("README.md"));
807    }
808
809    #[test]
810    fn contiguous_runs_collapses_adjacent_lines() {
811        let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
812        assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
813        assert!(contiguous_runs(&BTreeSet::new()).is_empty());
814    }
815
816    #[test]
817    fn one_line_flattens_and_caps() {
818        assert_eq!(one_line("a -\n  b"), "a - b");
819        let long = "x".repeat(80);
820        let capped = one_line(&long);
821        assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
822    }
823
824    // A pared `cosmic-ray dump`: each line is `[work_item, result]` — one survived
825    // comparison-operator mutant and one killed binary-operator mutant.
826    const COSMIC_RAY_DUMP: &str = concat!(
827        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"}]"#,
828        "\n",
829        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"}]"#,
830        "\n",
831    );
832
833    #[test]
834    fn collects_only_survived_cosmic_ray_mutants() {
835        let survivors = parse_cosmic_ray_dump(COSMIC_RAY_DUMP).expect("valid dump");
836        // Only the survived mutant — the killed one is not a survivor.
837        assert_eq!(survivors.len(), 1);
838        assert_eq!(survivors[0].file, "calc.py");
839        assert_eq!(survivors[0].line, 6);
840        assert!(survivors[0]
841            .description
842            .contains("ReplaceComparisonOperator"));
843        assert!(survivors[0].description.contains("is_positive"));
844    }
845
846    #[test]
847    fn an_unexecuted_cosmic_ray_item_is_not_a_survivor() {
848        // A work item with a null result (never run) must not count as a survivor.
849        let dump = r#"[{"mutations":[{"module_path":"calc.py","operator_name":"core/NumberReplacer","start_pos":[3,5],"end_pos":[3,6]}]},null]"#;
850        assert!(parse_cosmic_ray_dump(dump).unwrap().is_empty());
851    }
852
853    #[test]
854    fn is_mutatable_py_keeps_sources_and_drops_tests() {
855        assert!(is_mutatable_py("calc.py"));
856        assert!(is_mutatable_py("pkg/util.py"));
857        assert!(!is_mutatable_py("calc_test.py"));
858        assert!(!is_mutatable_py("test_calc.py"));
859        assert!(!is_mutatable_py("pkg/conftest.py"));
860        assert!(!is_mutatable_py("README.md"));
861    }
862}