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