Skip to main content

testing_conventions/
mutation.rs

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