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