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 mut mutants = to_scan_relative(parse_normalized_results(&json)?, prefix.as_deref());
616    mutants.retain(|mutant| !is_declaration_only(root, &mutant.file, Language::TypeScript));
617    let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
618    Ok(Measurement::Tested {
619        count: normalized_conclusive_count(&mutants),
620        survivors,
621    })
622}
623
624/// What a TypeScript run is scoped to.
625enum TsScope {
626    /// `--base` changed no mutable lines: the engine has nothing to judge.
627    NothingToJudge,
628    /// The adapter's `--mutate` specs. `None` leaves the whole package in scope, which is
629    /// what a scan path that *is* the package root wants.
630    Mutate(Option<Vec<String>>),
631}
632
633/// Resolve what `base` scopes the TypeScript adapter to. With a `base`, the changed ranges
634/// become `--mutate` specs and an empty set means there is nothing to judge; without one,
635/// the scan prefix alone scopes the run.
636fn ts_base_scope(root: &Path, base: Option<&str>, prefix: Option<&str>) -> Result<TsScope> {
637    let Some(base) = base else {
638        return Ok(TsScope::Mutate(prefix.map(scan_scoped_mutate_globs)));
639    };
640    let ranges = mutate_ranges(root, base)?;
641    if ranges.is_empty() {
642        return Ok(TsScope::NothingToJudge);
643    }
644    Ok(TsScope::Mutate(Some(prefix_mutate_specs(ranges, prefix))))
645}
646
647/// The scan path relative to its package root, as a `/`-joined string. `None` when the scan
648/// path *is* the package root, which also covers a loose tree with no manifest.
649fn scan_prefix(root: &Path, package_root: &Path) -> Option<String> {
650    let rel = root.strip_prefix(package_root).ok()?;
651    let parts: Vec<String> = rel
652        .components()
653        .map(|part| part.as_os_str().to_string_lossy().into_owned())
654        .collect();
655    if parts.is_empty() {
656        None
657    } else {
658        Some(parts.join("/"))
659    }
660}
661
662/// Prefix diff-scoped mutate specs (`<file>:<start>-<end>`, scan-path-relative) with the
663/// scan prefix, so they address the same files from the package root the adapter runs at.
664fn prefix_mutate_specs(specs: Vec<String>, prefix: Option<&str>) -> Vec<String> {
665    match prefix {
666        None => specs,
667        Some(prefix) => specs
668            .into_iter()
669            .map(|spec| format!("{prefix}/{spec}"))
670            .collect(),
671    }
672}
673
674/// Stryker's default `mutate` set re-rooted at the scan path: every source under it except
675/// test files and `__tests__` trees — the same shape Stryker itself defaults to for
676/// `{src,lib}`, addressed from the package root the adapter runs at.
677fn scan_scoped_mutate_globs(prefix: &str) -> Vec<String> {
678    const EXTENSIONS: &str = "+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)";
679    vec![
680        format!("{prefix}/**/!(*.+(s|S)pec|*.+(t|T)est).{EXTENSIONS}"),
681        format!("!{prefix}/**/__tests__/**/*.{EXTENSIONS}"),
682    ]
683}
684
685/// The test files under the scan path, addressed from the package root the adapter runs at.
686/// Stryker matches these against the project's input files and hands the runner that subset,
687/// so vitest stays rooted at the package root and its own `include` resolves unchanged.
688fn scan_scoped_test_file_globs(prefix: &str) -> Vec<String> {
689    vec![format!("{prefix}/**")]
690}
691
692/// Rebase package-root-relative mutant paths onto the scan path: strip the scan prefix so
693/// exemption matching and the reported survivors address scan-path-relative files, as every
694/// other check does. A mutant outside the scan path is outside the gate's scope and dropped.
695fn to_scan_relative(mutants: Vec<NormalizedMutant>, prefix: Option<&str>) -> Vec<NormalizedMutant> {
696    let Some(prefix) = prefix else {
697        return mutants;
698    };
699    let prefix = format!("{prefix}/");
700    mutants
701        .into_iter()
702        .filter_map(|mut mutant| {
703            mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
704            Some(mutant)
705        })
706        .collect()
707}
708
709/// The checked working directory for an adapter run rooted at `root`, for the named `engine`.
710/// [`crate::tiers::package_root`] hands back `""` for a relative scan path like `src`, and
711/// `Command::current_dir("")` fails with the same ENOENT a missing interpreter gives.
712fn adapter_cwd<'a>(root: &'a Path, engine: &str) -> Result<&'a Path> {
713    let cwd = if root.as_os_str().is_empty() {
714        Path::new(".")
715    } else {
716        root
717    };
718    if !cwd.is_dir() {
719        bail!(
720            "the {engine} mutation adapter's working directory `{}` is not a directory",
721            cwd.display()
722        );
723    }
724    Ok(cwd)
725}
726
727/// The context a failed adapter spawn carries. `Command::output()` surfaces a bare ENOENT
728/// that names nothing, so the message names every path the spawn used: the interpreter, the
729/// entry point it was handed, and the directory it ran in.
730fn spawn_context(interpreter: &str, entry: &str, cwd: &Path) -> String {
731    format!(
732        "spawning `{interpreter} {entry}` in `{}` (is `{interpreter}` on PATH?)",
733        cwd.display()
734    )
735}
736
737/// The adapter's optional argv: a `--mutate` pair when the run is scoped to a file list, and
738/// a `--test-files` pair when it is scoped to a test-glob list. Either absent means "no
739/// restriction", which is not the same as an empty list.
740fn ts_adapter_args(mutate: Option<&[String]>, test_files: Option<&[String]>) -> Vec<String> {
741    let mut args = Vec::new();
742    if let Some(specs) = mutate {
743        args.push("--mutate".to_string());
744        args.push(specs.join(","));
745    }
746    if let Some(globs) = test_files {
747        args.push("--test-files".to_string());
748        args.push(globs.join(","));
749    }
750    args
751}
752
753/// Run the bundled TS mutation `adapter` at `package_root` and return the normalized-results
754/// JSON it writes. Results go to a temp file the adapter names via `--out`, so Stryker's own
755/// stdout logging can't corrupt them; a non-zero adapter exit surfaces its captured output.
756fn run_ts_adapter(
757    package_root: &Path,
758    adapter: &Path,
759    mutate: Option<&[String]>,
760    test_files: Option<&[String]>,
761) -> Result<String> {
762    let (_out, results) = adapter_results_path()?;
763    let cwd = adapter_cwd(package_root, "TypeScript")?;
764
765    let mut command = Command::new("node");
766    command
767        .current_dir(cwd)
768        .arg(adapter)
769        .arg("--out")
770        .arg(&results)
771        .args(ts_adapter_args(mutate, test_files));
772    let output =
773        command
774            .output()
775            .context(spawn_context("node", &adapter.display().to_string(), cwd))?;
776    adapter_exit(&output, "TypeScript", cwd)?;
777    read_adapter_results(&results, "TypeScript")
778}
779
780/// The adapter's written results JSON, read back after a successful run.
781fn read_adapter_results(results: &Path, engine: &str) -> Result<String> {
782    std::fs::read_to_string(results).with_context(|| {
783        format!(
784            "reading the {engine} mutation adapter's results from `{}`",
785            results.display()
786        )
787    })
788}
789
790/// A unique temp dir for one TS mutation adapter run's `--out` JSON, removed on drop so
791/// the scanned project stays pristine and parallel runs don't collide.
792/// Where an adapter run writes its normalized results, with the directory's lifetime guard.
793/// Results go to a file the adapter names via `--out`, never to stdout, so the engine's own
794/// logging cannot corrupt them. Hold the guard for as long as the file is wanted.
795fn adapter_results_path() -> Result<(AdapterOut, PathBuf)> {
796    let out = AdapterOut::new();
797    std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
798    let results = out.0.join("results.json");
799    Ok((out, results))
800}
801
802/// The error a failed mutation adapter becomes. Both adapters report the same way, so both
803/// surface the same thing: the directory the run happened in, then its captured stdout and
804/// stderr, because the engine puts the actual cause in one or the other.
805fn adapter_exit(output: &std::process::Output, language: &str, cwd: &Path) -> Result<()> {
806    if !output.status.success() {
807        bail!(
808            "the {language} mutation adapter failed in `{}`:\n{}{}",
809            cwd.display(),
810            String::from_utf8_lossy(&output.stdout),
811            String::from_utf8_lossy(&output.stderr),
812        );
813    }
814    Ok(())
815}
816
817struct AdapterOut(PathBuf);
818
819impl AdapterOut {
820    fn new() -> Self {
821        static COUNTER: AtomicU64 = AtomicU64::new(0);
822        let name = format!(
823            "testing-conventions-ts-adapter-{}-{}",
824            std::process::id(),
825            COUNTER.fetch_add(1, Ordering::Relaxed),
826        );
827        AdapterOut(std::env::temp_dir().join(name))
828    }
829}
830
831impl Drop for AdapterOut {
832    fn drop(&mut self) {
833        let _ = std::fs::remove_dir_all(&self.0);
834    }
835}
836
837/// Build the Stryker `--mutate` specs scoping a run to the `<base>...HEAD` changed lines, as
838/// `<file>:<start>-<end>` ranges. Test and declaration files are filtered out here because
839/// passing `--mutate` replaces Stryker's configured set rather than narrowing it.
840fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
841    let changed = crate::patch_coverage::changed_lines(root, base)?;
842    let mut specs = Vec::new();
843    for (file, lines) in changed {
844        if !is_mutatable_ts(&file) || is_declaration_only(root, &file, Language::TypeScript) {
845            continue;
846        }
847        for (start, end) in contiguous_runs(&lines) {
848            specs.push(format!("{file}:{start}-{end}"));
849        }
850    }
851    Ok(specs)
852}
853
854/// Whether a changed file is a TypeScript/JavaScript *source* Stryker should mutate — a
855/// `.ts`/`.tsx`/`.mts`/`.cts`/`.js`/`.jsx`/`.mjs`/`.cjs` file that is not a declaration
856/// (`.d.ts`) or a test (`.test.` / `.spec.`).
857fn is_mutatable_ts(file: &str) -> bool {
858    let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
859        .iter()
860        .any(|ext| file.ends_with(ext));
861    let is_decl = file.ends_with(".d.ts");
862    let is_test = file.contains(".test.") || file.contains(".spec.");
863    is_source && !is_decl && !is_test
864}
865
866/// Fold a sorted set of line numbers into inclusive `(start, end)` contiguous runs.
867fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
868    let mut runs: Vec<(u64, u64)> = Vec::new();
869    for &line in lines {
870        match runs.last_mut() {
871            Some(run) if run.1 + 1 == line => run.1 = line,
872            _ => runs.push((line, line)),
873        }
874    }
875    runs
876}
877
878/// Run the bundled Python mutation adapter over the project at `root` and return the
879/// [`Measurement`] — the Python arm, parity with [`measure_rust`]. maturin ships the binary
880/// directly, so it invokes the adapter as a module resolved from the wheel's own environment.
881pub fn measure_python(
882    root: &Path,
883    exempt: &[String],
884    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
885    base: Option<&str>,
886) -> Result<Measurement> {
887    let changed = match base {
888        Some(base) => Some(crate::patch_coverage::changed_lines(root, base)?),
889        None => None,
890    };
891    let modules = match &changed {
892        None => Vec::new(),
893        Some(changed) => {
894            let modules = mutatable_py_modules(changed);
895            if modules.is_empty() {
896                return Ok(Measurement::EngineNotRun);
897            }
898            modules
899        }
900    };
901    let json = run_py_adapter(root, &modules)?;
902    let mutants = judged_py_mutants(parse_normalized_results(&json)?, root, changed.as_ref());
903    let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
904    Ok(Measurement::Tested {
905        count: normalized_conclusive_count(&mutants),
906        survivors,
907    })
908}
909
910/// The modules among `changed` the adapter can mutate. Empty when the diff touches no such
911/// Python source — there is nothing for the engine to do.
912fn mutatable_py_modules(changed: &BTreeMap<String, BTreeSet<u64>>) -> Vec<String> {
913    changed
914        .keys()
915        .filter(|file| is_mutatable_py(file))
916        .cloned()
917        .collect()
918}
919
920/// The mutants the Python arm judges: on a `changed` line when the run is diff-scoped, and
921/// outside a declaration-only module, which has no behaviour to mutate.
922fn judged_py_mutants(
923    mutants: Vec<NormalizedMutant>,
924    root: &Path,
925    changed: Option<&BTreeMap<String, BTreeSet<u64>>>,
926) -> Vec<NormalizedMutant> {
927    let mut mutants = mutants;
928    if let Some(changed) = changed {
929        mutants.retain(|mutant| {
930            changed
931                .get(&mutant.file)
932                .is_some_and(|lines| lines.contains(&u64::from(mutant.line)))
933        });
934    }
935    mutants.retain(|mutant| !is_declaration_only(root, &mutant.file, Language::Python));
936    mutants
937}
938
939/// Run the bundled Python mutation adapter over `root` and return the normalized-results
940/// JSON it writes. `modules`, when non-empty, scopes the run to those source files; empty
941/// runs the whole project. `PYTHONDONTWRITEBYTECODE` keeps `__pycache__` out of the tree.
942fn run_py_adapter(root: &Path, modules: &[String]) -> Result<String> {
943    let (_out, results) = adapter_results_path()?;
944    let cwd = adapter_cwd(root, "Python")?;
945
946    const ENTRY: &str = "-m testing_conventions.mutation.main";
947    let mut command = Command::new("python3");
948    command
949        .current_dir(cwd)
950        .args(["-m", "testing_conventions.mutation.main", "--out"])
951        .arg(&results)
952        .env("PYTHONDONTWRITEBYTECODE", "1");
953    for module in modules {
954        command.arg("--module").arg(module);
955    }
956    let output = command
957        .output()
958        .context(spawn_context("python3", ENTRY, cwd))?;
959    adapter_exit(&output, "Python", cwd)?;
960    read_adapter_results(&results, "Python")
961}
962
963/// Whether a changed file is a mutatable Python *source* — a `.py` that is not a test
964/// (`*_test.py` / `test_*.py`) or `conftest.py`.
965fn is_mutatable_py(file: &str) -> bool {
966    if !file.ends_with(".py") {
967        return false;
968    }
969    let base = file.rsplit('/').next().unwrap_or(file);
970    !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
971}
972
973/// A unique temp dir for one cargo-mutants run's `--output`, removed on drop so the
974/// scanned crate stays pristine and parallel runs don't collide.
975struct MutantsOut(PathBuf);
976
977impl MutantsOut {
978    fn new() -> Self {
979        static COUNTER: AtomicU64 = AtomicU64::new(0);
980        let name = format!(
981            "testing-conventions-mutants-{}-{}",
982            std::process::id(),
983            COUNTER.fetch_add(1, Ordering::Relaxed),
984        );
985        MutantsOut(std::env::temp_dir().join(name))
986    }
987}
988
989impl Drop for MutantsOut {
990    fn drop(&mut self) {
991        let _ = std::fs::remove_dir_all(&self.0);
992    }
993}
994
995/// The directory of the cargo workspace `root` belongs to. `cargo locate-project --workspace`
996/// is the authoritative lookup: membership involves member globs and `exclude` lists a
997/// manifest walk can't settle.
998fn cargo_workspace_root(root: &Path) -> Result<PathBuf> {
999    let output = Command::new("cargo")
1000        .current_dir(root)
1001        .args(["locate-project", "--workspace", "--message-format", "plain"])
1002        .output()
1003        .context("running `cargo locate-project` (is cargo installed?)")?;
1004    if !output.status.success() {
1005        bail!(
1006            "cargo locate-project failed in `{}`: {}",
1007            root.display(),
1008            String::from_utf8_lossy(&output.stderr)
1009        );
1010    }
1011    let manifest = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
1012    manifest_dir(&manifest)
1013}
1014
1015/// The directory holding the workspace manifest `cargo locate-project` reported.
1016fn manifest_dir(manifest: &Path) -> Result<PathBuf> {
1017    manifest.parent().map(Path::to_path_buf).with_context(|| {
1018        format!(
1019            "no parent dir for the workspace manifest `{}`",
1020            manifest.display()
1021        )
1022    })
1023}
1024
1025/// The scan path's prefix relative to the workspace root ([`scan_prefix`]), over
1026/// canonicalized paths so a relative CLI scan path resolves against the absolute path
1027/// `cargo locate-project` reports. `None` when the scan path *is* the workspace root.
1028fn canonical_scan_prefix(root: &Path, workspace_root: &Path) -> Option<String> {
1029    let root = root.canonicalize().ok()?;
1030    let workspace_root = workspace_root.canonicalize().ok()?;
1031    scan_prefix(&root, &workspace_root)
1032}
1033
1034/// Write the `<base>...HEAD` diff cargo-mutants' `--in-diff` scopes to, returning its path —
1035/// or `None` when the diff is empty. cargo-mutants matches `--in-diff` paths relative to the
1036/// cargo workspace root, so the diff is generated there, `--relative`, with `prefix` as a pathspec.
1037fn write_base_diff(
1038    root: &Path,
1039    workspace_root: &Path,
1040    prefix: Option<&str>,
1041    base: &str,
1042    out: &MutantsOut,
1043) -> Result<Option<PathBuf>> {
1044    let range = format!("{base}...HEAD");
1045    let (dir, args) = base_diff_command(root, workspace_root, prefix, &range);
1046    let output = Command::new("git")
1047        .current_dir(dir)
1048        .args(&args)
1049        .output()
1050        .context("running `git diff` for `--base` (is git installed?)")?;
1051    base_diff_exit(&output, &range)?;
1052    if output.stdout.is_empty() {
1053        return Ok(None);
1054    }
1055    std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
1056    let path = out.0.join("base.diff");
1057    std::fs::write(&path, &output.stdout).context("writing the base diff")?;
1058    Ok(Some(path))
1059}
1060
1061/// The written base diff, read back for parsing.
1062fn read_base_diff(path: &Path) -> Result<String> {
1063    std::fs::read_to_string(path)
1064        .with_context(|| format!("reading the written base diff `{}`", path.display()))
1065}
1066
1067/// The tool's own reading of a base diff: the changed files (new-side paths, `b/` stripped)
1068/// and the inserted line numbers per file. Paths stay workspace-root-relative, the basis
1069/// cargo-mutants addresses mutants on.
1070struct BaseDiff {
1071    files: Vec<String>,
1072    inserted: BTreeMap<String, BTreeSet<u32>>,
1073}
1074
1075/// Parse a unified diff into a [`BaseDiff`]. A deleted file (`+++ /dev/null`) carries neither
1076/// a changed file nor inserted lines.
1077fn parse_base_diff(diff: &str) -> BaseDiff {
1078    let mut files = Vec::new();
1079    let mut inserted: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
1080    let mut current: Option<String> = None;
1081    let mut lines = diff.lines();
1082    while let Some(line) = lines.next() {
1083        if let Some(path) = line.strip_prefix("+++ ") {
1084            current = (path != "/dev/null").then(|| {
1085                let path = path.strip_prefix("b/").unwrap_or(path).to_string();
1086                files.push(path.clone());
1087                path
1088            });
1089        } else if let Some(header) = line.strip_prefix("@@ ") {
1090            if let Some(counts) = parse_hunk_header(header) {
1091                consume_hunk(&mut lines, counts, current.as_ref(), &mut inserted);
1092            }
1093        }
1094    }
1095    BaseDiff { files, inserted }
1096}
1097
1098/// Record the new-side line numbers of one hunk's inserted lines, consuming its body by the
1099/// `(new start, old count, new count)` its `@@` header declared — so a content line beginning
1100/// `+++` or `---` never reads as a file header.
1101fn consume_hunk(
1102    lines: &mut std::str::Lines<'_>,
1103    (new_start, old_count, new_count): (u32, u32, u32),
1104    file: Option<&String>,
1105    inserted: &mut BTreeMap<String, BTreeSet<u32>>,
1106) {
1107    let mut new_line = new_start;
1108    let (mut old_left, mut new_left) = (old_count, new_count);
1109    while old_left > 0 || new_left > 0 {
1110        let Some(line) = lines.next() else { break };
1111        if line.starts_with('\\') {
1112            // "\ No newline at end of file" annotates the previous line and
1113            // counts against neither side.
1114        } else if line.starts_with('+') {
1115            if let Some(file) = file {
1116                inserted.entry(file.clone()).or_default().insert(new_line);
1117            }
1118            new_line += 1;
1119            new_left = new_left.saturating_sub(1);
1120        } else if line.starts_with('-') {
1121            old_left = old_left.saturating_sub(1);
1122        } else {
1123            new_line += 1;
1124            old_left = old_left.saturating_sub(1);
1125            new_left = new_left.saturating_sub(1);
1126        }
1127    }
1128}
1129
1130/// The `(new_start, old_count, new_count)` of a hunk header's `-a[,b] +c[,d]` part.
1131fn parse_hunk_header(header: &str) -> Option<(u32, u32, u32)> {
1132    let mut parts = header.split(' ');
1133    let (_, old_count) = parse_range(parts.next()?.strip_prefix('-')?)?;
1134    let (new_start, new_count) = parse_range(parts.next()?.strip_prefix('+')?)?;
1135    Some((new_start, old_count, new_count))
1136}
1137
1138/// A hunk range `start[,count]`; the count defaults to 1.
1139fn parse_range(range: &str) -> Option<(u32, u32)> {
1140    match range.split_once(',') {
1141        Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
1142        None => Some((range.parse().ok()?, 1)),
1143    }
1144}
1145
1146/// Rebase a cargo-mutants report's workspace-root-relative mutant paths onto the scan path, so
1147/// exemption matching and survivor reporting address scan-path-relative files. A baseline
1148/// outcome carries no path and passes through; a mutant outside the scan path is dropped.
1149fn rebase_report_paths(report: MutantsReport, prefix: Option<&str>) -> MutantsReport {
1150    let Some(prefix) = prefix else {
1151        return report;
1152    };
1153    let prefix = format!("{prefix}/");
1154    MutantsReport {
1155        outcomes: report
1156            .outcomes
1157            .into_iter()
1158            .filter_map(|mut outcome| {
1159                if let Scenario::Mutant(mutant) = &mut outcome.scenario {
1160                    mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
1161                }
1162                Some(outcome)
1163            })
1164            .collect(),
1165    }
1166}
1167
1168/// The cargo-mutants version the Rust arm provisions and pins to. Bumping this points the
1169/// cache at a fresh version-scoped directory, so the next run installs the new release.
1170const CARGO_MUTANTS_VERSION: &str = "27.1.0";
1171
1172/// Ensure the pinned cargo-mutants is available and return the absolute path to its binary,
1173/// provisioning it on first use. cargo ships no library form, so — unlike the in-process
1174/// TS/Python adapters — a pinned `cargo install` runs into the tool's own cache directory.
1175fn ensure_cargo_mutants() -> Result<PathBuf> {
1176    provision_pinned(&cargo_mutants_cache_root(), execute)
1177}
1178
1179/// Provision the pinned cargo-mutants under `root`, executing its `cargo install` with `run`.
1180fn provision_pinned(
1181    root: &Path,
1182    run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1183) -> Result<PathBuf> {
1184    let bin = root.join("bin").join(CARGO_MUTANTS_BIN_NAME);
1185    let lock_path = root.join(".install.lock");
1186    provision(&bin, &lock_path, || run_install(root, run))
1187}
1188
1189/// Execute a prepared command, capturing its output.
1190fn execute(command: &mut Command) -> std::io::Result<Output> {
1191    command.output()
1192}
1193
1194/// The cargo-mutants binary's file name (`.exe` on Windows), as `cargo install --root`
1195/// lays it out under `<root>/bin/`.
1196const CARGO_MUTANTS_BIN_NAME: &str = if cfg!(windows) {
1197    "cargo-mutants.exe"
1198} else {
1199    "cargo-mutants"
1200};
1201
1202/// The tool-owned, version-scoped cache directory cargo-mutants is installed under, so a
1203/// version bump provisions cleanly beside the old one and never clobbers a user's own
1204/// `~/.cargo/bin`.
1205fn cargo_mutants_cache_root() -> PathBuf {
1206    cache_base()
1207        .join("testing-conventions")
1208        .join(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}"))
1209}
1210
1211/// The base cache directory, read from OS-owned config. Split from [`resolve_cache_base`]
1212/// so the resolution logic is unit-tested without touching the process environment.
1213fn cache_base() -> PathBuf {
1214    resolve_cache_base(std::env::var_os("XDG_CACHE_HOME"), std::env::var_os("HOME"))
1215}
1216
1217/// Resolve the base cache dir: `XDG_CACHE_HOME` when set and non-empty, else `$HOME/.cache`,
1218/// else the temp dir. Pure over its inputs.
1219fn resolve_cache_base(xdg: Option<OsString>, home: Option<OsString>) -> PathBuf {
1220    if let Some(dir) = xdg.filter(|value| !value.is_empty()) {
1221        return PathBuf::from(dir);
1222    }
1223    if let Some(dir) = home.filter(|value| !value.is_empty()) {
1224        return PathBuf::from(dir).join(".cache");
1225    }
1226    std::env::temp_dir()
1227}
1228
1229/// Take the exclusive advisory lock at `lock_path`, creating its parent if needed, and
1230/// return the held file. The lock releases when the returned handle drops, so the caller
1231/// must keep it alive for the critical section.
1232fn hold_lock(lock_path: &Path) -> Result<std::fs::File> {
1233    if let Some(parent) = lock_path.parent() {
1234        std::fs::create_dir_all(parent).context("creating the provisioning lock's parent dir")?;
1235    }
1236    let lock_file = std::fs::OpenOptions::new()
1237        .create(true)
1238        .truncate(false)
1239        .write(true)
1240        .open(lock_path)
1241        .context("opening the provisioning lock file")?;
1242    lock_file
1243        .lock()
1244        .context("acquiring the provisioning lock")?;
1245    Ok(lock_file)
1246}
1247
1248/// `bin`, once an install claims to have put it there. An install that reports success
1249/// without producing the binary is an error rather than a path nothing will find.
1250fn installed_binary(bin: &Path) -> Result<PathBuf> {
1251    if !bin.exists() {
1252        bail!(
1253            "provisioning reported success but cargo-mutants is not at `{}`",
1254            bin.display()
1255        );
1256    }
1257    Ok(bin.to_path_buf())
1258}
1259
1260/// Return `bin` if it already exists, otherwise take an exclusive advisory lock at
1261/// `lock_path`, re-check, and run `install` if still absent. The lock keeps N concurrent
1262/// callers to one from-source compile instead of N. An install producing no binary is an error.
1263fn provision(
1264    bin: &Path,
1265    lock_path: &Path,
1266    install: impl FnOnce() -> Result<()>,
1267) -> Result<PathBuf> {
1268    if bin.exists() {
1269        return Ok(bin.to_path_buf());
1270    }
1271    let _lock = hold_lock(lock_path)?;
1272    // Re-check: another caller may have installed while this one waited for the lock.
1273    if bin.exists() {
1274        return Ok(bin.to_path_buf());
1275    }
1276    install()?;
1277    installed_binary(bin)
1278}
1279
1280/// The argv provisioning the pinned cargo-mutants into `root` (`cargo install cargo-mutants
1281/// --locked --version <X> --root <root>`). Split from execution so a test asserts the pin
1282/// and the isolated `--root` without a real install.
1283fn install_argv(root: &Path) -> Vec<OsString> {
1284    vec![
1285        OsString::from("install"),
1286        OsString::from("cargo-mutants"),
1287        OsString::from("--locked"),
1288        OsString::from("--version"),
1289        OsString::from(CARGO_MUTANTS_VERSION),
1290        OsString::from("--root"),
1291        root.as_os_str().to_os_string(),
1292    ]
1293}
1294
1295/// Provision cargo-mutants into `root`, executing the built `cargo install` with `run`, which
1296/// is injected so a test drives both branches with a fake. The coverage-instrumentation env is
1297/// stripped so the compile doesn't re-enter a `cargo llvm-cov` rustc wrapper.
1298fn run_install(
1299    root: &Path,
1300    run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1301) -> Result<()> {
1302    let mut command = Command::new("cargo");
1303    command.args(install_argv(root));
1304    strip_llvm_cov_env(&mut command);
1305    let output = run(&mut command)
1306        .context("provisioning cargo-mutants via `cargo install` (is cargo installed?)")?;
1307    if !output.status.success() {
1308        bail!(
1309            "failed to provision cargo-mutants {CARGO_MUTANTS_VERSION}:\n{}{}",
1310            String::from_utf8_lossy(&output.stdout),
1311            String::from_utf8_lossy(&output.stderr),
1312        );
1313    }
1314    Ok(())
1315}
1316
1317/// Strip the outer coverage-instrumentation env from a nested cargo invocation (the
1318/// cargo-mutants run, or the `cargo install` that provisions it) so it doesn't re-enter the
1319/// `cargo llvm-cov` rustc wrapper and hang, as when this rule's own tests run under coverage.
1320fn strip_llvm_cov_env(command: &mut Command) {
1321    for var in [
1322        "RUSTFLAGS",
1323        "CARGO_ENCODED_RUSTFLAGS",
1324        "RUSTDOCFLAGS",
1325        "CARGO_ENCODED_RUSTDOCFLAGS",
1326        "LLVM_PROFILE_FILE",
1327        "CARGO_LLVM_COV",
1328        "CARGO_LLVM_COV_SHOW_ENV",
1329        "CARGO_LLVM_COV_TARGET_DIR",
1330        "CARGO_LLVM_COV_BUILD_DIR",
1331        "RUSTC_WRAPPER",
1332        "RUSTC_WORKSPACE_WRAPPER",
1333        "__CARGO_LLVM_COV_RUSTC_WRAPPER",
1334        "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
1335        "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
1336    ] {
1337        command.env_remove(var);
1338    }
1339}
1340
1341/// Run the cargo-mutants argv ([`mutants_argv`]) in `root`, where `engine` is the provisioned
1342/// binary invoked by absolute path, returning its [`Output`]. The outer instrumentation env is
1343/// stripped so a nested run (this rule's own tests under coverage) can't re-enter the wrapper.
1344fn run_cargo_mutants(
1345    engine: &Path,
1346    root: &Path,
1347    out: &Path,
1348    in_diff: Option<&Path>,
1349    features: &[String],
1350) -> Result<Output> {
1351    let mut command = Command::new(engine);
1352    command
1353        .current_dir(root)
1354        .args(mutants_argv(out, in_diff, features));
1355    strip_llvm_cov_env(&mut command);
1356    let output = command.output().context("running cargo-mutants")?;
1357    classify_mutants_exit(root, &output)?;
1358    Ok(output)
1359}
1360
1361/// Decide whether an engine run that judged zero mutants is legitimate: `listed` is the crate's
1362/// full mutant list and `diff` the tool's own reading of the diff the engine filtered by. A
1363/// listed mutant whose span touches an inserted line proves the filter dropped real mutants.
1364fn zero_mutant_verdict(listed: &[MutantInfo], diff: &BaseDiff, run: &Output) -> Result<()> {
1365    let dropped = dropped_mutants(listed, diff);
1366    if dropped.is_empty() {
1367        return Ok(());
1368    }
1369    let sites: Vec<String> = dropped.iter().map(|mutant| mutant_site(mutant)).collect();
1370    bail!(
1371        "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{}{}",
1372        dropped.len(),
1373        listed.len(),
1374        sites.join("\n"),
1375        String::from_utf8_lossy(&run.stdout),
1376        String::from_utf8_lossy(&run.stderr),
1377    )
1378}
1379
1380/// The mutants among `listed` whose span touches one of `diff`'s inserted lines — the ones a
1381/// run that judged zero should have tested.
1382fn dropped_mutants<'a>(listed: &'a [MutantInfo], diff: &BaseDiff) -> Vec<&'a MutantInfo> {
1383    listed
1384        .iter()
1385        .filter(|mutant| {
1386            diff.inserted.get(&mutant.file).is_some_and(|lines| {
1387                lines
1388                    .range(mutant.span.start.line..=mutant.span.end.line)
1389                    .next()
1390                    .is_some()
1391            })
1392        })
1393        .collect()
1394}
1395
1396/// One dropped mutant as an indented `file:line: name` line for the failure message.
1397fn mutant_site(mutant: &MutantInfo) -> String {
1398    format!(
1399        "  {}:{}: {}",
1400        mutant.file,
1401        mutant.span.start.line,
1402        strip_embedded_location(&mutant.name)
1403    )
1404}
1405
1406/// The argv for one cargo-mutants mutant listing: `mutants --list --json
1407/// [--features <list>]`, mirroring the run's own feature selection so both see the same
1408/// mutant set.
1409fn list_argv(features: &[String]) -> Vec<OsString> {
1410    let mut argv = vec![
1411        OsString::from("mutants"),
1412        OsString::from("--list"),
1413        OsString::from("--json"),
1414    ];
1415    if !features.is_empty() {
1416        argv.push(OsString::from("--features"));
1417        argv.push(OsString::from(features.join(",")));
1418    }
1419    argv
1420}
1421
1422/// List the crate's discoverable mutants via `cargo mutants --list --json`, executing the
1423/// built command with `run`. `run` is injected so a test drives the success and failure
1424/// branches with a fake (no real engine).
1425fn list_cargo_mutants(
1426    engine: &Path,
1427    root: &Path,
1428    features: &[String],
1429    run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1430) -> Result<Vec<MutantInfo>> {
1431    let mut command = Command::new(engine);
1432    command.current_dir(root).args(list_argv(features));
1433    strip_llvm_cov_env(&mut command);
1434    let output = run(&mut command).context("listing the crate's mutants with cargo-mutants")?;
1435    if !output.status.success() {
1436        bail!(
1437            "cargo-mutants --list failed in `{}`:\n{}{}",
1438            root.display(),
1439            String::from_utf8_lossy(&output.stdout),
1440            String::from_utf8_lossy(&output.stderr),
1441        );
1442    }
1443    parse_mutants_list(&String::from_utf8_lossy(&output.stdout))
1444}
1445
1446/// Build cargo-mutants arguments for the library unit tier and requested diff/features.
1447fn mutants_argv(out: &Path, in_diff: Option<&Path>, features: &[String]) -> Vec<OsString> {
1448    let mut argv = vec![
1449        OsString::from("mutants"),
1450        OsString::from("--output"),
1451        out.as_os_str().to_os_string(),
1452        OsString::from("--cargo-test-arg"),
1453        OsString::from("--lib"),
1454    ];
1455    if let Some(diff) = in_diff {
1456        argv.push(OsString::from("--in-diff"));
1457        argv.push(diff.as_os_str().to_os_string());
1458    }
1459    if !features.is_empty() {
1460        argv.push(OsString::from("--features"));
1461        argv.push(OsString::from(features.join(",")));
1462    }
1463    argv
1464}
1465
1466/// Classify a finished cargo-mutants run's exit code as a normal outcome or a fatal error.
1467/// `0` (all caught), `2` (some missed) and `3` (some timed out, none missed) each write an
1468/// `outcomes.json` the gate reads. Any other code — a baseline that didn't build (4) — is fatal.
1469fn classify_mutants_exit(root: &Path, output: &Output) -> Result<()> {
1470    match output.status.code() {
1471        Some(0) | Some(2) | Some(3) => Ok(()),
1472        _ => bail!(
1473            "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
1474            root.display(),
1475            String::from_utf8_lossy(&output.stdout),
1476            String::from_utf8_lossy(&output.stderr),
1477        ),
1478    }
1479}
1480
1481#[cfg(test)]
1482mod tests {
1483    use super::*;
1484
1485    #[test]
1486    fn neither_scope_adds_an_argument() {
1487        assert!(ts_adapter_args(None, None).is_empty());
1488    }
1489
1490    #[test]
1491    fn each_scope_contributes_its_own_flag_and_a_comma_joined_list() {
1492        let mutate = vec!["src/a.ts".to_string(), "src/b.ts".to_string()];
1493        let tests = vec!["src/a.test.ts".to_string()];
1494        assert_eq!(
1495            ts_adapter_args(Some(&mutate), None),
1496            vec!["--mutate", "src/a.ts,src/b.ts"]
1497        );
1498        assert_eq!(
1499            ts_adapter_args(None, Some(&tests)),
1500            vec!["--test-files", "src/a.test.ts"]
1501        );
1502        assert_eq!(
1503            ts_adapter_args(Some(&mutate), Some(&tests)),
1504            vec![
1505                "--mutate",
1506                "src/a.ts,src/b.ts",
1507                "--test-files",
1508                "src/a.test.ts"
1509            ]
1510        );
1511    }
1512
1513    #[test]
1514    fn an_empty_list_still_passes_its_flag() {
1515        // `Some(&[])` is "restrict to nothing", which is not `None`'s "do not restrict".
1516        assert_eq!(ts_adapter_args(Some(&[]), None), vec!["--mutate", ""]);
1517    }
1518
1519    #[test]
1520    fn with_no_prefix_the_base_diff_runs_unscoped_at_the_scan_path() {
1521        let (dir, args) = base_diff_command(
1522            Path::new("/crate"),
1523            Path::new("/workspace"),
1524            None,
1525            "main...HEAD",
1526        );
1527        assert_eq!(dir, Path::new("/crate"));
1528        assert_eq!(args, vec!["diff", "--relative", "main...HEAD"]);
1529    }
1530
1531    #[test]
1532    fn with_a_prefix_the_base_diff_runs_at_the_workspace_root_scoped_by_it() {
1533        // cargo-mutants reads `--in-diff` paths relative to the cargo workspace root, so
1534        // the diff has to be generated in that frame.
1535        let (dir, args) = base_diff_command(
1536            Path::new("/workspace/crate"),
1537            Path::new("/workspace"),
1538            Some("crate/src"),
1539            "main...HEAD",
1540        );
1541        assert_eq!(dir, Path::new("/workspace"));
1542        assert_eq!(
1543            args,
1544            vec!["diff", "--relative", "main...HEAD", "--", "crate/src"]
1545        );
1546    }
1547
1548    #[test]
1549    fn a_failed_base_diff_names_the_range() {
1550        let output = std::process::Output {
1551            status: std::process::Command::new("false").status().unwrap(),
1552            stdout: Vec::new(),
1553            stderr: b"fatal: bad revision".to_vec(),
1554        };
1555        let err = base_diff_exit(&output, "nope...HEAD").unwrap_err();
1556        assert!(err.to_string().contains("nope...HEAD"), "got: {err}");
1557    }
1558
1559    #[test]
1560    fn an_adapter_failure_surfaces_both_captured_streams() {
1561        let output = std::process::Output {
1562            status: std::process::Command::new("false").status().unwrap(),
1563            stdout: b"stryker said this".to_vec(),
1564            stderr: b"and then this".to_vec(),
1565        };
1566        let err = adapter_exit(&output, "TypeScript", Path::new("/pkg")).unwrap_err();
1567        let text = err.to_string();
1568        assert!(
1569            text.contains("TypeScript mutation adapter failed"),
1570            "got: {text}"
1571        );
1572        assert!(text.contains("/pkg"), "got: {text}");
1573        assert!(text.contains("stryker said this"), "got: {text}");
1574        assert!(text.contains("and then this"), "got: {text}");
1575    }
1576
1577    #[test]
1578    fn an_adapter_that_succeeded_is_not_an_error() {
1579        let output = std::process::Output {
1580            status: std::process::Command::new("true").status().unwrap(),
1581            stdout: Vec::new(),
1582            stderr: Vec::new(),
1583        };
1584        assert!(adapter_exit(&output, "Python", Path::new("/pkg")).is_ok());
1585    }
1586
1587    #[test]
1588    fn an_install_that_produced_no_binary_is_an_error() {
1589        let missing = scratch_dir("installed-binary-absent").join("cargo-mutants");
1590        let err = installed_binary(&missing).unwrap_err();
1591        assert!(
1592            err.to_string().contains("provisioning reported success"),
1593            "got: {err}"
1594        );
1595    }
1596
1597    #[test]
1598    fn an_install_that_produced_the_binary_returns_its_path() {
1599        let dir = scratch_dir("installed-binary-present");
1600        std::fs::create_dir_all(&dir).unwrap();
1601        let bin = dir.join("cargo-mutants");
1602        std::fs::write(&bin, "").unwrap();
1603        assert_eq!(installed_binary(&bin).unwrap(), bin);
1604    }
1605
1606    /// A process-unique scratch path, so concurrent test binaries never collide.
1607    fn scratch_dir(name: &str) -> PathBuf {
1608        std::env::temp_dir().join(format!("testing-conventions-{name}-{}", std::process::id()))
1609    }
1610
1611    const NORMALIZED: &str = r#"[
1612        {"file": "src/a.ts", "line": 2, "status": "survived",
1613         "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
1614        {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
1615        {"file": "src/a.ts", "line": 9, "status": "killed",
1616         "mutator": "BooleanLiteral", "replacement": "false"},
1617        {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
1618        {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
1619        {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
1620    ]"#;
1621
1622    #[test]
1623    fn parses_the_normalized_schema() {
1624        let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
1625        assert_eq!(mutants.len(), 6);
1626        assert_eq!(mutants[0].status, MutantStatus::Survived);
1627        assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
1628        assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
1629        assert_eq!(mutants[1].replacement, None);
1630    }
1631
1632    #[test]
1633    fn normalized_survivors_are_survived_and_nocoverage_only() {
1634        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1635        let survivors = normalized_survivors(&mutants);
1636        assert_eq!(survivors.len(), 2);
1637        assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
1638        assert!(survivors[0].description.contains("ConditionalExpression"));
1639        assert!(survivors[0].description.contains("-> true"));
1640        assert_eq!(survivors[1].description, "ArithmeticOperator");
1641    }
1642
1643    #[test]
1644    fn normalized_mutated_lines_collects_only_viable_mutants() {
1645        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1646        assert_eq!(
1647            normalized_mutated_lines(&mutants),
1648            [2u32, 5, 9, 12]
1649                .into_iter()
1650                .map(|line| ("src/a.ts".to_string(), line))
1651                .collect()
1652        );
1653    }
1654
1655    #[test]
1656    fn normalized_conclusive_count_is_survived_killed_and_nocoverage() {
1657        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1658        assert_eq!(normalized_conclusive_count(&mutants), 3);
1659        assert_eq!(normalized_conclusive_count(&[]), 0);
1660    }
1661
1662    #[test]
1663    fn evaluate_normalized_reports_unexempted_survivors() {
1664        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1665        let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
1666        assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
1667    }
1668
1669    #[test]
1670    fn evaluate_normalized_drops_a_whole_file_exemption() {
1671        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1672        let kept =
1673            evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
1674        assert!(
1675            kept.is_empty(),
1676            "the whole-file exemption lifts both survivors"
1677        );
1678    }
1679
1680    #[test]
1681    fn evaluate_normalized_drops_a_line_scoped_exemption() {
1682        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1683        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
1684        let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1685        assert_eq!(kept.len(), 1);
1686        assert_eq!(kept[0].line, 5);
1687    }
1688
1689    #[test]
1690    fn evaluate_normalized_rejects_exempting_a_caught_line() {
1691        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1692        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
1693        let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
1694        assert!(
1695            err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
1696            "got: {err}"
1697        );
1698    }
1699
1700    #[test]
1701    fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
1702        let mutants = parse_normalized_results(NORMALIZED).unwrap();
1703        let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
1704        let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1705        assert_eq!(kept.len(), 2);
1706    }
1707
1708    const SAMPLE: &str = r#"{
1709        "outcomes": [
1710            {"scenario": "Baseline", "summary": "Success",
1711             "phase_results": []},
1712            {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
1713                "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
1714                "function": {"function_name": "is_positive"},
1715                "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
1716             "summary": "MissedMutant"},
1717            {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
1718                "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
1719                "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
1720             "summary": "CaughtMutant"}
1721        ],
1722        "total_mutants": 2
1723    }"#;
1724
1725    #[test]
1726    fn parses_the_outcomes_export() {
1727        let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
1728        assert_eq!(report.outcomes.len(), 3);
1729        assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
1730    }
1731
1732    #[test]
1733    fn collects_only_missed_mutants_as_survivors() {
1734        let report = parse_mutants_report(SAMPLE).unwrap();
1735        let survivors = unexplained_survivors(&report, &[]);
1736        assert_eq!(survivors.len(), 1);
1737        assert_eq!(survivors[0].file, "src/lib.rs");
1738        assert_eq!(survivors[0].line, 7);
1739        assert!(survivors[0].description.contains("replace > with =="));
1740    }
1741
1742    #[test]
1743    fn a_survivor_description_carries_no_location_prefix() {
1744        let report = parse_mutants_report(SAMPLE).unwrap();
1745        let survivors = unexplained_survivors(&report, &[]);
1746        assert_eq!(
1747            survivors[0].description, "replace > with == in is_positive",
1748            "the name's embedded `file:line:col:` prefix is stripped"
1749        );
1750    }
1751
1752    #[test]
1753    fn strip_embedded_location_removes_a_file_line_col_prefix() {
1754        assert_eq!(
1755            strip_embedded_location("src/lib.rs:7:5: replace > with == in is_positive"),
1756            "replace > with == in is_positive"
1757        );
1758    }
1759
1760    #[test]
1761    fn strip_embedded_location_keeps_a_name_without_one() {
1762        for name in [
1763            "replace add -> 0",
1764            "note: no location segment",
1765            "7:5: no file segment",
1766            "src/lib.rs:7:x: non-numeric column",
1767            "src/lib.rs:x:5: non-numeric line",
1768        ] {
1769            assert_eq!(strip_embedded_location(name), name);
1770        }
1771    }
1772
1773    #[test]
1774    fn conclusive_count_is_caught_plus_missed() {
1775        let report = parse_mutants_report(SAMPLE).unwrap();
1776        assert_eq!(conclusive_count(&report), 2);
1777        assert_eq!(conclusive_count(&MutantsReport { outcomes: vec![] }), 0);
1778    }
1779
1780    #[test]
1781    fn an_exemption_drops_a_survivor_in_that_file() {
1782        let report = parse_mutants_report(SAMPLE).unwrap();
1783        let exempt = vec!["src/lib.rs".to_string()];
1784        assert!(unexplained_survivors(&report, &exempt).is_empty());
1785    }
1786
1787    #[test]
1788    fn an_exemption_on_another_file_leaves_the_survivor() {
1789        let report = parse_mutants_report(SAMPLE).unwrap();
1790        let exempt = vec!["src/elsewhere.rs".to_string()];
1791        assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1792    }
1793
1794    const BASELINE_ONLY: &str = r#"{
1795        "outcomes": [
1796            {"scenario": "Baseline", "summary": "MissedMutant", "phase_results": []},
1797            {"scenario": "Baseline", "summary": "CaughtMutant", "phase_results": []}
1798        ],
1799        "total_mutants": 0
1800    }"#;
1801
1802    #[test]
1803    fn a_baseline_outcome_is_never_a_survivor() {
1804        let report = parse_mutants_report(BASELINE_ONLY).unwrap();
1805        assert!(unexplained_survivors(&report, &[]).is_empty());
1806    }
1807
1808    #[test]
1809    fn a_baseline_outcome_is_never_a_mutated_line() {
1810        let report = parse_mutants_report(BASELINE_ONLY).unwrap();
1811        assert!(mutated_lines(&report).is_empty());
1812    }
1813
1814    #[test]
1815    fn parse_base_diff_skips_a_malformed_hunk_header() {
1816        let diff = "\
1817diff --git a/src/lib.rs b/src/lib.rs
1818--- a/src/lib.rs
1819+++ b/src/lib.rs
1820@@ junk @@
1821";
1822        let parsed = parse_base_diff(diff);
1823        assert_eq!(parsed.files, vec!["src/lib.rs"]);
1824        assert!(parsed.inserted.is_empty());
1825    }
1826
1827    #[test]
1828    fn a_missing_base_diff_read_reports_its_path() {
1829        let missing = unique_tmp().join("base.diff");
1830        let err = read_base_diff(&missing).unwrap_err();
1831        let msg = format!("{err:#}");
1832        assert!(msg.contains("reading the written base diff"), "{msg}");
1833    }
1834
1835    #[test]
1836    fn a_missing_adapter_results_read_names_the_engine_and_path() {
1837        let missing = unique_tmp().join("results.json");
1838        let err = read_adapter_results(&missing, "TypeScript").unwrap_err();
1839        let msg = format!("{err:#}");
1840        assert!(
1841            msg.contains("TypeScript mutation adapter's results"),
1842            "{msg}"
1843        );
1844    }
1845
1846    #[test]
1847    fn manifest_dir_is_the_manifest_parent() {
1848        let dir = manifest_dir(Path::new("/w/Cargo.toml")).unwrap();
1849        assert_eq!(dir, Path::new("/w"));
1850    }
1851
1852    #[test]
1853    fn a_rootless_manifest_path_is_an_error() {
1854        let err = manifest_dir(Path::new("/")).unwrap_err();
1855        let msg = format!("{err:#}");
1856        assert!(msg.contains("no parent dir"), "{msg}");
1857    }
1858
1859    #[test]
1860    fn a_directory_outside_any_workspace_fails_locate_project() {
1861        let dir = unique_tmp();
1862        let err = cargo_workspace_root(&dir).unwrap_err();
1863        let msg = format!("{err:#}");
1864        assert!(msg.contains("cargo locate-project failed"), "{msg}");
1865        std::fs::remove_dir_all(&dir).ok();
1866    }
1867
1868    #[test]
1869    fn a_bad_base_ref_fails_the_base_diff() {
1870        let dir = unique_tmp();
1871        let init = Command::new("git")
1872            .current_dir(&dir)
1873            .args(["init", "-q"])
1874            .output()
1875            .unwrap();
1876        assert!(init.status.success());
1877        let out = MutantsOut::new();
1878        let err = write_base_diff(&dir, &dir, None, "tc-no-such-ref", &out).unwrap_err();
1879        let msg = format!("{err:#}");
1880        assert!(msg.contains("git diff"), "{msg}");
1881        std::fs::remove_dir_all(&dir).ok();
1882    }
1883
1884    #[test]
1885    fn a_python_adapter_failure_reports_the_adapter_output() {
1886        let dir = unique_tmp();
1887        let err = run_py_adapter(&dir, &[]).unwrap_err();
1888        let msg = format!("{err:#}");
1889        assert!(msg.contains("the Python mutation adapter failed"), "{msg}");
1890        std::fs::remove_dir_all(&dir).ok();
1891    }
1892
1893    #[test]
1894    fn is_declaration_only_is_true_for_a_const_only_rust_file() {
1895        let dir = unique_tmp();
1896        std::fs::write(
1897            dir.join("settings.rs"),
1898            "pub const TIMEOUT: u64 = 30 * 60;\n",
1899        )
1900        .unwrap();
1901        assert!(is_declaration_only(&dir, "settings.rs", Language::Rust));
1902        std::fs::remove_dir_all(&dir).ok();
1903    }
1904
1905    #[test]
1906    fn is_declaration_only_is_false_for_a_rust_file_with_a_function() {
1907        let dir = unique_tmp();
1908        std::fs::write(dir.join("lib.rs"), "pub fn run() {}\n").unwrap();
1909        assert!(!is_declaration_only(&dir, "lib.rs", Language::Rust));
1910        std::fs::remove_dir_all(&dir).ok();
1911    }
1912
1913    #[test]
1914    fn is_declaration_only_covers_python_and_typescript_too() {
1915        let dir = unique_tmp();
1916        std::fs::write(dir.join("settings.py"), "TIMEOUT = 30 * 60\n").unwrap();
1917        std::fs::write(dir.join("settings.ts"), "export const TIMEOUT = 30 * 60;\n").unwrap();
1918        assert!(is_declaration_only(&dir, "settings.py", Language::Python));
1919        assert!(is_declaration_only(
1920            &dir,
1921            "settings.ts",
1922            Language::TypeScript
1923        ));
1924        std::fs::remove_dir_all(&dir).ok();
1925    }
1926
1927    #[test]
1928    fn is_declaration_only_is_false_for_an_unreadable_file() {
1929        let dir = unique_tmp();
1930        assert!(!is_declaration_only(&dir, "missing.rs", Language::Rust));
1931        std::fs::remove_dir_all(&dir).ok();
1932    }
1933
1934    fn mutant_at(file: &str, line: u32, genre: &str) -> MutantInfo {
1935        MutantInfo {
1936            file: file.to_string(),
1937            span: Span {
1938                start: LineCol { line },
1939                end: LineCol { line },
1940            },
1941            name: format!("a mutant at {file}:{line}"),
1942            genre: genre.to_string(),
1943        }
1944    }
1945
1946    #[test]
1947    fn is_judged_is_true_for_an_ordinary_mutant() {
1948        let dir = unique_tmp();
1949        std::fs::write(
1950            dir.join("add.rs"),
1951            "pub fn add(a: u64) -> u64 {\n    a + 1\n}\n",
1952        )
1953        .unwrap();
1954        assert!(is_judged(&dir, &mutant_at("add.rs", 2, "FnValue")));
1955        std::fs::remove_dir_all(&dir).ok();
1956    }
1957
1958    #[test]
1959    fn is_judged_is_false_in_a_declaration_only_module() {
1960        let dir = unique_tmp();
1961        std::fs::write(
1962            dir.join("settings.rs"),
1963            "pub const TIMEOUT: u64 = 30 * 60;\n",
1964        )
1965        .unwrap();
1966        assert!(!is_judged(&dir, &mutant_at("settings.rs", 1, "FnValue")));
1967        std::fs::remove_dir_all(&dir).ok();
1968    }
1969
1970    #[test]
1971    fn is_judged_is_false_inside_an_item_the_test_build_skips() {
1972        let dir = unique_tmp();
1973        std::fs::write(
1974            dir.join("entrypoint.rs"),
1975            "#[cfg(not(test))]\npub fn main() -> u8 {\n    1\n}\n",
1976        )
1977        .unwrap();
1978        assert!(!is_judged(&dir, &mutant_at("entrypoint.rs", 3, "FnValue")));
1979        std::fs::remove_dir_all(&dir).ok();
1980    }
1981
1982    #[test]
1983    fn is_judged_is_false_for_a_whole_body_replacement_in_a_seam() {
1984        let dir = unique_tmp();
1985        std::fs::write(
1986            dir.join("probe.rs"),
1987            "pub fn probe() -> bool {\n    Command::new(\"true\").output().is_ok()\n}\n",
1988        )
1989        .unwrap();
1990        assert!(!is_judged(&dir, &mutant_at("probe.rs", 2, "FnValue")));
1991        std::fs::remove_dir_all(&dir).ok();
1992    }
1993
1994    #[test]
1995    fn is_judged_still_judges_an_operator_swap_in_a_seam() {
1996        let dir = unique_tmp();
1997        std::fs::write(
1998            dir.join("probe.rs"),
1999            "pub fn probe() -> bool {\n    Command::new(\"true\").output().is_ok()\n}\n",
2000        )
2001        .unwrap();
2002        assert!(is_judged(&dir, &mutant_at("probe.rs", 2, "BinaryOperator")));
2003        std::fs::remove_dir_all(&dir).ok();
2004    }
2005
2006    #[test]
2007    fn rebase_report_paths_strips_the_workspace_prefix() {
2008        let report = parse_mutants_report(SAMPLE).unwrap();
2009        let prefixed = MutantsReport {
2010            outcomes: report
2011                .outcomes
2012                .iter()
2013                .cloned()
2014                .map(|mut outcome| {
2015                    if let Scenario::Mutant(mutant) = &mut outcome.scenario {
2016                        mutant.file = format!("member/{}", mutant.file);
2017                    }
2018                    outcome
2019                })
2020                .collect(),
2021        };
2022        let rebased = rebase_report_paths(prefixed, Some("member"));
2023        let survivors = unexplained_survivors(&rebased, &[]);
2024        assert_eq!(survivors.len(), 1);
2025        assert_eq!(survivors[0].file, "src/lib.rs");
2026        assert_eq!(rebased.outcomes.len(), 3);
2027    }
2028
2029    #[test]
2030    fn rebase_report_paths_drops_an_out_of_scope_mutant_and_keeps_none_identity() {
2031        let report = parse_mutants_report(SAMPLE).unwrap();
2032        let rebased = rebase_report_paths(report.clone(), Some("member"));
2033        assert_eq!(
2034            rebased.outcomes.len(),
2035            1,
2036            "only the pathless baseline outcome remains"
2037        );
2038        let unchanged = rebase_report_paths(report, None);
2039        assert_eq!(unchanged.outcomes.len(), 3);
2040        assert_eq!(unexplained_survivors(&unchanged, &[])[0].file, "src/lib.rs");
2041    }
2042
2043    #[test]
2044    fn adapter_cwd_normalises_the_empty_package_root_to_the_current_dir() {
2045        // `tiers::package_root` yields `""` for a relative scan path such as `src`, and
2046        // `Command::current_dir("")` fails with ENOENT — which the adapter's error context
2047        // mislabelled as a missing `node`, hitting every TypeScript consumer of the gate.
2048        assert_eq!(
2049            adapter_cwd(Path::new(""), "TypeScript").unwrap(),
2050            Path::new(".")
2051        );
2052        assert_eq!(
2053            adapter_cwd(Path::new("src"), "TypeScript").unwrap(),
2054            Path::new("src")
2055        );
2056    }
2057
2058    #[test]
2059    fn adapter_cwd_rejects_a_directory_that_is_not_there() {
2060        // `Command::output()` reports a missing working directory with the same ENOENT as a
2061        // missing interpreter, so an unchecked spawn blames the interpreter for a wrong path.
2062        let err = adapter_cwd(Path::new("no/such/dir"), "Python")
2063            .expect_err("a directory that is not there is an error");
2064        assert_eq!(
2065            err.to_string(),
2066            "the Python mutation adapter's working directory `no/such/dir` is not a directory"
2067        );
2068    }
2069
2070    #[test]
2071    fn spawn_context_names_the_interpreter_the_entry_and_the_working_directory() {
2072        assert_eq!(
2073            spawn_context("node", "/pkg/dist/mutation/main.js", Path::new("/pkg")),
2074            "spawning `node /pkg/dist/mutation/main.js` in `/pkg` (is `node` on PATH?)"
2075        );
2076    }
2077
2078    #[test]
2079    fn scan_prefix_is_the_scan_path_relative_to_the_package_root() {
2080        assert_eq!(
2081            scan_prefix(Path::new("/repo/pkg/src"), Path::new("/repo/pkg")),
2082            Some("src".to_string())
2083        );
2084        assert_eq!(
2085            scan_prefix(Path::new("/repo/pkg/src/nested"), Path::new("/repo/pkg")),
2086            Some("src/nested".to_string())
2087        );
2088        assert_eq!(
2089            scan_prefix(Path::new("/repo/pkg"), Path::new("/repo/pkg")),
2090            None
2091        );
2092        assert_eq!(
2093            scan_prefix(Path::new("pkg/src"), Path::new("pkg")),
2094            Some("src".to_string())
2095        );
2096    }
2097
2098    #[test]
2099    fn prefix_mutate_specs_rebases_diff_ranges_onto_the_package_root() {
2100        let specs = vec!["index.ts:8-11".to_string(), "a/b.ts:2-2".to_string()];
2101        assert_eq!(
2102            prefix_mutate_specs(specs.clone(), Some("src")),
2103            vec![
2104                "src/index.ts:8-11".to_string(),
2105                "src/a/b.ts:2-2".to_string()
2106            ]
2107        );
2108        assert_eq!(prefix_mutate_specs(specs.clone(), None), specs);
2109    }
2110
2111    #[test]
2112    fn scan_scoped_mutate_globs_mirror_strykers_default_under_the_scan_path() {
2113        assert_eq!(
2114            scan_scoped_mutate_globs("src"),
2115            vec![
2116                "src/**/!(*.+(s|S)pec|*.+(t|T)est).+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
2117                    .to_string(),
2118                "!src/**/__tests__/**/*.+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
2119                    .to_string(),
2120            ]
2121        );
2122    }
2123
2124    #[test]
2125    fn scan_scoped_test_file_globs_narrow_the_run_without_moving_the_runner_root() {
2126        assert_eq!(
2127            scan_scoped_test_file_globs("src"),
2128            vec!["src/**".to_string()]
2129        );
2130        assert_eq!(
2131            scan_scoped_test_file_globs("packages/core/src"),
2132            vec!["packages/core/src/**".to_string()]
2133        );
2134    }
2135
2136    #[test]
2137    fn to_scan_relative_strips_the_prefix_and_drops_out_of_scope_mutants() {
2138        let mutants = parse_normalized_results(
2139            r#"[
2140                {"file": "src/a.ts", "line": 2, "status": "survived", "mutator": "X"},
2141                {"file": "tests/e2e/t.ts", "line": 9, "status": "survived", "mutator": "X"}
2142            ]"#,
2143        )
2144        .unwrap();
2145        let rebased = to_scan_relative(mutants.clone(), Some("src"));
2146        assert_eq!(rebased.len(), 1, "the out-of-scan-path mutant is dropped");
2147        assert_eq!(rebased[0].file, "a.ts");
2148        let unchanged = to_scan_relative(mutants, None);
2149        assert_eq!(unchanged.len(), 2);
2150        assert_eq!(unchanged[0].file, "src/a.ts");
2151    }
2152
2153    #[test]
2154    fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
2155        assert!(is_mutatable_ts("src/index.ts"));
2156        assert!(is_mutatable_ts("src/util.tsx"));
2157        assert!(is_mutatable_ts("src/util.js"));
2158        assert!(!is_mutatable_ts("src/index.test.ts"));
2159        assert!(!is_mutatable_ts("src/index.spec.ts"));
2160        assert!(!is_mutatable_ts("src/types.d.ts"));
2161        assert!(!is_mutatable_ts("README.md"));
2162    }
2163
2164    #[test]
2165    fn contiguous_runs_collapses_adjacent_lines() {
2166        let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
2167        assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
2168        assert!(contiguous_runs(&BTreeSet::new()).is_empty());
2169    }
2170
2171    #[test]
2172    fn one_line_flattens_and_caps() {
2173        assert_eq!(one_line("a -\n  b"), "a - b");
2174        let long = "x".repeat(80);
2175        let capped = one_line(&long);
2176        assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
2177    }
2178
2179    #[test]
2180    fn mutatable_py_modules_keeps_only_the_changed_python_sources() {
2181        let changed = BTreeMap::from([
2182            ("pkg/calc.py".to_string(), BTreeSet::from([3u64])),
2183            ("pkg/calc_test.py".to_string(), BTreeSet::from([4u64])),
2184            ("README.md".to_string(), BTreeSet::from([1u64])),
2185            ("pkg/util.py".to_string(), BTreeSet::from([9u64])),
2186        ]);
2187
2188        assert_eq!(
2189            mutatable_py_modules(&changed),
2190            ["pkg/calc.py", "pkg/util.py"]
2191        );
2192    }
2193
2194    #[test]
2195    fn mutatable_py_modules_is_empty_when_the_diff_touches_no_python_source() {
2196        let changed = BTreeMap::from([("docs/guide.md".to_string(), BTreeSet::from([1u64]))]);
2197
2198        assert!(mutatable_py_modules(&changed).is_empty());
2199    }
2200
2201    #[test]
2202    fn a_diff_scoped_python_run_judges_only_mutants_on_a_changed_line() {
2203        let mutants = vec![py_mutant("pkg/calc.py", 3), py_mutant("pkg/calc.py", 99)];
2204        let changed = BTreeMap::from([("pkg/calc.py".to_string(), BTreeSet::from([3u64]))]);
2205
2206        let judged = judged_py_mutants(mutants, Path::new("/nonexistent-tc-py"), Some(&changed));
2207
2208        assert_eq!(
2209            judged.iter().map(|m| m.line).collect::<Vec<_>>(),
2210            [3],
2211            "a mutant off the diff is not this run's to judge"
2212        );
2213    }
2214
2215    #[test]
2216    fn an_unscoped_python_run_judges_every_mutant() {
2217        let mutants = vec![py_mutant("pkg/calc.py", 3), py_mutant("pkg/calc.py", 99)];
2218
2219        let judged = judged_py_mutants(mutants, Path::new("/nonexistent-tc-py"), None);
2220
2221        assert_eq!(judged.iter().map(|m| m.line).collect::<Vec<_>>(), [3, 99]);
2222    }
2223
2224    fn py_mutant(file: &str, line: u32) -> NormalizedMutant {
2225        NormalizedMutant {
2226            file: file.to_string(),
2227            line,
2228            status: MutantStatus::Survived,
2229            mutator: "ConditionalExpression".to_string(),
2230            replacement: None,
2231        }
2232    }
2233
2234    #[test]
2235    fn is_mutatable_py_keeps_sources_and_drops_tests() {
2236        assert!(is_mutatable_py("calc.py"));
2237        assert!(is_mutatable_py("pkg/util.py"));
2238        assert!(!is_mutatable_py("calc_test.py"));
2239        assert!(!is_mutatable_py("test_calc.py"));
2240        assert!(!is_mutatable_py("pkg/conftest.py"));
2241        assert!(!is_mutatable_py("README.md"));
2242    }
2243
2244    #[test]
2245    fn mutated_lines_collects_caught_and_missed() {
2246        let report = parse_mutants_report(SAMPLE).unwrap();
2247        assert_eq!(
2248            mutated_lines(&report),
2249            [
2250                ("src/lib.rs".to_string(), 7),
2251                ("src/other.rs".to_string(), 3)
2252            ]
2253            .into_iter()
2254            .collect()
2255        );
2256    }
2257
2258    #[test]
2259    fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
2260        let report = parse_mutants_report(SAMPLE).unwrap();
2261        let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
2262        let kept = evaluate_scoped(
2263            cargo_mutants_survivors(&report),
2264            &mutated_lines(&report),
2265            &[],
2266            &line_scoped,
2267        )
2268        .unwrap();
2269        assert!(
2270            kept.is_empty(),
2271            "the src/lib.rs:7 survivor should be lifted"
2272        );
2273    }
2274
2275    #[test]
2276    fn evaluate_scoped_rejects_exempting_a_caught_line() {
2277        let report = parse_mutants_report(SAMPLE).unwrap();
2278        let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
2279        let err = evaluate_scoped(
2280            cargo_mutants_survivors(&report),
2281            &mutated_lines(&report),
2282            &[],
2283            &line_scoped,
2284        )
2285        .unwrap_err();
2286        assert!(
2287            err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
2288            "got: {err}"
2289        );
2290    }
2291
2292    #[test]
2293    fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
2294        let report = parse_mutants_report(SAMPLE).unwrap();
2295        let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
2296        let kept = evaluate_scoped(
2297            cargo_mutants_survivors(&report),
2298            &mutated_lines(&report),
2299            &[],
2300            &line_scoped,
2301        )
2302        .unwrap();
2303        assert_eq!(kept.len(), 1);
2304        assert_eq!(kept[0].line, 7);
2305    }
2306
2307    #[test]
2308    fn evaluate_scoped_still_honors_a_whole_file_exemption() {
2309        let report = parse_mutants_report(SAMPLE).unwrap();
2310        let kept = evaluate_scoped(
2311            cargo_mutants_survivors(&report),
2312            &mutated_lines(&report),
2313            &["src/lib.rs".to_string()],
2314            &BTreeMap::new(),
2315        )
2316        .unwrap();
2317        assert!(kept.is_empty());
2318    }
2319
2320    fn unique_tmp() -> PathBuf {
2321        static COUNTER: AtomicU64 = AtomicU64::new(0);
2322        let dir = std::env::temp_dir().join(format!(
2323            "tc-provision-test-{}-{}",
2324            std::process::id(),
2325            COUNTER.fetch_add(1, Ordering::Relaxed)
2326        ));
2327        std::fs::create_dir_all(&dir).unwrap();
2328        dir
2329    }
2330
2331    enum Install {
2332        MustNotRun,
2333        WritesNothing,
2334        WritesBin,
2335        Fails,
2336        CountsSleepsAndWritesBin(std::sync::Arc<AtomicU64>),
2337    }
2338
2339    fn write_bin(bin: &Path) {
2340        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
2341        std::fs::write(bin, b"binary").unwrap();
2342    }
2343
2344    fn drive_provision(bin: &Path, lock: &Path, install: Install) -> Result<PathBuf> {
2345        provision(bin, lock, || match install {
2346            Install::MustNotRun => panic!("must not reinstall"),
2347            Install::WritesNothing => Ok(()),
2348            Install::WritesBin => {
2349                write_bin(bin);
2350                Ok(())
2351            }
2352            Install::Fails => bail!("install blew up"),
2353            Install::CountsSleepsAndWritesBin(count) => {
2354                count.fetch_add(1, Ordering::SeqCst);
2355                std::thread::sleep(std::time::Duration::from_millis(50));
2356                write_bin(bin);
2357                Ok(())
2358            }
2359        })
2360    }
2361
2362    #[test]
2363    fn provision_returns_an_existing_binary_without_installing() {
2364        let tmp = unique_tmp();
2365        let bin = tmp.join("bin").join("cargo-mutants");
2366        let lock = tmp.join(".install.lock");
2367        write_bin(&bin);
2368        let got = drive_provision(&bin, &lock, Install::MustNotRun).unwrap();
2369        assert_eq!(got, bin);
2370        std::fs::remove_dir_all(&tmp).unwrap();
2371    }
2372
2373    #[test]
2374    fn the_must_not_run_sentinel_panics_when_installation_runs() {
2375        let tmp = unique_tmp();
2376        std::fs::create_dir_all(&tmp).unwrap();
2377        let bin = tmp.join("bin").join("cargo-mutants");
2378        let lock = tmp.join(".install.lock");
2379        let panicked =
2380            std::panic::catch_unwind(|| drive_provision(&bin, &lock, Install::MustNotRun)).is_err();
2381        std::fs::remove_dir_all(&tmp).unwrap();
2382        assert!(panicked);
2383    }
2384
2385    #[test]
2386    fn provision_with_a_rootless_lock_path_fails_to_open_the_lock() {
2387        let bin = unique_tmp().join("bin").join("cargo-mutants");
2388        let err = drive_provision(&bin, Path::new("/"), Install::WritesNothing).unwrap_err();
2389        let msg = format!("{err:#}");
2390        assert!(msg.contains("opening the provisioning lock"), "{msg}");
2391    }
2392
2393    #[test]
2394    fn provision_installs_when_the_binary_is_absent() {
2395        let tmp = unique_tmp();
2396        let bin = tmp.join("bin").join("cargo-mutants");
2397        let lock = tmp.join(".install.lock");
2398        let got = drive_provision(&bin, &lock, Install::WritesBin).unwrap();
2399        assert_eq!(got, bin);
2400        assert_eq!(
2401            std::fs::read(&bin).unwrap(),
2402            b"binary",
2403            "an absent binary must be installed"
2404        );
2405        std::fs::remove_dir_all(&tmp).unwrap();
2406    }
2407
2408    #[test]
2409    fn provision_errors_when_install_produces_no_binary() {
2410        let tmp = unique_tmp();
2411        let bin = tmp.join("bin").join("cargo-mutants");
2412        let lock = tmp.join(".install.lock");
2413        let err = drive_provision(&bin, &lock, Install::WritesNothing).unwrap_err();
2414        assert!(
2415            err.to_string().contains("cargo-mutants is not at"),
2416            "got: {err}"
2417        );
2418        std::fs::remove_dir_all(&tmp).unwrap();
2419    }
2420
2421    #[test]
2422    fn provision_propagates_an_install_failure() {
2423        let tmp = unique_tmp();
2424        let bin = tmp.join("bin").join("cargo-mutants");
2425        let lock = tmp.join(".install.lock");
2426        let err = drive_provision(&bin, &lock, Install::Fails).unwrap_err();
2427        assert!(err.to_string().contains("install blew up"), "got: {err}");
2428        std::fs::remove_dir_all(&tmp).unwrap();
2429    }
2430
2431    #[test]
2432    fn provision_does_not_duplicate_the_install_under_concurrent_callers() {
2433        // On a cold cache, N concurrent callers must share one install: cargo-mutants' compile
2434        // duplicated N times turned a ~1-minute cold-cache cost into ~7 minutes. The barrier and
2435        // the sleeping installer widen the race window so this reproduces deterministically.
2436        use std::sync::{Arc, Barrier};
2437        use std::thread;
2438
2439        let tmp = unique_tmp();
2440        let bin = tmp.join("bin").join("cargo-mutants");
2441        let lock = tmp.join(".install.lock");
2442        let install_count = Arc::new(AtomicU64::new(0));
2443        let barrier = Arc::new(Barrier::new(2));
2444
2445        let handles: Vec<_> = (0..2)
2446            .map(|_| {
2447                let bin = bin.clone();
2448                let lock = lock.clone();
2449                let install_count = Arc::clone(&install_count);
2450                let barrier = Arc::clone(&barrier);
2451                thread::spawn(move || {
2452                    barrier.wait();
2453                    drive_provision(
2454                        &bin,
2455                        &lock,
2456                        Install::CountsSleepsAndWritesBin(install_count),
2457                    )
2458                })
2459            })
2460            .collect();
2461
2462        for h in handles {
2463            h.join()
2464                .expect("provisioning thread must not panic")
2465                .unwrap();
2466        }
2467
2468        assert_eq!(
2469            install_count.load(Ordering::SeqCst),
2470            1,
2471            "two concurrent callers on a cold cache must share one install, not each run their own"
2472        );
2473        std::fs::remove_dir_all(&tmp).unwrap();
2474    }
2475
2476    #[test]
2477    fn resolve_cache_base_prefers_xdg_then_home_then_temp() {
2478        let xdg = |s: &str| Some(OsString::from(s));
2479        assert_eq!(
2480            resolve_cache_base(xdg("/xdg"), xdg("/home")),
2481            PathBuf::from("/xdg")
2482        );
2483        assert_eq!(
2484            resolve_cache_base(xdg(""), xdg("/home")),
2485            PathBuf::from("/home/.cache")
2486        );
2487        assert_eq!(
2488            resolve_cache_base(None, xdg("/home")),
2489            PathBuf::from("/home/.cache")
2490        );
2491        assert_eq!(resolve_cache_base(None, None), std::env::temp_dir());
2492        assert_eq!(
2493            resolve_cache_base(xdg(""), Some(OsString::new())),
2494            std::env::temp_dir()
2495        );
2496    }
2497
2498    #[test]
2499    fn cache_root_is_absolute_and_version_scoped() {
2500        let root = cargo_mutants_cache_root();
2501        assert!(
2502            root.ends_with(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}")),
2503            "version-scoped; got {root:?}"
2504        );
2505        assert!(
2506            root.to_string_lossy().contains("testing-conventions"),
2507            "tool-namespaced; got {root:?}"
2508        );
2509        assert!(
2510            root.is_absolute(),
2511            "expected an absolute path; got {root:?}"
2512        );
2513    }
2514
2515    #[test]
2516    fn install_argv_pins_the_version_and_isolates_the_root() {
2517        let argv: Vec<String> = install_argv(Path::new("/cache/cargo-mutants-27"))
2518            .iter()
2519            .map(|arg| arg.to_string_lossy().into_owned())
2520            .collect();
2521        assert_eq!(
2522            argv,
2523            vec![
2524                "install",
2525                "cargo-mutants",
2526                "--locked",
2527                "--version",
2528                CARGO_MUTANTS_VERSION,
2529                "--root",
2530                "/cache/cargo-mutants-27",
2531            ]
2532        );
2533    }
2534
2535    #[test]
2536    fn mutants_argv_enables_features_on_the_engine_itself() {
2537        let argv = |diff, features: &[&str]| -> Vec<String> {
2538            mutants_argv(
2539                Path::new("/out"),
2540                diff,
2541                &features.iter().map(|f| f.to_string()).collect::<Vec<_>>(),
2542            )
2543            .iter()
2544            .map(|arg| arg.to_string_lossy().into_owned())
2545            .collect()
2546        };
2547        assert_eq!(
2548            argv(None, &["cli", "boost"]),
2549            vec![
2550                "mutants",
2551                "--output",
2552                "/out",
2553                "--cargo-test-arg",
2554                "--lib",
2555                "--features",
2556                "cli,boost"
2557            ]
2558        );
2559        assert_eq!(
2560            argv(Some(Path::new("/out/base.diff")), &["cli"]),
2561            vec![
2562                "mutants",
2563                "--output",
2564                "/out",
2565                "--cargo-test-arg",
2566                "--lib",
2567                "--in-diff",
2568                "/out/base.diff",
2569                "--features",
2570                "cli",
2571            ]
2572        );
2573        assert_eq!(
2574            argv(None, &[]),
2575            vec!["mutants", "--output", "/out", "--cargo-test-arg", "--lib"]
2576        );
2577    }
2578
2579    #[test]
2580    fn list_argv_mirrors_the_run_feature_selection() {
2581        let argv = |features: &[&str]| -> Vec<String> {
2582            list_argv(&features.iter().map(|f| f.to_string()).collect::<Vec<_>>())
2583                .iter()
2584                .map(|arg| arg.to_string_lossy().into_owned())
2585                .collect()
2586        };
2587        assert_eq!(argv(&[]), vec!["mutants", "--list", "--json"]);
2588        assert_eq!(
2589            argv(&["cli", "boost"]),
2590            vec!["mutants", "--list", "--json", "--features", "cli,boost"]
2591        );
2592    }
2593
2594    #[test]
2595    fn parse_base_diff_maps_inserted_lines_per_hunk() {
2596        let diff = "\
2597diff --git a/src/lib.rs b/src/lib.rs
2598--- a/src/lib.rs
2599+++ b/src/lib.rs
2600@@ -1,4 +1,5 @@
2601 fn a() {}
2602+fn b() {}
2603 fn c() {}
2604-fn d() {}
2605+fn e() {}
2606 fn f() {}
2607@@ -10,2 +11,4 @@
2608 tail
2609+one
2610+two
2611 more
2612";
2613        let parsed = parse_base_diff(diff);
2614        assert_eq!(parsed.files, vec!["src/lib.rs"]);
2615        assert_eq!(
2616            parsed.inserted.get("src/lib.rs"),
2617            Some(&BTreeSet::from([2, 4, 12, 13]))
2618        );
2619    }
2620
2621    #[test]
2622    fn parse_base_diff_leaves_a_deletion_only_file_without_inserted_lines() {
2623        let diff = "\
2624--- a/src/gone.rs
2625+++ b/src/gone.rs
2626@@ -5,2 +4,0 @@
2627-x
2628-y
2629";
2630        let parsed = parse_base_diff(diff);
2631        assert_eq!(parsed.files, vec!["src/gone.rs"]);
2632        assert!(parsed.inserted.is_empty());
2633    }
2634
2635    #[test]
2636    fn parse_base_diff_skips_a_deleted_file() {
2637        let diff = "\
2638--- a/src/dead.rs
2639+++ /dev/null
2640@@ -1,2 +0,0 @@
2641-a
2642-b
2643";
2644        let parsed = parse_base_diff(diff);
2645        assert!(parsed.files.is_empty());
2646        assert!(parsed.inserted.is_empty());
2647    }
2648
2649    #[test]
2650    fn parse_base_diff_consumes_hunk_bodies_by_count_so_content_never_reads_as_a_header() {
2651        // The inserted content line begins with `+++`; consuming the hunk by its declared
2652        // counts keeps it a body line, not a second file header.
2653        let diff = "\
2654+++ b/notes.txt
2655@@ -1,1 +1,2 @@
2656 keep
2657++++ not a header
2658";
2659        let parsed = parse_base_diff(diff);
2660        assert_eq!(parsed.files, vec!["notes.txt"]);
2661        assert_eq!(parsed.inserted.get("notes.txt"), Some(&BTreeSet::from([2])));
2662    }
2663
2664    #[test]
2665    fn parse_base_diff_defaults_an_elided_hunk_count_to_one() {
2666        let diff = "\
2667+++ b/one.txt
2668@@ -1 +1 @@
2669-old
2670+new
2671";
2672        let parsed = parse_base_diff(diff);
2673        assert_eq!(parsed.inserted.get("one.txt"), Some(&BTreeSet::from([1])));
2674    }
2675
2676    #[test]
2677    fn parse_base_diff_skips_no_newline_annotations_mid_hunk() {
2678        let diff = "\
2679+++ b/n.txt
2680@@ -1 +1 @@
2681-old
2682\\ No newline at end of file
2683+new
2684\\ No newline at end of file
2685";
2686        let parsed = parse_base_diff(diff);
2687        assert_eq!(parsed.inserted.get("n.txt"), Some(&BTreeSet::from([1])));
2688    }
2689
2690    #[cfg(unix)]
2691    fn fake_output(code: i32, stderr: &str) -> Output {
2692        use std::os::unix::process::ExitStatusExt;
2693        Output {
2694            status: std::process::ExitStatus::from_raw(code << 8),
2695            stdout: Vec::new(),
2696            stderr: stderr.as_bytes().to_vec(),
2697        }
2698    }
2699
2700    #[cfg(unix)]
2701    enum FakeRun {
2702        AssertsVersionAndSucceeds,
2703        FailsWith(&'static str),
2704        SpawnError,
2705    }
2706
2707    #[cfg(unix)]
2708    fn drive_install(root: &Path, run: FakeRun) -> Result<()> {
2709        run_install(root, |command| match run {
2710            FakeRun::AssertsVersionAndSucceeds => {
2711                let argv: Vec<String> = command
2712                    .get_args()
2713                    .map(|arg| arg.to_string_lossy().into_owned())
2714                    .collect();
2715                assert!(argv.contains(&CARGO_MUTANTS_VERSION.to_string()));
2716                Ok(fake_output(0, ""))
2717            }
2718            FakeRun::FailsWith(stderr) => Ok(fake_output(1, stderr)),
2719            FakeRun::SpawnError => Err(std::io::Error::new(
2720                std::io::ErrorKind::NotFound,
2721                "no cargo",
2722            )),
2723        })
2724    }
2725
2726    #[cfg(unix)]
2727    #[test]
2728    fn run_install_succeeds_on_a_zero_exit() {
2729        drive_install(Path::new("/cache/root"), FakeRun::AssertsVersionAndSucceeds).unwrap();
2730    }
2731
2732    #[cfg(unix)]
2733    #[test]
2734    fn run_install_reports_a_nonzero_exit_with_the_engine_output() {
2735        let err = drive_install(
2736            Path::new("/cache/root"),
2737            FakeRun::FailsWith("error: could not compile cargo-mutants"),
2738        )
2739        .unwrap_err();
2740        assert!(
2741            err.to_string()
2742                .contains("failed to provision cargo-mutants")
2743                && err.to_string().contains("could not compile"),
2744            "got: {err}"
2745        );
2746    }
2747
2748    #[cfg(unix)]
2749    #[test]
2750    fn run_install_propagates_a_spawn_failure() {
2751        let err = drive_install(Path::new("/cache/root"), FakeRun::SpawnError).unwrap_err();
2752        assert!(
2753            err.to_string().contains("is cargo installed?"),
2754            "got: {err}"
2755        );
2756    }
2757
2758    #[cfg(unix)]
2759    #[test]
2760    fn provision_pinned_installs_via_the_injected_runner() {
2761        let root = unique_tmp();
2762        let bin = root.join("bin").join(CARGO_MUTANTS_BIN_NAME);
2763        let expected = bin.clone();
2764        let got = provision_pinned(&root, |_| {
2765            write_bin(&bin);
2766            Ok(fake_output(0, ""))
2767        })
2768        .unwrap();
2769        assert_eq!(got, expected);
2770        std::fs::remove_dir_all(&root).unwrap();
2771    }
2772
2773    #[test]
2774    fn execute_surfaces_a_spawn_failure() {
2775        let err = execute(&mut Command::new("/nonexistent-tc-cargo")).unwrap_err();
2776        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
2777    }
2778
2779    #[cfg(unix)]
2780    fn fake_stdout(code: i32, stdout: &str) -> Output {
2781        use std::os::unix::process::ExitStatusExt;
2782        Output {
2783            status: std::process::ExitStatus::from_raw(code << 8),
2784            stdout: stdout.as_bytes().to_vec(),
2785            stderr: Vec::new(),
2786        }
2787    }
2788
2789    #[cfg(unix)]
2790    enum FakeList {
2791        AssertsArgvAndReturns(&'static str, Vec<&'static str>),
2792        FailsWith(&'static str),
2793        SpawnError,
2794    }
2795
2796    #[cfg(unix)]
2797    fn drive_list(features: &[String], run: FakeList) -> Result<Vec<MutantInfo>> {
2798        list_cargo_mutants(
2799            Path::new("/cache/bin/cargo-mutants"),
2800            Path::new("/crate"),
2801            features,
2802            |command| match run {
2803                FakeList::AssertsArgvAndReturns(json, expected) => {
2804                    let argv: Vec<String> = command
2805                        .get_args()
2806                        .map(|arg| arg.to_string_lossy().into_owned())
2807                        .collect();
2808                    assert_eq!(argv, expected);
2809                    assert_eq!(command.get_current_dir(), Some(Path::new("/crate")));
2810                    Ok(fake_stdout(0, json))
2811                }
2812                FakeList::FailsWith(stderr) => Ok(fake_output(1, stderr)),
2813                FakeList::SpawnError => Err(std::io::Error::new(
2814                    std::io::ErrorKind::NotFound,
2815                    "no engine",
2816                )),
2817            },
2818        )
2819    }
2820
2821    #[cfg(unix)]
2822    #[test]
2823    fn list_cargo_mutants_parses_the_listing_from_a_clean_run() {
2824        let json = r#"[{"file": "src/lib.rs", "name": "replace add -> 0", "genre": "FnValue",
2825            "span": {"start": {"line": 3, "column": 1}, "end": {"line": 5, "column": 2}}}]"#;
2826        let listed = drive_list(
2827            &["cli".to_string()],
2828            FakeList::AssertsArgvAndReturns(
2829                json,
2830                vec!["mutants", "--list", "--json", "--features", "cli"],
2831            ),
2832        )
2833        .unwrap();
2834        assert_eq!(listed.len(), 1);
2835        assert_eq!(listed[0].file, "src/lib.rs");
2836        assert_eq!(listed[0].span.start.line, 3);
2837        assert_eq!(listed[0].span.end.line, 5);
2838        assert_eq!(listed[0].name, "replace add -> 0");
2839    }
2840
2841    #[cfg(unix)]
2842    #[test]
2843    fn list_cargo_mutants_reports_a_nonzero_exit_with_the_engine_output() {
2844        let err = drive_list(&[], FakeList::FailsWith("error: no such option")).unwrap_err();
2845        assert!(
2846            err.to_string().contains("cargo-mutants --list failed")
2847                && err.to_string().contains("no such option"),
2848            "got: {err}"
2849        );
2850    }
2851
2852    #[cfg(unix)]
2853    #[test]
2854    fn list_cargo_mutants_propagates_a_spawn_failure() {
2855        let err = drive_list(&[], FakeList::SpawnError).unwrap_err();
2856        assert!(
2857            err.to_string()
2858                .contains("listing the crate's mutants with cargo-mutants"),
2859            "got: {err}"
2860        );
2861    }
2862
2863    #[cfg(unix)]
2864    #[test]
2865    fn a_dropped_site_renders_indented_with_its_location_stripped_from_the_name() {
2866        let mutant = listed_mutant("src/a.rs", 12, 14, "src/a.rs:12:5: replace foo with bar");
2867
2868        assert_eq!(mutant_site(&mutant), "  src/a.rs:12: replace foo with bar");
2869    }
2870
2871    #[cfg(unix)]
2872    fn listed_mutant(file: &str, start: u32, end: u32, name: &str) -> MutantInfo {
2873        MutantInfo {
2874            file: file.to_string(),
2875            span: Span {
2876                start: LineCol { line: start },
2877                end: LineCol { line: end },
2878            },
2879            name: name.to_string(),
2880            genre: "FnValue".to_string(),
2881        }
2882    }
2883
2884    #[cfg(unix)]
2885    fn diff_with_inserted(file: &str, lines: &[u32]) -> BaseDiff {
2886        BaseDiff {
2887            files: vec![file.to_string()],
2888            inserted: BTreeMap::from([(file.to_string(), lines.iter().copied().collect())]),
2889        }
2890    }
2891
2892    #[cfg(unix)]
2893    #[test]
2894    fn zero_mutant_verdict_accepts_a_zero_with_no_mutant_on_the_inserted_lines() {
2895        let run = fake_output(0, "");
2896        zero_mutant_verdict(&[], &diff_with_inserted("src/lib.rs", &[5]), &run).unwrap();
2897        let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2898        zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[4, 9]), &run).unwrap();
2899        zero_mutant_verdict(&listed, &diff_with_inserted("src/other.rs", &[6]), &run).unwrap();
2900    }
2901
2902    #[cfg(unix)]
2903    #[test]
2904    fn zero_mutant_verdict_is_fatal_on_a_mutant_at_either_span_boundary() {
2905        let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2906        let run = fake_stdout(0, "0 mutants tested");
2907        for line in [5, 8] {
2908            let err =
2909                zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[line]), &run)
2910                    .unwrap_err();
2911            let message = err.to_string();
2912            assert!(
2913                message.contains("1 of the crate's 1 mutant site(s)")
2914                    && message.contains("src/lib.rs:5: replace add -> 0")
2915                    && message.contains("0 mutants tested"),
2916                "got: {message}"
2917            );
2918        }
2919    }
2920
2921    #[cfg(unix)]
2922    #[test]
2923    fn zero_mutant_verdict_names_each_dropped_site_once() {
2924        let listed = [
2925            listed_mutant(
2926                "src/lib.rs",
2927                7,
2928                7,
2929                "src/lib.rs:7:7: replace > with == in is_positive",
2930            ),
2931            listed_mutant("src/lib.rs", 7, 7, "replace add -> 0"),
2932        ];
2933        let run = fake_stdout(0, "0 mutants tested");
2934        let message = zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[7]), &run)
2935            .unwrap_err()
2936            .to_string();
2937        assert!(
2938            message.contains("  src/lib.rs:7: replace > with == in is_positive"),
2939            "the name's embedded `file:line:col:` prefix is stripped; got: {message}"
2940        );
2941        assert!(
2942            !message.contains(": src/lib.rs:7:7:"),
2943            "a dropped site carries one location; got: {message}"
2944        );
2945        assert!(
2946            message.contains("  src/lib.rs:7: replace add -> 0"),
2947            "a name with no embedded location keeps its rendered location; got: {message}"
2948        );
2949    }
2950
2951    #[cfg(unix)]
2952    #[test]
2953    fn classify_mutants_exit_accepts_the_caught_and_survivor_exits() {
2954        classify_mutants_exit(Path::new("/crate"), &fake_output(0, "")).unwrap();
2955        classify_mutants_exit(Path::new("/crate"), &fake_output(2, "")).unwrap();
2956    }
2957
2958    #[cfg(unix)]
2959    #[test]
2960    fn classify_mutants_exit_accepts_a_timeout_exit_3() {
2961        classify_mutants_exit(Path::new("/crate"), &fake_output(3, ""))
2962            .expect("a timeout (exit 3) is inconclusive, not fatal");
2963    }
2964
2965    #[cfg(unix)]
2966    #[test]
2967    fn classify_mutants_exit_is_fatal_on_a_baseline_failure() {
2968        let err = classify_mutants_exit(Path::new("/crate"), &fake_output(4, "baseline broke"))
2969            .unwrap_err();
2970        assert!(
2971            err.to_string().contains("did not run cleanly")
2972                && err.to_string().contains("baseline broke"),
2973            "got: {err}"
2974        );
2975    }
2976
2977    #[test]
2978    fn cargo_mutants_bin_name_matches_the_platform() {
2979        #[cfg(windows)]
2980        assert_eq!(CARGO_MUTANTS_BIN_NAME, "cargo-mutants.exe");
2981        #[cfg(not(windows))]
2982        assert_eq!(CARGO_MUTANTS_BIN_NAME, "cargo-mutants");
2983    }
2984
2985    /// A one-file unified diff inserting a single line, for scope decisions.
2986    fn one_file_diff(file: &str) -> String {
2987        format!("diff --git a/{file} b/{file}\n--- a/{file}\n+++ b/{file}\n@@ -1,0 +1,1 @@\n+x\n")
2988    }
2989
2990    #[test]
2991    fn a_diff_touching_rust_source_is_in_scope_with_its_parsed_reading() {
2992        let (path, parsed) = rust_scope(
2993            Some(PathBuf::from("base.diff")),
2994            Some(one_file_diff("src/lib.rs")),
2995        )
2996        .expect("a diff touching a .rs file is in scope");
2997
2998        assert_eq!(path, PathBuf::from("base.diff"));
2999        assert_eq!(parsed.files, vec!["src/lib.rs".to_string()]);
3000    }
3001
3002    #[test]
3003    fn a_diff_touching_no_rust_source_is_out_of_scope() {
3004        assert!(rust_scope(
3005            Some(PathBuf::from("base.diff")),
3006            Some(one_file_diff("docs/readme.md")),
3007        )
3008        .is_none());
3009    }
3010
3011    #[test]
3012    fn a_run_whose_git_diff_wrote_nothing_is_out_of_scope() {
3013        assert!(rust_scope(None, None).is_none());
3014        assert!(rust_scope(Some(PathBuf::from("base.diff")), None).is_none());
3015    }
3016
3017    #[test]
3018    fn a_deletion_only_hunk_still_consumes_the_lines_it_declared() {
3019        // The old-side count has to be walked even when the hunk inserts nothing, or the
3020        // line after the hunk — which may itself begin `---` or `+++` — reads as a file
3021        // header in the caller.
3022        let text = "-gone\n--- a/not-a-file.rs\n";
3023        let mut lines = text.lines();
3024        let mut inserted = BTreeMap::new();
3025
3026        consume_hunk(
3027            &mut lines,
3028            (1, 1, 0),
3029            Some(&"src/lib.rs".to_string()),
3030            &mut inserted,
3031        );
3032
3033        assert!(inserted.is_empty(), "a deletion inserts nothing");
3034        assert_eq!(lines.next(), Some("--- a/not-a-file.rs"));
3035    }
3036}