Skip to main content

testing_conventions/
mutation.rs

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