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