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