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