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