Skip to main content

testing_conventions/
mutation.rs

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