1use 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
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Survivor {
17 pub file: String,
20 pub line: u32,
22 pub description: String,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Measurement {
31 EngineNotRun,
33 Tested {
36 count: usize,
37 survivors: Vec<Survivor>,
38 },
39}
40
41pub type MutatedLines = BTreeSet<(String, u32)>;
45
46#[derive(Debug, Clone, Deserialize)]
49pub struct MutantsReport {
50 pub outcomes: Vec<MutantOutcome>,
51}
52
53#[derive(Debug, Clone, Deserialize)]
57pub struct MutantOutcome {
58 pub summary: String,
59 pub scenario: Scenario,
60}
61
62#[derive(Debug, Clone, Deserialize)]
65pub enum Scenario {
66 Baseline,
67 Mutant(MutantInfo),
68}
69
70#[derive(Debug, Clone, Deserialize)]
74pub struct MutantInfo {
75 pub file: String,
76 pub span: Span,
77 pub name: String,
78}
79
80#[derive(Debug, Clone, Deserialize)]
82pub struct Span {
83 pub start: LineCol,
84 pub end: LineCol,
85}
86
87#[derive(Debug, Clone, Deserialize)]
89pub struct LineCol {
90 pub line: u32,
91}
92
93pub fn parse_mutants_report(json: &str) -> Result<MutantsReport> {
95 serde_json::from_str(json).context("parsing cargo-mutants outcomes.json")
96}
97
98fn parse_mutants_list(json: &str) -> Result<Vec<MutantInfo>> {
101 serde_json::from_str(json).context("parsing the cargo-mutants mutant list")
102}
103
104pub fn unexplained_survivors(report: &MutantsReport, exempt: &[String]) -> Vec<Survivor> {
108 evaluate(cargo_mutants_survivors(report), exempt)
109}
110
111fn cargo_mutants_survivors(report: &MutantsReport) -> Vec<Survivor> {
115 report
116 .outcomes
117 .iter()
118 .filter_map(|outcome| {
119 if outcome.summary != "MissedMutant" {
120 return None;
121 }
122 let Scenario::Mutant(mutant) = &outcome.scenario else {
123 return None;
124 };
125 Some(Survivor {
126 file: mutant.file.clone(),
127 line: mutant.span.start.line,
128 description: mutant.name.clone(),
129 })
130 })
131 .collect()
132}
133
134pub 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
153fn conclusive_count(report: &MutantsReport) -> usize {
157 report
158 .outcomes
159 .iter()
160 .filter(|outcome| outcome.summary == "CaughtMutant" || outcome.summary == "MissedMutant")
161 .count()
162}
163
164pub fn evaluate(survivors: Vec<Survivor>, exempt: &[String]) -> Vec<Survivor> {
167 survivors
168 .into_iter()
169 .filter(|survivor| !exempt.iter().any(|path| path == &survivor.file))
170 .collect()
171}
172
173pub fn evaluate_scoped(
177 survivors: Vec<Survivor>,
178 mutated: &MutatedLines,
179 whole_file: &[String],
180 line_scoped: &BTreeMap<String, BTreeSet<u32>>,
181) -> Result<Vec<Survivor>> {
182 let mut over: Vec<String> = Vec::new();
183 for (file, lines) in line_scoped {
184 for &line in lines {
185 let has_survivor = survivors
186 .iter()
187 .any(|survivor| survivor.file == *file && survivor.line == line);
188 if has_survivor {
189 continue;
190 }
191 if mutated.contains(&(file.clone(), line)) {
192 over.push(format!("\n {file}:{line}"));
193 }
194 }
195 }
196 if !over.is_empty() {
197 bail!(
198 "a line-scoped mutation exemption may only list a line with a surviving mutant, but \
199 these had mutants that were all caught:{}",
200 over.concat()
201 );
202 }
203 Ok(survivors
204 .into_iter()
205 .filter(|survivor| {
206 let whole = whole_file.iter().any(|path| path == &survivor.file);
207 let line = line_scoped
208 .get(&survivor.file)
209 .is_some_and(|lines| lines.contains(&survivor.line));
210 !(whole || line)
211 })
212 .collect())
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum MutantStatus {
221 Survived,
223 Killed,
225 NoCoverage,
227 Timeout,
229 CompileError,
231 RuntimeError,
233}
234
235impl MutantStatus {
236 fn is_survivor(self) -> bool {
239 matches!(self, MutantStatus::Survived | MutantStatus::NoCoverage)
240 }
241
242 fn is_viable(self) -> bool {
245 matches!(
246 self,
247 MutantStatus::Survived
248 | MutantStatus::Killed
249 | MutantStatus::NoCoverage
250 | MutantStatus::Timeout
251 )
252 }
253
254 fn is_conclusive(self) -> bool {
258 matches!(
259 self,
260 MutantStatus::Survived | MutantStatus::Killed | MutantStatus::NoCoverage
261 )
262 }
263}
264
265#[derive(Debug, Clone, Deserialize)]
268pub struct NormalizedMutant {
269 pub file: String,
271 pub line: u32,
273 pub status: MutantStatus,
275 pub mutator: String,
277 #[serde(default)]
279 pub replacement: Option<String>,
280}
281
282pub fn parse_normalized_results(json: &str) -> Result<Vec<NormalizedMutant>> {
285 serde_json::from_str(json).context("parsing normalized mutation results")
286}
287
288pub fn evaluate_normalized(
292 mutants: &[NormalizedMutant],
293 whole_file: &[String],
294 line_scoped: &BTreeMap<String, BTreeSet<u32>>,
295) -> Result<Vec<Survivor>> {
296 evaluate_scoped(
297 normalized_survivors(mutants),
298 &normalized_mutated_lines(mutants),
299 whole_file,
300 line_scoped,
301 )
302}
303
304fn normalized_survivors(mutants: &[NormalizedMutant]) -> Vec<Survivor> {
306 mutants
307 .iter()
308 .filter(|mutant| mutant.status.is_survivor())
309 .map(|mutant| Survivor {
310 file: mutant.file.clone(),
311 line: mutant.line,
312 description: describe_normalized(mutant),
313 })
314 .collect()
315}
316
317fn normalized_mutated_lines(mutants: &[NormalizedMutant]) -> MutatedLines {
320 mutants
321 .iter()
322 .filter(|mutant| mutant.status.is_viable())
323 .map(|mutant| (mutant.file.clone(), mutant.line))
324 .collect()
325}
326
327fn normalized_conclusive_count(mutants: &[NormalizedMutant]) -> usize {
330 mutants
331 .iter()
332 .filter(|mutant| mutant.status.is_conclusive())
333 .count()
334}
335
336fn describe_normalized(mutant: &NormalizedMutant) -> String {
339 match &mutant.replacement {
340 Some(replacement) => format!("{} (-> {})", mutant.mutator, one_line(replacement)),
341 None => mutant.mutator.clone(),
342 }
343}
344
345pub fn measure_rust(
349 root: &Path,
350 exempt: &[String],
351 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
352 base: Option<&str>,
353 features: &[String],
354) -> Result<Measurement> {
355 let out = MutantsOut::new();
356 let workspace_root = cargo_workspace_root(root)?;
360 let prefix = canonical_scan_prefix(root, &workspace_root);
361 let mut base_diff = None;
362 let diff = match base {
363 Some(base) => {
364 match write_base_diff(root, &workspace_root, prefix.as_deref(), base, &out)? {
365 None => return Ok(Measurement::EngineNotRun),
366 Some(path) => {
367 let parsed =
368 parse_base_diff(&std::fs::read_to_string(&path).with_context(|| {
369 format!("reading the written base diff `{}`", path.display())
370 })?);
371 if !parsed.files.iter().any(|file| file.ends_with(".rs")) {
372 return Ok(Measurement::EngineNotRun);
373 }
374 base_diff = Some(parsed);
375 Some(path)
376 }
377 }
378 }
379 None => None,
380 };
381 let engine = ensure_cargo_mutants()?;
382 let run = run_cargo_mutants(&engine, root, &out.0, diff.as_deref(), features)?;
383 let outcomes = out.0.join("mutants.out").join("outcomes.json");
384 let json = match std::fs::read_to_string(&outcomes) {
388 Ok(json) => json,
389 Err(_) => {
390 if let Some(diff) = &base_diff {
391 let listed =
392 list_cargo_mutants(&engine, root, features, |command| command.output())?;
393 zero_mutant_verdict(&listed, diff, &run)?;
394 }
395 return Ok(Measurement::Tested {
396 count: 0,
397 survivors: Vec::new(),
398 });
399 }
400 };
401 let report = rebase_report_paths(parse_mutants_report(&json)?, prefix.as_deref());
402 let survivors = evaluate_scoped(
403 cargo_mutants_survivors(&report),
404 &mutated_lines(&report),
405 exempt,
406 exempt_lines,
407 )?;
408 Ok(Measurement::Tested {
409 count: conclusive_count(&report),
410 survivors,
411 })
412}
413
414fn one_line(replacement: &str) -> String {
417 let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
418 const MAX: usize = 60;
419 if flat.chars().count() > MAX {
420 format!("{}…", flat.chars().take(MAX).collect::<String>())
421 } else {
422 flat
423 }
424}
425
426pub fn measure_typescript(
430 root: &Path,
431 exempt: &[String],
432 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
433 base: Option<&str>,
434 adapter: &Path,
435) -> Result<Measurement> {
436 let package_root =
437 crate::tiers::package_root(root, "package.json").unwrap_or_else(|| root.to_path_buf());
438 let prefix = scan_prefix(root, &package_root);
439 let mutate = match base {
440 Some(base) => {
441 let ranges = mutate_ranges(root, base)?;
442 if ranges.is_empty() {
443 return Ok(Measurement::EngineNotRun);
444 }
445 Some(prefix_mutate_specs(ranges, prefix.as_deref()))
446 }
447 None => prefix.as_deref().map(scan_scoped_mutate_globs),
448 };
449 let test_files = prefix.as_deref().map(scan_scoped_test_file_globs);
450 let json = run_ts_adapter(
451 &package_root,
452 adapter,
453 mutate.as_deref(),
454 test_files.as_deref(),
455 )?;
456 let mutants = to_scan_relative(parse_normalized_results(&json)?, prefix.as_deref());
457 let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
458 Ok(Measurement::Tested {
459 count: normalized_conclusive_count(&mutants),
460 survivors,
461 })
462}
463
464fn scan_prefix(root: &Path, package_root: &Path) -> Option<String> {
467 let rel = root.strip_prefix(package_root).ok()?;
468 let parts: Vec<String> = rel
469 .components()
470 .map(|part| part.as_os_str().to_string_lossy().into_owned())
471 .collect();
472 if parts.is_empty() {
473 None
474 } else {
475 Some(parts.join("/"))
476 }
477}
478
479fn prefix_mutate_specs(specs: Vec<String>, prefix: Option<&str>) -> Vec<String> {
482 match prefix {
483 None => specs,
484 Some(prefix) => specs
485 .into_iter()
486 .map(|spec| format!("{prefix}/{spec}"))
487 .collect(),
488 }
489}
490
491fn scan_scoped_mutate_globs(prefix: &str) -> Vec<String> {
495 const EXTENSIONS: &str = "+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)";
496 vec![
497 format!("{prefix}/**/!(*.+(s|S)pec|*.+(t|T)est).{EXTENSIONS}"),
498 format!("!{prefix}/**/__tests__/**/*.{EXTENSIONS}"),
499 ]
500}
501
502fn scan_scoped_test_file_globs(prefix: &str) -> Vec<String> {
506 vec![format!("{prefix}/**")]
507}
508
509fn to_scan_relative(mutants: Vec<NormalizedMutant>, prefix: Option<&str>) -> Vec<NormalizedMutant> {
513 let Some(prefix) = prefix else {
514 return mutants;
515 };
516 let prefix = format!("{prefix}/");
517 mutants
518 .into_iter()
519 .filter_map(|mut mutant| {
520 mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
521 Some(mutant)
522 })
523 .collect()
524}
525
526fn adapter_cwd<'a>(root: &'a Path, engine: &str) -> Result<&'a Path> {
530 let cwd = if root.as_os_str().is_empty() {
531 Path::new(".")
532 } else {
533 root
534 };
535 if !cwd.is_dir() {
536 bail!(
537 "the {engine} mutation adapter's working directory `{}` is not a directory",
538 cwd.display()
539 );
540 }
541 Ok(cwd)
542}
543
544fn spawn_context(interpreter: &str, entry: &str, cwd: &Path) -> String {
548 format!(
549 "spawning `{interpreter} {entry}` in `{}` (is `{interpreter}` on PATH?)",
550 cwd.display()
551 )
552}
553
554fn run_ts_adapter(
558 package_root: &Path,
559 adapter: &Path,
560 mutate: Option<&[String]>,
561 test_files: Option<&[String]>,
562) -> Result<String> {
563 let out = AdapterOut::new();
564 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
565 let results = out.0.join("results.json");
566
567 let cwd = adapter_cwd(package_root, "TypeScript")?;
568
569 let mut command = Command::new("node");
570 command
571 .current_dir(cwd)
572 .arg(adapter)
573 .arg("--out")
574 .arg(&results);
575 if let Some(specs) = mutate {
576 command.arg("--mutate").arg(specs.join(","));
577 }
578 if let Some(globs) = test_files {
579 command.arg("--test-files").arg(globs.join(","));
580 }
581 let output = command
582 .output()
583 .with_context(|| spawn_context("node", &adapter.display().to_string(), cwd))?;
584 if !output.status.success() {
585 bail!(
586 "the TypeScript mutation adapter failed in `{}`:\n{}{}",
587 cwd.display(),
588 String::from_utf8_lossy(&output.stdout),
589 String::from_utf8_lossy(&output.stderr),
590 );
591 }
592 std::fs::read_to_string(&results).with_context(|| {
593 format!(
594 "reading the TypeScript mutation adapter's results from `{}`",
595 results.display()
596 )
597 })
598}
599
600struct AdapterOut(PathBuf);
603
604impl AdapterOut {
605 fn new() -> Self {
606 static COUNTER: AtomicU64 = AtomicU64::new(0);
607 let name = format!(
608 "testing-conventions-ts-adapter-{}-{}",
609 std::process::id(),
610 COUNTER.fetch_add(1, Ordering::Relaxed),
611 );
612 AdapterOut(std::env::temp_dir().join(name))
613 }
614}
615
616impl Drop for AdapterOut {
617 fn drop(&mut self) {
618 let _ = std::fs::remove_dir_all(&self.0);
619 }
620}
621
622fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
626 let changed = crate::patch_coverage::changed_lines(root, base)?;
627 let mut specs = Vec::new();
628 for (file, lines) in changed {
629 if !is_mutatable_ts(&file) {
630 continue;
631 }
632 for (start, end) in contiguous_runs(&lines) {
633 specs.push(format!("{file}:{start}-{end}"));
634 }
635 }
636 Ok(specs)
637}
638
639fn is_mutatable_ts(file: &str) -> bool {
643 let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
644 .iter()
645 .any(|ext| file.ends_with(ext));
646 let is_decl = file.ends_with(".d.ts");
647 let is_test = file.contains(".test.") || file.contains(".spec.");
648 is_source && !is_decl && !is_test
649}
650
651fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
653 let mut runs: Vec<(u64, u64)> = Vec::new();
654 for &line in lines {
655 match runs.last_mut() {
656 Some(run) if run.1 + 1 == line => run.1 = line,
657 _ => runs.push((line, line)),
658 }
659 }
660 runs
661}
662
663pub fn measure_python(
667 root: &Path,
668 exempt: &[String],
669 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
670 base: Option<&str>,
671) -> Result<Measurement> {
672 let changed = match base {
673 Some(base) => Some(crate::patch_coverage::changed_lines(root, base)?),
674 None => None,
675 };
676 let modules: Vec<String> = match &changed {
677 None => Vec::new(),
678 Some(changed) => {
679 let modules: Vec<String> = changed
680 .keys()
681 .filter(|file| is_mutatable_py(file))
682 .cloned()
683 .collect();
684 if modules.is_empty() {
685 return Ok(Measurement::EngineNotRun);
686 }
687 modules
688 }
689 };
690 let json = run_py_adapter(root, &modules)?;
691 let mut mutants = parse_normalized_results(&json)?;
692 if let Some(changed) = &changed {
693 mutants.retain(|mutant| {
694 changed
695 .get(&mutant.file)
696 .is_some_and(|lines| lines.contains(&u64::from(mutant.line)))
697 });
698 }
699 let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
700 Ok(Measurement::Tested {
701 count: normalized_conclusive_count(&mutants),
702 survivors,
703 })
704}
705
706fn run_py_adapter(root: &Path, modules: &[String]) -> Result<String> {
710 let out = AdapterOut::new();
711 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
712 let results = out.0.join("results.json");
713
714 let cwd = adapter_cwd(root, "Python")?;
715
716 const ENTRY: &str = "-m testing_conventions.mutation.main";
717 let mut command = Command::new("python3");
718 command
719 .current_dir(cwd)
720 .args(["-m", "testing_conventions.mutation.main", "--out"])
721 .arg(&results)
722 .env("PYTHONDONTWRITEBYTECODE", "1");
723 for module in modules {
724 command.arg("--module").arg(module);
725 }
726 let output = command
727 .output()
728 .with_context(|| spawn_context("python3", ENTRY, cwd))?;
729 if !output.status.success() {
730 bail!(
731 "the Python mutation adapter failed in `{}`:\n{}{}",
732 cwd.display(),
733 String::from_utf8_lossy(&output.stdout),
734 String::from_utf8_lossy(&output.stderr),
735 );
736 }
737 std::fs::read_to_string(&results).with_context(|| {
738 format!(
739 "reading the Python mutation adapter's results from `{}`",
740 results.display()
741 )
742 })
743}
744
745fn is_mutatable_py(file: &str) -> bool {
748 if !file.ends_with(".py") {
749 return false;
750 }
751 let base = file.rsplit('/').next().unwrap_or(file);
752 !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
753}
754
755struct MutantsOut(PathBuf);
758
759impl MutantsOut {
760 fn new() -> Self {
761 static COUNTER: AtomicU64 = AtomicU64::new(0);
762 let name = format!(
763 "testing-conventions-mutants-{}-{}",
764 std::process::id(),
765 COUNTER.fetch_add(1, Ordering::Relaxed),
766 );
767 MutantsOut(std::env::temp_dir().join(name))
768 }
769}
770
771impl Drop for MutantsOut {
772 fn drop(&mut self) {
773 let _ = std::fs::remove_dir_all(&self.0);
774 }
775}
776
777fn cargo_workspace_root(root: &Path) -> Result<PathBuf> {
781 let output = Command::new("cargo")
782 .current_dir(root)
783 .args(["locate-project", "--workspace", "--message-format", "plain"])
784 .output()
785 .context("running `cargo locate-project` (is cargo installed?)")?;
786 if !output.status.success() {
787 bail!(
788 "cargo locate-project failed in `{}`: {}",
789 root.display(),
790 String::from_utf8_lossy(&output.stderr)
791 );
792 }
793 let manifest = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
794 manifest.parent().map(Path::to_path_buf).with_context(|| {
795 format!(
796 "no parent dir for the workspace manifest `{}`",
797 manifest.display()
798 )
799 })
800}
801
802fn canonical_scan_prefix(root: &Path, workspace_root: &Path) -> Option<String> {
806 let root = root.canonicalize().ok()?;
807 let workspace_root = workspace_root.canonicalize().ok()?;
808 scan_prefix(&root, &workspace_root)
809}
810
811fn write_base_diff(
815 root: &Path,
816 workspace_root: &Path,
817 prefix: Option<&str>,
818 base: &str,
819 out: &MutantsOut,
820) -> Result<Option<PathBuf>> {
821 let range = format!("{base}...HEAD");
822 let (dir, args) = match prefix {
823 None => (root, vec!["diff", "--relative", &range]),
824 Some(prefix) => (
825 workspace_root,
826 vec!["diff", "--relative", &range, "--", prefix],
827 ),
828 };
829 let output = Command::new("git")
830 .current_dir(dir)
831 .args(&args)
832 .output()
833 .context("running `git diff` for `--base` (is git installed?)")?;
834 if !output.status.success() {
835 bail!(
836 "git diff {range} failed: {}",
837 String::from_utf8_lossy(&output.stderr)
838 );
839 }
840 if output.stdout.is_empty() {
841 return Ok(None);
842 }
843 std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
844 let path = out.0.join("base.diff");
845 std::fs::write(&path, &output.stdout).context("writing the base diff")?;
846 Ok(Some(path))
847}
848
849struct BaseDiff {
853 files: Vec<String>,
854 inserted: BTreeMap<String, BTreeSet<u32>>,
855}
856
857fn parse_base_diff(diff: &str) -> BaseDiff {
861 let mut files = Vec::new();
862 let mut inserted: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
863 let mut current: Option<String> = None;
864 let mut lines = diff.lines();
865 while let Some(line) = lines.next() {
866 if let Some(path) = line.strip_prefix("+++ ") {
867 current = (path != "/dev/null").then(|| {
868 let path = path.strip_prefix("b/").unwrap_or(path).to_string();
869 files.push(path.clone());
870 path
871 });
872 } else if let Some(header) = line.strip_prefix("@@ ") {
873 let Some((new_start, old_count, new_count)) = parse_hunk_header(header) else {
874 continue;
875 };
876 let mut new_line = new_start;
877 let (mut old_left, mut new_left) = (old_count, new_count);
878 while old_left > 0 || new_left > 0 {
879 let Some(line) = lines.next() else { break };
880 if line.starts_with('\\') {
881 } else if line.starts_with('+') {
884 if let Some(file) = ¤t {
885 inserted.entry(file.clone()).or_default().insert(new_line);
886 }
887 new_line += 1;
888 new_left = new_left.saturating_sub(1);
889 } else if line.starts_with('-') {
890 old_left = old_left.saturating_sub(1);
891 } else {
892 new_line += 1;
893 old_left = old_left.saturating_sub(1);
894 new_left = new_left.saturating_sub(1);
895 }
896 }
897 }
898 }
899 BaseDiff { files, inserted }
900}
901
902fn parse_hunk_header(header: &str) -> Option<(u32, u32, u32)> {
904 let mut parts = header.split(' ');
905 let (_, old_count) = parse_range(parts.next()?.strip_prefix('-')?)?;
906 let (new_start, new_count) = parse_range(parts.next()?.strip_prefix('+')?)?;
907 Some((new_start, old_count, new_count))
908}
909
910fn parse_range(range: &str) -> Option<(u32, u32)> {
912 match range.split_once(',') {
913 Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
914 None => Some((range.parse().ok()?, 1)),
915 }
916}
917
918fn rebase_report_paths(report: MutantsReport, prefix: Option<&str>) -> MutantsReport {
922 let Some(prefix) = prefix else {
923 return report;
924 };
925 let prefix = format!("{prefix}/");
926 MutantsReport {
927 outcomes: report
928 .outcomes
929 .into_iter()
930 .filter_map(|mut outcome| {
931 if let Scenario::Mutant(mutant) = &mut outcome.scenario {
932 mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
933 }
934 Some(outcome)
935 })
936 .collect(),
937 }
938}
939
940const CARGO_MUTANTS_VERSION: &str = "27.1.0";
943
944fn ensure_cargo_mutants() -> Result<PathBuf> {
948 let root = cargo_mutants_cache_root();
949 let bin = root.join("bin").join(cargo_mutants_bin_name());
950 let lock_path = root.join(".install.lock");
951 provision(&bin, &lock_path, || {
952 run_install(&root, |command| command.output())
953 })
954}
955
956fn cargo_mutants_bin_name() -> &'static str {
959 if cfg!(windows) {
960 "cargo-mutants.exe"
961 } else {
962 "cargo-mutants"
963 }
964}
965
966fn cargo_mutants_cache_root() -> PathBuf {
970 cache_base()
971 .join("testing-conventions")
972 .join(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}"))
973}
974
975fn cache_base() -> PathBuf {
978 resolve_cache_base(std::env::var_os("XDG_CACHE_HOME"), std::env::var_os("HOME"))
979}
980
981fn resolve_cache_base(xdg: Option<OsString>, home: Option<OsString>) -> PathBuf {
984 if let Some(dir) = xdg.filter(|value| !value.is_empty()) {
985 return PathBuf::from(dir);
986 }
987 if let Some(dir) = home.filter(|value| !value.is_empty()) {
988 return PathBuf::from(dir).join(".cache");
989 }
990 std::env::temp_dir()
991}
992
993fn provision(
997 bin: &Path,
998 lock_path: &Path,
999 install: impl FnOnce() -> Result<()>,
1000) -> Result<PathBuf> {
1001 if bin.exists() {
1002 return Ok(bin.to_path_buf());
1003 }
1004 if let Some(parent) = lock_path.parent() {
1005 std::fs::create_dir_all(parent).context("creating the provisioning lock's parent dir")?;
1006 }
1007 let lock_file = std::fs::OpenOptions::new()
1008 .create(true)
1009 .truncate(false)
1010 .write(true)
1011 .open(lock_path)
1012 .context("opening the provisioning lock file")?;
1013 lock_file
1014 .lock()
1015 .context("acquiring the provisioning lock")?;
1016 if bin.exists() {
1018 return Ok(bin.to_path_buf());
1019 }
1020 install()?;
1021 if !bin.exists() {
1022 bail!(
1023 "provisioning reported success but cargo-mutants is not at `{}`",
1024 bin.display()
1025 );
1026 }
1027 Ok(bin.to_path_buf())
1028}
1029
1030fn install_argv(root: &Path) -> Vec<OsString> {
1034 vec![
1035 OsString::from("install"),
1036 OsString::from("cargo-mutants"),
1037 OsString::from("--locked"),
1038 OsString::from("--version"),
1039 OsString::from(CARGO_MUTANTS_VERSION),
1040 OsString::from("--root"),
1041 root.as_os_str().to_os_string(),
1042 ]
1043}
1044
1045fn run_install(
1049 root: &Path,
1050 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1051) -> Result<()> {
1052 let mut command = Command::new("cargo");
1053 command.args(install_argv(root));
1054 strip_llvm_cov_env(&mut command);
1055 let output = run(&mut command)
1056 .context("provisioning cargo-mutants via `cargo install` (is cargo installed?)")?;
1057 if !output.status.success() {
1058 bail!(
1059 "failed to provision cargo-mutants {CARGO_MUTANTS_VERSION}:\n{}{}",
1060 String::from_utf8_lossy(&output.stdout),
1061 String::from_utf8_lossy(&output.stderr),
1062 );
1063 }
1064 Ok(())
1065}
1066
1067fn strip_llvm_cov_env(command: &mut Command) {
1071 for var in [
1072 "RUSTFLAGS",
1073 "CARGO_ENCODED_RUSTFLAGS",
1074 "RUSTDOCFLAGS",
1075 "CARGO_ENCODED_RUSTDOCFLAGS",
1076 "LLVM_PROFILE_FILE",
1077 "CARGO_LLVM_COV",
1078 "CARGO_LLVM_COV_SHOW_ENV",
1079 "CARGO_LLVM_COV_TARGET_DIR",
1080 "CARGO_LLVM_COV_BUILD_DIR",
1081 "RUSTC_WRAPPER",
1082 "RUSTC_WORKSPACE_WRAPPER",
1083 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
1084 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
1085 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
1086 ] {
1087 command.env_remove(var);
1088 }
1089}
1090
1091fn run_cargo_mutants(
1095 engine: &Path,
1096 root: &Path,
1097 out: &Path,
1098 in_diff: Option<&Path>,
1099 features: &[String],
1100) -> Result<Output> {
1101 let mut command = Command::new(engine);
1102 command
1103 .current_dir(root)
1104 .args(mutants_argv(out, in_diff, features));
1105 strip_llvm_cov_env(&mut command);
1106 let output = command.output().context("running cargo-mutants")?;
1107 classify_mutants_exit(root, &output)?;
1108 Ok(output)
1109}
1110
1111fn zero_mutant_verdict(listed: &[MutantInfo], diff: &BaseDiff, run: &Output) -> Result<()> {
1115 let dropped: Vec<&MutantInfo> = listed
1116 .iter()
1117 .filter(|mutant| {
1118 diff.inserted.get(&mutant.file).is_some_and(|lines| {
1119 lines
1120 .range(mutant.span.start.line..=mutant.span.end.line)
1121 .next()
1122 .is_some()
1123 })
1124 })
1125 .collect();
1126 if dropped.is_empty() {
1127 return Ok(());
1128 }
1129 let sites: Vec<String> = dropped
1130 .iter()
1131 .map(|mutant| {
1132 format!(
1133 " {}:{}: {}",
1134 mutant.file, mutant.span.start.line, mutant.name
1135 )
1136 })
1137 .collect();
1138 bail!(
1139 "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{}{}",
1140 dropped.len(),
1141 listed.len(),
1142 sites.join("\n"),
1143 String::from_utf8_lossy(&run.stdout),
1144 String::from_utf8_lossy(&run.stderr),
1145 )
1146}
1147
1148fn list_argv(features: &[String]) -> Vec<OsString> {
1152 let mut argv = vec![
1153 OsString::from("mutants"),
1154 OsString::from("--list"),
1155 OsString::from("--json"),
1156 ];
1157 if !features.is_empty() {
1158 argv.push(OsString::from("--features"));
1159 argv.push(OsString::from(features.join(",")));
1160 }
1161 argv
1162}
1163
1164fn list_cargo_mutants(
1168 engine: &Path,
1169 root: &Path,
1170 features: &[String],
1171 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1172) -> Result<Vec<MutantInfo>> {
1173 let mut command = Command::new(engine);
1174 command.current_dir(root).args(list_argv(features));
1175 strip_llvm_cov_env(&mut command);
1176 let output = run(&mut command).context("listing the crate's mutants with cargo-mutants")?;
1177 if !output.status.success() {
1178 bail!(
1179 "cargo-mutants --list failed in `{}`:\n{}{}",
1180 root.display(),
1181 String::from_utf8_lossy(&output.stdout),
1182 String::from_utf8_lossy(&output.stderr),
1183 );
1184 }
1185 parse_mutants_list(&String::from_utf8_lossy(&output.stdout))
1186}
1187
1188fn mutants_argv(out: &Path, in_diff: Option<&Path>, features: &[String]) -> Vec<OsString> {
1192 let mut argv = vec![
1193 OsString::from("mutants"),
1194 OsString::from("--output"),
1195 out.as_os_str().to_os_string(),
1196 ];
1197 if let Some(diff) = in_diff {
1198 argv.push(OsString::from("--in-diff"));
1199 argv.push(diff.as_os_str().to_os_string());
1200 }
1201 if !features.is_empty() {
1202 argv.push(OsString::from("--features"));
1203 argv.push(OsString::from(features.join(",")));
1204 }
1205 argv
1206}
1207
1208fn classify_mutants_exit(root: &Path, output: &Output) -> Result<()> {
1212 match output.status.code() {
1213 Some(0) | Some(2) | Some(3) => Ok(()),
1214 _ => bail!(
1215 "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
1216 root.display(),
1217 String::from_utf8_lossy(&output.stdout),
1218 String::from_utf8_lossy(&output.stderr),
1219 ),
1220 }
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225 use super::*;
1226
1227 const NORMALIZED: &str = r#"[
1228 {"file": "src/a.ts", "line": 2, "status": "survived",
1229 "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
1230 {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
1231 {"file": "src/a.ts", "line": 9, "status": "killed",
1232 "mutator": "BooleanLiteral", "replacement": "false"},
1233 {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
1234 {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
1235 {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
1236 ]"#;
1237
1238 #[test]
1239 fn parses_the_normalized_schema() {
1240 let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
1241 assert_eq!(mutants.len(), 6);
1242 assert_eq!(mutants[0].status, MutantStatus::Survived);
1243 assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
1244 assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
1245 assert_eq!(mutants[1].replacement, None);
1246 }
1247
1248 #[test]
1249 fn normalized_survivors_are_survived_and_nocoverage_only() {
1250 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1251 let survivors = normalized_survivors(&mutants);
1252 assert_eq!(survivors.len(), 2);
1253 assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
1254 assert!(survivors[0].description.contains("ConditionalExpression"));
1255 assert!(survivors[0].description.contains("-> true"));
1256 assert_eq!(survivors[1].description, "ArithmeticOperator");
1257 }
1258
1259 #[test]
1260 fn normalized_mutated_lines_collects_only_viable_mutants() {
1261 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1262 assert_eq!(
1263 normalized_mutated_lines(&mutants),
1264 [2u32, 5, 9, 12]
1265 .into_iter()
1266 .map(|line| ("src/a.ts".to_string(), line))
1267 .collect()
1268 );
1269 }
1270
1271 #[test]
1272 fn normalized_conclusive_count_is_survived_killed_and_nocoverage() {
1273 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1274 assert_eq!(normalized_conclusive_count(&mutants), 3);
1275 assert_eq!(normalized_conclusive_count(&[]), 0);
1276 }
1277
1278 #[test]
1279 fn evaluate_normalized_reports_unexempted_survivors() {
1280 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1281 let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
1282 assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
1283 }
1284
1285 #[test]
1286 fn evaluate_normalized_drops_a_whole_file_exemption() {
1287 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1288 let kept =
1289 evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
1290 assert!(
1291 kept.is_empty(),
1292 "the whole-file exemption lifts both survivors"
1293 );
1294 }
1295
1296 #[test]
1297 fn evaluate_normalized_drops_a_line_scoped_exemption() {
1298 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1299 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
1300 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1301 assert_eq!(kept.len(), 1);
1302 assert_eq!(kept[0].line, 5);
1303 }
1304
1305 #[test]
1306 fn evaluate_normalized_rejects_exempting_a_caught_line() {
1307 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1308 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
1309 let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
1310 assert!(
1311 err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
1312 "got: {err}"
1313 );
1314 }
1315
1316 #[test]
1317 fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
1318 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1319 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
1320 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1321 assert_eq!(kept.len(), 2);
1322 }
1323
1324 const SAMPLE: &str = r#"{
1325 "outcomes": [
1326 {"scenario": "Baseline", "summary": "Success",
1327 "phase_results": []},
1328 {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
1329 "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
1330 "function": {"function_name": "is_positive"},
1331 "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
1332 "summary": "MissedMutant"},
1333 {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
1334 "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
1335 "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
1336 "summary": "CaughtMutant"}
1337 ],
1338 "total_mutants": 2
1339 }"#;
1340
1341 #[test]
1342 fn parses_the_outcomes_export() {
1343 let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
1344 assert_eq!(report.outcomes.len(), 3);
1345 assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
1346 }
1347
1348 #[test]
1349 fn collects_only_missed_mutants_as_survivors() {
1350 let report = parse_mutants_report(SAMPLE).unwrap();
1351 let survivors = unexplained_survivors(&report, &[]);
1352 assert_eq!(survivors.len(), 1);
1353 assert_eq!(survivors[0].file, "src/lib.rs");
1354 assert_eq!(survivors[0].line, 7);
1355 assert!(survivors[0].description.contains("replace > with =="));
1356 }
1357
1358 #[test]
1359 fn conclusive_count_is_caught_plus_missed() {
1360 let report = parse_mutants_report(SAMPLE).unwrap();
1361 assert_eq!(conclusive_count(&report), 2);
1362 assert_eq!(conclusive_count(&MutantsReport { outcomes: vec![] }), 0);
1363 }
1364
1365 #[test]
1366 fn an_exemption_drops_a_survivor_in_that_file() {
1367 let report = parse_mutants_report(SAMPLE).unwrap();
1368 let exempt = vec!["src/lib.rs".to_string()];
1369 assert!(unexplained_survivors(&report, &exempt).is_empty());
1370 }
1371
1372 #[test]
1373 fn an_exemption_on_another_file_leaves_the_survivor() {
1374 let report = parse_mutants_report(SAMPLE).unwrap();
1375 let exempt = vec!["src/elsewhere.rs".to_string()];
1376 assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1377 }
1378
1379 #[test]
1380 fn rebase_report_paths_strips_the_workspace_prefix() {
1381 let report = parse_mutants_report(SAMPLE).unwrap();
1382 let prefixed = MutantsReport {
1383 outcomes: report
1384 .outcomes
1385 .iter()
1386 .cloned()
1387 .map(|mut outcome| {
1388 if let Scenario::Mutant(mutant) = &mut outcome.scenario {
1389 mutant.file = format!("member/{}", mutant.file);
1390 }
1391 outcome
1392 })
1393 .collect(),
1394 };
1395 let rebased = rebase_report_paths(prefixed, Some("member"));
1396 let survivors = unexplained_survivors(&rebased, &[]);
1397 assert_eq!(survivors.len(), 1);
1398 assert_eq!(survivors[0].file, "src/lib.rs");
1399 assert_eq!(rebased.outcomes.len(), 3);
1400 }
1401
1402 #[test]
1403 fn rebase_report_paths_drops_an_out_of_scope_mutant_and_keeps_none_identity() {
1404 let report = parse_mutants_report(SAMPLE).unwrap();
1405 let rebased = rebase_report_paths(report.clone(), Some("member"));
1406 assert_eq!(
1407 rebased.outcomes.len(),
1408 1,
1409 "only the pathless baseline outcome remains"
1410 );
1411 let unchanged = rebase_report_paths(report, None);
1412 assert_eq!(unchanged.outcomes.len(), 3);
1413 assert_eq!(unexplained_survivors(&unchanged, &[])[0].file, "src/lib.rs");
1414 }
1415
1416 #[test]
1417 fn adapter_cwd_normalises_the_empty_package_root_to_the_current_dir() {
1418 assert_eq!(
1422 adapter_cwd(Path::new(""), "TypeScript").unwrap(),
1423 Path::new(".")
1424 );
1425 assert_eq!(
1426 adapter_cwd(Path::new("src"), "TypeScript").unwrap(),
1427 Path::new("src")
1428 );
1429 }
1430
1431 #[test]
1432 fn adapter_cwd_rejects_a_directory_that_is_not_there() {
1433 let err = adapter_cwd(Path::new("no/such/dir"), "Python")
1436 .expect_err("a directory that is not there is an error");
1437 assert_eq!(
1438 err.to_string(),
1439 "the Python mutation adapter's working directory `no/such/dir` is not a directory"
1440 );
1441 }
1442
1443 #[test]
1444 fn spawn_context_names_the_interpreter_the_entry_and_the_working_directory() {
1445 assert_eq!(
1446 spawn_context("node", "/pkg/dist/mutation/main.js", Path::new("/pkg")),
1447 "spawning `node /pkg/dist/mutation/main.js` in `/pkg` (is `node` on PATH?)"
1448 );
1449 }
1450
1451 #[test]
1452 fn scan_prefix_is_the_scan_path_relative_to_the_package_root() {
1453 assert_eq!(
1454 scan_prefix(Path::new("/repo/pkg/src"), Path::new("/repo/pkg")),
1455 Some("src".to_string())
1456 );
1457 assert_eq!(
1458 scan_prefix(Path::new("/repo/pkg/src/nested"), Path::new("/repo/pkg")),
1459 Some("src/nested".to_string())
1460 );
1461 assert_eq!(
1462 scan_prefix(Path::new("/repo/pkg"), Path::new("/repo/pkg")),
1463 None
1464 );
1465 assert_eq!(
1466 scan_prefix(Path::new("pkg/src"), Path::new("pkg")),
1467 Some("src".to_string())
1468 );
1469 }
1470
1471 #[test]
1472 fn prefix_mutate_specs_rebases_diff_ranges_onto_the_package_root() {
1473 let specs = vec!["index.ts:8-11".to_string(), "a/b.ts:2-2".to_string()];
1474 assert_eq!(
1475 prefix_mutate_specs(specs.clone(), Some("src")),
1476 vec![
1477 "src/index.ts:8-11".to_string(),
1478 "src/a/b.ts:2-2".to_string()
1479 ]
1480 );
1481 assert_eq!(prefix_mutate_specs(specs.clone(), None), specs);
1482 }
1483
1484 #[test]
1485 fn scan_scoped_mutate_globs_mirror_strykers_default_under_the_scan_path() {
1486 assert_eq!(
1487 scan_scoped_mutate_globs("src"),
1488 vec![
1489 "src/**/!(*.+(s|S)pec|*.+(t|T)est).+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
1490 .to_string(),
1491 "!src/**/__tests__/**/*.+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
1492 .to_string(),
1493 ]
1494 );
1495 }
1496
1497 #[test]
1498 fn scan_scoped_test_file_globs_narrow_the_run_without_moving_the_runner_root() {
1499 assert_eq!(
1500 scan_scoped_test_file_globs("src"),
1501 vec!["src/**".to_string()]
1502 );
1503 assert_eq!(
1504 scan_scoped_test_file_globs("packages/core/src"),
1505 vec!["packages/core/src/**".to_string()]
1506 );
1507 }
1508
1509 #[test]
1510 fn to_scan_relative_strips_the_prefix_and_drops_out_of_scope_mutants() {
1511 let mutants = parse_normalized_results(
1512 r#"[
1513 {"file": "src/a.ts", "line": 2, "status": "survived", "mutator": "X"},
1514 {"file": "tests/e2e/t.ts", "line": 9, "status": "survived", "mutator": "X"}
1515 ]"#,
1516 )
1517 .unwrap();
1518 let rebased = to_scan_relative(mutants.clone(), Some("src"));
1519 assert_eq!(rebased.len(), 1, "the out-of-scan-path mutant is dropped");
1520 assert_eq!(rebased[0].file, "a.ts");
1521 let unchanged = to_scan_relative(mutants, None);
1522 assert_eq!(unchanged.len(), 2);
1523 assert_eq!(unchanged[0].file, "src/a.ts");
1524 }
1525
1526 #[test]
1527 fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
1528 assert!(is_mutatable_ts("src/index.ts"));
1529 assert!(is_mutatable_ts("src/util.tsx"));
1530 assert!(is_mutatable_ts("src/util.js"));
1531 assert!(!is_mutatable_ts("src/index.test.ts"));
1532 assert!(!is_mutatable_ts("src/index.spec.ts"));
1533 assert!(!is_mutatable_ts("src/types.d.ts"));
1534 assert!(!is_mutatable_ts("README.md"));
1535 }
1536
1537 #[test]
1538 fn contiguous_runs_collapses_adjacent_lines() {
1539 let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
1540 assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
1541 assert!(contiguous_runs(&BTreeSet::new()).is_empty());
1542 }
1543
1544 #[test]
1545 fn one_line_flattens_and_caps() {
1546 assert_eq!(one_line("a -\n b"), "a - b");
1547 let long = "x".repeat(80);
1548 let capped = one_line(&long);
1549 assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
1550 }
1551
1552 #[test]
1553 fn is_mutatable_py_keeps_sources_and_drops_tests() {
1554 assert!(is_mutatable_py("calc.py"));
1555 assert!(is_mutatable_py("pkg/util.py"));
1556 assert!(!is_mutatable_py("calc_test.py"));
1557 assert!(!is_mutatable_py("test_calc.py"));
1558 assert!(!is_mutatable_py("pkg/conftest.py"));
1559 assert!(!is_mutatable_py("README.md"));
1560 }
1561
1562 #[test]
1563 fn mutated_lines_collects_caught_and_missed() {
1564 let report = parse_mutants_report(SAMPLE).unwrap();
1565 assert_eq!(
1566 mutated_lines(&report),
1567 [
1568 ("src/lib.rs".to_string(), 7),
1569 ("src/other.rs".to_string(), 3)
1570 ]
1571 .into_iter()
1572 .collect()
1573 );
1574 }
1575
1576 #[test]
1577 fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1578 let report = parse_mutants_report(SAMPLE).unwrap();
1579 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1580 let kept = evaluate_scoped(
1581 cargo_mutants_survivors(&report),
1582 &mutated_lines(&report),
1583 &[],
1584 &line_scoped,
1585 )
1586 .unwrap();
1587 assert!(
1588 kept.is_empty(),
1589 "the src/lib.rs:7 survivor should be lifted"
1590 );
1591 }
1592
1593 #[test]
1594 fn evaluate_scoped_rejects_exempting_a_caught_line() {
1595 let report = parse_mutants_report(SAMPLE).unwrap();
1596 let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
1597 let err = evaluate_scoped(
1598 cargo_mutants_survivors(&report),
1599 &mutated_lines(&report),
1600 &[],
1601 &line_scoped,
1602 )
1603 .unwrap_err();
1604 assert!(
1605 err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
1606 "got: {err}"
1607 );
1608 }
1609
1610 #[test]
1611 fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
1612 let report = parse_mutants_report(SAMPLE).unwrap();
1613 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
1614 let kept = evaluate_scoped(
1615 cargo_mutants_survivors(&report),
1616 &mutated_lines(&report),
1617 &[],
1618 &line_scoped,
1619 )
1620 .unwrap();
1621 assert_eq!(kept.len(), 1);
1622 assert_eq!(kept[0].line, 7);
1623 }
1624
1625 #[test]
1626 fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1627 let report = parse_mutants_report(SAMPLE).unwrap();
1628 let kept = evaluate_scoped(
1629 cargo_mutants_survivors(&report),
1630 &mutated_lines(&report),
1631 &["src/lib.rs".to_string()],
1632 &BTreeMap::new(),
1633 )
1634 .unwrap();
1635 assert!(kept.is_empty());
1636 }
1637
1638 fn unique_tmp() -> PathBuf {
1639 static COUNTER: AtomicU64 = AtomicU64::new(0);
1640 let dir = std::env::temp_dir().join(format!(
1641 "tc-provision-test-{}-{}",
1642 std::process::id(),
1643 COUNTER.fetch_add(1, Ordering::Relaxed)
1644 ));
1645 std::fs::create_dir_all(&dir).unwrap();
1646 dir
1647 }
1648
1649 #[test]
1650 fn provision_returns_an_existing_binary_without_installing() {
1651 let tmp = unique_tmp();
1652 let bin = tmp.join("bin").join("cargo-mutants");
1653 let lock = tmp.join(".install.lock");
1654 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1655 std::fs::write(&bin, b"binary").unwrap();
1656 let mut installed = false;
1657 let got = provision(&bin, &lock, || {
1658 installed = true;
1659 Ok(())
1660 })
1661 .unwrap();
1662 assert_eq!(got, bin);
1663 assert!(!installed, "a present binary must not be reinstalled");
1664 std::fs::remove_dir_all(&tmp).unwrap();
1665 }
1666
1667 #[test]
1668 fn provision_installs_when_the_binary_is_absent() {
1669 let tmp = unique_tmp();
1670 let bin = tmp.join("bin").join("cargo-mutants");
1671 let lock = tmp.join(".install.lock");
1672 let mut installed = false;
1673 let got = provision(&bin, &lock, || {
1674 installed = true;
1675 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1676 std::fs::write(&bin, b"binary").unwrap();
1677 Ok(())
1678 })
1679 .unwrap();
1680 assert!(installed, "an absent binary must be installed");
1681 assert_eq!(got, bin);
1682 std::fs::remove_dir_all(&tmp).unwrap();
1683 }
1684
1685 #[test]
1686 fn provision_errors_when_install_produces_no_binary() {
1687 let tmp = unique_tmp();
1688 let bin = tmp.join("bin").join("cargo-mutants");
1689 let lock = tmp.join(".install.lock");
1690 let err = provision(&bin, &lock, || Ok(())).unwrap_err();
1691 assert!(
1692 err.to_string().contains("cargo-mutants is not at"),
1693 "got: {err}"
1694 );
1695 std::fs::remove_dir_all(&tmp).unwrap();
1696 }
1697
1698 #[test]
1699 fn provision_propagates_an_install_failure() {
1700 let tmp = unique_tmp();
1701 let bin = tmp.join("bin").join("cargo-mutants");
1702 let lock = tmp.join(".install.lock");
1703 let err = provision(&bin, &lock, || bail!("install blew up")).unwrap_err();
1704 assert!(err.to_string().contains("install blew up"), "got: {err}");
1705 std::fs::remove_dir_all(&tmp).unwrap();
1706 }
1707
1708 #[test]
1709 fn provision_does_not_duplicate_the_install_under_concurrent_callers() {
1710 use std::sync::{Arc, Barrier};
1714 use std::thread;
1715 use std::time::Duration;
1716
1717 let tmp = unique_tmp();
1718 let bin = tmp.join("bin").join("cargo-mutants");
1719 let lock = tmp.join(".install.lock");
1720 let install_count = Arc::new(AtomicU64::new(0));
1721 let barrier = Arc::new(Barrier::new(2));
1722
1723 let handles: Vec<_> = (0..2)
1724 .map(|_| {
1725 let bin = bin.clone();
1726 let lock = lock.clone();
1727 let install_count = Arc::clone(&install_count);
1728 let barrier = Arc::clone(&barrier);
1729 thread::spawn(move || {
1730 barrier.wait();
1731 provision(&bin, &lock, || {
1732 install_count.fetch_add(1, Ordering::SeqCst);
1733 thread::sleep(Duration::from_millis(50));
1734 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1735 std::fs::write(&bin, b"binary").unwrap();
1736 Ok(())
1737 })
1738 })
1739 })
1740 .collect();
1741
1742 for h in handles {
1743 h.join()
1744 .expect("provisioning thread must not panic")
1745 .unwrap();
1746 }
1747
1748 assert_eq!(
1749 install_count.load(Ordering::SeqCst),
1750 1,
1751 "two concurrent callers on a cold cache must share one install, not each run their own"
1752 );
1753 std::fs::remove_dir_all(&tmp).unwrap();
1754 }
1755
1756 #[test]
1757 fn resolve_cache_base_prefers_xdg_then_home_then_temp() {
1758 let xdg = |s: &str| Some(OsString::from(s));
1759 assert_eq!(
1760 resolve_cache_base(xdg("/xdg"), xdg("/home")),
1761 PathBuf::from("/xdg")
1762 );
1763 assert_eq!(
1764 resolve_cache_base(xdg(""), xdg("/home")),
1765 PathBuf::from("/home/.cache")
1766 );
1767 assert_eq!(
1768 resolve_cache_base(None, xdg("/home")),
1769 PathBuf::from("/home/.cache")
1770 );
1771 assert_eq!(resolve_cache_base(None, None), std::env::temp_dir());
1772 assert_eq!(
1773 resolve_cache_base(xdg(""), Some(OsString::new())),
1774 std::env::temp_dir()
1775 );
1776 }
1777
1778 #[test]
1779 fn cache_root_is_absolute_and_version_scoped() {
1780 let root = cargo_mutants_cache_root();
1781 assert!(
1782 root.ends_with(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}")),
1783 "version-scoped; got {root:?}"
1784 );
1785 assert!(
1786 root.to_string_lossy().contains("testing-conventions"),
1787 "tool-namespaced; got {root:?}"
1788 );
1789 assert!(
1790 root.is_absolute(),
1791 "expected an absolute path; got {root:?}"
1792 );
1793 }
1794
1795 #[test]
1796 fn install_argv_pins_the_version_and_isolates_the_root() {
1797 let argv: Vec<String> = install_argv(Path::new("/cache/cargo-mutants-27"))
1798 .iter()
1799 .map(|arg| arg.to_string_lossy().into_owned())
1800 .collect();
1801 assert_eq!(
1802 argv,
1803 vec![
1804 "install",
1805 "cargo-mutants",
1806 "--locked",
1807 "--version",
1808 CARGO_MUTANTS_VERSION,
1809 "--root",
1810 "/cache/cargo-mutants-27",
1811 ]
1812 );
1813 }
1814
1815 #[test]
1816 fn mutants_argv_enables_features_on_the_engine_itself() {
1817 let argv = |diff, features: &[&str]| -> Vec<String> {
1818 mutants_argv(
1819 Path::new("/out"),
1820 diff,
1821 &features.iter().map(|f| f.to_string()).collect::<Vec<_>>(),
1822 )
1823 .iter()
1824 .map(|arg| arg.to_string_lossy().into_owned())
1825 .collect()
1826 };
1827 assert_eq!(
1828 argv(None, &["cli", "boost"]),
1829 vec!["mutants", "--output", "/out", "--features", "cli,boost"]
1830 );
1831 assert_eq!(
1832 argv(Some(Path::new("/out/base.diff")), &["cli"]),
1833 vec![
1834 "mutants",
1835 "--output",
1836 "/out",
1837 "--in-diff",
1838 "/out/base.diff",
1839 "--features",
1840 "cli",
1841 ]
1842 );
1843 assert_eq!(argv(None, &[]), vec!["mutants", "--output", "/out"]);
1844 }
1845
1846 #[test]
1847 fn list_argv_mirrors_the_run_feature_selection() {
1848 let argv = |features: &[&str]| -> Vec<String> {
1849 list_argv(&features.iter().map(|f| f.to_string()).collect::<Vec<_>>())
1850 .iter()
1851 .map(|arg| arg.to_string_lossy().into_owned())
1852 .collect()
1853 };
1854 assert_eq!(argv(&[]), vec!["mutants", "--list", "--json"]);
1855 assert_eq!(
1856 argv(&["cli", "boost"]),
1857 vec!["mutants", "--list", "--json", "--features", "cli,boost"]
1858 );
1859 }
1860
1861 #[test]
1862 fn parse_base_diff_maps_inserted_lines_per_hunk() {
1863 let diff = "\
1864diff --git a/src/lib.rs b/src/lib.rs
1865--- a/src/lib.rs
1866+++ b/src/lib.rs
1867@@ -1,4 +1,5 @@
1868 fn a() {}
1869+fn b() {}
1870 fn c() {}
1871-fn d() {}
1872+fn e() {}
1873 fn f() {}
1874@@ -10,2 +11,4 @@
1875 tail
1876+one
1877+two
1878 more
1879";
1880 let parsed = parse_base_diff(diff);
1881 assert_eq!(parsed.files, vec!["src/lib.rs"]);
1882 assert_eq!(
1883 parsed.inserted.get("src/lib.rs"),
1884 Some(&BTreeSet::from([2, 4, 12, 13]))
1885 );
1886 }
1887
1888 #[test]
1889 fn parse_base_diff_leaves_a_deletion_only_file_without_inserted_lines() {
1890 let diff = "\
1891--- a/src/gone.rs
1892+++ b/src/gone.rs
1893@@ -5,2 +4,0 @@
1894-x
1895-y
1896";
1897 let parsed = parse_base_diff(diff);
1898 assert_eq!(parsed.files, vec!["src/gone.rs"]);
1899 assert!(parsed.inserted.is_empty());
1900 }
1901
1902 #[test]
1903 fn parse_base_diff_skips_a_deleted_file() {
1904 let diff = "\
1905--- a/src/dead.rs
1906+++ /dev/null
1907@@ -1,2 +0,0 @@
1908-a
1909-b
1910";
1911 let parsed = parse_base_diff(diff);
1912 assert!(parsed.files.is_empty());
1913 assert!(parsed.inserted.is_empty());
1914 }
1915
1916 #[test]
1917 fn parse_base_diff_consumes_hunk_bodies_by_count_so_content_never_reads_as_a_header() {
1918 let diff = "\
1921+++ b/notes.txt
1922@@ -1,1 +1,2 @@
1923 keep
1924++++ not a header
1925";
1926 let parsed = parse_base_diff(diff);
1927 assert_eq!(parsed.files, vec!["notes.txt"]);
1928 assert_eq!(parsed.inserted.get("notes.txt"), Some(&BTreeSet::from([2])));
1929 }
1930
1931 #[test]
1932 fn parse_base_diff_defaults_an_elided_hunk_count_to_one() {
1933 let diff = "\
1934+++ b/one.txt
1935@@ -1 +1 @@
1936-old
1937+new
1938";
1939 let parsed = parse_base_diff(diff);
1940 assert_eq!(parsed.inserted.get("one.txt"), Some(&BTreeSet::from([1])));
1941 }
1942
1943 #[test]
1944 fn parse_base_diff_skips_no_newline_annotations_mid_hunk() {
1945 let diff = "\
1946+++ b/n.txt
1947@@ -1 +1 @@
1948-old
1949\\ No newline at end of file
1950+new
1951\\ No newline at end of file
1952";
1953 let parsed = parse_base_diff(diff);
1954 assert_eq!(parsed.inserted.get("n.txt"), Some(&BTreeSet::from([1])));
1955 }
1956
1957 #[cfg(unix)]
1958 fn fake_output(code: i32, stderr: &str) -> Output {
1959 use std::os::unix::process::ExitStatusExt;
1960 Output {
1961 status: std::process::ExitStatus::from_raw(code << 8),
1962 stdout: Vec::new(),
1963 stderr: stderr.as_bytes().to_vec(),
1964 }
1965 }
1966
1967 #[cfg(unix)]
1968 #[test]
1969 fn run_install_succeeds_on_a_zero_exit() {
1970 let mut ran = false;
1971 run_install(Path::new("/cache/root"), |command| {
1972 ran = true;
1973 let argv: Vec<String> = command
1974 .get_args()
1975 .map(|arg| arg.to_string_lossy().into_owned())
1976 .collect();
1977 assert!(argv.contains(&CARGO_MUTANTS_VERSION.to_string()));
1978 Ok(fake_output(0, ""))
1979 })
1980 .unwrap();
1981 assert!(ran);
1982 }
1983
1984 #[cfg(unix)]
1985 #[test]
1986 fn run_install_reports_a_nonzero_exit_with_the_engine_output() {
1987 let err = run_install(Path::new("/cache/root"), |_| {
1988 Ok(fake_output(1, "error: could not compile cargo-mutants"))
1989 })
1990 .unwrap_err();
1991 assert!(
1992 err.to_string()
1993 .contains("failed to provision cargo-mutants")
1994 && err.to_string().contains("could not compile"),
1995 "got: {err}"
1996 );
1997 }
1998
1999 #[cfg(unix)]
2000 #[test]
2001 fn run_install_propagates_a_spawn_failure() {
2002 let err = run_install(Path::new("/cache/root"), |_| {
2003 Err(std::io::Error::new(
2004 std::io::ErrorKind::NotFound,
2005 "no cargo",
2006 ))
2007 })
2008 .unwrap_err();
2009 assert!(
2010 err.to_string().contains("is cargo installed?"),
2011 "got: {err}"
2012 );
2013 }
2014
2015 #[cfg(unix)]
2016 fn fake_stdout(code: i32, stdout: &str) -> Output {
2017 use std::os::unix::process::ExitStatusExt;
2018 Output {
2019 status: std::process::ExitStatus::from_raw(code << 8),
2020 stdout: stdout.as_bytes().to_vec(),
2021 stderr: Vec::new(),
2022 }
2023 }
2024
2025 #[cfg(unix)]
2026 #[test]
2027 fn list_cargo_mutants_parses_the_listing_from_a_clean_run() {
2028 let json = r#"[{"file": "src/lib.rs", "name": "replace add -> 0",
2029 "span": {"start": {"line": 3, "column": 1}, "end": {"line": 5, "column": 2}}}]"#;
2030 let listed = list_cargo_mutants(
2031 Path::new("/cache/bin/cargo-mutants"),
2032 Path::new("/crate"),
2033 &["cli".to_string()],
2034 |command| {
2035 let argv: Vec<String> = command
2036 .get_args()
2037 .map(|arg| arg.to_string_lossy().into_owned())
2038 .collect();
2039 assert_eq!(
2040 argv,
2041 vec!["mutants", "--list", "--json", "--features", "cli"]
2042 );
2043 assert_eq!(command.get_current_dir(), Some(Path::new("/crate")));
2044 Ok(fake_stdout(0, json))
2045 },
2046 )
2047 .unwrap();
2048 assert_eq!(listed.len(), 1);
2049 assert_eq!(listed[0].file, "src/lib.rs");
2050 assert_eq!(listed[0].span.start.line, 3);
2051 assert_eq!(listed[0].span.end.line, 5);
2052 assert_eq!(listed[0].name, "replace add -> 0");
2053 }
2054
2055 #[cfg(unix)]
2056 #[test]
2057 fn list_cargo_mutants_reports_a_nonzero_exit_with_the_engine_output() {
2058 let err = list_cargo_mutants(
2059 Path::new("/cache/bin/cargo-mutants"),
2060 Path::new("/crate"),
2061 &[],
2062 |_| Ok(fake_output(1, "error: no such option")),
2063 )
2064 .unwrap_err();
2065 assert!(
2066 err.to_string().contains("cargo-mutants --list failed")
2067 && err.to_string().contains("no such option"),
2068 "got: {err}"
2069 );
2070 }
2071
2072 #[cfg(unix)]
2073 #[test]
2074 fn list_cargo_mutants_propagates_a_spawn_failure() {
2075 let err = list_cargo_mutants(
2076 Path::new("/cache/bin/cargo-mutants"),
2077 Path::new("/crate"),
2078 &[],
2079 |_| {
2080 Err(std::io::Error::new(
2081 std::io::ErrorKind::NotFound,
2082 "no engine",
2083 ))
2084 },
2085 )
2086 .unwrap_err();
2087 assert!(
2088 err.to_string()
2089 .contains("listing the crate's mutants with cargo-mutants"),
2090 "got: {err}"
2091 );
2092 }
2093
2094 #[cfg(unix)]
2095 fn listed_mutant(file: &str, start: u32, end: u32, name: &str) -> MutantInfo {
2096 MutantInfo {
2097 file: file.to_string(),
2098 span: Span {
2099 start: LineCol { line: start },
2100 end: LineCol { line: end },
2101 },
2102 name: name.to_string(),
2103 }
2104 }
2105
2106 #[cfg(unix)]
2107 fn diff_with_inserted(file: &str, lines: &[u32]) -> BaseDiff {
2108 BaseDiff {
2109 files: vec![file.to_string()],
2110 inserted: BTreeMap::from([(file.to_string(), lines.iter().copied().collect())]),
2111 }
2112 }
2113
2114 #[cfg(unix)]
2115 #[test]
2116 fn zero_mutant_verdict_accepts_a_zero_with_no_mutant_on_the_inserted_lines() {
2117 let run = fake_output(0, "");
2118 zero_mutant_verdict(&[], &diff_with_inserted("src/lib.rs", &[5]), &run).unwrap();
2119 let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2120 zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[4, 9]), &run).unwrap();
2121 zero_mutant_verdict(&listed, &diff_with_inserted("src/other.rs", &[6]), &run).unwrap();
2122 }
2123
2124 #[cfg(unix)]
2125 #[test]
2126 fn zero_mutant_verdict_is_fatal_on_a_mutant_at_either_span_boundary() {
2127 let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2128 let run = fake_stdout(0, "0 mutants tested");
2129 for line in [5, 8] {
2130 let err =
2131 zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[line]), &run)
2132 .unwrap_err();
2133 let message = err.to_string();
2134 assert!(
2135 message.contains("1 of the crate's 1 mutant site(s)")
2136 && message.contains("src/lib.rs:5: replace add -> 0")
2137 && message.contains("0 mutants tested"),
2138 "got: {message}"
2139 );
2140 }
2141 }
2142
2143 #[cfg(unix)]
2144 #[test]
2145 fn classify_mutants_exit_accepts_the_caught_and_survivor_exits() {
2146 classify_mutants_exit(Path::new("/crate"), &fake_output(0, "")).unwrap();
2147 classify_mutants_exit(Path::new("/crate"), &fake_output(2, "")).unwrap();
2148 }
2149
2150 #[cfg(unix)]
2151 #[test]
2152 fn classify_mutants_exit_accepts_a_timeout_exit_3() {
2153 classify_mutants_exit(Path::new("/crate"), &fake_output(3, ""))
2154 .expect("a timeout (exit 3) is inconclusive, not fatal");
2155 }
2156
2157 #[cfg(unix)]
2158 #[test]
2159 fn classify_mutants_exit_is_fatal_on_a_baseline_failure() {
2160 let err = classify_mutants_exit(Path::new("/crate"), &fake_output(4, "baseline broke"))
2161 .unwrap_err();
2162 assert!(
2163 err.to_string().contains("did not run cleanly")
2164 && err.to_string().contains("baseline broke"),
2165 "got: {err}"
2166 );
2167 }
2168
2169 #[test]
2170 fn cargo_mutants_bin_name_matches_the_platform() {
2171 let name = cargo_mutants_bin_name();
2172 if cfg!(windows) {
2173 assert_eq!(name, "cargo-mutants.exe");
2174 } else {
2175 assert_eq!(name, "cargo-mutants");
2176 }
2177 }
2178}