1use std::collections::{BTreeMap, BTreeSet};
19use std::ffi::OsString;
20use std::path::{Path, PathBuf};
21use std::process::{Command, Output};
22use std::sync::atomic::{AtomicU64, Ordering};
23
24use anyhow::{bail, Context, Result};
25use serde::Deserialize;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Survivor {
30 pub file: String,
33 pub line: u32,
35 pub description: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum Measurement {
46 EngineNotRun,
48 Tested {
51 count: usize,
52 survivors: Vec<Survivor>,
53 },
54}
55
56pub type MutatedLines = BTreeSet<(String, u32)>;
60
61#[derive(Debug, Clone, Deserialize)]
64pub struct MutantsReport {
65 pub outcomes: Vec<MutantOutcome>,
66}
67
68#[derive(Debug, Clone, Deserialize)]
72pub struct MutantOutcome {
73 pub summary: String,
74 pub scenario: Scenario,
75}
76
77#[derive(Debug, Clone, Deserialize)]
80pub enum Scenario {
81 Baseline,
82 Mutant(MutantInfo),
83}
84
85#[derive(Debug, Clone, Deserialize)]
89pub struct MutantInfo {
90 pub file: String,
91 pub span: Span,
92 pub name: String,
93}
94
95#[derive(Debug, Clone, Deserialize)]
97pub struct Span {
98 pub start: LineCol,
99 pub end: LineCol,
100}
101
102#[derive(Debug, Clone, Deserialize)]
104pub struct LineCol {
105 pub line: u32,
106}
107
108pub fn parse_mutants_report(json: &str) -> Result<MutantsReport> {
110 serde_json::from_str(json).context("parsing cargo-mutants outcomes.json")
111}
112
113fn parse_mutants_list(json: &str) -> Result<Vec<MutantInfo>> {
116 serde_json::from_str(json).context("parsing the cargo-mutants mutant list")
117}
118
119pub fn unexplained_survivors(report: &MutantsReport, exempt: &[String]) -> Vec<Survivor> {
128 evaluate(cargo_mutants_survivors(report), exempt)
129}
130
131fn cargo_mutants_survivors(report: &MutantsReport) -> Vec<Survivor> {
135 report
136 .outcomes
137 .iter()
138 .filter_map(|outcome| {
139 if outcome.summary != "MissedMutant" {
140 return None;
141 }
142 let Scenario::Mutant(mutant) = &outcome.scenario else {
143 return None;
144 };
145 Some(Survivor {
146 file: mutant.file.clone(),
147 line: mutant.span.start.line,
148 description: mutant.name.clone(),
149 })
150 })
151 .collect()
152}
153
154pub fn mutated_lines(report: &MutantsReport) -> MutatedLines {
160 report
161 .outcomes
162 .iter()
163 .filter_map(|outcome| {
164 if outcome.summary != "CaughtMutant" && outcome.summary != "MissedMutant" {
165 return None;
166 }
167 let Scenario::Mutant(mutant) = &outcome.scenario else {
168 return None;
169 };
170 Some((mutant.file.clone(), mutant.span.start.line))
171 })
172 .collect()
173}
174
175fn conclusive_count(report: &MutantsReport) -> usize {
179 report
180 .outcomes
181 .iter()
182 .filter(|outcome| outcome.summary == "CaughtMutant" || outcome.summary == "MissedMutant")
183 .count()
184}
185
186pub fn evaluate(survivors: Vec<Survivor>, exempt: &[String]) -> Vec<Survivor> {
191 survivors
192 .into_iter()
193 .filter(|survivor| !exempt.iter().any(|path| path == &survivor.file))
194 .collect()
195}
196
197pub fn evaluate_scoped(
209 survivors: Vec<Survivor>,
210 mutated: &MutatedLines,
211 whole_file: &[String],
212 line_scoped: &BTreeMap<String, BTreeSet<u32>>,
213) -> Result<Vec<Survivor>> {
214 let mut over: Vec<String> = Vec::new();
215 for (file, lines) in line_scoped {
216 for &line in lines {
217 let has_survivor = survivors
218 .iter()
219 .any(|survivor| survivor.file == *file && survivor.line == line);
220 if has_survivor {
221 continue;
222 }
223 if mutated.contains(&(file.clone(), line)) {
224 over.push(format!("\n {file}:{line}"));
225 }
226 }
227 }
228 if !over.is_empty() {
229 bail!(
230 "a line-scoped mutation exemption may only list a line with a surviving mutant, but \
231 these had mutants that were all caught:{}",
232 over.concat()
233 );
234 }
235 Ok(survivors
236 .into_iter()
237 .filter(|survivor| {
238 let whole = whole_file.iter().any(|path| path == &survivor.file);
239 let line = line_scoped
240 .get(&survivor.file)
241 .is_some_and(|lines| lines.contains(&survivor.line));
242 !(whole || line)
243 })
244 .collect())
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
253#[serde(rename_all = "snake_case")]
254pub enum MutantStatus {
255 Survived,
257 Killed,
259 NoCoverage,
261 Timeout,
263 CompileError,
265 RuntimeError,
267}
268
269impl MutantStatus {
270 fn is_survivor(self) -> bool {
273 matches!(self, MutantStatus::Survived | MutantStatus::NoCoverage)
274 }
275
276 fn is_viable(self) -> bool {
281 matches!(
282 self,
283 MutantStatus::Survived
284 | MutantStatus::Killed
285 | MutantStatus::NoCoverage
286 | MutantStatus::Timeout
287 )
288 }
289
290 fn is_conclusive(self) -> bool {
294 matches!(
295 self,
296 MutantStatus::Survived | MutantStatus::Killed | MutantStatus::NoCoverage
297 )
298 }
299}
300
301#[derive(Debug, Clone, Deserialize)]
304pub struct NormalizedMutant {
305 pub file: String,
307 pub line: u32,
309 pub status: MutantStatus,
311 pub mutator: String,
313 #[serde(default)]
315 pub replacement: Option<String>,
316}
317
318pub fn parse_normalized_results(json: &str) -> Result<Vec<NormalizedMutant>> {
321 serde_json::from_str(json).context("parsing normalized mutation results")
322}
323
324pub fn evaluate_normalized(
332 mutants: &[NormalizedMutant],
333 whole_file: &[String],
334 line_scoped: &BTreeMap<String, BTreeSet<u32>>,
335) -> Result<Vec<Survivor>> {
336 evaluate_scoped(
337 normalized_survivors(mutants),
338 &normalized_mutated_lines(mutants),
339 whole_file,
340 line_scoped,
341 )
342}
343
344fn normalized_survivors(mutants: &[NormalizedMutant]) -> Vec<Survivor> {
346 mutants
347 .iter()
348 .filter(|mutant| mutant.status.is_survivor())
349 .map(|mutant| Survivor {
350 file: mutant.file.clone(),
351 line: mutant.line,
352 description: describe_normalized(mutant),
353 })
354 .collect()
355}
356
357fn normalized_mutated_lines(mutants: &[NormalizedMutant]) -> MutatedLines {
360 mutants
361 .iter()
362 .filter(|mutant| mutant.status.is_viable())
363 .map(|mutant| (mutant.file.clone(), mutant.line))
364 .collect()
365}
366
367fn normalized_conclusive_count(mutants: &[NormalizedMutant]) -> usize {
370 mutants
371 .iter()
372 .filter(|mutant| mutant.status.is_conclusive())
373 .count()
374}
375
376fn describe_normalized(mutant: &NormalizedMutant) -> String {
379 match &mutant.replacement {
380 Some(replacement) => format!("{} (-> {})", mutant.mutator, one_line(replacement)),
381 None => mutant.mutator.clone(),
382 }
383}
384
385pub fn measure_rust(
396 root: &Path,
397 exempt: &[String],
398 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
399 base: Option<&str>,
400 features: &[String],
401) -> Result<Measurement> {
402 let out = MutantsOut::new();
403 let workspace_root = cargo_workspace_root(root)?;
408 let prefix = canonical_scan_prefix(root, &workspace_root);
409 let mut base_diff = None;
410 let diff = match base {
411 Some(base) => {
414 match write_base_diff(root, &workspace_root, prefix.as_deref(), base, &out)? {
415 None => return Ok(Measurement::EngineNotRun),
416 Some(path) => {
417 let parsed =
418 parse_base_diff(&std::fs::read_to_string(&path).with_context(|| {
419 format!("reading the written base diff `{}`", path.display())
420 })?);
421 if !parsed.files.iter().any(|file| file.ends_with(".rs")) {
425 return Ok(Measurement::EngineNotRun);
426 }
427 base_diff = Some(parsed);
428 Some(path)
429 }
430 }
431 }
432 None => None,
433 };
434 let engine = ensure_cargo_mutants()?;
435 let run = run_cargo_mutants(&engine, root, &out.0, diff.as_deref(), features)?;
436 let outcomes = out.0.join("mutants.out").join("outcomes.json");
437 let json = match std::fs::read_to_string(&outcomes) {
444 Ok(json) => json,
445 Err(_) => {
446 if let Some(diff) = &base_diff {
447 let listed =
448 list_cargo_mutants(&engine, root, features, |command| command.output())?;
449 zero_mutant_verdict(&listed, diff, &run)?;
450 }
451 return Ok(Measurement::Tested {
452 count: 0,
453 survivors: Vec::new(),
454 });
455 }
456 };
457 let report = rebase_report_paths(parse_mutants_report(&json)?, prefix.as_deref());
458 let survivors = evaluate_scoped(
459 cargo_mutants_survivors(&report),
460 &mutated_lines(&report),
461 exempt,
462 exempt_lines,
463 )?;
464 Ok(Measurement::Tested {
465 count: conclusive_count(&report),
466 survivors,
467 })
468}
469
470fn one_line(replacement: &str) -> String {
473 let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
474 const MAX: usize = 60;
475 if flat.chars().count() > MAX {
476 format!("{}…", flat.chars().take(MAX).collect::<String>())
477 } else {
478 flat
479 }
480}
481
482pub fn measure_typescript(
514 root: &Path,
515 exempt: &[String],
516 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
517 base: Option<&str>,
518 adapter: &Path,
519) -> Result<Measurement> {
520 let package_root =
521 crate::tiers::package_root(root, "package.json").unwrap_or_else(|| root.to_path_buf());
522 let prefix = scan_prefix(root, &package_root);
523 let mutate = match base {
524 Some(base) => {
525 let ranges = mutate_ranges(root, base)?;
526 if ranges.is_empty() {
529 return Ok(Measurement::EngineNotRun);
530 }
531 Some(prefix_mutate_specs(ranges, prefix.as_deref()))
532 }
533 None => prefix.as_deref().map(scan_scoped_mutate_globs),
534 };
535 let json = run_ts_adapter(&package_root, adapter, mutate.as_deref(), prefix.as_deref())?;
536 let mutants = to_scan_relative(parse_normalized_results(&json)?, prefix.as_deref());
537 let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
538 Ok(Measurement::Tested {
539 count: normalized_conclusive_count(&mutants),
540 survivors,
541 })
542}
543
544fn scan_prefix(root: &Path, package_root: &Path) -> Option<String> {
549 let rel = root.strip_prefix(package_root).ok()?;
550 let parts: Vec<String> = rel
551 .components()
552 .map(|part| part.as_os_str().to_string_lossy().into_owned())
553 .collect();
554 if parts.is_empty() {
555 None
556 } else {
557 Some(parts.join("/"))
558 }
559}
560
561fn prefix_mutate_specs(specs: Vec<String>, prefix: Option<&str>) -> Vec<String> {
564 match prefix {
565 None => specs,
566 Some(prefix) => specs
567 .into_iter()
568 .map(|spec| format!("{prefix}/{spec}"))
569 .collect(),
570 }
571}
572
573fn scan_scoped_mutate_globs(prefix: &str) -> Vec<String> {
577 const EXTENSIONS: &str = "+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)";
578 vec![
579 format!("{prefix}/**/!(*.+(s|S)pec|*.+(t|T)est).{EXTENSIONS}"),
580 format!("!{prefix}/**/__tests__/**/*.{EXTENSIONS}"),
581 ]
582}
583
584fn to_scan_relative(mutants: Vec<NormalizedMutant>, prefix: Option<&str>) -> Vec<NormalizedMutant> {
588 let Some(prefix) = prefix else {
589 return mutants;
590 };
591 let prefix = format!("{prefix}/");
592 mutants
593 .into_iter()
594 .filter_map(|mut mutant| {
595 mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
596 Some(mutant)
597 })
598 .collect()
599}
600
601fn adapter_cwd<'a>(root: &'a Path, engine: &str) -> Result<&'a Path> {
614 let cwd = if root.as_os_str().is_empty() {
615 Path::new(".")
616 } else {
617 root
618 };
619 if !cwd.is_dir() {
620 bail!(
621 "the {engine} mutation adapter's working directory `{}` is not a directory",
622 cwd.display()
623 );
624 }
625 Ok(cwd)
626}
627
628fn spawn_context(interpreter: &str, entry: &str, cwd: &Path) -> String {
633 format!(
634 "spawning `{interpreter} {entry}` in `{}` (is `{interpreter}` on PATH?)",
635 cwd.display()
636 )
637}
638
639fn run_ts_adapter(
652 package_root: &Path,
653 adapter: &Path,
654 mutate: Option<&[String]>,
655 vitest_dir: Option<&str>,
656) -> Result<String> {
657 let out = AdapterOut::new();
658 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
659 let results = out.0.join("results.json");
660
661 let cwd = adapter_cwd(package_root, "TypeScript")?;
662
663 let mut command = Command::new("node");
664 command
665 .current_dir(cwd)
666 .arg(adapter)
667 .arg("--out")
668 .arg(&results);
669 if let Some(specs) = mutate {
670 command.arg("--mutate").arg(specs.join(","));
671 }
672 if let Some(dir) = vitest_dir {
673 command.arg("--vitest-dir").arg(dir);
674 }
675 let output = command
676 .output()
677 .with_context(|| spawn_context("node", &adapter.display().to_string(), cwd))?;
678 if !output.status.success() {
679 bail!(
680 "the TypeScript mutation adapter failed in `{}`:\n{}{}",
681 cwd.display(),
682 String::from_utf8_lossy(&output.stdout),
683 String::from_utf8_lossy(&output.stderr),
684 );
685 }
686 std::fs::read_to_string(&results).with_context(|| {
687 format!(
688 "reading the TypeScript mutation adapter's results from `{}`",
689 results.display()
690 )
691 })
692}
693
694struct AdapterOut(PathBuf);
697
698impl AdapterOut {
699 fn new() -> Self {
700 static COUNTER: AtomicU64 = AtomicU64::new(0);
701 let name = format!(
702 "testing-conventions-ts-adapter-{}-{}",
703 std::process::id(),
704 COUNTER.fetch_add(1, Ordering::Relaxed),
705 );
706 AdapterOut(std::env::temp_dir().join(name))
707 }
708}
709
710impl Drop for AdapterOut {
711 fn drop(&mut self) {
712 let _ = std::fs::remove_dir_all(&self.0);
713 }
714}
715
716fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
722 let changed = crate::patch_coverage::changed_lines(root, base)?;
723 let mut specs = Vec::new();
724 for (file, lines) in changed {
725 if !is_mutatable_ts(&file) {
726 continue;
727 }
728 for (start, end) in contiguous_runs(&lines) {
729 specs.push(format!("{file}:{start}-{end}"));
730 }
731 }
732 Ok(specs)
733}
734
735fn is_mutatable_ts(file: &str) -> bool {
739 let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
740 .iter()
741 .any(|ext| file.ends_with(ext));
742 let is_decl = file.ends_with(".d.ts");
743 let is_test = file.contains(".test.") || file.contains(".spec.");
744 is_source && !is_decl && !is_test
745}
746
747fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
749 let mut runs: Vec<(u64, u64)> = Vec::new();
750 for &line in lines {
751 match runs.last_mut() {
752 Some(run) if run.1 + 1 == line => run.1 = line,
753 _ => runs.push((line, line)),
754 }
755 }
756 runs
757}
758
759pub fn measure_python(
777 root: &Path,
778 exempt: &[String],
779 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
780 base: Option<&str>,
781) -> Result<Measurement> {
782 let changed = match base {
783 Some(base) => Some(crate::patch_coverage::changed_lines(root, base)?),
784 None => None,
785 };
786 let modules: Vec<String> = match &changed {
787 None => Vec::new(),
788 Some(changed) => {
789 let modules: Vec<String> = changed
790 .keys()
791 .filter(|file| is_mutatable_py(file))
792 .cloned()
793 .collect();
794 if modules.is_empty() {
797 return Ok(Measurement::EngineNotRun);
798 }
799 modules
800 }
801 };
802 let json = run_py_adapter(root, &modules)?;
803 let mut mutants = parse_normalized_results(&json)?;
804 if let Some(changed) = &changed {
805 mutants.retain(|mutant| {
807 changed
808 .get(&mutant.file)
809 .is_some_and(|lines| lines.contains(&u64::from(mutant.line)))
810 });
811 }
812 let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
813 Ok(Measurement::Tested {
814 count: normalized_conclusive_count(&mutants),
815 survivors,
816 })
817}
818
819fn run_py_adapter(root: &Path, modules: &[String]) -> Result<String> {
827 let out = AdapterOut::new();
828 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
829 let results = out.0.join("results.json");
830
831 let cwd = adapter_cwd(root, "Python")?;
832
833 const ENTRY: &str = "-m testing_conventions.mutation.main";
834 let mut command = Command::new("python3");
835 command
836 .current_dir(cwd)
837 .args(["-m", "testing_conventions.mutation.main", "--out"])
838 .arg(&results)
839 .env("PYTHONDONTWRITEBYTECODE", "1");
840 for module in modules {
841 command.arg("--module").arg(module);
842 }
843 let output = command
844 .output()
845 .with_context(|| spawn_context("python3", ENTRY, cwd))?;
846 if !output.status.success() {
847 bail!(
848 "the Python mutation adapter failed in `{}`:\n{}{}",
849 cwd.display(),
850 String::from_utf8_lossy(&output.stdout),
851 String::from_utf8_lossy(&output.stderr),
852 );
853 }
854 std::fs::read_to_string(&results).with_context(|| {
855 format!(
856 "reading the Python mutation adapter's results from `{}`",
857 results.display()
858 )
859 })
860}
861
862fn is_mutatable_py(file: &str) -> bool {
865 if !file.ends_with(".py") {
866 return false;
867 }
868 let base = file.rsplit('/').next().unwrap_or(file);
869 !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
870}
871
872struct MutantsOut(PathBuf);
875
876impl MutantsOut {
877 fn new() -> Self {
878 static COUNTER: AtomicU64 = AtomicU64::new(0);
879 let name = format!(
880 "testing-conventions-mutants-{}-{}",
881 std::process::id(),
882 COUNTER.fetch_add(1, Ordering::Relaxed),
883 );
884 MutantsOut(std::env::temp_dir().join(name))
885 }
886}
887
888impl Drop for MutantsOut {
889 fn drop(&mut self) {
890 let _ = std::fs::remove_dir_all(&self.0);
891 }
892}
893
894fn cargo_workspace_root(root: &Path) -> Result<PathBuf> {
899 let output = Command::new("cargo")
900 .current_dir(root)
901 .args(["locate-project", "--workspace", "--message-format", "plain"])
902 .output()
903 .context("running `cargo locate-project` (is cargo installed?)")?;
904 if !output.status.success() {
905 bail!(
906 "cargo locate-project failed in `{}`: {}",
907 root.display(),
908 String::from_utf8_lossy(&output.stderr)
909 );
910 }
911 let manifest = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
912 manifest.parent().map(Path::to_path_buf).with_context(|| {
913 format!(
914 "no parent dir for the workspace manifest `{}`",
915 manifest.display()
916 )
917 })
918}
919
920fn canonical_scan_prefix(root: &Path, workspace_root: &Path) -> Option<String> {
924 let root = root.canonicalize().ok()?;
925 let workspace_root = workspace_root.canonicalize().ok()?;
926 scan_prefix(&root, &workspace_root)
927}
928
929fn write_base_diff(
939 root: &Path,
940 workspace_root: &Path,
941 prefix: Option<&str>,
942 base: &str,
943 out: &MutantsOut,
944) -> Result<Option<PathBuf>> {
945 let range = format!("{base}...HEAD");
946 let (dir, args) = match prefix {
947 None => (root, vec!["diff", "--relative", &range]),
948 Some(prefix) => (
949 workspace_root,
950 vec!["diff", "--relative", &range, "--", prefix],
951 ),
952 };
953 let output = Command::new("git")
954 .current_dir(dir)
955 .args(&args)
956 .output()
957 .context("running `git diff` for `--base` (is git installed?)")?;
958 if !output.status.success() {
959 bail!(
960 "git diff {range} failed: {}",
961 String::from_utf8_lossy(&output.stderr)
962 );
963 }
964 if output.stdout.is_empty() {
965 return Ok(None);
966 }
967 std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
968 let path = out.0.join("base.diff");
969 std::fs::write(&path, &output.stdout).context("writing the base diff")?;
970 Ok(Some(path))
971}
972
973struct BaseDiff {
978 files: Vec<String>,
979 inserted: BTreeMap<String, BTreeSet<u32>>,
980}
981
982fn parse_base_diff(diff: &str) -> BaseDiff {
987 let mut files = Vec::new();
988 let mut inserted: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
989 let mut current: Option<String> = None;
990 let mut lines = diff.lines();
991 while let Some(line) = lines.next() {
992 if let Some(path) = line.strip_prefix("+++ ") {
993 current = (path != "/dev/null").then(|| {
994 let path = path.strip_prefix("b/").unwrap_or(path).to_string();
995 files.push(path.clone());
996 path
997 });
998 } else if let Some(header) = line.strip_prefix("@@ ") {
999 let Some((new_start, old_count, new_count)) = parse_hunk_header(header) else {
1000 continue;
1001 };
1002 let mut new_line = new_start;
1003 let (mut old_left, mut new_left) = (old_count, new_count);
1004 while old_left > 0 || new_left > 0 {
1005 let Some(line) = lines.next() else { break };
1006 if line.starts_with('\\') {
1007 } else if line.starts_with('+') {
1010 if let Some(file) = ¤t {
1011 inserted.entry(file.clone()).or_default().insert(new_line);
1012 }
1013 new_line += 1;
1014 new_left = new_left.saturating_sub(1);
1015 } else if line.starts_with('-') {
1016 old_left = old_left.saturating_sub(1);
1017 } else {
1018 new_line += 1;
1019 old_left = old_left.saturating_sub(1);
1020 new_left = new_left.saturating_sub(1);
1021 }
1022 }
1023 }
1024 }
1025 BaseDiff { files, inserted }
1026}
1027
1028fn parse_hunk_header(header: &str) -> Option<(u32, u32, u32)> {
1030 let mut parts = header.split(' ');
1031 let (_, old_count) = parse_range(parts.next()?.strip_prefix('-')?)?;
1032 let (new_start, new_count) = parse_range(parts.next()?.strip_prefix('+')?)?;
1033 Some((new_start, old_count, new_count))
1034}
1035
1036fn parse_range(range: &str) -> Option<(u32, u32)> {
1038 match range.split_once(',') {
1039 Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
1040 None => Some((range.parse().ok()?, 1)),
1041 }
1042}
1043
1044fn rebase_report_paths(report: MutantsReport, prefix: Option<&str>) -> MutantsReport {
1051 let Some(prefix) = prefix else {
1052 return report;
1053 };
1054 let prefix = format!("{prefix}/");
1055 MutantsReport {
1056 outcomes: report
1057 .outcomes
1058 .into_iter()
1059 .filter_map(|mut outcome| {
1060 if let Scenario::Mutant(mutant) = &mut outcome.scenario {
1061 mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
1062 }
1063 Some(outcome)
1064 })
1065 .collect(),
1066 }
1067}
1068
1069const CARGO_MUTANTS_VERSION: &str = "27.1.0";
1072
1073fn ensure_cargo_mutants() -> Result<PathBuf> {
1083 let root = cargo_mutants_cache_root();
1084 let bin = root.join("bin").join(cargo_mutants_bin_name());
1085 let lock_path = root.join(".install.lock");
1086 provision(&bin, &lock_path, || {
1087 run_install(&root, |command| command.output())
1088 })
1089}
1090
1091fn cargo_mutants_bin_name() -> &'static str {
1094 if cfg!(windows) {
1095 "cargo-mutants.exe"
1096 } else {
1097 "cargo-mutants"
1098 }
1099}
1100
1101fn cargo_mutants_cache_root() -> PathBuf {
1105 cache_base()
1106 .join("testing-conventions")
1107 .join(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}"))
1108}
1109
1110fn cache_base() -> PathBuf {
1113 resolve_cache_base(std::env::var_os("XDG_CACHE_HOME"), std::env::var_os("HOME"))
1114}
1115
1116fn resolve_cache_base(xdg: Option<OsString>, home: Option<OsString>) -> PathBuf {
1119 if let Some(dir) = xdg.filter(|value| !value.is_empty()) {
1120 return PathBuf::from(dir);
1121 }
1122 if let Some(dir) = home.filter(|value| !value.is_empty()) {
1123 return PathBuf::from(dir).join(".cache");
1124 }
1125 std::env::temp_dir()
1126}
1127
1128fn provision(
1143 bin: &Path,
1144 lock_path: &Path,
1145 install: impl FnOnce() -> Result<()>,
1146) -> Result<PathBuf> {
1147 if bin.exists() {
1148 return Ok(bin.to_path_buf());
1149 }
1150 if let Some(parent) = lock_path.parent() {
1151 std::fs::create_dir_all(parent).context("creating the provisioning lock's parent dir")?;
1152 }
1153 let lock_file = std::fs::OpenOptions::new()
1154 .create(true)
1155 .truncate(false)
1156 .write(true)
1157 .open(lock_path)
1158 .context("opening the provisioning lock file")?;
1159 lock_file
1160 .lock()
1161 .context("acquiring the provisioning lock")?;
1162 if bin.exists() {
1164 return Ok(bin.to_path_buf());
1165 }
1166 install()?;
1167 if !bin.exists() {
1168 bail!(
1169 "provisioning reported success but cargo-mutants is not at `{}`",
1170 bin.display()
1171 );
1172 }
1173 Ok(bin.to_path_buf())
1174}
1175
1176fn install_argv(root: &Path) -> Vec<OsString> {
1180 vec![
1181 OsString::from("install"),
1182 OsString::from("cargo-mutants"),
1183 OsString::from("--locked"),
1184 OsString::from("--version"),
1185 OsString::from(CARGO_MUTANTS_VERSION),
1186 OsString::from("--root"),
1187 root.as_os_str().to_os_string(),
1188 ]
1189}
1190
1191fn run_install(
1196 root: &Path,
1197 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1198) -> Result<()> {
1199 let mut command = Command::new("cargo");
1200 command.args(install_argv(root));
1201 strip_llvm_cov_env(&mut command);
1202 let output = run(&mut command)
1203 .context("provisioning cargo-mutants via `cargo install` (is cargo installed?)")?;
1204 if !output.status.success() {
1205 bail!(
1206 "failed to provision cargo-mutants {CARGO_MUTANTS_VERSION}:\n{}{}",
1207 String::from_utf8_lossy(&output.stdout),
1208 String::from_utf8_lossy(&output.stderr),
1209 );
1210 }
1211 Ok(())
1212}
1213
1214fn strip_llvm_cov_env(command: &mut Command) {
1218 for var in [
1219 "RUSTFLAGS",
1220 "CARGO_ENCODED_RUSTFLAGS",
1221 "RUSTDOCFLAGS",
1222 "CARGO_ENCODED_RUSTDOCFLAGS",
1223 "LLVM_PROFILE_FILE",
1224 "CARGO_LLVM_COV",
1225 "CARGO_LLVM_COV_SHOW_ENV",
1226 "CARGO_LLVM_COV_TARGET_DIR",
1227 "CARGO_LLVM_COV_BUILD_DIR",
1228 "RUSTC_WRAPPER",
1229 "RUSTC_WORKSPACE_WRAPPER",
1230 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
1231 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
1232 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
1233 ] {
1234 command.env_remove(var);
1235 }
1236}
1237
1238fn run_cargo_mutants(
1246 engine: &Path,
1247 root: &Path,
1248 out: &Path,
1249 in_diff: Option<&Path>,
1250 features: &[String],
1251) -> Result<Output> {
1252 let mut command = Command::new(engine);
1253 command
1254 .current_dir(root)
1255 .args(mutants_argv(out, in_diff, features));
1256 strip_llvm_cov_env(&mut command);
1257 let output = command.output().context("running cargo-mutants")?;
1258 classify_mutants_exit(root, &output)?;
1259 Ok(output)
1260}
1261
1262fn zero_mutant_verdict(listed: &[MutantInfo], diff: &BaseDiff, run: &Output) -> Result<()> {
1270 let dropped: Vec<&MutantInfo> = listed
1271 .iter()
1272 .filter(|mutant| {
1273 diff.inserted.get(&mutant.file).is_some_and(|lines| {
1274 lines
1275 .range(mutant.span.start.line..=mutant.span.end.line)
1276 .next()
1277 .is_some()
1278 })
1279 })
1280 .collect();
1281 if dropped.is_empty() {
1282 return Ok(());
1283 }
1284 let sites: Vec<String> = dropped
1285 .iter()
1286 .map(|mutant| {
1287 format!(
1288 " {}:{}: {}",
1289 mutant.file, mutant.span.start.line, mutant.name
1290 )
1291 })
1292 .collect();
1293 bail!(
1294 "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{}{}",
1295 dropped.len(),
1296 listed.len(),
1297 sites.join("\n"),
1298 String::from_utf8_lossy(&run.stdout),
1299 String::from_utf8_lossy(&run.stderr),
1300 )
1301}
1302
1303fn list_argv(features: &[String]) -> Vec<OsString> {
1307 let mut argv = vec![
1308 OsString::from("mutants"),
1309 OsString::from("--list"),
1310 OsString::from("--json"),
1311 ];
1312 if !features.is_empty() {
1313 argv.push(OsString::from("--features"));
1314 argv.push(OsString::from(features.join(",")));
1315 }
1316 argv
1317}
1318
1319fn list_cargo_mutants(
1323 engine: &Path,
1324 root: &Path,
1325 features: &[String],
1326 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1327) -> Result<Vec<MutantInfo>> {
1328 let mut command = Command::new(engine);
1329 command.current_dir(root).args(list_argv(features));
1330 strip_llvm_cov_env(&mut command);
1331 let output = run(&mut command).context("listing the crate's mutants with cargo-mutants")?;
1332 if !output.status.success() {
1333 bail!(
1334 "cargo-mutants --list failed in `{}`:\n{}{}",
1335 root.display(),
1336 String::from_utf8_lossy(&output.stdout),
1337 String::from_utf8_lossy(&output.stderr),
1338 );
1339 }
1340 parse_mutants_list(&String::from_utf8_lossy(&output.stdout))
1341}
1342
1343fn mutants_argv(out: &Path, in_diff: Option<&Path>, features: &[String]) -> Vec<OsString> {
1354 let mut argv = vec![
1355 OsString::from("mutants"),
1356 OsString::from("--output"),
1357 out.as_os_str().to_os_string(),
1358 ];
1359 if let Some(diff) = in_diff {
1360 argv.push(OsString::from("--in-diff"));
1361 argv.push(diff.as_os_str().to_os_string());
1362 }
1363 if !features.is_empty() {
1364 argv.push(OsString::from("--features"));
1365 argv.push(OsString::from(features.join(",")));
1366 }
1367 argv
1368}
1369
1370fn classify_mutants_exit(root: &Path, output: &Output) -> Result<()> {
1380 match output.status.code() {
1381 Some(0) | Some(2) | Some(3) => Ok(()),
1384 _ => bail!(
1385 "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
1386 root.display(),
1387 String::from_utf8_lossy(&output.stdout),
1388 String::from_utf8_lossy(&output.stderr),
1389 ),
1390 }
1391}
1392
1393#[cfg(test)]
1394mod tests {
1395 use super::*;
1396
1397 const NORMALIZED: &str = r#"[
1401 {"file": "src/a.ts", "line": 2, "status": "survived",
1402 "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
1403 {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
1404 {"file": "src/a.ts", "line": 9, "status": "killed",
1405 "mutator": "BooleanLiteral", "replacement": "false"},
1406 {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
1407 {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
1408 {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
1409 ]"#;
1410
1411 #[test]
1412 fn parses_the_normalized_schema() {
1413 let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
1414 assert_eq!(mutants.len(), 6);
1415 assert_eq!(mutants[0].status, MutantStatus::Survived);
1416 assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
1417 assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
1418 assert_eq!(mutants[1].replacement, None);
1419 }
1420
1421 #[test]
1422 fn normalized_survivors_are_survived_and_nocoverage_only() {
1423 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1424 let survivors = normalized_survivors(&mutants);
1425 assert_eq!(survivors.len(), 2);
1427 assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
1428 assert!(survivors[0].description.contains("ConditionalExpression"));
1430 assert!(survivors[0].description.contains("-> true"));
1431 assert_eq!(survivors[1].description, "ArithmeticOperator");
1432 }
1433
1434 #[test]
1435 fn normalized_mutated_lines_collects_only_viable_mutants() {
1436 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1437 assert_eq!(
1440 normalized_mutated_lines(&mutants),
1441 [2u32, 5, 9, 12]
1442 .into_iter()
1443 .map(|line| ("src/a.ts".to_string(), line))
1444 .collect()
1445 );
1446 }
1447
1448 #[test]
1449 fn normalized_conclusive_count_is_survived_killed_and_nocoverage() {
1450 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1453 assert_eq!(normalized_conclusive_count(&mutants), 3);
1454 assert_eq!(normalized_conclusive_count(&[]), 0);
1455 }
1456
1457 #[test]
1458 fn evaluate_normalized_reports_unexempted_survivors() {
1459 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1460 let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
1461 assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
1462 }
1463
1464 #[test]
1465 fn evaluate_normalized_drops_a_whole_file_exemption() {
1466 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1467 let kept =
1468 evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
1469 assert!(
1470 kept.is_empty(),
1471 "the whole-file exemption lifts both survivors"
1472 );
1473 }
1474
1475 #[test]
1476 fn evaluate_normalized_drops_a_line_scoped_exemption() {
1477 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1478 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
1479 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1480 assert_eq!(kept.len(), 1);
1482 assert_eq!(kept[0].line, 5);
1483 }
1484
1485 #[test]
1486 fn evaluate_normalized_rejects_exempting_a_caught_line() {
1487 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1490 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
1491 let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
1492 assert!(
1493 err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
1494 "got: {err}"
1495 );
1496 }
1497
1498 #[test]
1499 fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
1500 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1503 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
1504 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1505 assert_eq!(kept.len(), 2);
1506 }
1507
1508 const SAMPLE: &str = r#"{
1511 "outcomes": [
1512 {"scenario": "Baseline", "summary": "Success",
1513 "phase_results": []},
1514 {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
1515 "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
1516 "function": {"function_name": "is_positive"},
1517 "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
1518 "summary": "MissedMutant"},
1519 {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
1520 "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
1521 "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
1522 "summary": "CaughtMutant"}
1523 ],
1524 "total_mutants": 2
1525 }"#;
1526
1527 #[test]
1528 fn parses_the_outcomes_export() {
1529 let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
1530 assert_eq!(report.outcomes.len(), 3);
1531 assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
1532 }
1533
1534 #[test]
1535 fn collects_only_missed_mutants_as_survivors() {
1536 let report = parse_mutants_report(SAMPLE).unwrap();
1537 let survivors = unexplained_survivors(&report, &[]);
1538 assert_eq!(survivors.len(), 1);
1540 assert_eq!(survivors[0].file, "src/lib.rs");
1541 assert_eq!(survivors[0].line, 7);
1542 assert!(survivors[0].description.contains("replace > with =="));
1543 }
1544
1545 #[test]
1546 fn conclusive_count_is_caught_plus_missed() {
1547 let report = parse_mutants_report(SAMPLE).unwrap();
1549 assert_eq!(conclusive_count(&report), 2);
1550 assert_eq!(conclusive_count(&MutantsReport { outcomes: vec![] }), 0);
1551 }
1552
1553 #[test]
1554 fn an_exemption_drops_a_survivor_in_that_file() {
1555 let report = parse_mutants_report(SAMPLE).unwrap();
1556 let exempt = vec!["src/lib.rs".to_string()];
1557 assert!(unexplained_survivors(&report, &exempt).is_empty());
1558 }
1559
1560 #[test]
1561 fn an_exemption_on_another_file_leaves_the_survivor() {
1562 let report = parse_mutants_report(SAMPLE).unwrap();
1563 let exempt = vec!["src/elsewhere.rs".to_string()];
1564 assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1565 }
1566
1567 #[test]
1568 fn rebase_report_paths_strips_the_workspace_prefix() {
1569 let report = parse_mutants_report(SAMPLE).unwrap();
1572 let prefixed = MutantsReport {
1573 outcomes: report
1574 .outcomes
1575 .iter()
1576 .cloned()
1577 .map(|mut outcome| {
1578 if let Scenario::Mutant(mutant) = &mut outcome.scenario {
1579 mutant.file = format!("member/{}", mutant.file);
1580 }
1581 outcome
1582 })
1583 .collect(),
1584 };
1585 let rebased = rebase_report_paths(prefixed, Some("member"));
1586 let survivors = unexplained_survivors(&rebased, &[]);
1587 assert_eq!(survivors.len(), 1);
1588 assert_eq!(survivors[0].file, "src/lib.rs");
1589 assert_eq!(rebased.outcomes.len(), 3);
1591 }
1592
1593 #[test]
1594 fn rebase_report_paths_drops_an_out_of_scope_mutant_and_keeps_none_identity() {
1595 let report = parse_mutants_report(SAMPLE).unwrap();
1596 let rebased = rebase_report_paths(report.clone(), Some("member"));
1598 assert_eq!(
1599 rebased.outcomes.len(),
1600 1,
1601 "only the pathless baseline outcome remains"
1602 );
1603 let unchanged = rebase_report_paths(report, None);
1605 assert_eq!(unchanged.outcomes.len(), 3);
1606 assert_eq!(unexplained_survivors(&unchanged, &[])[0].file, "src/lib.rs");
1607 }
1608
1609 #[test]
1610 fn adapter_cwd_normalises_the_empty_package_root_to_the_current_dir() {
1611 assert_eq!(
1617 adapter_cwd(Path::new(""), "TypeScript").unwrap(),
1618 Path::new(".")
1619 );
1620 assert_eq!(
1621 adapter_cwd(Path::new("src"), "TypeScript").unwrap(),
1622 Path::new("src")
1623 );
1624 }
1625
1626 #[test]
1627 fn adapter_cwd_rejects_a_directory_that_is_not_there() {
1628 let err = adapter_cwd(Path::new("no/such/dir"), "Python")
1632 .expect_err("a directory that is not there is an error");
1633 assert_eq!(
1634 err.to_string(),
1635 "the Python mutation adapter's working directory `no/such/dir` is not a directory"
1636 );
1637 }
1638
1639 #[test]
1640 fn spawn_context_names_the_interpreter_the_entry_and_the_working_directory() {
1641 assert_eq!(
1644 spawn_context("node", "/pkg/dist/mutation/main.js", Path::new("/pkg")),
1645 "spawning `node /pkg/dist/mutation/main.js` in `/pkg` (is `node` on PATH?)"
1646 );
1647 }
1648
1649 #[test]
1650 fn scan_prefix_is_the_scan_path_relative_to_the_package_root() {
1651 assert_eq!(
1652 scan_prefix(Path::new("/repo/pkg/src"), Path::new("/repo/pkg")),
1653 Some("src".to_string())
1654 );
1655 assert_eq!(
1656 scan_prefix(Path::new("/repo/pkg/src/nested"), Path::new("/repo/pkg")),
1657 Some("src/nested".to_string())
1658 );
1659 assert_eq!(
1661 scan_prefix(Path::new("/repo/pkg"), Path::new("/repo/pkg")),
1662 None
1663 );
1664 assert_eq!(
1666 scan_prefix(Path::new("pkg/src"), Path::new("pkg")),
1667 Some("src".to_string())
1668 );
1669 }
1670
1671 #[test]
1672 fn prefix_mutate_specs_rebases_diff_ranges_onto_the_package_root() {
1673 let specs = vec!["index.ts:8-11".to_string(), "a/b.ts:2-2".to_string()];
1674 assert_eq!(
1675 prefix_mutate_specs(specs.clone(), Some("src")),
1676 vec![
1677 "src/index.ts:8-11".to_string(),
1678 "src/a/b.ts:2-2".to_string()
1679 ]
1680 );
1681 assert_eq!(prefix_mutate_specs(specs.clone(), None), specs);
1682 }
1683
1684 #[test]
1685 fn scan_scoped_mutate_globs_mirror_strykers_default_under_the_scan_path() {
1686 assert_eq!(
1687 scan_scoped_mutate_globs("src"),
1688 vec![
1689 "src/**/!(*.+(s|S)pec|*.+(t|T)est).+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
1690 .to_string(),
1691 "!src/**/__tests__/**/*.+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
1692 .to_string(),
1693 ]
1694 );
1695 }
1696
1697 #[test]
1698 fn to_scan_relative_strips_the_prefix_and_drops_out_of_scope_mutants() {
1699 let mutants = parse_normalized_results(
1700 r#"[
1701 {"file": "src/a.ts", "line": 2, "status": "survived", "mutator": "X"},
1702 {"file": "tests/e2e/t.ts", "line": 9, "status": "survived", "mutator": "X"}
1703 ]"#,
1704 )
1705 .unwrap();
1706 let rebased = to_scan_relative(mutants.clone(), Some("src"));
1707 assert_eq!(rebased.len(), 1, "the out-of-scan-path mutant is dropped");
1708 assert_eq!(rebased[0].file, "a.ts");
1709 let unchanged = to_scan_relative(mutants, None);
1711 assert_eq!(unchanged.len(), 2);
1712 assert_eq!(unchanged[0].file, "src/a.ts");
1713 }
1714
1715 #[test]
1716 fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
1717 assert!(is_mutatable_ts("src/index.ts"));
1718 assert!(is_mutatable_ts("src/util.tsx"));
1719 assert!(is_mutatable_ts("src/util.js"));
1720 assert!(!is_mutatable_ts("src/index.test.ts"));
1721 assert!(!is_mutatable_ts("src/index.spec.ts"));
1722 assert!(!is_mutatable_ts("src/types.d.ts"));
1723 assert!(!is_mutatable_ts("README.md"));
1724 }
1725
1726 #[test]
1727 fn contiguous_runs_collapses_adjacent_lines() {
1728 let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
1729 assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
1730 assert!(contiguous_runs(&BTreeSet::new()).is_empty());
1731 }
1732
1733 #[test]
1734 fn one_line_flattens_and_caps() {
1735 assert_eq!(one_line("a -\n b"), "a - b");
1736 let long = "x".repeat(80);
1737 let capped = one_line(&long);
1738 assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
1739 }
1740
1741 #[test]
1742 fn is_mutatable_py_keeps_sources_and_drops_tests() {
1743 assert!(is_mutatable_py("calc.py"));
1744 assert!(is_mutatable_py("pkg/util.py"));
1745 assert!(!is_mutatable_py("calc_test.py"));
1746 assert!(!is_mutatable_py("test_calc.py"));
1747 assert!(!is_mutatable_py("pkg/conftest.py"));
1748 assert!(!is_mutatable_py("README.md"));
1749 }
1750
1751 #[test]
1752 fn mutated_lines_collects_caught_and_missed() {
1753 let report = parse_mutants_report(SAMPLE).unwrap();
1756 assert_eq!(
1757 mutated_lines(&report),
1758 [
1759 ("src/lib.rs".to_string(), 7),
1760 ("src/other.rs".to_string(), 3)
1761 ]
1762 .into_iter()
1763 .collect()
1764 );
1765 }
1766
1767 #[test]
1768 fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1769 let report = parse_mutants_report(SAMPLE).unwrap();
1770 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1771 let kept = evaluate_scoped(
1772 cargo_mutants_survivors(&report),
1773 &mutated_lines(&report),
1774 &[],
1775 &line_scoped,
1776 )
1777 .unwrap();
1778 assert!(
1779 kept.is_empty(),
1780 "the src/lib.rs:7 survivor should be lifted"
1781 );
1782 }
1783
1784 #[test]
1785 fn evaluate_scoped_rejects_exempting_a_caught_line() {
1786 let report = parse_mutants_report(SAMPLE).unwrap();
1788 let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
1789 let err = evaluate_scoped(
1790 cargo_mutants_survivors(&report),
1791 &mutated_lines(&report),
1792 &[],
1793 &line_scoped,
1794 )
1795 .unwrap_err();
1796 assert!(
1797 err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
1798 "got: {err}"
1799 );
1800 }
1801
1802 #[test]
1803 fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
1804 let report = parse_mutants_report(SAMPLE).unwrap();
1807 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
1808 let kept = evaluate_scoped(
1809 cargo_mutants_survivors(&report),
1810 &mutated_lines(&report),
1811 &[],
1812 &line_scoped,
1813 )
1814 .unwrap();
1815 assert_eq!(kept.len(), 1);
1816 assert_eq!(kept[0].line, 7);
1817 }
1818
1819 #[test]
1820 fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1821 let report = parse_mutants_report(SAMPLE).unwrap();
1822 let kept = evaluate_scoped(
1823 cargo_mutants_survivors(&report),
1824 &mutated_lines(&report),
1825 &["src/lib.rs".to_string()],
1826 &BTreeMap::new(),
1827 )
1828 .unwrap();
1829 assert!(kept.is_empty());
1830 }
1831
1832 fn unique_tmp() -> PathBuf {
1833 static COUNTER: AtomicU64 = AtomicU64::new(0);
1834 let dir = std::env::temp_dir().join(format!(
1835 "tc-provision-test-{}-{}",
1836 std::process::id(),
1837 COUNTER.fetch_add(1, Ordering::Relaxed)
1838 ));
1839 std::fs::create_dir_all(&dir).unwrap();
1840 dir
1841 }
1842
1843 #[test]
1844 fn provision_returns_an_existing_binary_without_installing() {
1845 let tmp = unique_tmp();
1846 let bin = tmp.join("bin").join("cargo-mutants");
1847 let lock = tmp.join(".install.lock");
1848 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1849 std::fs::write(&bin, b"binary").unwrap();
1850 let mut installed = false;
1851 let got = provision(&bin, &lock, || {
1852 installed = true;
1853 Ok(())
1854 })
1855 .unwrap();
1856 assert_eq!(got, bin);
1857 assert!(!installed, "a present binary must not be reinstalled");
1858 std::fs::remove_dir_all(&tmp).unwrap();
1859 }
1860
1861 #[test]
1862 fn provision_installs_when_the_binary_is_absent() {
1863 let tmp = unique_tmp();
1864 let bin = tmp.join("bin").join("cargo-mutants");
1865 let lock = tmp.join(".install.lock");
1866 let mut installed = false;
1867 let got = provision(&bin, &lock, || {
1868 installed = true;
1869 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1870 std::fs::write(&bin, b"binary").unwrap();
1871 Ok(())
1872 })
1873 .unwrap();
1874 assert!(installed, "an absent binary must be installed");
1875 assert_eq!(got, bin);
1876 std::fs::remove_dir_all(&tmp).unwrap();
1877 }
1878
1879 #[test]
1880 fn provision_errors_when_install_produces_no_binary() {
1881 let tmp = unique_tmp();
1882 let bin = tmp.join("bin").join("cargo-mutants");
1883 let lock = tmp.join(".install.lock");
1884 let err = provision(&bin, &lock, || Ok(())).unwrap_err();
1885 assert!(
1886 err.to_string().contains("cargo-mutants is not at"),
1887 "got: {err}"
1888 );
1889 std::fs::remove_dir_all(&tmp).unwrap();
1890 }
1891
1892 #[test]
1893 fn provision_propagates_an_install_failure() {
1894 let tmp = unique_tmp();
1895 let bin = tmp.join("bin").join("cargo-mutants");
1896 let lock = tmp.join(".install.lock");
1897 let err = provision(&bin, &lock, || bail!("install blew up")).unwrap_err();
1898 assert!(err.to_string().contains("install blew up"), "got: {err}");
1899 std::fs::remove_dir_all(&tmp).unwrap();
1900 }
1901
1902 #[test]
1903 fn provision_does_not_duplicate_the_install_under_concurrent_callers() {
1904 use std::sync::{Arc, Barrier};
1911 use std::thread;
1912 use std::time::Duration;
1913
1914 let tmp = unique_tmp();
1915 let bin = tmp.join("bin").join("cargo-mutants");
1916 let lock = tmp.join(".install.lock");
1917 let install_count = Arc::new(AtomicU64::new(0));
1918 let barrier = Arc::new(Barrier::new(2));
1919
1920 let handles: Vec<_> = (0..2)
1921 .map(|_| {
1922 let bin = bin.clone();
1923 let lock = lock.clone();
1924 let install_count = Arc::clone(&install_count);
1925 let barrier = Arc::clone(&barrier);
1926 thread::spawn(move || {
1927 barrier.wait();
1928 provision(&bin, &lock, || {
1929 install_count.fetch_add(1, Ordering::SeqCst);
1930 thread::sleep(Duration::from_millis(50));
1931 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1932 std::fs::write(&bin, b"binary").unwrap();
1933 Ok(())
1934 })
1935 })
1936 })
1937 .collect();
1938
1939 for h in handles {
1940 h.join()
1941 .expect("provisioning thread must not panic")
1942 .unwrap();
1943 }
1944
1945 assert_eq!(
1946 install_count.load(Ordering::SeqCst),
1947 1,
1948 "two concurrent callers on a cold cache must share one install, not each run their own"
1949 );
1950 std::fs::remove_dir_all(&tmp).unwrap();
1951 }
1952
1953 #[test]
1954 fn resolve_cache_base_prefers_xdg_then_home_then_temp() {
1955 let xdg = |s: &str| Some(OsString::from(s));
1956 assert_eq!(
1958 resolve_cache_base(xdg("/xdg"), xdg("/home")),
1959 PathBuf::from("/xdg")
1960 );
1961 assert_eq!(
1963 resolve_cache_base(xdg(""), xdg("/home")),
1964 PathBuf::from("/home/.cache")
1965 );
1966 assert_eq!(
1968 resolve_cache_base(None, xdg("/home")),
1969 PathBuf::from("/home/.cache")
1970 );
1971 assert_eq!(resolve_cache_base(None, None), std::env::temp_dir());
1973 assert_eq!(
1974 resolve_cache_base(xdg(""), Some(OsString::new())),
1975 std::env::temp_dir()
1976 );
1977 }
1978
1979 #[test]
1980 fn cache_root_is_absolute_and_version_scoped() {
1981 let root = cargo_mutants_cache_root();
1982 assert!(
1983 root.ends_with(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}")),
1984 "version-scoped; got {root:?}"
1985 );
1986 assert!(
1987 root.to_string_lossy().contains("testing-conventions"),
1988 "tool-namespaced; got {root:?}"
1989 );
1990 assert!(
1992 root.is_absolute(),
1993 "expected an absolute path; got {root:?}"
1994 );
1995 }
1996
1997 #[test]
1998 fn install_argv_pins_the_version_and_isolates_the_root() {
1999 let argv: Vec<String> = install_argv(Path::new("/cache/cargo-mutants-27"))
2000 .iter()
2001 .map(|arg| arg.to_string_lossy().into_owned())
2002 .collect();
2003 assert_eq!(
2004 argv,
2005 vec![
2006 "install",
2007 "cargo-mutants",
2008 "--locked",
2009 "--version",
2010 CARGO_MUTANTS_VERSION,
2011 "--root",
2012 "/cache/cargo-mutants-27",
2013 ]
2014 );
2015 }
2016
2017 #[test]
2018 fn mutants_argv_enables_features_on_the_engine_itself() {
2019 let argv = |diff, features: &[&str]| -> Vec<String> {
2020 mutants_argv(
2021 Path::new("/out"),
2022 diff,
2023 &features.iter().map(|f| f.to_string()).collect::<Vec<_>>(),
2024 )
2025 .iter()
2026 .map(|arg| arg.to_string_lossy().into_owned())
2027 .collect()
2028 };
2029 assert_eq!(
2033 argv(None, &["cli", "boost"]),
2034 vec!["mutants", "--output", "/out", "--features", "cli,boost"]
2035 );
2036 assert_eq!(
2037 argv(Some(Path::new("/out/base.diff")), &["cli"]),
2038 vec![
2039 "mutants",
2040 "--output",
2041 "/out",
2042 "--in-diff",
2043 "/out/base.diff",
2044 "--features",
2045 "cli",
2046 ]
2047 );
2048 assert_eq!(argv(None, &[]), vec!["mutants", "--output", "/out"]);
2050 }
2051
2052 #[test]
2053 fn list_argv_mirrors_the_run_feature_selection() {
2054 let argv = |features: &[&str]| -> Vec<String> {
2055 list_argv(&features.iter().map(|f| f.to_string()).collect::<Vec<_>>())
2056 .iter()
2057 .map(|arg| arg.to_string_lossy().into_owned())
2058 .collect()
2059 };
2060 assert_eq!(argv(&[]), vec!["mutants", "--list", "--json"]);
2061 assert_eq!(
2062 argv(&["cli", "boost"]),
2063 vec!["mutants", "--list", "--json", "--features", "cli,boost"]
2064 );
2065 }
2066
2067 #[test]
2068 fn parse_base_diff_maps_inserted_lines_per_hunk() {
2069 let diff = "\
2070diff --git a/src/lib.rs b/src/lib.rs
2071--- a/src/lib.rs
2072+++ b/src/lib.rs
2073@@ -1,4 +1,5 @@
2074 fn a() {}
2075+fn b() {}
2076 fn c() {}
2077-fn d() {}
2078+fn e() {}
2079 fn f() {}
2080@@ -10,2 +11,4 @@
2081 tail
2082+one
2083+two
2084 more
2085";
2086 let parsed = parse_base_diff(diff);
2087 assert_eq!(parsed.files, vec!["src/lib.rs"]);
2088 assert_eq!(
2089 parsed.inserted.get("src/lib.rs"),
2090 Some(&BTreeSet::from([2, 4, 12, 13]))
2091 );
2092 }
2093
2094 #[test]
2095 fn parse_base_diff_leaves_a_deletion_only_file_without_inserted_lines() {
2096 let diff = "\
2097--- a/src/gone.rs
2098+++ b/src/gone.rs
2099@@ -5,2 +4,0 @@
2100-x
2101-y
2102";
2103 let parsed = parse_base_diff(diff);
2104 assert_eq!(parsed.files, vec!["src/gone.rs"]);
2105 assert!(parsed.inserted.is_empty());
2106 }
2107
2108 #[test]
2109 fn parse_base_diff_skips_a_deleted_file() {
2110 let diff = "\
2113--- a/src/dead.rs
2114+++ /dev/null
2115@@ -1,2 +0,0 @@
2116-a
2117-b
2118";
2119 let parsed = parse_base_diff(diff);
2120 assert!(parsed.files.is_empty());
2121 assert!(parsed.inserted.is_empty());
2122 }
2123
2124 #[test]
2125 fn parse_base_diff_consumes_hunk_bodies_by_count_so_content_never_reads_as_a_header() {
2126 let diff = "\
2129+++ b/notes.txt
2130@@ -1,1 +1,2 @@
2131 keep
2132++++ not a header
2133";
2134 let parsed = parse_base_diff(diff);
2135 assert_eq!(parsed.files, vec!["notes.txt"]);
2136 assert_eq!(parsed.inserted.get("notes.txt"), Some(&BTreeSet::from([2])));
2137 }
2138
2139 #[test]
2140 fn parse_base_diff_defaults_an_elided_hunk_count_to_one() {
2141 let diff = "\
2142+++ b/one.txt
2143@@ -1 +1 @@
2144-old
2145+new
2146";
2147 let parsed = parse_base_diff(diff);
2148 assert_eq!(parsed.inserted.get("one.txt"), Some(&BTreeSet::from([1])));
2149 }
2150
2151 #[test]
2152 fn parse_base_diff_skips_no_newline_annotations_mid_hunk() {
2153 let diff = "\
2156+++ b/n.txt
2157@@ -1 +1 @@
2158-old
2159\\ No newline at end of file
2160+new
2161\\ No newline at end of file
2162";
2163 let parsed = parse_base_diff(diff);
2164 assert_eq!(parsed.inserted.get("n.txt"), Some(&BTreeSet::from([1])));
2165 }
2166
2167 #[cfg(unix)]
2168 fn fake_output(code: i32, stderr: &str) -> Output {
2169 use std::os::unix::process::ExitStatusExt;
2170 Output {
2171 status: std::process::ExitStatus::from_raw(code << 8),
2172 stdout: Vec::new(),
2173 stderr: stderr.as_bytes().to_vec(),
2174 }
2175 }
2176
2177 #[cfg(unix)]
2178 #[test]
2179 fn run_install_succeeds_on_a_zero_exit() {
2180 let mut ran = false;
2181 run_install(Path::new("/cache/root"), |command| {
2182 ran = true;
2183 let argv: Vec<String> = command
2185 .get_args()
2186 .map(|arg| arg.to_string_lossy().into_owned())
2187 .collect();
2188 assert!(argv.contains(&CARGO_MUTANTS_VERSION.to_string()));
2189 Ok(fake_output(0, ""))
2190 })
2191 .unwrap();
2192 assert!(ran);
2193 }
2194
2195 #[cfg(unix)]
2196 #[test]
2197 fn run_install_reports_a_nonzero_exit_with_the_engine_output() {
2198 let err = run_install(Path::new("/cache/root"), |_| {
2199 Ok(fake_output(1, "error: could not compile cargo-mutants"))
2200 })
2201 .unwrap_err();
2202 assert!(
2203 err.to_string()
2204 .contains("failed to provision cargo-mutants")
2205 && err.to_string().contains("could not compile"),
2206 "got: {err}"
2207 );
2208 }
2209
2210 #[cfg(unix)]
2211 #[test]
2212 fn run_install_propagates_a_spawn_failure() {
2213 let err = run_install(Path::new("/cache/root"), |_| {
2214 Err(std::io::Error::new(
2215 std::io::ErrorKind::NotFound,
2216 "no cargo",
2217 ))
2218 })
2219 .unwrap_err();
2220 assert!(
2221 err.to_string().contains("is cargo installed?"),
2222 "got: {err}"
2223 );
2224 }
2225
2226 #[cfg(unix)]
2227 fn fake_stdout(code: i32, stdout: &str) -> Output {
2228 use std::os::unix::process::ExitStatusExt;
2229 Output {
2230 status: std::process::ExitStatus::from_raw(code << 8),
2231 stdout: stdout.as_bytes().to_vec(),
2232 stderr: Vec::new(),
2233 }
2234 }
2235
2236 #[cfg(unix)]
2237 #[test]
2238 fn list_cargo_mutants_parses_the_listing_from_a_clean_run() {
2239 let json = r#"[{"file": "src/lib.rs", "name": "replace add -> 0",
2240 "span": {"start": {"line": 3, "column": 1}, "end": {"line": 5, "column": 2}}}]"#;
2241 let listed = list_cargo_mutants(
2242 Path::new("/cache/bin/cargo-mutants"),
2243 Path::new("/crate"),
2244 &["cli".to_string()],
2245 |command| {
2246 let argv: Vec<String> = command
2247 .get_args()
2248 .map(|arg| arg.to_string_lossy().into_owned())
2249 .collect();
2250 assert_eq!(
2251 argv,
2252 vec!["mutants", "--list", "--json", "--features", "cli"]
2253 );
2254 assert_eq!(command.get_current_dir(), Some(Path::new("/crate")));
2255 Ok(fake_stdout(0, json))
2256 },
2257 )
2258 .unwrap();
2259 assert_eq!(listed.len(), 1);
2260 assert_eq!(listed[0].file, "src/lib.rs");
2261 assert_eq!(listed[0].span.start.line, 3);
2262 assert_eq!(listed[0].span.end.line, 5);
2263 assert_eq!(listed[0].name, "replace add -> 0");
2264 }
2265
2266 #[cfg(unix)]
2267 #[test]
2268 fn list_cargo_mutants_reports_a_nonzero_exit_with_the_engine_output() {
2269 let err = list_cargo_mutants(
2270 Path::new("/cache/bin/cargo-mutants"),
2271 Path::new("/crate"),
2272 &[],
2273 |_| Ok(fake_output(1, "error: no such option")),
2274 )
2275 .unwrap_err();
2276 assert!(
2277 err.to_string().contains("cargo-mutants --list failed")
2278 && err.to_string().contains("no such option"),
2279 "got: {err}"
2280 );
2281 }
2282
2283 #[cfg(unix)]
2284 #[test]
2285 fn list_cargo_mutants_propagates_a_spawn_failure() {
2286 let err = list_cargo_mutants(
2287 Path::new("/cache/bin/cargo-mutants"),
2288 Path::new("/crate"),
2289 &[],
2290 |_| {
2291 Err(std::io::Error::new(
2292 std::io::ErrorKind::NotFound,
2293 "no engine",
2294 ))
2295 },
2296 )
2297 .unwrap_err();
2298 assert!(
2299 err.to_string()
2300 .contains("listing the crate's mutants with cargo-mutants"),
2301 "got: {err}"
2302 );
2303 }
2304
2305 #[cfg(unix)]
2306 fn listed_mutant(file: &str, start: u32, end: u32, name: &str) -> MutantInfo {
2307 MutantInfo {
2308 file: file.to_string(),
2309 span: Span {
2310 start: LineCol { line: start },
2311 end: LineCol { line: end },
2312 },
2313 name: name.to_string(),
2314 }
2315 }
2316
2317 #[cfg(unix)]
2318 fn diff_with_inserted(file: &str, lines: &[u32]) -> BaseDiff {
2319 BaseDiff {
2320 files: vec![file.to_string()],
2321 inserted: BTreeMap::from([(file.to_string(), lines.iter().copied().collect())]),
2322 }
2323 }
2324
2325 #[cfg(unix)]
2326 #[test]
2327 fn zero_mutant_verdict_accepts_a_zero_with_no_mutant_on_the_inserted_lines() {
2328 let run = fake_output(0, "");
2329 zero_mutant_verdict(&[], &diff_with_inserted("src/lib.rs", &[5]), &run).unwrap();
2331 let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2333 zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[4, 9]), &run).unwrap();
2334 zero_mutant_verdict(&listed, &diff_with_inserted("src/other.rs", &[6]), &run).unwrap();
2336 }
2337
2338 #[cfg(unix)]
2339 #[test]
2340 fn zero_mutant_verdict_is_fatal_on_a_mutant_at_either_span_boundary() {
2341 let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2342 let run = fake_stdout(0, "0 mutants tested");
2343 for line in [5, 8] {
2344 let err =
2345 zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[line]), &run)
2346 .unwrap_err();
2347 let message = err.to_string();
2348 assert!(
2349 message.contains("1 of the crate's 1 mutant site(s)")
2350 && message.contains("src/lib.rs:5: replace add -> 0")
2351 && message.contains("0 mutants tested"),
2352 "got: {message}"
2353 );
2354 }
2355 }
2356
2357 #[cfg(unix)]
2358 #[test]
2359 fn classify_mutants_exit_accepts_the_caught_and_survivor_exits() {
2360 classify_mutants_exit(Path::new("/crate"), &fake_output(0, "")).unwrap();
2362 classify_mutants_exit(Path::new("/crate"), &fake_output(2, "")).unwrap();
2363 }
2364
2365 #[cfg(unix)]
2366 #[test]
2367 fn classify_mutants_exit_accepts_a_timeout_exit_3() {
2368 classify_mutants_exit(Path::new("/crate"), &fake_output(3, ""))
2372 .expect("a timeout (exit 3) is inconclusive, not fatal");
2373 }
2374
2375 #[cfg(unix)]
2376 #[test]
2377 fn classify_mutants_exit_is_fatal_on_a_baseline_failure() {
2378 let err = classify_mutants_exit(Path::new("/crate"), &fake_output(4, "baseline broke"))
2380 .unwrap_err();
2381 assert!(
2382 err.to_string().contains("did not run cleanly")
2383 && err.to_string().contains("baseline broke"),
2384 "got: {err}"
2385 );
2386 }
2387
2388 #[test]
2389 fn cargo_mutants_bin_name_matches_the_platform() {
2390 let name = cargo_mutants_bin_name();
2391 if cfg!(windows) {
2392 assert_eq!(name, "cargo-mutants.exe");
2393 } else {
2394 assert_eq!(name, "cargo-mutants");
2395 }
2396 }
2397}