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