Skip to main content

testing_conventions/
mutation.rs

1//! Mutation testing for Rust (`unit mutation --language rust`, #201) — the rung
2//! above coverage. A test that *runs* a line still passes if you delete its
3//! assertions; a surviving mutant proves it. This module wraps
4//! [cargo-mutants](https://github.com/sourcefrog/cargo-mutants): it runs the engine,
5//! reads its `outcomes.json`, and reports the **surviving** mutants the suite failed
6//! to catch.
7//!
8//! The gate is **binary, not a percentage** (equivalent mutants make a fixed score
9//! unreachable, and a score isn't comparable across engines) and on by default: any
10//! *un-exempted* surviving mutant is a finding. This module stays a pure measurement —
11//! [`measure_rust`] returns the survivors and [`unexplained_survivors`] is the pure
12//! core over a parsed report; the CLI layer turns a non-empty result into the failure.
13//!
14//! Diff-scoping (`--base`) is delegated to cargo-mutants' own `--in-diff`: the
15//! `<base>...HEAD` diff is written out and passed through, so only mutants on changed
16//! lines are tested ("no unexplained surviving mutant on the lines you touched").
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::path::{Path, PathBuf};
20use std::process::Command;
21use std::sync::atomic::{AtomicU64, Ordering};
22
23use anyhow::{bail, Context, Result};
24use serde::Deserialize;
25
26/// A surviving mutant — a mutation the unit suite ran but failed to catch.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Survivor {
29    /// The mutated file, as cargo-mutants reports it (crate-root-relative, `/`-separated).
30    pub file: String,
31    /// The 1-based line the mutation starts on.
32    pub line: u32,
33    /// cargo-mutants' human description (e.g. `replace > with == in is_positive`).
34    pub description: String,
35}
36
37/// The `(file, line)` locations an engine produced a viable mutant for — the input the
38/// #226 line-scoped guard reads to tell an over-exemption (a listed line whose mutants
39/// were all caught) from an out-of-scope line (no mutant there).
40pub type MutatedLines = BTreeSet<(String, u32)>;
41
42/// One mutation run's raw output: the surviving mutants and every viable mutant's
43/// location ([`MutatedLines`]).
44type RunOutcome = (Vec<Survivor>, MutatedLines);
45
46/// A cargo-mutants `outcomes.json` export, pared to what the rule reads. Unmodeled
47/// fields (`total_mutants`, `caught`, timings, …) are ignored.
48#[derive(Debug, Clone, Deserialize)]
49pub struct MutantsReport {
50    pub outcomes: Vec<MutantOutcome>,
51}
52
53/// One scenario's outcome. `summary` is cargo-mutants' result word — `Success` for the
54/// unmutated baseline, `CaughtMutant` / `MissedMutant` (and `Timeout` / `Unviable`)
55/// for each mutant.
56#[derive(Debug, Clone, Deserialize)]
57pub struct MutantOutcome {
58    pub summary: String,
59    pub scenario: Scenario,
60}
61
62/// The scenario a result came from: the unmutated baseline, or one mutant. Matches
63/// cargo-mutants' externally-tagged JSON (`"Baseline"` vs `{"Mutant": {…}}`).
64#[derive(Debug, Clone, Deserialize)]
65pub enum Scenario {
66    Baseline,
67    Mutant(MutantInfo),
68}
69
70/// The mutant a scenario describes, pared to the location + description the report
71/// needs. cargo-mutants also carries `function`, `genre`, `package`, `replacement`;
72/// those are ignored.
73#[derive(Debug, Clone, Deserialize)]
74pub struct MutantInfo {
75    pub file: String,
76    pub span: Span,
77    pub name: String,
78}
79
80/// A source span; only the start line is read.
81#[derive(Debug, Clone, Deserialize)]
82pub struct Span {
83    pub start: LineCol,
84}
85
86/// A line/column position; only the line is read.
87#[derive(Debug, Clone, Deserialize)]
88pub struct LineCol {
89    pub line: u32,
90}
91
92/// Parse a cargo-mutants `outcomes.json` export.
93pub fn parse_mutants_report(json: &str) -> Result<MutantsReport> {
94    serde_json::from_str(json).context("parsing cargo-mutants outcomes.json")
95}
96
97/// The surviving mutants not lifted by a `mutation` exemption — the rule's findings.
98///
99/// A survivor is a `MissedMutant` outcome (the suite ran the mutated code but no test
100/// failed). `exempt` is the resolved set of `mutation`-rule exempt paths (crate-root
101/// relative); a survivor in an exempt file is dropped (an equivalent or deliberately
102/// defensive mutation, lifted with a reason). `Timeout` / `Unviable` are *not*
103/// survivors — a timeout is inconclusive, not a pass, and an unviable mutant never
104/// compiled.
105pub fn unexplained_survivors(report: &MutantsReport, exempt: &[String]) -> Vec<Survivor> {
106    evaluate(cargo_mutants_survivors(report), exempt)
107}
108
109/// The surviving mutants in a cargo-mutants report — the raw list before exemptions.
110/// A survivor is a `MissedMutant` outcome (the suite ran the mutated code but no test
111/// failed). `Timeout` / `Unviable` are not survivors.
112fn cargo_mutants_survivors(report: &MutantsReport) -> Vec<Survivor> {
113    report
114        .outcomes
115        .iter()
116        .filter_map(|outcome| {
117            if outcome.summary != "MissedMutant" {
118                return None;
119            }
120            let Scenario::Mutant(mutant) = &outcome.scenario else {
121                return None;
122            };
123            Some(Survivor {
124                file: mutant.file.clone(),
125                line: mutant.span.start.line,
126                description: mutant.name.clone(),
127            })
128        })
129        .collect()
130}
131
132/// The `(file, line)` locations cargo-mutants produced a **viable, conclusive** mutant
133/// for — caught or missed (`CaughtMutant` / `MissedMutant`), not the inconclusive
134/// `Timeout` / `Unviable`. The #226 line-scoped guard reads this to tell an
135/// over-exemption (a listed line whose mutants were all *caught*, no survivor) from an
136/// out-of-scope line (no mutant there at all — e.g. outside a `--base` diff).
137pub fn mutated_lines(report: &MutantsReport) -> MutatedLines {
138    report
139        .outcomes
140        .iter()
141        .filter_map(|outcome| {
142            if outcome.summary != "CaughtMutant" && outcome.summary != "MissedMutant" {
143                return None;
144            }
145            let Scenario::Mutant(mutant) = &outcome.scenario else {
146                return None;
147            };
148            Some((mutant.file.clone(), mutant.span.start.line))
149        })
150        .collect()
151}
152
153/// The shared whole-file evaluation core: drop the survivors lifted by a file-level
154/// `mutation` exemption (a file-path match), leaving the rule's findings. The
155/// line-scoped path ([`evaluate_scoped`]) generalizes this to per-line exemptions with
156/// a determinism guard.
157pub fn evaluate(survivors: Vec<Survivor>, exempt: &[String]) -> Vec<Survivor> {
158    survivors
159        .into_iter()
160        .filter(|survivor| !exempt.iter().any(|path| path == &survivor.file))
161        .collect()
162}
163
164/// Apply file- and line-scoped `mutation` exemptions to the raw `survivors`, with the
165/// #226 determinism guard. `mutated` is the set of `(file, line)` that produced a
166/// viable mutant (caught or survived); `whole_file` is the file-level exemptions and
167/// `line_scoped` the per-line ones.
168///
169/// Guard: a line-scoped exemption that names a line whose mutants were **all caught**
170/// (in `mutated`, but with no survivor) is over-exemption — a hard error, the
171/// counterpart to the stale-path rule. A listed line with **no** mutant at all is left
172/// alone (it may simply be outside a `--base` diff), neither an error nor a drop. Then
173/// every survivor whose file is whole-file-exempt, or whose `(file, line)` is
174/// line-exempt, is dropped; an unlisted survivor still fails the gate.
175pub fn evaluate_scoped(
176    survivors: Vec<Survivor>,
177    mutated: &MutatedLines,
178    whole_file: &[String],
179    line_scoped: &BTreeMap<String, BTreeSet<u32>>,
180) -> Result<Vec<Survivor>> {
181    let mut over: Vec<String> = Vec::new();
182    for (file, lines) in line_scoped {
183        for &line in lines {
184            let has_survivor = survivors
185                .iter()
186                .any(|survivor| survivor.file == *file && survivor.line == line);
187            if has_survivor {
188                continue;
189            }
190            if mutated.contains(&(file.clone(), line)) {
191                over.push(format!("\n  {file}:{line}"));
192            }
193        }
194    }
195    if !over.is_empty() {
196        bail!(
197            "a line-scoped mutation exemption may only list a line with a surviving mutant, but \
198             these had mutants that were all caught:{}",
199            over.concat()
200        );
201    }
202    Ok(survivors
203        .into_iter()
204        .filter(|survivor| {
205            let whole = whole_file.iter().any(|path| path == &survivor.file);
206            let line = line_scoped
207                .get(&survivor.file)
208                .is_some_and(|lines| lines.contains(&survivor.line));
209            !(whole || line)
210        })
211        .collect())
212}
213
214/// Run cargo-mutants over the crate at `root` and return its un-exempted survivors.
215///
216/// With `base` set, only mutants on the `<base>...HEAD` changed lines are tested (via
217/// cargo-mutants' `--in-diff`); without it, the whole crate. `exempt` is the file-level
218/// `mutation` exempt paths and `exempt_lines` the line-scoped ones (#226), applied with
219/// the determinism guard in [`evaluate_scoped`]. `cargo-mutants` must be installed.
220pub fn measure_rust(
221    root: &Path,
222    exempt: &[String],
223    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
224    base: Option<&str>,
225) -> Result<Vec<Survivor>> {
226    let out = MutantsOut::new();
227    let diff = match base {
228        // An empty diff (no changed lines under the crate — a PR that doesn't touch it)
229        // means nothing to mutate: no survivors, no cargo-mutants run.
230        Some(base) => match write_base_diff(root, base, &out)? {
231            None => return Ok(Vec::new()),
232            Some(path) => Some(path),
233        },
234        None => None,
235    };
236    run_cargo_mutants(root, &out.0, diff.as_deref())?;
237    let outcomes = out.0.join("mutants.out").join("outcomes.json");
238    // cargo-mutants writes no `outcomes.json` when a run produces no mutants (e.g. an
239    // `--in-diff` that matches none of the crate's lines). `run_cargo_mutants` already
240    // bailed on a fatal exit, so a missing report here is "no mutants" → no survivors.
241    let json = match std::fs::read_to_string(&outcomes) {
242        Ok(json) => json,
243        Err(_) => return Ok(Vec::new()),
244    };
245    let report = parse_mutants_report(&json)?;
246    evaluate_scoped(
247        cargo_mutants_survivors(&report),
248        &mutated_lines(&report),
249        exempt,
250        exempt_lines,
251    )
252}
253
254/// A Stryker `mutation.json` report (the mutation-testing-elements schema), pared to
255/// the fields the rule reads. Unmodeled keys (`schemaVersion`, `thresholds`, `source`,
256/// `testFiles`, `projectRoot`, `config`, …) are ignored.
257#[derive(Debug, Clone, Deserialize)]
258pub struct StrykerReport {
259    /// Per-file mutants, keyed by project-relative, `/`-separated path.
260    pub files: BTreeMap<String, StrykerFile>,
261}
262
263/// One file's mutants in a Stryker report.
264#[derive(Debug, Clone, Deserialize)]
265pub struct StrykerFile {
266    #[serde(default)]
267    pub mutants: Vec<StrykerMutant>,
268}
269
270/// One mutant, pared to the location + status + description the rule needs. Stryker
271/// also carries `id`, `coveredBy`, `static`, `testsCompleted`, …; those are ignored.
272#[derive(Debug, Clone, Deserialize)]
273#[serde(rename_all = "camelCase")]
274pub struct StrykerMutant {
275    pub mutator_name: String,
276    #[serde(default)]
277    pub replacement: Option<String>,
278    pub status: String,
279    pub location: StrykerLocation,
280}
281
282/// A mutant's source location; only the start line is read (reusing [`LineCol`], whose
283/// extra `column` field Stryker also provides and serde ignores).
284#[derive(Debug, Clone, Deserialize)]
285pub struct StrykerLocation {
286    pub start: LineCol,
287}
288
289/// Parse a Stryker `mutation.json` report.
290pub fn parse_stryker_report(json: &str) -> Result<StrykerReport> {
291    serde_json::from_str(json).context("parsing Stryker mutation.json")
292}
293
294/// The surviving mutants in a Stryker report — the raw list before exemptions.
295///
296/// A survivor is a `Survived` mutant (a test ran the mutated code but none failed) or a
297/// `NoCoverage` one (no test exercised it at all — worse). `Killed` / `Timeout` are
298/// caught; `CompileError` / `RuntimeError` never produced a viable mutant; `Ignored` /
299/// `Pending` are out of scope. (Mirrors the cargo-mutants `MissedMutant`-only rule.)
300pub fn stryker_survivors(report: &StrykerReport) -> Vec<Survivor> {
301    let mut survivors = Vec::new();
302    for (file, contents) in &report.files {
303        for mutant in &contents.mutants {
304            if mutant.status != "Survived" && mutant.status != "NoCoverage" {
305                continue;
306            }
307            let description = match &mutant.replacement {
308                Some(replacement) => {
309                    format!("{} (-> {})", mutant.mutator_name, one_line(replacement))
310                }
311                None => mutant.mutator_name.clone(),
312            };
313            survivors.push(Survivor {
314                file: file.clone(),
315                line: mutant.location.start.line,
316                description,
317            });
318        }
319    }
320    survivors
321}
322
323/// Collapse a (possibly multi-line) replacement to a single trimmed line, capped, so a
324/// survivor's one-line description stays readable.
325fn one_line(replacement: &str) -> String {
326    let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
327    const MAX: usize = 60;
328    if flat.chars().count() > MAX {
329        format!("{}…", flat.chars().take(MAX).collect::<String>())
330    } else {
331        flat
332    }
333}
334
335/// Run Stryker over the TypeScript project at `root` and return its un-exempted
336/// survivors — the TS arm of the mutation rule (#202), parity with [`measure_rust`].
337///
338/// With `base` set, only mutants on the `<base>...HEAD` changed lines are tested —
339/// Stryker has no native git-diff mode, so the changed lines become `--mutate
340/// <file>:<line>-<line>` ranges (line granularity, matching cargo-mutants' `--in-diff`).
341/// Without it, the project's configured `mutate` set runs. `exempt` is the file-level
342/// exempt paths and `exempt_lines` the line-scoped ones (#226). Stryker must be
343/// installed / resolvable.
344pub fn measure_typescript(
345    root: &Path,
346    exempt: &[String],
347    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
348    base: Option<&str>,
349) -> Result<Vec<Survivor>> {
350    let mutate = match base {
351        Some(base) => {
352            let ranges = mutate_ranges(root, base)?;
353            // Nothing mutatable changed on the diff: no run, no survivors.
354            if ranges.is_empty() {
355                return Ok(Vec::new());
356            }
357            Some(ranges)
358        }
359        None => None,
360    };
361    let json = run_stryker(root, mutate.as_deref())?;
362    let report = parse_stryker_report(&json)?;
363    evaluate_scoped(
364        stryker_survivors(&report),
365        &stryker_mutated_lines(&report),
366        exempt,
367        exempt_lines,
368    )
369}
370
371/// The `(file, line)` locations Stryker produced a **viable** mutant for — caught,
372/// survived, no-coverage, or timed out, but not the unviable `CompileError` /
373/// `RuntimeError` (or the skipped `Ignored` / `Pending`). The #226 guard reads this the
374/// same way as cargo-mutants' [`mutated_lines`].
375fn stryker_mutated_lines(report: &StrykerReport) -> MutatedLines {
376    let mut mutated = BTreeSet::new();
377    for (file, contents) in &report.files {
378        for mutant in &contents.mutants {
379            if matches!(
380                mutant.status.as_str(),
381                "Killed" | "Survived" | "NoCoverage" | "Timeout"
382            ) {
383                mutated.insert((file.clone(), mutant.location.start.line));
384            }
385        }
386    }
387    mutated
388}
389
390/// Build the Stryker `--mutate` specs scoping a run to the `<base>...HEAD` changed
391/// lines: each mutatable source file's contiguous runs of changed lines become a
392/// `<file>:<start>-<end>` range (Stryker's line-range form). Reuses the patch-coverage
393/// diff parser. Test and declaration files are filtered out — Stryker's configured
394/// `mutate` set normally excludes them, but passing `--mutate` replaces that set.
395fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
396    let changed = crate::patch_coverage::changed_lines(root, base)?;
397    let mut specs = Vec::new();
398    for (file, lines) in changed {
399        if !is_mutatable_ts(&file) {
400            continue;
401        }
402        for (start, end) in contiguous_runs(&lines) {
403            specs.push(format!("{file}:{start}-{end}"));
404        }
405    }
406    Ok(specs)
407}
408
409/// Whether a changed file is a TypeScript/JavaScript *source* Stryker should mutate — a
410/// `.ts`/`.tsx`/`.mts`/`.cts`/`.js`/`.jsx`/`.mjs`/`.cjs` file that is not a declaration
411/// (`.d.ts`) or a test (`.test.` / `.spec.`).
412fn is_mutatable_ts(file: &str) -> bool {
413    let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
414        .iter()
415        .any(|ext| file.ends_with(ext));
416    let is_decl = file.ends_with(".d.ts");
417    let is_test = file.contains(".test.") || file.contains(".spec.");
418    is_source && !is_decl && !is_test
419}
420
421/// Fold a sorted set of line numbers into inclusive `(start, end)` contiguous runs.
422fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
423    let mut runs: Vec<(u64, u64)> = Vec::new();
424    for &line in lines {
425        match runs.last_mut() {
426            Some(run) if run.1 + 1 == line => run.1 = line,
427            _ => runs.push((line, line)),
428        }
429    }
430    runs
431}
432
433/// Run Stryker over `root` (resolving the project's own config) with the json reporter,
434/// returning the contents of its `mutation.json`. `mutate`, when set, scopes the run to
435/// `--mutate` line ranges. The report goes to Stryker's default
436/// `reports/mutation/mutation.json`; it's read and then pruned (the file and any empty
437/// parents) so the scanned tree stays pristine and a populated `reports/` is untouched.
438///
439/// Stryker's exit code is *not* trusted to mean "no survivors": a configured
440/// `thresholds.break` makes it exit non-zero on a low score, which is exactly the
441/// survivor case the rule reports on. So the report is read whenever it exists; only a
442/// missing report (a real run failure) is fatal.
443fn run_stryker(root: &Path, mutate: Option<&[String]>) -> Result<String> {
444    let report_path = root.join("reports").join("mutation").join("mutation.json");
445    let _cleanup = ReportCleanup(report_path.clone());
446    // Drop any stale report so a previous run's output is never mistaken for this one's.
447    let _ = std::fs::remove_file(&report_path);
448
449    let mut command = Command::new("npx");
450    command
451        .current_dir(root)
452        // `--no-install`, never `--yes`: run the project's own pinned Stryker (resolved
453        // via Node's parent-dir lookup) and refuse to download anything. With `--yes`, a
454        // missing `@stryker-mutator/core` would silently fetch the long-deprecated
455        // standalone `stryker` package (renamed in 2019) and crash with MODULE_NOT_FOUND;
456        // the TS arm must fail clean like the cosmic-ray / cargo-mutants arms, which run
457        // their binary directly.
458        .args(["--no-install", "stryker", "run", "--reporters", "json"]);
459    if let Some(specs) = mutate {
460        command.arg("--mutate").arg(specs.join(","));
461    }
462    let output = command
463        .env("CI", "1")
464        .output()
465        .context("running `npx --no-install stryker run`")?;
466
467    std::fs::read_to_string(&report_path).map_err(|_| {
468        anyhow::anyhow!(
469            "Stryker produced no report in `{}`. The rule runs the project's own Stryker via \
470             `npx --no-install` and never downloads it, so `@stryker-mutator/core` (plus a \
471             test-runner plugin) must be installed in the project. Stryker output:\n{}{}",
472            root.display(),
473            String::from_utf8_lossy(&output.stdout),
474            String::from_utf8_lossy(&output.stderr),
475        )
476    })
477}
478
479/// Removes the Stryker json report on drop, pruning the `mutation/` and `reports/`
480/// parents only if they're left empty — so a user's own populated `reports/` survives.
481struct ReportCleanup(PathBuf);
482
483impl Drop for ReportCleanup {
484    fn drop(&mut self) {
485        let _ = std::fs::remove_file(&self.0);
486        if let Some(mutation_dir) = self.0.parent() {
487            // `remove_dir` only succeeds on an empty dir, so populated trees are safe.
488            let _ = std::fs::remove_dir(mutation_dir);
489            if let Some(reports_dir) = mutation_dir.parent() {
490                let _ = std::fs::remove_dir(reports_dir);
491            }
492        }
493    }
494}
495
496/// One line of `cosmic-ray dump` output: a `[work_item, result]` pair. The result is
497/// absent (`null`) for an un-executed work item.
498#[derive(Debug, Clone, Deserialize)]
499pub struct CosmicRayLine(pub CrWorkItem, pub Option<CrResult>);
500
501/// A cosmic-ray work item, pared to its one mutation's location. (cosmic-ray models a
502/// list of mutations per item, but the operators here produce one apiece.)
503#[derive(Debug, Clone, Deserialize)]
504pub struct CrWorkItem {
505    pub mutations: Vec<CrMutation>,
506}
507
508/// One mutation, pared to the location + operator the rule reads. cosmic-ray also
509/// carries `occurrence`, `end_pos`, `operator_args`; those are ignored.
510#[derive(Debug, Clone, Deserialize)]
511pub struct CrMutation {
512    pub module_path: String,
513    pub operator_name: String,
514    /// `[line, column]`, 1-based line.
515    pub start_pos: (u32, u32),
516    #[serde(default)]
517    pub definition_name: Option<String>,
518}
519
520/// A work item's result; only the test outcome is read (`survived` / `killed` /
521/// `incompetent`).
522#[derive(Debug, Clone, Deserialize)]
523pub struct CrResult {
524    #[serde(default)]
525    pub test_outcome: Option<String>,
526}
527
528/// Parse `cosmic-ray dump` output (JSON Lines) into the surviving mutants — the raw
529/// list before exemptions.
530///
531/// A survivor is a work item whose result is `survived` (the suite ran the mutated code
532/// but no test failed). `killed` / `incompetent` (the mutant didn't run — e.g. a syntax
533/// error) are not survivors. (Mirrors the cargo-mutants `MissedMutant` / Stryker
534/// `Survived` rules.)
535pub fn parse_cosmic_ray_dump(dump: &str) -> Result<Vec<Survivor>> {
536    let mut survivors = Vec::new();
537    for line in dump.lines() {
538        if line.trim().is_empty() {
539            continue;
540        }
541        let CosmicRayLine(item, result) =
542            serde_json::from_str(line).context("parsing a cosmic-ray dump line")?;
543        let survived = matches!(result, Some(CrResult { test_outcome: Some(outcome) }) if outcome == "survived");
544        if !survived {
545            continue;
546        }
547        let Some(mutation) = item.mutations.first() else {
548            continue;
549        };
550        let definition = mutation.definition_name.as_deref().unwrap_or("<module>");
551        survivors.push(Survivor {
552            file: mutation.module_path.clone(),
553            line: mutation.start_pos.0,
554            description: format!("{} in {}", mutation.operator_name, definition),
555        });
556    }
557    Ok(survivors)
558}
559
560/// Run cosmic-ray over the Python project at `root` and return its un-exempted
561/// survivors — the Python arm of the mutation rule (#203), parity with [`measure_rust`]
562/// and [`measure_typescript`].
563///
564/// With `base` set, only mutants on the `<base>...HEAD` changed lines are reported:
565/// cosmic-ray has no native git-diff mode, so the run is scoped to the changed `.py`
566/// files (one session each) and the survivors are then filtered to the changed lines —
567/// line granularity, matching the other arms. Without it, the whole project's sources
568/// run (tests excluded). `exempt` is the file-level exempt paths and `exempt_lines` the
569/// line-scoped ones (#226). cosmic-ray + pytest must be installed.
570pub fn measure_python(
571    root: &Path,
572    exempt: &[String],
573    exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
574    base: Option<&str>,
575) -> Result<Vec<Survivor>> {
576    let (survivors, mutated) = match base {
577        None => run_cosmic_ray(root, ".", &PY_TEST_EXCLUDES)?,
578        Some(base) => {
579            let changed = crate::patch_coverage::changed_lines(root, base)?;
580            let mut all_survivors = Vec::new();
581            let mut all_mutated = BTreeSet::new();
582            for (file, lines) in &changed {
583                if !is_mutatable_py(file) {
584                    continue;
585                }
586                // The file is a single non-test module, so no test exclusions are needed.
587                let (survivors, mutated) = run_cosmic_ray(root, file, &[])?;
588                for survivor in survivors {
589                    if lines.contains(&(survivor.line as u64)) {
590                        all_survivors.push(survivor);
591                    }
592                }
593                for (mutated_file, line) in mutated {
594                    if lines.contains(&u64::from(line)) {
595                        all_mutated.insert((mutated_file, line));
596                    }
597                }
598            }
599            (all_survivors, all_mutated)
600        }
601    };
602    evaluate_scoped(survivors, &mutated, exempt, exempt_lines)
603}
604
605/// The test/conftest globs excluded from whole-project mutation (cosmic-ray would
606/// otherwise mutate the suite itself).
607const PY_TEST_EXCLUDES: [&str; 3] = ["*_test.py", "test_*.py", "conftest.py"];
608
609/// Whether a changed file is a mutatable Python *source* — a `.py` that is not a test
610/// (`*_test.py` / `test_*.py`) or `conftest.py`.
611fn is_mutatable_py(file: &str) -> bool {
612    if !file.ends_with(".py") {
613        return false;
614    }
615    let base = file.rsplit('/').next().unwrap_or(file);
616    !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
617}
618
619/// Run one cosmic-ray session over `module_path` (relative to `root`) and return its
620/// surviving mutants **and** the `(file, line)` set of every viable (executed) mutant
621/// — the latter for the #226 line-scoped guard. A baseline check runs first so a suite
622/// that fails *unmutated* errors rather than reporting a false "all killed". The
623/// cosmic-ray config and session DB live in an out-of-tree temp dir; cosmic-ray mutates
624/// each file in place and reverts it, so the scanned tree is left as it was.
625fn run_cosmic_ray(root: &Path, module_path: &str, excluded_modules: &[&str]) -> Result<RunOutcome> {
626    let dir = CosmicRayDir::new();
627    std::fs::create_dir_all(&dir.0).context("creating the cosmic-ray temp dir")?;
628    let config = dir.0.join("cr.toml");
629    let session = dir.0.join("session.sqlite");
630
631    let excludes = excluded_modules
632        .iter()
633        .map(|glob| format!("\"{glob}\""))
634        .collect::<Vec<_>>()
635        .join(", ");
636    std::fs::write(
637        &config,
638        format!(
639            "[cosmic-ray]\n\
640             module-path = \"{module_path}\"\n\
641             timeout = 30.0\n\
642             excluded-modules = [{excludes}]\n\
643             test-command = \"python3 -m pytest -q -p no:cacheprovider\"\n\
644             \n\
645             [cosmic-ray.distributor]\n\
646             name = \"local\"\n"
647        ),
648    )
649    .context("writing the cosmic-ray config")?;
650
651    // Baseline: the suite must pass unmutated, or every mutant would "die" on the
652    // already-failing tests and we'd report a false pass.
653    let baseline = cosmic_ray(root, &["baseline", path_str(&config)])?;
654    if !baseline.status.success() {
655        bail!(
656            "the Python unit suite did not pass unmutated in `{}` (cosmic-ray baseline failed):\n{}{}",
657            root.display(),
658            String::from_utf8_lossy(&baseline.stdout),
659            String::from_utf8_lossy(&baseline.stderr),
660        );
661    }
662
663    let init = cosmic_ray(root, &["init", path_str(&config), path_str(&session)])?;
664    if !init.status.success() {
665        bail!(
666            "cosmic-ray init failed in `{}`:\n{}{}",
667            root.display(),
668            String::from_utf8_lossy(&init.stdout),
669            String::from_utf8_lossy(&init.stderr),
670        );
671    }
672    let exec = cosmic_ray(root, &["exec", path_str(&config), path_str(&session)])?;
673    if !exec.status.success() {
674        bail!(
675            "cosmic-ray exec failed in `{}`:\n{}{}",
676            root.display(),
677            String::from_utf8_lossy(&exec.stdout),
678            String::from_utf8_lossy(&exec.stderr),
679        );
680    }
681    let dump = cosmic_ray(root, &["dump", path_str(&session)])?;
682    if !dump.status.success() {
683        bail!(
684            "cosmic-ray dump failed in `{}`:\n{}",
685            root.display(),
686            String::from_utf8_lossy(&dump.stderr),
687        );
688    }
689    let stdout = String::from_utf8_lossy(&dump.stdout);
690    Ok((
691        parse_cosmic_ray_dump(&stdout)?,
692        cosmic_ray_mutated_lines(&stdout)?,
693    ))
694}
695
696/// The `(file, line)` locations cosmic-ray ran a **viable** (executed) mutant for —
697/// `survived` or `killed`, not the `incompetent` mutants that never ran (a syntax
698/// error) or the un-executed (`null`-result) work items. The #226 guard reads this the
699/// same way as the cargo-mutants / Stryker [`mutated_lines`].
700pub fn cosmic_ray_mutated_lines(dump: &str) -> Result<MutatedLines> {
701    let mut mutated = BTreeSet::new();
702    for line in dump.lines() {
703        if line.trim().is_empty() {
704            continue;
705        }
706        let CosmicRayLine(item, result) =
707            serde_json::from_str(line).context("parsing a cosmic-ray dump line")?;
708        let outcome = result.and_then(|result| result.test_outcome);
709        if !matches!(outcome.as_deref(), Some("survived") | Some("killed")) {
710            continue;
711        }
712        if let Some(mutation) = item.mutations.first() {
713            mutated.insert((mutation.module_path.clone(), mutation.start_pos.0));
714        }
715    }
716    Ok(mutated)
717}
718
719/// Run a `cosmic-ray` subcommand in `root`, capturing its output. `PYTHONDONTWRITEBYTECODE`
720/// keeps `__pycache__` out of the scanned tree.
721fn cosmic_ray(root: &Path, args: &[&str]) -> Result<std::process::Output> {
722    Command::new("cosmic-ray")
723        .current_dir(root)
724        .args(args)
725        .env("PYTHONDONTWRITEBYTECODE", "1")
726        .output()
727        .context("running `cosmic-ray` (is it installed?)")
728}
729
730fn path_str(path: &Path) -> &str {
731    path.to_str().expect("temp path is valid UTF-8")
732}
733
734/// A unique temp dir for one cosmic-ray session's config + SQLite, removed on drop so
735/// the scanned tree stays pristine and parallel runs don't collide.
736struct CosmicRayDir(PathBuf);
737
738impl CosmicRayDir {
739    fn new() -> Self {
740        static COUNTER: AtomicU64 = AtomicU64::new(0);
741        let name = format!(
742            "testing-conventions-cosmic-ray-{}-{}",
743            std::process::id(),
744            COUNTER.fetch_add(1, Ordering::Relaxed),
745        );
746        CosmicRayDir(std::env::temp_dir().join(name))
747    }
748}
749
750impl Drop for CosmicRayDir {
751    fn drop(&mut self) {
752        let _ = std::fs::remove_dir_all(&self.0);
753    }
754}
755
756/// A unique temp dir for one cargo-mutants run's `--output`, removed on drop so the
757/// scanned crate stays pristine and parallel runs don't collide.
758struct MutantsOut(PathBuf);
759
760impl MutantsOut {
761    fn new() -> Self {
762        static COUNTER: AtomicU64 = AtomicU64::new(0);
763        let name = format!(
764            "testing-conventions-mutants-{}-{}",
765            std::process::id(),
766            COUNTER.fetch_add(1, Ordering::Relaxed),
767        );
768        MutantsOut(std::env::temp_dir().join(name))
769    }
770}
771
772impl Drop for MutantsOut {
773    fn drop(&mut self) {
774        let _ = std::fs::remove_dir_all(&self.0);
775    }
776}
777
778/// Write the `<base>...HEAD` diff cargo-mutants' `--in-diff` scopes to, returning its
779/// path — or `None` when the diff is empty (no changed lines under the crate).
780///
781/// `--relative` restricts the diff to changes under `root` (the crate dir) and makes
782/// the paths relative to it. cargo-mutants runs *in* the crate dir and matches its
783/// `--in-diff` paths crate-relative, so without `--relative` the diff is repo-relative
784/// and matches nothing whenever the crate is a subdirectory of the git repo (the common
785/// case). Scoping also means a PR that doesn't touch the crate yields an empty diff.
786fn write_base_diff(root: &Path, base: &str, out: &MutantsOut) -> Result<Option<PathBuf>> {
787    let range = format!("{base}...HEAD");
788    let output = Command::new("git")
789        .current_dir(root)
790        .args(["diff", "--relative", &range])
791        .output()
792        .context("running `git diff` for `--base` (is git installed?)")?;
793    if !output.status.success() {
794        bail!(
795            "git diff {range} failed: {}",
796            String::from_utf8_lossy(&output.stderr)
797        );
798    }
799    if output.stdout.is_empty() {
800        return Ok(None);
801    }
802    std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
803    let path = out.0.join("base.diff");
804    std::fs::write(&path, &output.stdout).context("writing the base diff")?;
805    Ok(Some(path))
806}
807
808/// Run `cargo mutants --output <out> [--in-diff <diff>]` in `root`.
809///
810/// cargo-mutants exits `0` when every mutant is caught and `2` when some survive (or
811/// time out / are unviable) — both are normal here, since survivors are the rule's
812/// *output*, not an error. Any other code (usage error, or a baseline that didn't
813/// build/pass) is fatal. As with the coverage run, the outer instrumentation env is
814/// stripped so a nested run (this rule's own tests under `cargo llvm-cov`) doesn't
815/// re-enter the rustc wrapper and hang.
816fn run_cargo_mutants(root: &Path, out: &Path, in_diff: Option<&Path>) -> Result<()> {
817    let mut command = Command::new("cargo");
818    command
819        .current_dir(root)
820        .arg("mutants")
821        .arg("--output")
822        .arg(out);
823    if let Some(diff) = in_diff {
824        command.arg("--in-diff").arg(diff);
825    }
826    for var in [
827        "RUSTFLAGS",
828        "CARGO_ENCODED_RUSTFLAGS",
829        "RUSTDOCFLAGS",
830        "CARGO_ENCODED_RUSTDOCFLAGS",
831        "LLVM_PROFILE_FILE",
832        "CARGO_LLVM_COV",
833        "CARGO_LLVM_COV_SHOW_ENV",
834        "CARGO_LLVM_COV_TARGET_DIR",
835        "CARGO_LLVM_COV_BUILD_DIR",
836        "RUSTC_WRAPPER",
837        "RUSTC_WORKSPACE_WRAPPER",
838        "__CARGO_LLVM_COV_RUSTC_WRAPPER",
839        "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
840        "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
841    ] {
842        command.env_remove(var);
843    }
844    let output = command
845        .output()
846        .context("running `cargo mutants` (is cargo-mutants installed?)")?;
847    match output.status.code() {
848        // 0 = all caught, 2 = some survived/timed out: both produce a report to read.
849        Some(0) | Some(2) => Ok(()),
850        _ => bail!(
851            "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
852            root.display(),
853            String::from_utf8_lossy(&output.stdout),
854            String::from_utf8_lossy(&output.stderr),
855        ),
856    }
857}
858
859#[cfg(test)]
860mod tests {
861    use super::*;
862
863    // A pared `outcomes.json`: the baseline, one missed mutant, and one caught — the
864    // real shape (externally-tagged `scenario`, extra fields the rule ignores).
865    const SAMPLE: &str = r#"{
866        "outcomes": [
867            {"scenario": "Baseline", "summary": "Success",
868             "phase_results": []},
869            {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
870                "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
871                "function": {"function_name": "is_positive"},
872                "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
873             "summary": "MissedMutant"},
874            {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
875                "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
876                "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
877             "summary": "CaughtMutant"}
878        ],
879        "total_mutants": 2
880    }"#;
881
882    #[test]
883    fn parses_the_outcomes_export() {
884        let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
885        assert_eq!(report.outcomes.len(), 3);
886        assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
887    }
888
889    #[test]
890    fn collects_only_missed_mutants_as_survivors() {
891        let report = parse_mutants_report(SAMPLE).unwrap();
892        let survivors = unexplained_survivors(&report, &[]);
893        // Only the MissedMutant — the baseline and the CaughtMutant are not survivors.
894        assert_eq!(survivors.len(), 1);
895        assert_eq!(survivors[0].file, "src/lib.rs");
896        assert_eq!(survivors[0].line, 7);
897        assert!(survivors[0].description.contains("replace > with =="));
898    }
899
900    #[test]
901    fn an_exemption_drops_a_survivor_in_that_file() {
902        let report = parse_mutants_report(SAMPLE).unwrap();
903        let exempt = vec!["src/lib.rs".to_string()];
904        assert!(unexplained_survivors(&report, &exempt).is_empty());
905    }
906
907    #[test]
908    fn an_exemption_on_another_file_leaves_the_survivor() {
909        let report = parse_mutants_report(SAMPLE).unwrap();
910        let exempt = vec!["src/elsewhere.rs".to_string()];
911        assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
912    }
913
914    // A pared Stryker `mutation.json`: one Survived, one NoCoverage, one Killed — the
915    // real shape (per-file `files` map, extra fields the rule ignores).
916    const STRYKER_SAMPLE: &str = r#"{
917        "schemaVersion": "1.0",
918        "files": {
919            "src/index.ts": {
920                "language": "typescript",
921                "source": "...",
922                "mutants": [
923                    {"id": "0", "mutatorName": "ConditionalExpression", "replacement": "true",
924                     "status": "Survived", "coveredBy": ["t0"],
925                     "location": {"start": {"line": 2, "column": 10}, "end": {"line": 2, "column": 15}}},
926                    {"id": "1", "mutatorName": "ArithmeticOperator", "replacement": "a - b",
927                     "status": "NoCoverage",
928                     "location": {"start": {"line": 5, "column": 3}, "end": {"line": 5, "column": 8}}},
929                    {"id": "2", "mutatorName": "BooleanLiteral", "replacement": "false",
930                     "status": "Killed",
931                     "location": {"start": {"line": 9, "column": 1}, "end": {"line": 9, "column": 6}}}
932                ]
933            }
934        }
935    }"#;
936
937    #[test]
938    fn parses_a_stryker_report() {
939        let report = parse_stryker_report(STRYKER_SAMPLE).expect("valid mutation.json");
940        assert_eq!(report.files["src/index.ts"].mutants.len(), 3);
941    }
942
943    #[test]
944    fn collects_survived_and_nocoverage_as_survivors() {
945        let report = parse_stryker_report(STRYKER_SAMPLE).unwrap();
946        let survivors = stryker_survivors(&report);
947        // Survived + NoCoverage are survivors; the Killed mutant is not.
948        assert_eq!(survivors.len(), 2);
949        assert!(survivors.iter().all(|s| s.file == "src/index.ts"));
950        assert_eq!(survivors[0].line, 2);
951        assert!(survivors[0].description.contains("ConditionalExpression"));
952        assert!(survivors[0].description.contains("true"));
953        assert_eq!(survivors[1].line, 5);
954    }
955
956    #[test]
957    fn evaluate_drops_exempt_files_for_either_engine() {
958        let report = parse_stryker_report(STRYKER_SAMPLE).unwrap();
959        let survivors = stryker_survivors(&report);
960        let exempt = vec!["src/index.ts".to_string()];
961        assert!(evaluate(survivors, &exempt).is_empty());
962    }
963
964    #[test]
965    fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
966        assert!(is_mutatable_ts("src/index.ts"));
967        assert!(is_mutatable_ts("src/util.tsx"));
968        assert!(is_mutatable_ts("src/util.js"));
969        assert!(!is_mutatable_ts("src/index.test.ts"));
970        assert!(!is_mutatable_ts("src/index.spec.ts"));
971        assert!(!is_mutatable_ts("src/types.d.ts"));
972        assert!(!is_mutatable_ts("README.md"));
973    }
974
975    #[test]
976    fn contiguous_runs_collapses_adjacent_lines() {
977        let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
978        assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
979        assert!(contiguous_runs(&BTreeSet::new()).is_empty());
980    }
981
982    #[test]
983    fn one_line_flattens_and_caps() {
984        assert_eq!(one_line("a -\n  b"), "a - b");
985        let long = "x".repeat(80);
986        let capped = one_line(&long);
987        assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
988    }
989
990    // A pared `cosmic-ray dump`: each line is `[work_item, result]` — one survived
991    // comparison-operator mutant and one killed binary-operator mutant.
992    const COSMIC_RAY_DUMP: &str = concat!(
993        r#"[{"job_id":"a","mutations":[{"module_path":"calc.py","operator_name":"core/ReplaceComparisonOperator_Gt_NotEq","occurrence":0,"start_pos":[6,11],"end_pos":[6,12],"operator_args":{},"definition_name":"is_positive"}]},{"worker_outcome":"normal","test_outcome":"survived"}]"#,
994        "\n",
995        r#"[{"job_id":"b","mutations":[{"module_path":"calc.py","operator_name":"core/ReplaceBinaryOperator_Add_Div","occurrence":0,"start_pos":[2,13],"end_pos":[2,14],"operator_args":{},"definition_name":"add"}]},{"worker_outcome":"normal","test_outcome":"killed"}]"#,
996        "\n",
997    );
998
999    #[test]
1000    fn collects_only_survived_cosmic_ray_mutants() {
1001        let survivors = parse_cosmic_ray_dump(COSMIC_RAY_DUMP).expect("valid dump");
1002        // Only the survived mutant — the killed one is not a survivor.
1003        assert_eq!(survivors.len(), 1);
1004        assert_eq!(survivors[0].file, "calc.py");
1005        assert_eq!(survivors[0].line, 6);
1006        assert!(survivors[0]
1007            .description
1008            .contains("ReplaceComparisonOperator"));
1009        assert!(survivors[0].description.contains("is_positive"));
1010    }
1011
1012    #[test]
1013    fn an_unexecuted_cosmic_ray_item_is_not_a_survivor() {
1014        // A work item with a null result (never run) must not count as a survivor.
1015        let dump = r#"[{"mutations":[{"module_path":"calc.py","operator_name":"core/NumberReplacer","start_pos":[3,5],"end_pos":[3,6]}]},null]"#;
1016        assert!(parse_cosmic_ray_dump(dump).unwrap().is_empty());
1017    }
1018
1019    #[test]
1020    fn is_mutatable_py_keeps_sources_and_drops_tests() {
1021        assert!(is_mutatable_py("calc.py"));
1022        assert!(is_mutatable_py("pkg/util.py"));
1023        assert!(!is_mutatable_py("calc_test.py"));
1024        assert!(!is_mutatable_py("test_calc.py"));
1025        assert!(!is_mutatable_py("pkg/conftest.py"));
1026        assert!(!is_mutatable_py("README.md"));
1027    }
1028
1029    // --- line-scoped exemptions (#226) ---
1030
1031    #[test]
1032    fn mutated_lines_collects_caught_and_missed() {
1033        // The MissedMutant (src/lib.rs:7) and the CaughtMutant (src/other.rs:3) are both
1034        // viable, conclusive mutants; the Baseline is not.
1035        let report = parse_mutants_report(SAMPLE).unwrap();
1036        assert_eq!(
1037            mutated_lines(&report),
1038            [
1039                ("src/lib.rs".to_string(), 7),
1040                ("src/other.rs".to_string(), 3)
1041            ]
1042            .into_iter()
1043            .collect()
1044        );
1045    }
1046
1047    #[test]
1048    fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1049        let report = parse_mutants_report(SAMPLE).unwrap();
1050        let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1051        let kept = evaluate_scoped(
1052            cargo_mutants_survivors(&report),
1053            &mutated_lines(&report),
1054            &[],
1055            &line_scoped,
1056        )
1057        .unwrap();
1058        assert!(
1059            kept.is_empty(),
1060            "the src/lib.rs:7 survivor should be lifted"
1061        );
1062    }
1063
1064    #[test]
1065    fn evaluate_scoped_rejects_exempting_a_caught_line() {
1066        // src/other.rs:3 had only a caught mutant (no survivor) — over-exemption.
1067        let report = parse_mutants_report(SAMPLE).unwrap();
1068        let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
1069        let err = evaluate_scoped(
1070            cargo_mutants_survivors(&report),
1071            &mutated_lines(&report),
1072            &[],
1073            &line_scoped,
1074        )
1075        .unwrap_err();
1076        assert!(
1077            err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
1078            "got: {err}"
1079        );
1080    }
1081
1082    #[test]
1083    fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
1084        // Line 99 has no mutant at all (e.g. outside a `--base` diff) — neither an error
1085        // nor a drop; the real survivor on line 7 still stands.
1086        let report = parse_mutants_report(SAMPLE).unwrap();
1087        let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
1088        let kept = evaluate_scoped(
1089            cargo_mutants_survivors(&report),
1090            &mutated_lines(&report),
1091            &[],
1092            &line_scoped,
1093        )
1094        .unwrap();
1095        assert_eq!(kept.len(), 1);
1096        assert_eq!(kept[0].line, 7);
1097    }
1098
1099    #[test]
1100    fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1101        let report = parse_mutants_report(SAMPLE).unwrap();
1102        let kept = evaluate_scoped(
1103            cargo_mutants_survivors(&report),
1104            &mutated_lines(&report),
1105            &["src/lib.rs".to_string()],
1106            &BTreeMap::new(),
1107        )
1108        .unwrap();
1109        assert!(kept.is_empty());
1110    }
1111
1112    #[test]
1113    fn stryker_mutated_lines_collects_every_viable_mutant() {
1114        // Survived (2), NoCoverage (5), and Killed (9) all ran; nothing is unviable here.
1115        let report = parse_stryker_report(STRYKER_SAMPLE).unwrap();
1116        assert_eq!(
1117            stryker_mutated_lines(&report),
1118            [
1119                ("src/index.ts".to_string(), 2),
1120                ("src/index.ts".to_string(), 5),
1121                ("src/index.ts".to_string(), 9),
1122            ]
1123            .into_iter()
1124            .collect()
1125        );
1126    }
1127
1128    #[test]
1129    fn cosmic_ray_mutated_lines_collects_executed_mutants() {
1130        // The survived (line 6) and killed (line 2) work items both ran.
1131        let mutated = cosmic_ray_mutated_lines(COSMIC_RAY_DUMP).unwrap();
1132        assert_eq!(
1133            mutated,
1134            [("calc.py".to_string(), 2), ("calc.py".to_string(), 6)]
1135                .into_iter()
1136                .collect()
1137        );
1138    }
1139}