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 json = run_ts_adapter(&package_root, adapter, mutate.as_deref(), prefix.as_deref())?;
450 let mutants = to_scan_relative(parse_normalized_results(&json)?, prefix.as_deref());
451 let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
452 Ok(Measurement::Tested {
453 count: normalized_conclusive_count(&mutants),
454 survivors,
455 })
456}
457
458fn scan_prefix(root: &Path, package_root: &Path) -> Option<String> {
461 let rel = root.strip_prefix(package_root).ok()?;
462 let parts: Vec<String> = rel
463 .components()
464 .map(|part| part.as_os_str().to_string_lossy().into_owned())
465 .collect();
466 if parts.is_empty() {
467 None
468 } else {
469 Some(parts.join("/"))
470 }
471}
472
473fn prefix_mutate_specs(specs: Vec<String>, prefix: Option<&str>) -> Vec<String> {
476 match prefix {
477 None => specs,
478 Some(prefix) => specs
479 .into_iter()
480 .map(|spec| format!("{prefix}/{spec}"))
481 .collect(),
482 }
483}
484
485fn scan_scoped_mutate_globs(prefix: &str) -> Vec<String> {
489 const EXTENSIONS: &str = "+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)";
490 vec![
491 format!("{prefix}/**/!(*.+(s|S)pec|*.+(t|T)est).{EXTENSIONS}"),
492 format!("!{prefix}/**/__tests__/**/*.{EXTENSIONS}"),
493 ]
494}
495
496fn to_scan_relative(mutants: Vec<NormalizedMutant>, prefix: Option<&str>) -> Vec<NormalizedMutant> {
500 let Some(prefix) = prefix else {
501 return mutants;
502 };
503 let prefix = format!("{prefix}/");
504 mutants
505 .into_iter()
506 .filter_map(|mut mutant| {
507 mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
508 Some(mutant)
509 })
510 .collect()
511}
512
513fn adapter_cwd<'a>(root: &'a Path, engine: &str) -> Result<&'a Path> {
517 let cwd = if root.as_os_str().is_empty() {
518 Path::new(".")
519 } else {
520 root
521 };
522 if !cwd.is_dir() {
523 bail!(
524 "the {engine} mutation adapter's working directory `{}` is not a directory",
525 cwd.display()
526 );
527 }
528 Ok(cwd)
529}
530
531fn spawn_context(interpreter: &str, entry: &str, cwd: &Path) -> String {
535 format!(
536 "spawning `{interpreter} {entry}` in `{}` (is `{interpreter}` on PATH?)",
537 cwd.display()
538 )
539}
540
541fn run_ts_adapter(
545 package_root: &Path,
546 adapter: &Path,
547 mutate: Option<&[String]>,
548 vitest_dir: Option<&str>,
549) -> Result<String> {
550 let out = AdapterOut::new();
551 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
552 let results = out.0.join("results.json");
553
554 let cwd = adapter_cwd(package_root, "TypeScript")?;
555
556 let mut command = Command::new("node");
557 command
558 .current_dir(cwd)
559 .arg(adapter)
560 .arg("--out")
561 .arg(&results);
562 if let Some(specs) = mutate {
563 command.arg("--mutate").arg(specs.join(","));
564 }
565 if let Some(dir) = vitest_dir {
566 command.arg("--vitest-dir").arg(dir);
567 }
568 let output = command
569 .output()
570 .with_context(|| spawn_context("node", &adapter.display().to_string(), cwd))?;
571 if !output.status.success() {
572 bail!(
573 "the TypeScript mutation adapter failed in `{}`:\n{}{}",
574 cwd.display(),
575 String::from_utf8_lossy(&output.stdout),
576 String::from_utf8_lossy(&output.stderr),
577 );
578 }
579 std::fs::read_to_string(&results).with_context(|| {
580 format!(
581 "reading the TypeScript mutation adapter's results from `{}`",
582 results.display()
583 )
584 })
585}
586
587struct AdapterOut(PathBuf);
590
591impl AdapterOut {
592 fn new() -> Self {
593 static COUNTER: AtomicU64 = AtomicU64::new(0);
594 let name = format!(
595 "testing-conventions-ts-adapter-{}-{}",
596 std::process::id(),
597 COUNTER.fetch_add(1, Ordering::Relaxed),
598 );
599 AdapterOut(std::env::temp_dir().join(name))
600 }
601}
602
603impl Drop for AdapterOut {
604 fn drop(&mut self) {
605 let _ = std::fs::remove_dir_all(&self.0);
606 }
607}
608
609fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
613 let changed = crate::patch_coverage::changed_lines(root, base)?;
614 let mut specs = Vec::new();
615 for (file, lines) in changed {
616 if !is_mutatable_ts(&file) {
617 continue;
618 }
619 for (start, end) in contiguous_runs(&lines) {
620 specs.push(format!("{file}:{start}-{end}"));
621 }
622 }
623 Ok(specs)
624}
625
626fn is_mutatable_ts(file: &str) -> bool {
630 let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
631 .iter()
632 .any(|ext| file.ends_with(ext));
633 let is_decl = file.ends_with(".d.ts");
634 let is_test = file.contains(".test.") || file.contains(".spec.");
635 is_source && !is_decl && !is_test
636}
637
638fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
640 let mut runs: Vec<(u64, u64)> = Vec::new();
641 for &line in lines {
642 match runs.last_mut() {
643 Some(run) if run.1 + 1 == line => run.1 = line,
644 _ => runs.push((line, line)),
645 }
646 }
647 runs
648}
649
650pub fn measure_python(
654 root: &Path,
655 exempt: &[String],
656 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
657 base: Option<&str>,
658) -> Result<Measurement> {
659 let changed = match base {
660 Some(base) => Some(crate::patch_coverage::changed_lines(root, base)?),
661 None => None,
662 };
663 let modules: Vec<String> = match &changed {
664 None => Vec::new(),
665 Some(changed) => {
666 let modules: Vec<String> = changed
667 .keys()
668 .filter(|file| is_mutatable_py(file))
669 .cloned()
670 .collect();
671 if modules.is_empty() {
672 return Ok(Measurement::EngineNotRun);
673 }
674 modules
675 }
676 };
677 let json = run_py_adapter(root, &modules)?;
678 let mut mutants = parse_normalized_results(&json)?;
679 if let Some(changed) = &changed {
680 mutants.retain(|mutant| {
681 changed
682 .get(&mutant.file)
683 .is_some_and(|lines| lines.contains(&u64::from(mutant.line)))
684 });
685 }
686 let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
687 Ok(Measurement::Tested {
688 count: normalized_conclusive_count(&mutants),
689 survivors,
690 })
691}
692
693fn run_py_adapter(root: &Path, modules: &[String]) -> Result<String> {
697 let out = AdapterOut::new();
698 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
699 let results = out.0.join("results.json");
700
701 let cwd = adapter_cwd(root, "Python")?;
702
703 const ENTRY: &str = "-m testing_conventions.mutation.main";
704 let mut command = Command::new("python3");
705 command
706 .current_dir(cwd)
707 .args(["-m", "testing_conventions.mutation.main", "--out"])
708 .arg(&results)
709 .env("PYTHONDONTWRITEBYTECODE", "1");
710 for module in modules {
711 command.arg("--module").arg(module);
712 }
713 let output = command
714 .output()
715 .with_context(|| spawn_context("python3", ENTRY, cwd))?;
716 if !output.status.success() {
717 bail!(
718 "the Python mutation adapter failed in `{}`:\n{}{}",
719 cwd.display(),
720 String::from_utf8_lossy(&output.stdout),
721 String::from_utf8_lossy(&output.stderr),
722 );
723 }
724 std::fs::read_to_string(&results).with_context(|| {
725 format!(
726 "reading the Python mutation adapter's results from `{}`",
727 results.display()
728 )
729 })
730}
731
732fn is_mutatable_py(file: &str) -> bool {
735 if !file.ends_with(".py") {
736 return false;
737 }
738 let base = file.rsplit('/').next().unwrap_or(file);
739 !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
740}
741
742struct MutantsOut(PathBuf);
745
746impl MutantsOut {
747 fn new() -> Self {
748 static COUNTER: AtomicU64 = AtomicU64::new(0);
749 let name = format!(
750 "testing-conventions-mutants-{}-{}",
751 std::process::id(),
752 COUNTER.fetch_add(1, Ordering::Relaxed),
753 );
754 MutantsOut(std::env::temp_dir().join(name))
755 }
756}
757
758impl Drop for MutantsOut {
759 fn drop(&mut self) {
760 let _ = std::fs::remove_dir_all(&self.0);
761 }
762}
763
764fn cargo_workspace_root(root: &Path) -> Result<PathBuf> {
768 let output = Command::new("cargo")
769 .current_dir(root)
770 .args(["locate-project", "--workspace", "--message-format", "plain"])
771 .output()
772 .context("running `cargo locate-project` (is cargo installed?)")?;
773 if !output.status.success() {
774 bail!(
775 "cargo locate-project failed in `{}`: {}",
776 root.display(),
777 String::from_utf8_lossy(&output.stderr)
778 );
779 }
780 let manifest = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
781 manifest.parent().map(Path::to_path_buf).with_context(|| {
782 format!(
783 "no parent dir for the workspace manifest `{}`",
784 manifest.display()
785 )
786 })
787}
788
789fn canonical_scan_prefix(root: &Path, workspace_root: &Path) -> Option<String> {
793 let root = root.canonicalize().ok()?;
794 let workspace_root = workspace_root.canonicalize().ok()?;
795 scan_prefix(&root, &workspace_root)
796}
797
798fn write_base_diff(
802 root: &Path,
803 workspace_root: &Path,
804 prefix: Option<&str>,
805 base: &str,
806 out: &MutantsOut,
807) -> Result<Option<PathBuf>> {
808 let range = format!("{base}...HEAD");
809 let (dir, args) = match prefix {
810 None => (root, vec!["diff", "--relative", &range]),
811 Some(prefix) => (
812 workspace_root,
813 vec!["diff", "--relative", &range, "--", prefix],
814 ),
815 };
816 let output = Command::new("git")
817 .current_dir(dir)
818 .args(&args)
819 .output()
820 .context("running `git diff` for `--base` (is git installed?)")?;
821 if !output.status.success() {
822 bail!(
823 "git diff {range} failed: {}",
824 String::from_utf8_lossy(&output.stderr)
825 );
826 }
827 if output.stdout.is_empty() {
828 return Ok(None);
829 }
830 std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
831 let path = out.0.join("base.diff");
832 std::fs::write(&path, &output.stdout).context("writing the base diff")?;
833 Ok(Some(path))
834}
835
836struct BaseDiff {
840 files: Vec<String>,
841 inserted: BTreeMap<String, BTreeSet<u32>>,
842}
843
844fn parse_base_diff(diff: &str) -> BaseDiff {
848 let mut files = Vec::new();
849 let mut inserted: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
850 let mut current: Option<String> = None;
851 let mut lines = diff.lines();
852 while let Some(line) = lines.next() {
853 if let Some(path) = line.strip_prefix("+++ ") {
854 current = (path != "/dev/null").then(|| {
855 let path = path.strip_prefix("b/").unwrap_or(path).to_string();
856 files.push(path.clone());
857 path
858 });
859 } else if let Some(header) = line.strip_prefix("@@ ") {
860 let Some((new_start, old_count, new_count)) = parse_hunk_header(header) else {
861 continue;
862 };
863 let mut new_line = new_start;
864 let (mut old_left, mut new_left) = (old_count, new_count);
865 while old_left > 0 || new_left > 0 {
866 let Some(line) = lines.next() else { break };
867 if line.starts_with('\\') {
868 } else if line.starts_with('+') {
871 if let Some(file) = ¤t {
872 inserted.entry(file.clone()).or_default().insert(new_line);
873 }
874 new_line += 1;
875 new_left = new_left.saturating_sub(1);
876 } else if line.starts_with('-') {
877 old_left = old_left.saturating_sub(1);
878 } else {
879 new_line += 1;
880 old_left = old_left.saturating_sub(1);
881 new_left = new_left.saturating_sub(1);
882 }
883 }
884 }
885 }
886 BaseDiff { files, inserted }
887}
888
889fn parse_hunk_header(header: &str) -> Option<(u32, u32, u32)> {
891 let mut parts = header.split(' ');
892 let (_, old_count) = parse_range(parts.next()?.strip_prefix('-')?)?;
893 let (new_start, new_count) = parse_range(parts.next()?.strip_prefix('+')?)?;
894 Some((new_start, old_count, new_count))
895}
896
897fn parse_range(range: &str) -> Option<(u32, u32)> {
899 match range.split_once(',') {
900 Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
901 None => Some((range.parse().ok()?, 1)),
902 }
903}
904
905fn rebase_report_paths(report: MutantsReport, prefix: Option<&str>) -> MutantsReport {
909 let Some(prefix) = prefix else {
910 return report;
911 };
912 let prefix = format!("{prefix}/");
913 MutantsReport {
914 outcomes: report
915 .outcomes
916 .into_iter()
917 .filter_map(|mut outcome| {
918 if let Scenario::Mutant(mutant) = &mut outcome.scenario {
919 mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
920 }
921 Some(outcome)
922 })
923 .collect(),
924 }
925}
926
927const CARGO_MUTANTS_VERSION: &str = "27.1.0";
930
931fn ensure_cargo_mutants() -> Result<PathBuf> {
935 let root = cargo_mutants_cache_root();
936 let bin = root.join("bin").join(cargo_mutants_bin_name());
937 let lock_path = root.join(".install.lock");
938 provision(&bin, &lock_path, || {
939 run_install(&root, |command| command.output())
940 })
941}
942
943fn cargo_mutants_bin_name() -> &'static str {
946 if cfg!(windows) {
947 "cargo-mutants.exe"
948 } else {
949 "cargo-mutants"
950 }
951}
952
953fn cargo_mutants_cache_root() -> PathBuf {
957 cache_base()
958 .join("testing-conventions")
959 .join(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}"))
960}
961
962fn cache_base() -> PathBuf {
965 resolve_cache_base(std::env::var_os("XDG_CACHE_HOME"), std::env::var_os("HOME"))
966}
967
968fn resolve_cache_base(xdg: Option<OsString>, home: Option<OsString>) -> PathBuf {
971 if let Some(dir) = xdg.filter(|value| !value.is_empty()) {
972 return PathBuf::from(dir);
973 }
974 if let Some(dir) = home.filter(|value| !value.is_empty()) {
975 return PathBuf::from(dir).join(".cache");
976 }
977 std::env::temp_dir()
978}
979
980fn provision(
984 bin: &Path,
985 lock_path: &Path,
986 install: impl FnOnce() -> Result<()>,
987) -> Result<PathBuf> {
988 if bin.exists() {
989 return Ok(bin.to_path_buf());
990 }
991 if let Some(parent) = lock_path.parent() {
992 std::fs::create_dir_all(parent).context("creating the provisioning lock's parent dir")?;
993 }
994 let lock_file = std::fs::OpenOptions::new()
995 .create(true)
996 .truncate(false)
997 .write(true)
998 .open(lock_path)
999 .context("opening the provisioning lock file")?;
1000 lock_file
1001 .lock()
1002 .context("acquiring the provisioning lock")?;
1003 if bin.exists() {
1005 return Ok(bin.to_path_buf());
1006 }
1007 install()?;
1008 if !bin.exists() {
1009 bail!(
1010 "provisioning reported success but cargo-mutants is not at `{}`",
1011 bin.display()
1012 );
1013 }
1014 Ok(bin.to_path_buf())
1015}
1016
1017fn install_argv(root: &Path) -> Vec<OsString> {
1021 vec![
1022 OsString::from("install"),
1023 OsString::from("cargo-mutants"),
1024 OsString::from("--locked"),
1025 OsString::from("--version"),
1026 OsString::from(CARGO_MUTANTS_VERSION),
1027 OsString::from("--root"),
1028 root.as_os_str().to_os_string(),
1029 ]
1030}
1031
1032fn run_install(
1036 root: &Path,
1037 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1038) -> Result<()> {
1039 let mut command = Command::new("cargo");
1040 command.args(install_argv(root));
1041 strip_llvm_cov_env(&mut command);
1042 let output = run(&mut command)
1043 .context("provisioning cargo-mutants via `cargo install` (is cargo installed?)")?;
1044 if !output.status.success() {
1045 bail!(
1046 "failed to provision cargo-mutants {CARGO_MUTANTS_VERSION}:\n{}{}",
1047 String::from_utf8_lossy(&output.stdout),
1048 String::from_utf8_lossy(&output.stderr),
1049 );
1050 }
1051 Ok(())
1052}
1053
1054fn strip_llvm_cov_env(command: &mut Command) {
1058 for var in [
1059 "RUSTFLAGS",
1060 "CARGO_ENCODED_RUSTFLAGS",
1061 "RUSTDOCFLAGS",
1062 "CARGO_ENCODED_RUSTDOCFLAGS",
1063 "LLVM_PROFILE_FILE",
1064 "CARGO_LLVM_COV",
1065 "CARGO_LLVM_COV_SHOW_ENV",
1066 "CARGO_LLVM_COV_TARGET_DIR",
1067 "CARGO_LLVM_COV_BUILD_DIR",
1068 "RUSTC_WRAPPER",
1069 "RUSTC_WORKSPACE_WRAPPER",
1070 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
1071 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
1072 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
1073 ] {
1074 command.env_remove(var);
1075 }
1076}
1077
1078fn run_cargo_mutants(
1082 engine: &Path,
1083 root: &Path,
1084 out: &Path,
1085 in_diff: Option<&Path>,
1086 features: &[String],
1087) -> Result<Output> {
1088 let mut command = Command::new(engine);
1089 command
1090 .current_dir(root)
1091 .args(mutants_argv(out, in_diff, features));
1092 strip_llvm_cov_env(&mut command);
1093 let output = command.output().context("running cargo-mutants")?;
1094 classify_mutants_exit(root, &output)?;
1095 Ok(output)
1096}
1097
1098fn zero_mutant_verdict(listed: &[MutantInfo], diff: &BaseDiff, run: &Output) -> Result<()> {
1102 let dropped: Vec<&MutantInfo> = listed
1103 .iter()
1104 .filter(|mutant| {
1105 diff.inserted.get(&mutant.file).is_some_and(|lines| {
1106 lines
1107 .range(mutant.span.start.line..=mutant.span.end.line)
1108 .next()
1109 .is_some()
1110 })
1111 })
1112 .collect();
1113 if dropped.is_empty() {
1114 return Ok(());
1115 }
1116 let sites: Vec<String> = dropped
1117 .iter()
1118 .map(|mutant| {
1119 format!(
1120 " {}:{}: {}",
1121 mutant.file, mutant.span.start.line, mutant.name
1122 )
1123 })
1124 .collect();
1125 bail!(
1126 "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{}{}",
1127 dropped.len(),
1128 listed.len(),
1129 sites.join("\n"),
1130 String::from_utf8_lossy(&run.stdout),
1131 String::from_utf8_lossy(&run.stderr),
1132 )
1133}
1134
1135fn list_argv(features: &[String]) -> Vec<OsString> {
1139 let mut argv = vec![
1140 OsString::from("mutants"),
1141 OsString::from("--list"),
1142 OsString::from("--json"),
1143 ];
1144 if !features.is_empty() {
1145 argv.push(OsString::from("--features"));
1146 argv.push(OsString::from(features.join(",")));
1147 }
1148 argv
1149}
1150
1151fn list_cargo_mutants(
1155 engine: &Path,
1156 root: &Path,
1157 features: &[String],
1158 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1159) -> Result<Vec<MutantInfo>> {
1160 let mut command = Command::new(engine);
1161 command.current_dir(root).args(list_argv(features));
1162 strip_llvm_cov_env(&mut command);
1163 let output = run(&mut command).context("listing the crate's mutants with cargo-mutants")?;
1164 if !output.status.success() {
1165 bail!(
1166 "cargo-mutants --list failed in `{}`:\n{}{}",
1167 root.display(),
1168 String::from_utf8_lossy(&output.stdout),
1169 String::from_utf8_lossy(&output.stderr),
1170 );
1171 }
1172 parse_mutants_list(&String::from_utf8_lossy(&output.stdout))
1173}
1174
1175fn mutants_argv(out: &Path, in_diff: Option<&Path>, features: &[String]) -> Vec<OsString> {
1179 let mut argv = vec![
1180 OsString::from("mutants"),
1181 OsString::from("--output"),
1182 out.as_os_str().to_os_string(),
1183 ];
1184 if let Some(diff) = in_diff {
1185 argv.push(OsString::from("--in-diff"));
1186 argv.push(diff.as_os_str().to_os_string());
1187 }
1188 if !features.is_empty() {
1189 argv.push(OsString::from("--features"));
1190 argv.push(OsString::from(features.join(",")));
1191 }
1192 argv
1193}
1194
1195fn classify_mutants_exit(root: &Path, output: &Output) -> Result<()> {
1199 match output.status.code() {
1200 Some(0) | Some(2) | Some(3) => Ok(()),
1201 _ => bail!(
1202 "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
1203 root.display(),
1204 String::from_utf8_lossy(&output.stdout),
1205 String::from_utf8_lossy(&output.stderr),
1206 ),
1207 }
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212 use super::*;
1213
1214 const NORMALIZED: &str = r#"[
1215 {"file": "src/a.ts", "line": 2, "status": "survived",
1216 "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
1217 {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
1218 {"file": "src/a.ts", "line": 9, "status": "killed",
1219 "mutator": "BooleanLiteral", "replacement": "false"},
1220 {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
1221 {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
1222 {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
1223 ]"#;
1224
1225 #[test]
1226 fn parses_the_normalized_schema() {
1227 let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
1228 assert_eq!(mutants.len(), 6);
1229 assert_eq!(mutants[0].status, MutantStatus::Survived);
1230 assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
1231 assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
1232 assert_eq!(mutants[1].replacement, None);
1233 }
1234
1235 #[test]
1236 fn normalized_survivors_are_survived_and_nocoverage_only() {
1237 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1238 let survivors = normalized_survivors(&mutants);
1239 assert_eq!(survivors.len(), 2);
1240 assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
1241 assert!(survivors[0].description.contains("ConditionalExpression"));
1242 assert!(survivors[0].description.contains("-> true"));
1243 assert_eq!(survivors[1].description, "ArithmeticOperator");
1244 }
1245
1246 #[test]
1247 fn normalized_mutated_lines_collects_only_viable_mutants() {
1248 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1249 assert_eq!(
1250 normalized_mutated_lines(&mutants),
1251 [2u32, 5, 9, 12]
1252 .into_iter()
1253 .map(|line| ("src/a.ts".to_string(), line))
1254 .collect()
1255 );
1256 }
1257
1258 #[test]
1259 fn normalized_conclusive_count_is_survived_killed_and_nocoverage() {
1260 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1261 assert_eq!(normalized_conclusive_count(&mutants), 3);
1262 assert_eq!(normalized_conclusive_count(&[]), 0);
1263 }
1264
1265 #[test]
1266 fn evaluate_normalized_reports_unexempted_survivors() {
1267 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1268 let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
1269 assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
1270 }
1271
1272 #[test]
1273 fn evaluate_normalized_drops_a_whole_file_exemption() {
1274 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1275 let kept =
1276 evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
1277 assert!(
1278 kept.is_empty(),
1279 "the whole-file exemption lifts both survivors"
1280 );
1281 }
1282
1283 #[test]
1284 fn evaluate_normalized_drops_a_line_scoped_exemption() {
1285 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1286 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
1287 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1288 assert_eq!(kept.len(), 1);
1289 assert_eq!(kept[0].line, 5);
1290 }
1291
1292 #[test]
1293 fn evaluate_normalized_rejects_exempting_a_caught_line() {
1294 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1295 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
1296 let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
1297 assert!(
1298 err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
1299 "got: {err}"
1300 );
1301 }
1302
1303 #[test]
1304 fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
1305 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1306 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
1307 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1308 assert_eq!(kept.len(), 2);
1309 }
1310
1311 const SAMPLE: &str = r#"{
1312 "outcomes": [
1313 {"scenario": "Baseline", "summary": "Success",
1314 "phase_results": []},
1315 {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
1316 "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
1317 "function": {"function_name": "is_positive"},
1318 "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
1319 "summary": "MissedMutant"},
1320 {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
1321 "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
1322 "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
1323 "summary": "CaughtMutant"}
1324 ],
1325 "total_mutants": 2
1326 }"#;
1327
1328 #[test]
1329 fn parses_the_outcomes_export() {
1330 let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
1331 assert_eq!(report.outcomes.len(), 3);
1332 assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
1333 }
1334
1335 #[test]
1336 fn collects_only_missed_mutants_as_survivors() {
1337 let report = parse_mutants_report(SAMPLE).unwrap();
1338 let survivors = unexplained_survivors(&report, &[]);
1339 assert_eq!(survivors.len(), 1);
1340 assert_eq!(survivors[0].file, "src/lib.rs");
1341 assert_eq!(survivors[0].line, 7);
1342 assert!(survivors[0].description.contains("replace > with =="));
1343 }
1344
1345 #[test]
1346 fn conclusive_count_is_caught_plus_missed() {
1347 let report = parse_mutants_report(SAMPLE).unwrap();
1348 assert_eq!(conclusive_count(&report), 2);
1349 assert_eq!(conclusive_count(&MutantsReport { outcomes: vec![] }), 0);
1350 }
1351
1352 #[test]
1353 fn an_exemption_drops_a_survivor_in_that_file() {
1354 let report = parse_mutants_report(SAMPLE).unwrap();
1355 let exempt = vec!["src/lib.rs".to_string()];
1356 assert!(unexplained_survivors(&report, &exempt).is_empty());
1357 }
1358
1359 #[test]
1360 fn an_exemption_on_another_file_leaves_the_survivor() {
1361 let report = parse_mutants_report(SAMPLE).unwrap();
1362 let exempt = vec!["src/elsewhere.rs".to_string()];
1363 assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1364 }
1365
1366 #[test]
1367 fn rebase_report_paths_strips_the_workspace_prefix() {
1368 let report = parse_mutants_report(SAMPLE).unwrap();
1369 let prefixed = MutantsReport {
1370 outcomes: report
1371 .outcomes
1372 .iter()
1373 .cloned()
1374 .map(|mut outcome| {
1375 if let Scenario::Mutant(mutant) = &mut outcome.scenario {
1376 mutant.file = format!("member/{}", mutant.file);
1377 }
1378 outcome
1379 })
1380 .collect(),
1381 };
1382 let rebased = rebase_report_paths(prefixed, Some("member"));
1383 let survivors = unexplained_survivors(&rebased, &[]);
1384 assert_eq!(survivors.len(), 1);
1385 assert_eq!(survivors[0].file, "src/lib.rs");
1386 assert_eq!(rebased.outcomes.len(), 3);
1387 }
1388
1389 #[test]
1390 fn rebase_report_paths_drops_an_out_of_scope_mutant_and_keeps_none_identity() {
1391 let report = parse_mutants_report(SAMPLE).unwrap();
1392 let rebased = rebase_report_paths(report.clone(), Some("member"));
1393 assert_eq!(
1394 rebased.outcomes.len(),
1395 1,
1396 "only the pathless baseline outcome remains"
1397 );
1398 let unchanged = rebase_report_paths(report, None);
1399 assert_eq!(unchanged.outcomes.len(), 3);
1400 assert_eq!(unexplained_survivors(&unchanged, &[])[0].file, "src/lib.rs");
1401 }
1402
1403 #[test]
1404 fn adapter_cwd_normalises_the_empty_package_root_to_the_current_dir() {
1405 assert_eq!(
1409 adapter_cwd(Path::new(""), "TypeScript").unwrap(),
1410 Path::new(".")
1411 );
1412 assert_eq!(
1413 adapter_cwd(Path::new("src"), "TypeScript").unwrap(),
1414 Path::new("src")
1415 );
1416 }
1417
1418 #[test]
1419 fn adapter_cwd_rejects_a_directory_that_is_not_there() {
1420 let err = adapter_cwd(Path::new("no/such/dir"), "Python")
1423 .expect_err("a directory that is not there is an error");
1424 assert_eq!(
1425 err.to_string(),
1426 "the Python mutation adapter's working directory `no/such/dir` is not a directory"
1427 );
1428 }
1429
1430 #[test]
1431 fn spawn_context_names_the_interpreter_the_entry_and_the_working_directory() {
1432 assert_eq!(
1433 spawn_context("node", "/pkg/dist/mutation/main.js", Path::new("/pkg")),
1434 "spawning `node /pkg/dist/mutation/main.js` in `/pkg` (is `node` on PATH?)"
1435 );
1436 }
1437
1438 #[test]
1439 fn scan_prefix_is_the_scan_path_relative_to_the_package_root() {
1440 assert_eq!(
1441 scan_prefix(Path::new("/repo/pkg/src"), Path::new("/repo/pkg")),
1442 Some("src".to_string())
1443 );
1444 assert_eq!(
1445 scan_prefix(Path::new("/repo/pkg/src/nested"), Path::new("/repo/pkg")),
1446 Some("src/nested".to_string())
1447 );
1448 assert_eq!(
1449 scan_prefix(Path::new("/repo/pkg"), Path::new("/repo/pkg")),
1450 None
1451 );
1452 assert_eq!(
1453 scan_prefix(Path::new("pkg/src"), Path::new("pkg")),
1454 Some("src".to_string())
1455 );
1456 }
1457
1458 #[test]
1459 fn prefix_mutate_specs_rebases_diff_ranges_onto_the_package_root() {
1460 let specs = vec!["index.ts:8-11".to_string(), "a/b.ts:2-2".to_string()];
1461 assert_eq!(
1462 prefix_mutate_specs(specs.clone(), Some("src")),
1463 vec![
1464 "src/index.ts:8-11".to_string(),
1465 "src/a/b.ts:2-2".to_string()
1466 ]
1467 );
1468 assert_eq!(prefix_mutate_specs(specs.clone(), None), specs);
1469 }
1470
1471 #[test]
1472 fn scan_scoped_mutate_globs_mirror_strykers_default_under_the_scan_path() {
1473 assert_eq!(
1474 scan_scoped_mutate_globs("src"),
1475 vec![
1476 "src/**/!(*.+(s|S)pec|*.+(t|T)est).+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
1477 .to_string(),
1478 "!src/**/__tests__/**/*.+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
1479 .to_string(),
1480 ]
1481 );
1482 }
1483
1484 #[test]
1485 fn to_scan_relative_strips_the_prefix_and_drops_out_of_scope_mutants() {
1486 let mutants = parse_normalized_results(
1487 r#"[
1488 {"file": "src/a.ts", "line": 2, "status": "survived", "mutator": "X"},
1489 {"file": "tests/e2e/t.ts", "line": 9, "status": "survived", "mutator": "X"}
1490 ]"#,
1491 )
1492 .unwrap();
1493 let rebased = to_scan_relative(mutants.clone(), Some("src"));
1494 assert_eq!(rebased.len(), 1, "the out-of-scan-path mutant is dropped");
1495 assert_eq!(rebased[0].file, "a.ts");
1496 let unchanged = to_scan_relative(mutants, None);
1497 assert_eq!(unchanged.len(), 2);
1498 assert_eq!(unchanged[0].file, "src/a.ts");
1499 }
1500
1501 #[test]
1502 fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
1503 assert!(is_mutatable_ts("src/index.ts"));
1504 assert!(is_mutatable_ts("src/util.tsx"));
1505 assert!(is_mutatable_ts("src/util.js"));
1506 assert!(!is_mutatable_ts("src/index.test.ts"));
1507 assert!(!is_mutatable_ts("src/index.spec.ts"));
1508 assert!(!is_mutatable_ts("src/types.d.ts"));
1509 assert!(!is_mutatable_ts("README.md"));
1510 }
1511
1512 #[test]
1513 fn contiguous_runs_collapses_adjacent_lines() {
1514 let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
1515 assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
1516 assert!(contiguous_runs(&BTreeSet::new()).is_empty());
1517 }
1518
1519 #[test]
1520 fn one_line_flattens_and_caps() {
1521 assert_eq!(one_line("a -\n b"), "a - b");
1522 let long = "x".repeat(80);
1523 let capped = one_line(&long);
1524 assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
1525 }
1526
1527 #[test]
1528 fn is_mutatable_py_keeps_sources_and_drops_tests() {
1529 assert!(is_mutatable_py("calc.py"));
1530 assert!(is_mutatable_py("pkg/util.py"));
1531 assert!(!is_mutatable_py("calc_test.py"));
1532 assert!(!is_mutatable_py("test_calc.py"));
1533 assert!(!is_mutatable_py("pkg/conftest.py"));
1534 assert!(!is_mutatable_py("README.md"));
1535 }
1536
1537 #[test]
1538 fn mutated_lines_collects_caught_and_missed() {
1539 let report = parse_mutants_report(SAMPLE).unwrap();
1540 assert_eq!(
1541 mutated_lines(&report),
1542 [
1543 ("src/lib.rs".to_string(), 7),
1544 ("src/other.rs".to_string(), 3)
1545 ]
1546 .into_iter()
1547 .collect()
1548 );
1549 }
1550
1551 #[test]
1552 fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1553 let report = parse_mutants_report(SAMPLE).unwrap();
1554 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1555 let kept = evaluate_scoped(
1556 cargo_mutants_survivors(&report),
1557 &mutated_lines(&report),
1558 &[],
1559 &line_scoped,
1560 )
1561 .unwrap();
1562 assert!(
1563 kept.is_empty(),
1564 "the src/lib.rs:7 survivor should be lifted"
1565 );
1566 }
1567
1568 #[test]
1569 fn evaluate_scoped_rejects_exempting_a_caught_line() {
1570 let report = parse_mutants_report(SAMPLE).unwrap();
1571 let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
1572 let err = evaluate_scoped(
1573 cargo_mutants_survivors(&report),
1574 &mutated_lines(&report),
1575 &[],
1576 &line_scoped,
1577 )
1578 .unwrap_err();
1579 assert!(
1580 err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
1581 "got: {err}"
1582 );
1583 }
1584
1585 #[test]
1586 fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
1587 let report = parse_mutants_report(SAMPLE).unwrap();
1588 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
1589 let kept = evaluate_scoped(
1590 cargo_mutants_survivors(&report),
1591 &mutated_lines(&report),
1592 &[],
1593 &line_scoped,
1594 )
1595 .unwrap();
1596 assert_eq!(kept.len(), 1);
1597 assert_eq!(kept[0].line, 7);
1598 }
1599
1600 #[test]
1601 fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1602 let report = parse_mutants_report(SAMPLE).unwrap();
1603 let kept = evaluate_scoped(
1604 cargo_mutants_survivors(&report),
1605 &mutated_lines(&report),
1606 &["src/lib.rs".to_string()],
1607 &BTreeMap::new(),
1608 )
1609 .unwrap();
1610 assert!(kept.is_empty());
1611 }
1612
1613 fn unique_tmp() -> PathBuf {
1614 static COUNTER: AtomicU64 = AtomicU64::new(0);
1615 let dir = std::env::temp_dir().join(format!(
1616 "tc-provision-test-{}-{}",
1617 std::process::id(),
1618 COUNTER.fetch_add(1, Ordering::Relaxed)
1619 ));
1620 std::fs::create_dir_all(&dir).unwrap();
1621 dir
1622 }
1623
1624 #[test]
1625 fn provision_returns_an_existing_binary_without_installing() {
1626 let tmp = unique_tmp();
1627 let bin = tmp.join("bin").join("cargo-mutants");
1628 let lock = tmp.join(".install.lock");
1629 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1630 std::fs::write(&bin, b"binary").unwrap();
1631 let mut installed = false;
1632 let got = provision(&bin, &lock, || {
1633 installed = true;
1634 Ok(())
1635 })
1636 .unwrap();
1637 assert_eq!(got, bin);
1638 assert!(!installed, "a present binary must not be reinstalled");
1639 std::fs::remove_dir_all(&tmp).unwrap();
1640 }
1641
1642 #[test]
1643 fn provision_installs_when_the_binary_is_absent() {
1644 let tmp = unique_tmp();
1645 let bin = tmp.join("bin").join("cargo-mutants");
1646 let lock = tmp.join(".install.lock");
1647 let mut installed = false;
1648 let got = provision(&bin, &lock, || {
1649 installed = true;
1650 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1651 std::fs::write(&bin, b"binary").unwrap();
1652 Ok(())
1653 })
1654 .unwrap();
1655 assert!(installed, "an absent binary must be installed");
1656 assert_eq!(got, bin);
1657 std::fs::remove_dir_all(&tmp).unwrap();
1658 }
1659
1660 #[test]
1661 fn provision_errors_when_install_produces_no_binary() {
1662 let tmp = unique_tmp();
1663 let bin = tmp.join("bin").join("cargo-mutants");
1664 let lock = tmp.join(".install.lock");
1665 let err = provision(&bin, &lock, || Ok(())).unwrap_err();
1666 assert!(
1667 err.to_string().contains("cargo-mutants is not at"),
1668 "got: {err}"
1669 );
1670 std::fs::remove_dir_all(&tmp).unwrap();
1671 }
1672
1673 #[test]
1674 fn provision_propagates_an_install_failure() {
1675 let tmp = unique_tmp();
1676 let bin = tmp.join("bin").join("cargo-mutants");
1677 let lock = tmp.join(".install.lock");
1678 let err = provision(&bin, &lock, || bail!("install blew up")).unwrap_err();
1679 assert!(err.to_string().contains("install blew up"), "got: {err}");
1680 std::fs::remove_dir_all(&tmp).unwrap();
1681 }
1682
1683 #[test]
1684 fn provision_does_not_duplicate_the_install_under_concurrent_callers() {
1685 use std::sync::{Arc, Barrier};
1689 use std::thread;
1690 use std::time::Duration;
1691
1692 let tmp = unique_tmp();
1693 let bin = tmp.join("bin").join("cargo-mutants");
1694 let lock = tmp.join(".install.lock");
1695 let install_count = Arc::new(AtomicU64::new(0));
1696 let barrier = Arc::new(Barrier::new(2));
1697
1698 let handles: Vec<_> = (0..2)
1699 .map(|_| {
1700 let bin = bin.clone();
1701 let lock = lock.clone();
1702 let install_count = Arc::clone(&install_count);
1703 let barrier = Arc::clone(&barrier);
1704 thread::spawn(move || {
1705 barrier.wait();
1706 provision(&bin, &lock, || {
1707 install_count.fetch_add(1, Ordering::SeqCst);
1708 thread::sleep(Duration::from_millis(50));
1709 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1710 std::fs::write(&bin, b"binary").unwrap();
1711 Ok(())
1712 })
1713 })
1714 })
1715 .collect();
1716
1717 for h in handles {
1718 h.join()
1719 .expect("provisioning thread must not panic")
1720 .unwrap();
1721 }
1722
1723 assert_eq!(
1724 install_count.load(Ordering::SeqCst),
1725 1,
1726 "two concurrent callers on a cold cache must share one install, not each run their own"
1727 );
1728 std::fs::remove_dir_all(&tmp).unwrap();
1729 }
1730
1731 #[test]
1732 fn resolve_cache_base_prefers_xdg_then_home_then_temp() {
1733 let xdg = |s: &str| Some(OsString::from(s));
1734 assert_eq!(
1735 resolve_cache_base(xdg("/xdg"), xdg("/home")),
1736 PathBuf::from("/xdg")
1737 );
1738 assert_eq!(
1739 resolve_cache_base(xdg(""), xdg("/home")),
1740 PathBuf::from("/home/.cache")
1741 );
1742 assert_eq!(
1743 resolve_cache_base(None, xdg("/home")),
1744 PathBuf::from("/home/.cache")
1745 );
1746 assert_eq!(resolve_cache_base(None, None), std::env::temp_dir());
1747 assert_eq!(
1748 resolve_cache_base(xdg(""), Some(OsString::new())),
1749 std::env::temp_dir()
1750 );
1751 }
1752
1753 #[test]
1754 fn cache_root_is_absolute_and_version_scoped() {
1755 let root = cargo_mutants_cache_root();
1756 assert!(
1757 root.ends_with(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}")),
1758 "version-scoped; got {root:?}"
1759 );
1760 assert!(
1761 root.to_string_lossy().contains("testing-conventions"),
1762 "tool-namespaced; got {root:?}"
1763 );
1764 assert!(
1765 root.is_absolute(),
1766 "expected an absolute path; got {root:?}"
1767 );
1768 }
1769
1770 #[test]
1771 fn install_argv_pins_the_version_and_isolates_the_root() {
1772 let argv: Vec<String> = install_argv(Path::new("/cache/cargo-mutants-27"))
1773 .iter()
1774 .map(|arg| arg.to_string_lossy().into_owned())
1775 .collect();
1776 assert_eq!(
1777 argv,
1778 vec![
1779 "install",
1780 "cargo-mutants",
1781 "--locked",
1782 "--version",
1783 CARGO_MUTANTS_VERSION,
1784 "--root",
1785 "/cache/cargo-mutants-27",
1786 ]
1787 );
1788 }
1789
1790 #[test]
1791 fn mutants_argv_enables_features_on_the_engine_itself() {
1792 let argv = |diff, features: &[&str]| -> Vec<String> {
1793 mutants_argv(
1794 Path::new("/out"),
1795 diff,
1796 &features.iter().map(|f| f.to_string()).collect::<Vec<_>>(),
1797 )
1798 .iter()
1799 .map(|arg| arg.to_string_lossy().into_owned())
1800 .collect()
1801 };
1802 assert_eq!(
1803 argv(None, &["cli", "boost"]),
1804 vec!["mutants", "--output", "/out", "--features", "cli,boost"]
1805 );
1806 assert_eq!(
1807 argv(Some(Path::new("/out/base.diff")), &["cli"]),
1808 vec![
1809 "mutants",
1810 "--output",
1811 "/out",
1812 "--in-diff",
1813 "/out/base.diff",
1814 "--features",
1815 "cli",
1816 ]
1817 );
1818 assert_eq!(argv(None, &[]), vec!["mutants", "--output", "/out"]);
1819 }
1820
1821 #[test]
1822 fn list_argv_mirrors_the_run_feature_selection() {
1823 let argv = |features: &[&str]| -> Vec<String> {
1824 list_argv(&features.iter().map(|f| f.to_string()).collect::<Vec<_>>())
1825 .iter()
1826 .map(|arg| arg.to_string_lossy().into_owned())
1827 .collect()
1828 };
1829 assert_eq!(argv(&[]), vec!["mutants", "--list", "--json"]);
1830 assert_eq!(
1831 argv(&["cli", "boost"]),
1832 vec!["mutants", "--list", "--json", "--features", "cli,boost"]
1833 );
1834 }
1835
1836 #[test]
1837 fn parse_base_diff_maps_inserted_lines_per_hunk() {
1838 let diff = "\
1839diff --git a/src/lib.rs b/src/lib.rs
1840--- a/src/lib.rs
1841+++ b/src/lib.rs
1842@@ -1,4 +1,5 @@
1843 fn a() {}
1844+fn b() {}
1845 fn c() {}
1846-fn d() {}
1847+fn e() {}
1848 fn f() {}
1849@@ -10,2 +11,4 @@
1850 tail
1851+one
1852+two
1853 more
1854";
1855 let parsed = parse_base_diff(diff);
1856 assert_eq!(parsed.files, vec!["src/lib.rs"]);
1857 assert_eq!(
1858 parsed.inserted.get("src/lib.rs"),
1859 Some(&BTreeSet::from([2, 4, 12, 13]))
1860 );
1861 }
1862
1863 #[test]
1864 fn parse_base_diff_leaves_a_deletion_only_file_without_inserted_lines() {
1865 let diff = "\
1866--- a/src/gone.rs
1867+++ b/src/gone.rs
1868@@ -5,2 +4,0 @@
1869-x
1870-y
1871";
1872 let parsed = parse_base_diff(diff);
1873 assert_eq!(parsed.files, vec!["src/gone.rs"]);
1874 assert!(parsed.inserted.is_empty());
1875 }
1876
1877 #[test]
1878 fn parse_base_diff_skips_a_deleted_file() {
1879 let diff = "\
1880--- a/src/dead.rs
1881+++ /dev/null
1882@@ -1,2 +0,0 @@
1883-a
1884-b
1885";
1886 let parsed = parse_base_diff(diff);
1887 assert!(parsed.files.is_empty());
1888 assert!(parsed.inserted.is_empty());
1889 }
1890
1891 #[test]
1892 fn parse_base_diff_consumes_hunk_bodies_by_count_so_content_never_reads_as_a_header() {
1893 let diff = "\
1896+++ b/notes.txt
1897@@ -1,1 +1,2 @@
1898 keep
1899++++ not a header
1900";
1901 let parsed = parse_base_diff(diff);
1902 assert_eq!(parsed.files, vec!["notes.txt"]);
1903 assert_eq!(parsed.inserted.get("notes.txt"), Some(&BTreeSet::from([2])));
1904 }
1905
1906 #[test]
1907 fn parse_base_diff_defaults_an_elided_hunk_count_to_one() {
1908 let diff = "\
1909+++ b/one.txt
1910@@ -1 +1 @@
1911-old
1912+new
1913";
1914 let parsed = parse_base_diff(diff);
1915 assert_eq!(parsed.inserted.get("one.txt"), Some(&BTreeSet::from([1])));
1916 }
1917
1918 #[test]
1919 fn parse_base_diff_skips_no_newline_annotations_mid_hunk() {
1920 let diff = "\
1921+++ b/n.txt
1922@@ -1 +1 @@
1923-old
1924\\ No newline at end of file
1925+new
1926\\ No newline at end of file
1927";
1928 let parsed = parse_base_diff(diff);
1929 assert_eq!(parsed.inserted.get("n.txt"), Some(&BTreeSet::from([1])));
1930 }
1931
1932 #[cfg(unix)]
1933 fn fake_output(code: i32, stderr: &str) -> Output {
1934 use std::os::unix::process::ExitStatusExt;
1935 Output {
1936 status: std::process::ExitStatus::from_raw(code << 8),
1937 stdout: Vec::new(),
1938 stderr: stderr.as_bytes().to_vec(),
1939 }
1940 }
1941
1942 #[cfg(unix)]
1943 #[test]
1944 fn run_install_succeeds_on_a_zero_exit() {
1945 let mut ran = false;
1946 run_install(Path::new("/cache/root"), |command| {
1947 ran = true;
1948 let argv: Vec<String> = command
1949 .get_args()
1950 .map(|arg| arg.to_string_lossy().into_owned())
1951 .collect();
1952 assert!(argv.contains(&CARGO_MUTANTS_VERSION.to_string()));
1953 Ok(fake_output(0, ""))
1954 })
1955 .unwrap();
1956 assert!(ran);
1957 }
1958
1959 #[cfg(unix)]
1960 #[test]
1961 fn run_install_reports_a_nonzero_exit_with_the_engine_output() {
1962 let err = run_install(Path::new("/cache/root"), |_| {
1963 Ok(fake_output(1, "error: could not compile cargo-mutants"))
1964 })
1965 .unwrap_err();
1966 assert!(
1967 err.to_string()
1968 .contains("failed to provision cargo-mutants")
1969 && err.to_string().contains("could not compile"),
1970 "got: {err}"
1971 );
1972 }
1973
1974 #[cfg(unix)]
1975 #[test]
1976 fn run_install_propagates_a_spawn_failure() {
1977 let err = run_install(Path::new("/cache/root"), |_| {
1978 Err(std::io::Error::new(
1979 std::io::ErrorKind::NotFound,
1980 "no cargo",
1981 ))
1982 })
1983 .unwrap_err();
1984 assert!(
1985 err.to_string().contains("is cargo installed?"),
1986 "got: {err}"
1987 );
1988 }
1989
1990 #[cfg(unix)]
1991 fn fake_stdout(code: i32, stdout: &str) -> Output {
1992 use std::os::unix::process::ExitStatusExt;
1993 Output {
1994 status: std::process::ExitStatus::from_raw(code << 8),
1995 stdout: stdout.as_bytes().to_vec(),
1996 stderr: Vec::new(),
1997 }
1998 }
1999
2000 #[cfg(unix)]
2001 #[test]
2002 fn list_cargo_mutants_parses_the_listing_from_a_clean_run() {
2003 let json = r#"[{"file": "src/lib.rs", "name": "replace add -> 0",
2004 "span": {"start": {"line": 3, "column": 1}, "end": {"line": 5, "column": 2}}}]"#;
2005 let listed = list_cargo_mutants(
2006 Path::new("/cache/bin/cargo-mutants"),
2007 Path::new("/crate"),
2008 &["cli".to_string()],
2009 |command| {
2010 let argv: Vec<String> = command
2011 .get_args()
2012 .map(|arg| arg.to_string_lossy().into_owned())
2013 .collect();
2014 assert_eq!(
2015 argv,
2016 vec!["mutants", "--list", "--json", "--features", "cli"]
2017 );
2018 assert_eq!(command.get_current_dir(), Some(Path::new("/crate")));
2019 Ok(fake_stdout(0, json))
2020 },
2021 )
2022 .unwrap();
2023 assert_eq!(listed.len(), 1);
2024 assert_eq!(listed[0].file, "src/lib.rs");
2025 assert_eq!(listed[0].span.start.line, 3);
2026 assert_eq!(listed[0].span.end.line, 5);
2027 assert_eq!(listed[0].name, "replace add -> 0");
2028 }
2029
2030 #[cfg(unix)]
2031 #[test]
2032 fn list_cargo_mutants_reports_a_nonzero_exit_with_the_engine_output() {
2033 let err = list_cargo_mutants(
2034 Path::new("/cache/bin/cargo-mutants"),
2035 Path::new("/crate"),
2036 &[],
2037 |_| Ok(fake_output(1, "error: no such option")),
2038 )
2039 .unwrap_err();
2040 assert!(
2041 err.to_string().contains("cargo-mutants --list failed")
2042 && err.to_string().contains("no such option"),
2043 "got: {err}"
2044 );
2045 }
2046
2047 #[cfg(unix)]
2048 #[test]
2049 fn list_cargo_mutants_propagates_a_spawn_failure() {
2050 let err = list_cargo_mutants(
2051 Path::new("/cache/bin/cargo-mutants"),
2052 Path::new("/crate"),
2053 &[],
2054 |_| {
2055 Err(std::io::Error::new(
2056 std::io::ErrorKind::NotFound,
2057 "no engine",
2058 ))
2059 },
2060 )
2061 .unwrap_err();
2062 assert!(
2063 err.to_string()
2064 .contains("listing the crate's mutants with cargo-mutants"),
2065 "got: {err}"
2066 );
2067 }
2068
2069 #[cfg(unix)]
2070 fn listed_mutant(file: &str, start: u32, end: u32, name: &str) -> MutantInfo {
2071 MutantInfo {
2072 file: file.to_string(),
2073 span: Span {
2074 start: LineCol { line: start },
2075 end: LineCol { line: end },
2076 },
2077 name: name.to_string(),
2078 }
2079 }
2080
2081 #[cfg(unix)]
2082 fn diff_with_inserted(file: &str, lines: &[u32]) -> BaseDiff {
2083 BaseDiff {
2084 files: vec![file.to_string()],
2085 inserted: BTreeMap::from([(file.to_string(), lines.iter().copied().collect())]),
2086 }
2087 }
2088
2089 #[cfg(unix)]
2090 #[test]
2091 fn zero_mutant_verdict_accepts_a_zero_with_no_mutant_on_the_inserted_lines() {
2092 let run = fake_output(0, "");
2093 zero_mutant_verdict(&[], &diff_with_inserted("src/lib.rs", &[5]), &run).unwrap();
2094 let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2095 zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[4, 9]), &run).unwrap();
2096 zero_mutant_verdict(&listed, &diff_with_inserted("src/other.rs", &[6]), &run).unwrap();
2097 }
2098
2099 #[cfg(unix)]
2100 #[test]
2101 fn zero_mutant_verdict_is_fatal_on_a_mutant_at_either_span_boundary() {
2102 let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2103 let run = fake_stdout(0, "0 mutants tested");
2104 for line in [5, 8] {
2105 let err =
2106 zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[line]), &run)
2107 .unwrap_err();
2108 let message = err.to_string();
2109 assert!(
2110 message.contains("1 of the crate's 1 mutant site(s)")
2111 && message.contains("src/lib.rs:5: replace add -> 0")
2112 && message.contains("0 mutants tested"),
2113 "got: {message}"
2114 );
2115 }
2116 }
2117
2118 #[cfg(unix)]
2119 #[test]
2120 fn classify_mutants_exit_accepts_the_caught_and_survivor_exits() {
2121 classify_mutants_exit(Path::new("/crate"), &fake_output(0, "")).unwrap();
2122 classify_mutants_exit(Path::new("/crate"), &fake_output(2, "")).unwrap();
2123 }
2124
2125 #[cfg(unix)]
2126 #[test]
2127 fn classify_mutants_exit_accepts_a_timeout_exit_3() {
2128 classify_mutants_exit(Path::new("/crate"), &fake_output(3, ""))
2129 .expect("a timeout (exit 3) is inconclusive, not fatal");
2130 }
2131
2132 #[cfg(unix)]
2133 #[test]
2134 fn classify_mutants_exit_is_fatal_on_a_baseline_failure() {
2135 let err = classify_mutants_exit(Path::new("/crate"), &fake_output(4, "baseline broke"))
2136 .unwrap_err();
2137 assert!(
2138 err.to_string().contains("did not run cleanly")
2139 && err.to_string().contains("baseline broke"),
2140 "got: {err}"
2141 );
2142 }
2143
2144 #[test]
2145 fn cargo_mutants_bin_name_matches_the_platform() {
2146 let name = cargo_mutants_bin_name();
2147 if cfg!(windows) {
2148 assert_eq!(name, "cargo-mutants.exe");
2149 } else {
2150 assert_eq!(name, "cargo-mutants");
2151 }
2152 }
2153}