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/// A cargo-mutants `outcomes.json` export, pared to what the rule reads. Unmodeled
38/// fields (`total_mutants`, `caught`, timings, …) are ignored.
39#[derive(Debug, Clone, Deserialize)]
40pub struct MutantsReport {
41    pub outcomes: Vec<MutantOutcome>,
42}
43
44/// One scenario's outcome. `summary` is cargo-mutants' result word — `Success` for the
45/// unmutated baseline, `CaughtMutant` / `MissedMutant` (and `Timeout` / `Unviable`)
46/// for each mutant.
47#[derive(Debug, Clone, Deserialize)]
48pub struct MutantOutcome {
49    pub summary: String,
50    pub scenario: Scenario,
51}
52
53/// The scenario a result came from: the unmutated baseline, or one mutant. Matches
54/// cargo-mutants' externally-tagged JSON (`"Baseline"` vs `{"Mutant": {…}}`).
55#[derive(Debug, Clone, Deserialize)]
56pub enum Scenario {
57    Baseline,
58    Mutant(MutantInfo),
59}
60
61/// The mutant a scenario describes, pared to the location + description the report
62/// needs. cargo-mutants also carries `function`, `genre`, `package`, `replacement`;
63/// those are ignored.
64#[derive(Debug, Clone, Deserialize)]
65pub struct MutantInfo {
66    pub file: String,
67    pub span: Span,
68    pub name: String,
69}
70
71/// A source span; only the start line is read.
72#[derive(Debug, Clone, Deserialize)]
73pub struct Span {
74    pub start: LineCol,
75}
76
77/// A line/column position; only the line is read.
78#[derive(Debug, Clone, Deserialize)]
79pub struct LineCol {
80    pub line: u32,
81}
82
83/// Parse a cargo-mutants `outcomes.json` export.
84pub fn parse_mutants_report(json: &str) -> Result<MutantsReport> {
85    serde_json::from_str(json).context("parsing cargo-mutants outcomes.json")
86}
87
88/// The surviving mutants not lifted by a `mutation` exemption — the rule's findings.
89///
90/// A survivor is a `MissedMutant` outcome (the suite ran the mutated code but no test
91/// failed). `exempt` is the resolved set of `mutation`-rule exempt paths (crate-root
92/// relative); a survivor in an exempt file is dropped (an equivalent or deliberately
93/// defensive mutation, lifted with a reason). `Timeout` / `Unviable` are *not*
94/// survivors — a timeout is inconclusive, not a pass, and an unviable mutant never
95/// compiled.
96pub fn unexplained_survivors(report: &MutantsReport, exempt: &[String]) -> Vec<Survivor> {
97    let survivors = report
98        .outcomes
99        .iter()
100        .filter_map(|outcome| {
101            if outcome.summary != "MissedMutant" {
102                return None;
103            }
104            let Scenario::Mutant(mutant) = &outcome.scenario else {
105                return None;
106            };
107            Some(Survivor {
108                file: mutant.file.clone(),
109                line: mutant.span.start.line,
110                description: mutant.name.clone(),
111            })
112        })
113        .collect();
114    evaluate(survivors, exempt)
115}
116
117/// The shared evaluation core both engines feed: drop the survivors lifted by a
118/// `mutation` exemption (a file-path match), leaving the rule's findings. Each engine
119/// produces the raw survivor list its own way ([`unexplained_survivors`] from a
120/// cargo-mutants report, [`stryker_survivors`] from a Stryker report); this applies
121/// the reason-required exemptions identically across languages.
122pub fn evaluate(survivors: Vec<Survivor>, exempt: &[String]) -> Vec<Survivor> {
123    survivors
124        .into_iter()
125        .filter(|survivor| !exempt.iter().any(|path| path == &survivor.file))
126        .collect()
127}
128
129/// Run cargo-mutants over the crate at `root` and return its un-exempted survivors.
130///
131/// With `base` set, only mutants on the `<base>...HEAD` changed lines are tested (via
132/// cargo-mutants' `--in-diff`); without it, the whole crate. `exempt` is the resolved
133/// `mutation`-rule exempt paths. `cargo-mutants` must be installed.
134pub fn measure_rust(root: &Path, exempt: &[String], base: Option<&str>) -> Result<Vec<Survivor>> {
135    let out = MutantsOut::new();
136    let diff = match base {
137        Some(base) => Some(write_base_diff(root, base, &out)?),
138        None => None,
139    };
140    run_cargo_mutants(root, &out.0, diff.as_deref())?;
141    let outcomes = out.0.join("mutants.out").join("outcomes.json");
142    let json = std::fs::read_to_string(&outcomes).with_context(|| {
143        format!(
144            "reading cargo-mutants outcomes at `{}` — the run wrote none",
145            outcomes.display()
146        )
147    })?;
148    let report = parse_mutants_report(&json)?;
149    Ok(unexplained_survivors(&report, exempt))
150}
151
152/// A Stryker `mutation.json` report (the mutation-testing-elements schema), pared to
153/// the fields the rule reads. Unmodeled keys (`schemaVersion`, `thresholds`, `source`,
154/// `testFiles`, `projectRoot`, `config`, …) are ignored.
155#[derive(Debug, Clone, Deserialize)]
156pub struct StrykerReport {
157    /// Per-file mutants, keyed by project-relative, `/`-separated path.
158    pub files: BTreeMap<String, StrykerFile>,
159}
160
161/// One file's mutants in a Stryker report.
162#[derive(Debug, Clone, Deserialize)]
163pub struct StrykerFile {
164    #[serde(default)]
165    pub mutants: Vec<StrykerMutant>,
166}
167
168/// One mutant, pared to the location + status + description the rule needs. Stryker
169/// also carries `id`, `coveredBy`, `static`, `testsCompleted`, …; those are ignored.
170#[derive(Debug, Clone, Deserialize)]
171#[serde(rename_all = "camelCase")]
172pub struct StrykerMutant {
173    pub mutator_name: String,
174    #[serde(default)]
175    pub replacement: Option<String>,
176    pub status: String,
177    pub location: StrykerLocation,
178}
179
180/// A mutant's source location; only the start line is read (reusing [`LineCol`], whose
181/// extra `column` field Stryker also provides and serde ignores).
182#[derive(Debug, Clone, Deserialize)]
183pub struct StrykerLocation {
184    pub start: LineCol,
185}
186
187/// Parse a Stryker `mutation.json` report.
188pub fn parse_stryker_report(json: &str) -> Result<StrykerReport> {
189    serde_json::from_str(json).context("parsing Stryker mutation.json")
190}
191
192/// The surviving mutants in a Stryker report — the raw list before exemptions.
193///
194/// A survivor is a `Survived` mutant (a test ran the mutated code but none failed) or a
195/// `NoCoverage` one (no test exercised it at all — worse). `Killed` / `Timeout` are
196/// caught; `CompileError` / `RuntimeError` never produced a viable mutant; `Ignored` /
197/// `Pending` are out of scope. (Mirrors the cargo-mutants `MissedMutant`-only rule.)
198pub fn stryker_survivors(report: &StrykerReport) -> Vec<Survivor> {
199    let mut survivors = Vec::new();
200    for (file, contents) in &report.files {
201        for mutant in &contents.mutants {
202            if mutant.status != "Survived" && mutant.status != "NoCoverage" {
203                continue;
204            }
205            let description = match &mutant.replacement {
206                Some(replacement) => {
207                    format!("{} (-> {})", mutant.mutator_name, one_line(replacement))
208                }
209                None => mutant.mutator_name.clone(),
210            };
211            survivors.push(Survivor {
212                file: file.clone(),
213                line: mutant.location.start.line,
214                description,
215            });
216        }
217    }
218    survivors
219}
220
221/// Collapse a (possibly multi-line) replacement to a single trimmed line, capped, so a
222/// survivor's one-line description stays readable.
223fn one_line(replacement: &str) -> String {
224    let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
225    const MAX: usize = 60;
226    if flat.chars().count() > MAX {
227        format!("{}…", flat.chars().take(MAX).collect::<String>())
228    } else {
229        flat
230    }
231}
232
233/// Run Stryker over the TypeScript project at `root` and return its un-exempted
234/// survivors — the TS arm of the mutation rule (#202), parity with [`measure_rust`].
235///
236/// With `base` set, only mutants on the `<base>...HEAD` changed lines are tested —
237/// Stryker has no native git-diff mode, so the changed lines become `--mutate
238/// <file>:<line>-<line>` ranges (line granularity, matching cargo-mutants' `--in-diff`).
239/// Without it, the project's configured `mutate` set runs. `exempt` is the resolved
240/// `mutation`-rule exempt paths. Stryker must be installed / resolvable.
241pub fn measure_typescript(
242    root: &Path,
243    exempt: &[String],
244    base: Option<&str>,
245) -> Result<Vec<Survivor>> {
246    let mutate = match base {
247        Some(base) => {
248            let ranges = mutate_ranges(root, base)?;
249            // Nothing mutatable changed on the diff: no run, no survivors.
250            if ranges.is_empty() {
251                return Ok(Vec::new());
252            }
253            Some(ranges)
254        }
255        None => None,
256    };
257    let json = run_stryker(root, mutate.as_deref())?;
258    let report = parse_stryker_report(&json)?;
259    Ok(evaluate(stryker_survivors(&report), exempt))
260}
261
262/// Build the Stryker `--mutate` specs scoping a run to the `<base>...HEAD` changed
263/// lines: each mutatable source file's contiguous runs of changed lines become a
264/// `<file>:<start>-<end>` range (Stryker's line-range form). Reuses the patch-coverage
265/// diff parser. Test and declaration files are filtered out — Stryker's configured
266/// `mutate` set normally excludes them, but passing `--mutate` replaces that set.
267fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
268    let changed = crate::patch_coverage::changed_lines(root, base)?;
269    let mut specs = Vec::new();
270    for (file, lines) in changed {
271        if !is_mutatable_ts(&file) {
272            continue;
273        }
274        for (start, end) in contiguous_runs(&lines) {
275            specs.push(format!("{file}:{start}-{end}"));
276        }
277    }
278    Ok(specs)
279}
280
281/// Whether a changed file is a TypeScript/JavaScript *source* Stryker should mutate — a
282/// `.ts`/`.tsx`/`.mts`/`.cts`/`.js`/`.jsx`/`.mjs`/`.cjs` file that is not a declaration
283/// (`.d.ts`) or a test (`.test.` / `.spec.`).
284fn is_mutatable_ts(file: &str) -> bool {
285    let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
286        .iter()
287        .any(|ext| file.ends_with(ext));
288    let is_decl = file.ends_with(".d.ts");
289    let is_test = file.contains(".test.") || file.contains(".spec.");
290    is_source && !is_decl && !is_test
291}
292
293/// Fold a sorted set of line numbers into inclusive `(start, end)` contiguous runs.
294fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
295    let mut runs: Vec<(u64, u64)> = Vec::new();
296    for &line in lines {
297        match runs.last_mut() {
298            Some(run) if run.1 + 1 == line => run.1 = line,
299            _ => runs.push((line, line)),
300        }
301    }
302    runs
303}
304
305/// Run Stryker over `root` (resolving the project's own config) with the json reporter,
306/// returning the contents of its `mutation.json`. `mutate`, when set, scopes the run to
307/// `--mutate` line ranges. The report goes to Stryker's default
308/// `reports/mutation/mutation.json`; it's read and then pruned (the file and any empty
309/// parents) so the scanned tree stays pristine and a populated `reports/` is untouched.
310///
311/// Stryker's exit code is *not* trusted to mean "no survivors": a configured
312/// `thresholds.break` makes it exit non-zero on a low score, which is exactly the
313/// survivor case the rule reports on. So the report is read whenever it exists; only a
314/// missing report (a real run failure) is fatal.
315fn run_stryker(root: &Path, mutate: Option<&[String]>) -> Result<String> {
316    let report_path = root.join("reports").join("mutation").join("mutation.json");
317    let _cleanup = ReportCleanup(report_path.clone());
318    // Drop any stale report so a previous run's output is never mistaken for this one's.
319    let _ = std::fs::remove_file(&report_path);
320
321    let mut command = Command::new("npx");
322    command
323        .current_dir(root)
324        .args(["--yes", "stryker", "run", "--reporters", "json"]);
325    if let Some(specs) = mutate {
326        command.arg("--mutate").arg(specs.join(","));
327    }
328    let output = command
329        .env("CI", "1")
330        .output()
331        .context("running `npx stryker run` (is @stryker-mutator/core installed?)")?;
332
333    std::fs::read_to_string(&report_path).map_err(|_| {
334        anyhow::anyhow!(
335            "Stryker produced no report in `{}` (did it run cleanly?):\n{}{}",
336            root.display(),
337            String::from_utf8_lossy(&output.stdout),
338            String::from_utf8_lossy(&output.stderr),
339        )
340    })
341}
342
343/// Removes the Stryker json report on drop, pruning the `mutation/` and `reports/`
344/// parents only if they're left empty — so a user's own populated `reports/` survives.
345struct ReportCleanup(PathBuf);
346
347impl Drop for ReportCleanup {
348    fn drop(&mut self) {
349        let _ = std::fs::remove_file(&self.0);
350        if let Some(mutation_dir) = self.0.parent() {
351            // `remove_dir` only succeeds on an empty dir, so populated trees are safe.
352            let _ = std::fs::remove_dir(mutation_dir);
353            if let Some(reports_dir) = mutation_dir.parent() {
354                let _ = std::fs::remove_dir(reports_dir);
355            }
356        }
357    }
358}
359
360/// A unique temp dir for one cargo-mutants run's `--output`, removed on drop so the
361/// scanned crate stays pristine and parallel runs don't collide.
362struct MutantsOut(PathBuf);
363
364impl MutantsOut {
365    fn new() -> Self {
366        static COUNTER: AtomicU64 = AtomicU64::new(0);
367        let name = format!(
368            "testing-conventions-mutants-{}-{}",
369            std::process::id(),
370            COUNTER.fetch_add(1, Ordering::Relaxed),
371        );
372        MutantsOut(std::env::temp_dir().join(name))
373    }
374}
375
376impl Drop for MutantsOut {
377    fn drop(&mut self) {
378        let _ = std::fs::remove_dir_all(&self.0);
379    }
380}
381
382/// Write the `<base>...HEAD` diff cargo-mutants' `--in-diff` scopes to, returning its path.
383fn write_base_diff(root: &Path, base: &str, out: &MutantsOut) -> Result<PathBuf> {
384    let range = format!("{base}...HEAD");
385    let output = Command::new("git")
386        .current_dir(root)
387        .args(["diff", &range])
388        .output()
389        .context("running `git diff` for `--base` (is git installed?)")?;
390    if !output.status.success() {
391        bail!(
392            "git diff {range} failed: {}",
393            String::from_utf8_lossy(&output.stderr)
394        );
395    }
396    std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
397    let path = out.0.join("base.diff");
398    std::fs::write(&path, &output.stdout).context("writing the base diff")?;
399    Ok(path)
400}
401
402/// Run `cargo mutants --output <out> [--in-diff <diff>]` in `root`.
403///
404/// cargo-mutants exits `0` when every mutant is caught and `2` when some survive (or
405/// time out / are unviable) — both are normal here, since survivors are the rule's
406/// *output*, not an error. Any other code (usage error, or a baseline that didn't
407/// build/pass) is fatal. As with the coverage run, the outer instrumentation env is
408/// stripped so a nested run (this rule's own tests under `cargo llvm-cov`) doesn't
409/// re-enter the rustc wrapper and hang.
410fn run_cargo_mutants(root: &Path, out: &Path, in_diff: Option<&Path>) -> Result<()> {
411    let mut command = Command::new("cargo");
412    command
413        .current_dir(root)
414        .arg("mutants")
415        .arg("--output")
416        .arg(out);
417    if let Some(diff) = in_diff {
418        command.arg("--in-diff").arg(diff);
419    }
420    for var in [
421        "RUSTFLAGS",
422        "CARGO_ENCODED_RUSTFLAGS",
423        "RUSTDOCFLAGS",
424        "CARGO_ENCODED_RUSTDOCFLAGS",
425        "LLVM_PROFILE_FILE",
426        "CARGO_LLVM_COV",
427        "CARGO_LLVM_COV_SHOW_ENV",
428        "CARGO_LLVM_COV_TARGET_DIR",
429        "CARGO_LLVM_COV_BUILD_DIR",
430        "RUSTC_WRAPPER",
431        "RUSTC_WORKSPACE_WRAPPER",
432        "__CARGO_LLVM_COV_RUSTC_WRAPPER",
433        "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
434        "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
435    ] {
436        command.env_remove(var);
437    }
438    let output = command
439        .output()
440        .context("running `cargo mutants` (is cargo-mutants installed?)")?;
441    match output.status.code() {
442        // 0 = all caught, 2 = some survived/timed out: both produce a report to read.
443        Some(0) | Some(2) => Ok(()),
444        _ => bail!(
445            "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
446            root.display(),
447            String::from_utf8_lossy(&output.stdout),
448            String::from_utf8_lossy(&output.stderr),
449        ),
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    // A pared `outcomes.json`: the baseline, one missed mutant, and one caught — the
458    // real shape (externally-tagged `scenario`, extra fields the rule ignores).
459    const SAMPLE: &str = r#"{
460        "outcomes": [
461            {"scenario": "Baseline", "summary": "Success",
462             "phase_results": []},
463            {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
464                "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
465                "function": {"function_name": "is_positive"},
466                "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
467             "summary": "MissedMutant"},
468            {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
469                "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
470                "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
471             "summary": "CaughtMutant"}
472        ],
473        "total_mutants": 2
474    }"#;
475
476    #[test]
477    fn parses_the_outcomes_export() {
478        let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
479        assert_eq!(report.outcomes.len(), 3);
480        assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
481    }
482
483    #[test]
484    fn collects_only_missed_mutants_as_survivors() {
485        let report = parse_mutants_report(SAMPLE).unwrap();
486        let survivors = unexplained_survivors(&report, &[]);
487        // Only the MissedMutant — the baseline and the CaughtMutant are not survivors.
488        assert_eq!(survivors.len(), 1);
489        assert_eq!(survivors[0].file, "src/lib.rs");
490        assert_eq!(survivors[0].line, 7);
491        assert!(survivors[0].description.contains("replace > with =="));
492    }
493
494    #[test]
495    fn an_exemption_drops_a_survivor_in_that_file() {
496        let report = parse_mutants_report(SAMPLE).unwrap();
497        let exempt = vec!["src/lib.rs".to_string()];
498        assert!(unexplained_survivors(&report, &exempt).is_empty());
499    }
500
501    #[test]
502    fn an_exemption_on_another_file_leaves_the_survivor() {
503        let report = parse_mutants_report(SAMPLE).unwrap();
504        let exempt = vec!["src/elsewhere.rs".to_string()];
505        assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
506    }
507
508    // A pared Stryker `mutation.json`: one Survived, one NoCoverage, one Killed — the
509    // real shape (per-file `files` map, extra fields the rule ignores).
510    const STRYKER_SAMPLE: &str = r#"{
511        "schemaVersion": "1.0",
512        "files": {
513            "src/index.ts": {
514                "language": "typescript",
515                "source": "...",
516                "mutants": [
517                    {"id": "0", "mutatorName": "ConditionalExpression", "replacement": "true",
518                     "status": "Survived", "coveredBy": ["t0"],
519                     "location": {"start": {"line": 2, "column": 10}, "end": {"line": 2, "column": 15}}},
520                    {"id": "1", "mutatorName": "ArithmeticOperator", "replacement": "a - b",
521                     "status": "NoCoverage",
522                     "location": {"start": {"line": 5, "column": 3}, "end": {"line": 5, "column": 8}}},
523                    {"id": "2", "mutatorName": "BooleanLiteral", "replacement": "false",
524                     "status": "Killed",
525                     "location": {"start": {"line": 9, "column": 1}, "end": {"line": 9, "column": 6}}}
526                ]
527            }
528        }
529    }"#;
530
531    #[test]
532    fn parses_a_stryker_report() {
533        let report = parse_stryker_report(STRYKER_SAMPLE).expect("valid mutation.json");
534        assert_eq!(report.files["src/index.ts"].mutants.len(), 3);
535    }
536
537    #[test]
538    fn collects_survived_and_nocoverage_as_survivors() {
539        let report = parse_stryker_report(STRYKER_SAMPLE).unwrap();
540        let survivors = stryker_survivors(&report);
541        // Survived + NoCoverage are survivors; the Killed mutant is not.
542        assert_eq!(survivors.len(), 2);
543        assert!(survivors.iter().all(|s| s.file == "src/index.ts"));
544        assert_eq!(survivors[0].line, 2);
545        assert!(survivors[0].description.contains("ConditionalExpression"));
546        assert!(survivors[0].description.contains("true"));
547        assert_eq!(survivors[1].line, 5);
548    }
549
550    #[test]
551    fn evaluate_drops_exempt_files_for_either_engine() {
552        let report = parse_stryker_report(STRYKER_SAMPLE).unwrap();
553        let survivors = stryker_survivors(&report);
554        let exempt = vec!["src/index.ts".to_string()];
555        assert!(evaluate(survivors, &exempt).is_empty());
556    }
557
558    #[test]
559    fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
560        assert!(is_mutatable_ts("src/index.ts"));
561        assert!(is_mutatable_ts("src/util.tsx"));
562        assert!(is_mutatable_ts("src/util.js"));
563        assert!(!is_mutatable_ts("src/index.test.ts"));
564        assert!(!is_mutatable_ts("src/index.spec.ts"));
565        assert!(!is_mutatable_ts("src/types.d.ts"));
566        assert!(!is_mutatable_ts("README.md"));
567    }
568
569    #[test]
570    fn contiguous_runs_collapses_adjacent_lines() {
571        let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
572        assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
573        assert!(contiguous_runs(&BTreeSet::new()).is_empty());
574    }
575
576    #[test]
577    fn one_line_flattens_and_caps() {
578        assert_eq!(one_line("a -\n  b"), "a - b");
579        let long = "x".repeat(80);
580        let capped = one_line(&long);
581        assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
582    }
583}