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