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,
32 pub line: u32,
34 pub description: String,
36}
37
38pub type MutatedLines = BTreeSet<(String, u32)>;
42
43#[derive(Debug, Clone, Deserialize)]
46pub struct MutantsReport {
47 pub outcomes: Vec<MutantOutcome>,
48}
49
50#[derive(Debug, Clone, Deserialize)]
54pub struct MutantOutcome {
55 pub summary: String,
56 pub scenario: Scenario,
57}
58
59#[derive(Debug, Clone, Deserialize)]
62pub enum Scenario {
63 Baseline,
64 Mutant(MutantInfo),
65}
66
67#[derive(Debug, Clone, Deserialize)]
71pub struct MutantInfo {
72 pub file: String,
73 pub span: Span,
74 pub name: String,
75}
76
77#[derive(Debug, Clone, Deserialize)]
79pub struct Span {
80 pub start: LineCol,
81}
82
83#[derive(Debug, Clone, Deserialize)]
85pub struct LineCol {
86 pub line: u32,
87}
88
89pub fn parse_mutants_report(json: &str) -> Result<MutantsReport> {
91 serde_json::from_str(json).context("parsing cargo-mutants outcomes.json")
92}
93
94pub fn unexplained_survivors(report: &MutantsReport, exempt: &[String]) -> Vec<Survivor> {
103 evaluate(cargo_mutants_survivors(report), exempt)
104}
105
106fn cargo_mutants_survivors(report: &MutantsReport) -> Vec<Survivor> {
110 report
111 .outcomes
112 .iter()
113 .filter_map(|outcome| {
114 if outcome.summary != "MissedMutant" {
115 return None;
116 }
117 let Scenario::Mutant(mutant) = &outcome.scenario else {
118 return None;
119 };
120 Some(Survivor {
121 file: mutant.file.clone(),
122 line: mutant.span.start.line,
123 description: mutant.name.clone(),
124 })
125 })
126 .collect()
127}
128
129pub fn mutated_lines(report: &MutantsReport) -> MutatedLines {
135 report
136 .outcomes
137 .iter()
138 .filter_map(|outcome| {
139 if outcome.summary != "CaughtMutant" && outcome.summary != "MissedMutant" {
140 return None;
141 }
142 let Scenario::Mutant(mutant) = &outcome.scenario else {
143 return None;
144 };
145 Some((mutant.file.clone(), mutant.span.start.line))
146 })
147 .collect()
148}
149
150pub fn evaluate(survivors: Vec<Survivor>, exempt: &[String]) -> Vec<Survivor> {
155 survivors
156 .into_iter()
157 .filter(|survivor| !exempt.iter().any(|path| path == &survivor.file))
158 .collect()
159}
160
161pub fn evaluate_scoped(
173 survivors: Vec<Survivor>,
174 mutated: &MutatedLines,
175 whole_file: &[String],
176 line_scoped: &BTreeMap<String, BTreeSet<u32>>,
177) -> Result<Vec<Survivor>> {
178 let mut over: Vec<String> = Vec::new();
179 for (file, lines) in line_scoped {
180 for &line in lines {
181 let has_survivor = survivors
182 .iter()
183 .any(|survivor| survivor.file == *file && survivor.line == line);
184 if has_survivor {
185 continue;
186 }
187 if mutated.contains(&(file.clone(), line)) {
188 over.push(format!("\n {file}:{line}"));
189 }
190 }
191 }
192 if !over.is_empty() {
193 bail!(
194 "a line-scoped mutation exemption may only list a line with a surviving mutant, but \
195 these had mutants that were all caught:{}",
196 over.concat()
197 );
198 }
199 Ok(survivors
200 .into_iter()
201 .filter(|survivor| {
202 let whole = whole_file.iter().any(|path| path == &survivor.file);
203 let line = line_scoped
204 .get(&survivor.file)
205 .is_some_and(|lines| lines.contains(&survivor.line));
206 !(whole || line)
207 })
208 .collect())
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
217#[serde(rename_all = "snake_case")]
218pub enum MutantStatus {
219 Survived,
221 Killed,
223 NoCoverage,
225 Timeout,
227 CompileError,
229 RuntimeError,
231}
232
233impl MutantStatus {
234 fn is_survivor(self) -> bool {
237 matches!(self, MutantStatus::Survived | MutantStatus::NoCoverage)
238 }
239
240 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
255#[derive(Debug, Clone, Deserialize)]
258pub struct NormalizedMutant {
259 pub file: String,
261 pub line: u32,
263 pub status: MutantStatus,
265 pub mutator: String,
267 #[serde(default)]
269 pub replacement: Option<String>,
270}
271
272pub fn parse_normalized_results(json: &str) -> Result<Vec<NormalizedMutant>> {
275 serde_json::from_str(json).context("parsing normalized mutation results")
276}
277
278pub fn evaluate_normalized(
286 mutants: &[NormalizedMutant],
287 whole_file: &[String],
288 line_scoped: &BTreeMap<String, BTreeSet<u32>>,
289) -> Result<Vec<Survivor>> {
290 evaluate_scoped(
291 normalized_survivors(mutants),
292 &normalized_mutated_lines(mutants),
293 whole_file,
294 line_scoped,
295 )
296}
297
298fn normalized_survivors(mutants: &[NormalizedMutant]) -> Vec<Survivor> {
300 mutants
301 .iter()
302 .filter(|mutant| mutant.status.is_survivor())
303 .map(|mutant| Survivor {
304 file: mutant.file.clone(),
305 line: mutant.line,
306 description: describe_normalized(mutant),
307 })
308 .collect()
309}
310
311fn normalized_mutated_lines(mutants: &[NormalizedMutant]) -> MutatedLines {
314 mutants
315 .iter()
316 .filter(|mutant| mutant.status.is_viable())
317 .map(|mutant| (mutant.file.clone(), mutant.line))
318 .collect()
319}
320
321fn describe_normalized(mutant: &NormalizedMutant) -> String {
324 match &mutant.replacement {
325 Some(replacement) => format!("{} (-> {})", mutant.mutator, one_line(replacement)),
326 None => mutant.mutator.clone(),
327 }
328}
329
330pub fn measure_rust(
338 root: &Path,
339 exempt: &[String],
340 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
341 base: Option<&str>,
342 features: &[String],
343) -> Result<Vec<Survivor>> {
344 let out = MutantsOut::new();
345 let diff = match base {
346 Some(base) => match write_base_diff(root, base, &out)? {
349 None => return Ok(Vec::new()),
350 Some(path) => Some(path),
351 },
352 None => None,
353 };
354 let engine = ensure_cargo_mutants()?;
355 run_cargo_mutants(&engine, root, &out.0, diff.as_deref(), features)?;
356 let outcomes = out.0.join("mutants.out").join("outcomes.json");
357 let json = match std::fs::read_to_string(&outcomes) {
361 Ok(json) => json,
362 Err(_) => return Ok(Vec::new()),
363 };
364 let report = parse_mutants_report(&json)?;
365 evaluate_scoped(
366 cargo_mutants_survivors(&report),
367 &mutated_lines(&report),
368 exempt,
369 exempt_lines,
370 )
371}
372
373fn one_line(replacement: &str) -> String {
376 let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
377 const MAX: usize = 60;
378 if flat.chars().count() > MAX {
379 format!("{}…", flat.chars().take(MAX).collect::<String>())
380 } else {
381 flat
382 }
383}
384
385pub fn measure_typescript(
404 root: &Path,
405 exempt: &[String],
406 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
407 base: Option<&str>,
408 adapter: &Path,
409) -> Result<Vec<Survivor>> {
410 let mutate = match base {
411 Some(base) => {
412 let ranges = mutate_ranges(root, base)?;
413 if ranges.is_empty() {
415 return Ok(Vec::new());
416 }
417 Some(ranges)
418 }
419 None => None,
420 };
421 let json = run_ts_adapter(root, adapter, mutate.as_deref())?;
422 let mutants = parse_normalized_results(&json)?;
423 evaluate_normalized(&mutants, exempt, exempt_lines)
424}
425
426fn run_ts_adapter(root: &Path, adapter: &Path, mutate: Option<&[String]>) -> Result<String> {
437 let out = AdapterOut::new();
438 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
439 let results = out.0.join("results.json");
440
441 let mut command = Command::new("node");
442 command
443 .current_dir(root)
444 .arg(adapter)
445 .arg("--out")
446 .arg(&results);
447 if let Some(specs) = mutate {
448 command.arg("--mutate").arg(specs.join(","));
449 }
450 let output = command
451 .output()
452 .context("running the TypeScript mutation adapter (is `node` installed?)")?;
453 if !output.status.success() {
454 bail!(
455 "the TypeScript mutation adapter failed in `{}`:\n{}{}",
456 root.display(),
457 String::from_utf8_lossy(&output.stdout),
458 String::from_utf8_lossy(&output.stderr),
459 );
460 }
461 std::fs::read_to_string(&results).with_context(|| {
462 format!(
463 "reading the TypeScript mutation adapter's results from `{}`",
464 results.display()
465 )
466 })
467}
468
469struct AdapterOut(PathBuf);
472
473impl AdapterOut {
474 fn new() -> Self {
475 static COUNTER: AtomicU64 = AtomicU64::new(0);
476 let name = format!(
477 "testing-conventions-ts-adapter-{}-{}",
478 std::process::id(),
479 COUNTER.fetch_add(1, Ordering::Relaxed),
480 );
481 AdapterOut(std::env::temp_dir().join(name))
482 }
483}
484
485impl Drop for AdapterOut {
486 fn drop(&mut self) {
487 let _ = std::fs::remove_dir_all(&self.0);
488 }
489}
490
491fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
497 let changed = crate::patch_coverage::changed_lines(root, base)?;
498 let mut specs = Vec::new();
499 for (file, lines) in changed {
500 if !is_mutatable_ts(&file) {
501 continue;
502 }
503 for (start, end) in contiguous_runs(&lines) {
504 specs.push(format!("{file}:{start}-{end}"));
505 }
506 }
507 Ok(specs)
508}
509
510fn is_mutatable_ts(file: &str) -> bool {
514 let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
515 .iter()
516 .any(|ext| file.ends_with(ext));
517 let is_decl = file.ends_with(".d.ts");
518 let is_test = file.contains(".test.") || file.contains(".spec.");
519 is_source && !is_decl && !is_test
520}
521
522fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
524 let mut runs: Vec<(u64, u64)> = Vec::new();
525 for &line in lines {
526 match runs.last_mut() {
527 Some(run) if run.1 + 1 == line => run.1 = line,
528 _ => runs.push((line, line)),
529 }
530 }
531 runs
532}
533
534pub fn measure_python(
553 root: &Path,
554 exempt: &[String],
555 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
556 base: Option<&str>,
557) -> Result<Vec<Survivor>> {
558 let changed = match base {
559 Some(base) => Some(crate::patch_coverage::changed_lines(root, base)?),
560 None => None,
561 };
562 let modules: Vec<String> = match &changed {
563 None => Vec::new(),
564 Some(changed) => {
565 let modules: Vec<String> = changed
566 .keys()
567 .filter(|file| is_mutatable_py(file))
568 .cloned()
569 .collect();
570 if modules.is_empty() {
572 return Ok(Vec::new());
573 }
574 modules
575 }
576 };
577 let json = run_py_adapter(root, &modules)?;
578 let mut mutants = parse_normalized_results(&json)?;
579 if let Some(changed) = &changed {
580 mutants.retain(|mutant| {
582 changed
583 .get(&mutant.file)
584 .is_some_and(|lines| lines.contains(&u64::from(mutant.line)))
585 });
586 }
587 evaluate_normalized(&mutants, exempt, exempt_lines)
588}
589
590fn run_py_adapter(root: &Path, modules: &[String]) -> Result<String> {
598 let out = AdapterOut::new();
599 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
600 let results = out.0.join("results.json");
601
602 let mut command = Command::new("python3");
603 command
604 .current_dir(root)
605 .args(["-m", "testing_conventions.mutation.main", "--out"])
606 .arg(&results)
607 .env("PYTHONDONTWRITEBYTECODE", "1");
608 for module in modules {
609 command.arg("--module").arg(module);
610 }
611 let output = command
612 .output()
613 .context("running the Python mutation adapter (is `python3` installed?)")?;
614 if !output.status.success() {
615 bail!(
616 "the Python mutation adapter failed in `{}`:\n{}{}",
617 root.display(),
618 String::from_utf8_lossy(&output.stdout),
619 String::from_utf8_lossy(&output.stderr),
620 );
621 }
622 std::fs::read_to_string(&results).with_context(|| {
623 format!(
624 "reading the Python mutation adapter's results from `{}`",
625 results.display()
626 )
627 })
628}
629
630fn is_mutatable_py(file: &str) -> bool {
633 if !file.ends_with(".py") {
634 return false;
635 }
636 let base = file.rsplit('/').next().unwrap_or(file);
637 !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
638}
639
640struct MutantsOut(PathBuf);
643
644impl MutantsOut {
645 fn new() -> Self {
646 static COUNTER: AtomicU64 = AtomicU64::new(0);
647 let name = format!(
648 "testing-conventions-mutants-{}-{}",
649 std::process::id(),
650 COUNTER.fetch_add(1, Ordering::Relaxed),
651 );
652 MutantsOut(std::env::temp_dir().join(name))
653 }
654}
655
656impl Drop for MutantsOut {
657 fn drop(&mut self) {
658 let _ = std::fs::remove_dir_all(&self.0);
659 }
660}
661
662fn write_base_diff(root: &Path, base: &str, out: &MutantsOut) -> Result<Option<PathBuf>> {
671 let range = format!("{base}...HEAD");
672 let output = Command::new("git")
673 .current_dir(root)
674 .args(["diff", "--relative", &range])
675 .output()
676 .context("running `git diff` for `--base` (is git installed?)")?;
677 if !output.status.success() {
678 bail!(
679 "git diff {range} failed: {}",
680 String::from_utf8_lossy(&output.stderr)
681 );
682 }
683 if output.stdout.is_empty() {
684 return Ok(None);
685 }
686 std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
687 let path = out.0.join("base.diff");
688 std::fs::write(&path, &output.stdout).context("writing the base diff")?;
689 Ok(Some(path))
690}
691
692const CARGO_MUTANTS_VERSION: &str = "27.1.0";
695
696fn ensure_cargo_mutants() -> Result<PathBuf> {
706 let root = cargo_mutants_cache_root();
707 let bin = root.join("bin").join(cargo_mutants_bin_name());
708 let lock_path = root.join(".install.lock");
709 provision(&bin, &lock_path, || {
710 run_install(&root, |command| command.output())
711 })
712}
713
714fn cargo_mutants_bin_name() -> &'static str {
717 if cfg!(windows) {
718 "cargo-mutants.exe"
719 } else {
720 "cargo-mutants"
721 }
722}
723
724fn cargo_mutants_cache_root() -> PathBuf {
728 cache_base()
729 .join("testing-conventions")
730 .join(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}"))
731}
732
733fn cache_base() -> PathBuf {
736 resolve_cache_base(std::env::var_os("XDG_CACHE_HOME"), std::env::var_os("HOME"))
737}
738
739fn resolve_cache_base(xdg: Option<OsString>, home: Option<OsString>) -> PathBuf {
742 if let Some(dir) = xdg.filter(|value| !value.is_empty()) {
743 return PathBuf::from(dir);
744 }
745 if let Some(dir) = home.filter(|value| !value.is_empty()) {
746 return PathBuf::from(dir).join(".cache");
747 }
748 std::env::temp_dir()
749}
750
751fn provision(
766 bin: &Path,
767 lock_path: &Path,
768 install: impl FnOnce() -> Result<()>,
769) -> Result<PathBuf> {
770 if bin.exists() {
771 return Ok(bin.to_path_buf());
772 }
773 if let Some(parent) = lock_path.parent() {
774 std::fs::create_dir_all(parent).context("creating the provisioning lock's parent dir")?;
775 }
776 let lock_file = std::fs::OpenOptions::new()
777 .create(true)
778 .truncate(false)
779 .write(true)
780 .open(lock_path)
781 .context("opening the provisioning lock file")?;
782 lock_file
783 .lock()
784 .context("acquiring the provisioning lock")?;
785 if bin.exists() {
787 return Ok(bin.to_path_buf());
788 }
789 install()?;
790 if !bin.exists() {
791 bail!(
792 "provisioning reported success but cargo-mutants is not at `{}`",
793 bin.display()
794 );
795 }
796 Ok(bin.to_path_buf())
797}
798
799fn install_argv(root: &Path) -> Vec<OsString> {
803 vec![
804 OsString::from("install"),
805 OsString::from("cargo-mutants"),
806 OsString::from("--locked"),
807 OsString::from("--version"),
808 OsString::from(CARGO_MUTANTS_VERSION),
809 OsString::from("--root"),
810 root.as_os_str().to_os_string(),
811 ]
812}
813
814fn run_install(
819 root: &Path,
820 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
821) -> Result<()> {
822 let mut command = Command::new("cargo");
823 command.args(install_argv(root));
824 strip_llvm_cov_env(&mut command);
825 let output = run(&mut command)
826 .context("provisioning cargo-mutants via `cargo install` (is cargo installed?)")?;
827 if !output.status.success() {
828 bail!(
829 "failed to provision cargo-mutants {CARGO_MUTANTS_VERSION}:\n{}{}",
830 String::from_utf8_lossy(&output.stdout),
831 String::from_utf8_lossy(&output.stderr),
832 );
833 }
834 Ok(())
835}
836
837fn strip_llvm_cov_env(command: &mut Command) {
841 for var in [
842 "RUSTFLAGS",
843 "CARGO_ENCODED_RUSTFLAGS",
844 "RUSTDOCFLAGS",
845 "CARGO_ENCODED_RUSTDOCFLAGS",
846 "LLVM_PROFILE_FILE",
847 "CARGO_LLVM_COV",
848 "CARGO_LLVM_COV_SHOW_ENV",
849 "CARGO_LLVM_COV_TARGET_DIR",
850 "CARGO_LLVM_COV_BUILD_DIR",
851 "RUSTC_WRAPPER",
852 "RUSTC_WORKSPACE_WRAPPER",
853 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
854 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
855 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
856 ] {
857 command.env_remove(var);
858 }
859}
860
861fn run_cargo_mutants(
873 engine: &Path,
874 root: &Path,
875 out: &Path,
876 in_diff: Option<&Path>,
877 features: &[String],
878) -> Result<()> {
879 let mut command = Command::new(engine);
880 command
881 .current_dir(root)
882 .arg("mutants")
883 .arg("--output")
884 .arg(out);
885 if let Some(diff) = in_diff {
886 command.arg("--in-diff").arg(diff);
887 }
888 if !features.is_empty() {
889 command.args(["--", "--features"]).arg(features.join(","));
890 }
891 strip_llvm_cov_env(&mut command);
892 let output = command.output().context("running cargo-mutants")?;
893 match output.status.code() {
894 Some(0) | Some(2) => Ok(()),
896 _ => bail!(
897 "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
898 root.display(),
899 String::from_utf8_lossy(&output.stdout),
900 String::from_utf8_lossy(&output.stderr),
901 ),
902 }
903}
904
905#[cfg(test)]
906mod tests {
907 use super::*;
908
909 const NORMALIZED: &str = r#"[
915 {"file": "src/a.ts", "line": 2, "status": "survived",
916 "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
917 {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
918 {"file": "src/a.ts", "line": 9, "status": "killed",
919 "mutator": "BooleanLiteral", "replacement": "false"},
920 {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
921 {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
922 {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
923 ]"#;
924
925 #[test]
926 fn parses_the_normalized_schema() {
927 let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
928 assert_eq!(mutants.len(), 6);
929 assert_eq!(mutants[0].status, MutantStatus::Survived);
930 assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
931 assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
932 assert_eq!(mutants[1].replacement, None);
933 }
934
935 #[test]
936 fn normalized_survivors_are_survived_and_nocoverage_only() {
937 let mutants = parse_normalized_results(NORMALIZED).unwrap();
938 let survivors = normalized_survivors(&mutants);
939 assert_eq!(survivors.len(), 2);
941 assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
942 assert!(survivors[0].description.contains("ConditionalExpression"));
944 assert!(survivors[0].description.contains("-> true"));
945 assert_eq!(survivors[1].description, "ArithmeticOperator");
946 }
947
948 #[test]
949 fn normalized_mutated_lines_collects_only_viable_mutants() {
950 let mutants = parse_normalized_results(NORMALIZED).unwrap();
951 assert_eq!(
954 normalized_mutated_lines(&mutants),
955 [2u32, 5, 9, 12]
956 .into_iter()
957 .map(|line| ("src/a.ts".to_string(), line))
958 .collect()
959 );
960 }
961
962 #[test]
963 fn evaluate_normalized_reports_unexempted_survivors() {
964 let mutants = parse_normalized_results(NORMALIZED).unwrap();
965 let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
966 assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
967 }
968
969 #[test]
970 fn evaluate_normalized_drops_a_whole_file_exemption() {
971 let mutants = parse_normalized_results(NORMALIZED).unwrap();
972 let kept =
973 evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
974 assert!(
975 kept.is_empty(),
976 "the whole-file exemption lifts both survivors"
977 );
978 }
979
980 #[test]
981 fn evaluate_normalized_drops_a_line_scoped_exemption() {
982 let mutants = parse_normalized_results(NORMALIZED).unwrap();
983 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
984 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
985 assert_eq!(kept.len(), 1);
987 assert_eq!(kept[0].line, 5);
988 }
989
990 #[test]
991 fn evaluate_normalized_rejects_exempting_a_caught_line() {
992 let mutants = parse_normalized_results(NORMALIZED).unwrap();
995 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
996 let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
997 assert!(
998 err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
999 "got: {err}"
1000 );
1001 }
1002
1003 #[test]
1004 fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
1005 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1008 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
1009 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1010 assert_eq!(kept.len(), 2);
1011 }
1012
1013 const SAMPLE: &str = r#"{
1016 "outcomes": [
1017 {"scenario": "Baseline", "summary": "Success",
1018 "phase_results": []},
1019 {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
1020 "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
1021 "function": {"function_name": "is_positive"},
1022 "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
1023 "summary": "MissedMutant"},
1024 {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
1025 "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
1026 "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
1027 "summary": "CaughtMutant"}
1028 ],
1029 "total_mutants": 2
1030 }"#;
1031
1032 #[test]
1033 fn parses_the_outcomes_export() {
1034 let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
1035 assert_eq!(report.outcomes.len(), 3);
1036 assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
1037 }
1038
1039 #[test]
1040 fn collects_only_missed_mutants_as_survivors() {
1041 let report = parse_mutants_report(SAMPLE).unwrap();
1042 let survivors = unexplained_survivors(&report, &[]);
1043 assert_eq!(survivors.len(), 1);
1045 assert_eq!(survivors[0].file, "src/lib.rs");
1046 assert_eq!(survivors[0].line, 7);
1047 assert!(survivors[0].description.contains("replace > with =="));
1048 }
1049
1050 #[test]
1051 fn an_exemption_drops_a_survivor_in_that_file() {
1052 let report = parse_mutants_report(SAMPLE).unwrap();
1053 let exempt = vec!["src/lib.rs".to_string()];
1054 assert!(unexplained_survivors(&report, &exempt).is_empty());
1055 }
1056
1057 #[test]
1058 fn an_exemption_on_another_file_leaves_the_survivor() {
1059 let report = parse_mutants_report(SAMPLE).unwrap();
1060 let exempt = vec!["src/elsewhere.rs".to_string()];
1061 assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1062 }
1063
1064 #[test]
1065 fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
1066 assert!(is_mutatable_ts("src/index.ts"));
1067 assert!(is_mutatable_ts("src/util.tsx"));
1068 assert!(is_mutatable_ts("src/util.js"));
1069 assert!(!is_mutatable_ts("src/index.test.ts"));
1070 assert!(!is_mutatable_ts("src/index.spec.ts"));
1071 assert!(!is_mutatable_ts("src/types.d.ts"));
1072 assert!(!is_mutatable_ts("README.md"));
1073 }
1074
1075 #[test]
1076 fn contiguous_runs_collapses_adjacent_lines() {
1077 let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
1078 assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
1079 assert!(contiguous_runs(&BTreeSet::new()).is_empty());
1080 }
1081
1082 #[test]
1083 fn one_line_flattens_and_caps() {
1084 assert_eq!(one_line("a -\n b"), "a - b");
1085 let long = "x".repeat(80);
1086 let capped = one_line(&long);
1087 assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
1088 }
1089
1090 #[test]
1091 fn is_mutatable_py_keeps_sources_and_drops_tests() {
1092 assert!(is_mutatable_py("calc.py"));
1093 assert!(is_mutatable_py("pkg/util.py"));
1094 assert!(!is_mutatable_py("calc_test.py"));
1095 assert!(!is_mutatable_py("test_calc.py"));
1096 assert!(!is_mutatable_py("pkg/conftest.py"));
1097 assert!(!is_mutatable_py("README.md"));
1098 }
1099
1100 #[test]
1103 fn mutated_lines_collects_caught_and_missed() {
1104 let report = parse_mutants_report(SAMPLE).unwrap();
1107 assert_eq!(
1108 mutated_lines(&report),
1109 [
1110 ("src/lib.rs".to_string(), 7),
1111 ("src/other.rs".to_string(), 3)
1112 ]
1113 .into_iter()
1114 .collect()
1115 );
1116 }
1117
1118 #[test]
1119 fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1120 let report = parse_mutants_report(SAMPLE).unwrap();
1121 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1122 let kept = evaluate_scoped(
1123 cargo_mutants_survivors(&report),
1124 &mutated_lines(&report),
1125 &[],
1126 &line_scoped,
1127 )
1128 .unwrap();
1129 assert!(
1130 kept.is_empty(),
1131 "the src/lib.rs:7 survivor should be lifted"
1132 );
1133 }
1134
1135 #[test]
1136 fn evaluate_scoped_rejects_exempting_a_caught_line() {
1137 let report = parse_mutants_report(SAMPLE).unwrap();
1139 let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
1140 let err = evaluate_scoped(
1141 cargo_mutants_survivors(&report),
1142 &mutated_lines(&report),
1143 &[],
1144 &line_scoped,
1145 )
1146 .unwrap_err();
1147 assert!(
1148 err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
1149 "got: {err}"
1150 );
1151 }
1152
1153 #[test]
1154 fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
1155 let report = parse_mutants_report(SAMPLE).unwrap();
1158 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
1159 let kept = evaluate_scoped(
1160 cargo_mutants_survivors(&report),
1161 &mutated_lines(&report),
1162 &[],
1163 &line_scoped,
1164 )
1165 .unwrap();
1166 assert_eq!(kept.len(), 1);
1167 assert_eq!(kept[0].line, 7);
1168 }
1169
1170 #[test]
1171 fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1172 let report = parse_mutants_report(SAMPLE).unwrap();
1173 let kept = evaluate_scoped(
1174 cargo_mutants_survivors(&report),
1175 &mutated_lines(&report),
1176 &["src/lib.rs".to_string()],
1177 &BTreeMap::new(),
1178 )
1179 .unwrap();
1180 assert!(kept.is_empty());
1181 }
1182
1183 fn unique_tmp() -> PathBuf {
1186 static COUNTER: AtomicU64 = AtomicU64::new(0);
1187 let dir = std::env::temp_dir().join(format!(
1188 "tc-provision-test-{}-{}",
1189 std::process::id(),
1190 COUNTER.fetch_add(1, Ordering::Relaxed)
1191 ));
1192 std::fs::create_dir_all(&dir).unwrap();
1193 dir
1194 }
1195
1196 #[test]
1197 fn provision_returns_an_existing_binary_without_installing() {
1198 let tmp = unique_tmp();
1199 let bin = tmp.join("bin").join("cargo-mutants");
1200 let lock = tmp.join(".install.lock");
1201 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1202 std::fs::write(&bin, b"binary").unwrap();
1203 let mut installed = false;
1204 let got = provision(&bin, &lock, || {
1205 installed = true;
1206 Ok(())
1207 })
1208 .unwrap();
1209 assert_eq!(got, bin);
1210 assert!(!installed, "a present binary must not be reinstalled");
1211 std::fs::remove_dir_all(&tmp).unwrap();
1212 }
1213
1214 #[test]
1215 fn provision_installs_when_the_binary_is_absent() {
1216 let tmp = unique_tmp();
1217 let bin = tmp.join("bin").join("cargo-mutants");
1218 let lock = tmp.join(".install.lock");
1219 let mut installed = false;
1220 let got = provision(&bin, &lock, || {
1221 installed = true;
1222 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1223 std::fs::write(&bin, b"binary").unwrap();
1224 Ok(())
1225 })
1226 .unwrap();
1227 assert!(installed, "an absent binary must be installed");
1228 assert_eq!(got, bin);
1229 std::fs::remove_dir_all(&tmp).unwrap();
1230 }
1231
1232 #[test]
1233 fn provision_errors_when_install_produces_no_binary() {
1234 let tmp = unique_tmp();
1235 let bin = tmp.join("bin").join("cargo-mutants");
1236 let lock = tmp.join(".install.lock");
1237 let err = provision(&bin, &lock, || Ok(())).unwrap_err();
1238 assert!(
1239 err.to_string().contains("cargo-mutants is not at"),
1240 "got: {err}"
1241 );
1242 std::fs::remove_dir_all(&tmp).unwrap();
1243 }
1244
1245 #[test]
1246 fn provision_propagates_an_install_failure() {
1247 let tmp = unique_tmp();
1248 let bin = tmp.join("bin").join("cargo-mutants");
1249 let lock = tmp.join(".install.lock");
1250 let err = provision(&bin, &lock, || bail!("install blew up")).unwrap_err();
1251 assert!(err.to_string().contains("install blew up"), "got: {err}");
1252 std::fs::remove_dir_all(&tmp).unwrap();
1253 }
1254
1255 #[test]
1256 fn provision_does_not_duplicate_the_install_under_concurrent_callers() {
1257 use std::sync::{Arc, Barrier};
1264 use std::thread;
1265 use std::time::Duration;
1266
1267 let tmp = unique_tmp();
1268 let bin = tmp.join("bin").join("cargo-mutants");
1269 let lock = tmp.join(".install.lock");
1270 let install_count = Arc::new(AtomicU64::new(0));
1271 let barrier = Arc::new(Barrier::new(2));
1272
1273 let handles: Vec<_> = (0..2)
1274 .map(|_| {
1275 let bin = bin.clone();
1276 let lock = lock.clone();
1277 let install_count = Arc::clone(&install_count);
1278 let barrier = Arc::clone(&barrier);
1279 thread::spawn(move || {
1280 barrier.wait();
1281 provision(&bin, &lock, || {
1282 install_count.fetch_add(1, Ordering::SeqCst);
1283 thread::sleep(Duration::from_millis(50));
1284 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1285 std::fs::write(&bin, b"binary").unwrap();
1286 Ok(())
1287 })
1288 })
1289 })
1290 .collect();
1291
1292 for h in handles {
1293 h.join()
1294 .expect("provisioning thread must not panic")
1295 .unwrap();
1296 }
1297
1298 assert_eq!(
1299 install_count.load(Ordering::SeqCst),
1300 1,
1301 "two concurrent callers on a cold cache must share one install, not each run their own"
1302 );
1303 std::fs::remove_dir_all(&tmp).unwrap();
1304 }
1305
1306 #[test]
1307 fn resolve_cache_base_prefers_xdg_then_home_then_temp() {
1308 let xdg = |s: &str| Some(OsString::from(s));
1309 assert_eq!(
1311 resolve_cache_base(xdg("/xdg"), xdg("/home")),
1312 PathBuf::from("/xdg")
1313 );
1314 assert_eq!(
1316 resolve_cache_base(xdg(""), xdg("/home")),
1317 PathBuf::from("/home/.cache")
1318 );
1319 assert_eq!(
1321 resolve_cache_base(None, xdg("/home")),
1322 PathBuf::from("/home/.cache")
1323 );
1324 assert_eq!(resolve_cache_base(None, None), std::env::temp_dir());
1326 assert_eq!(
1327 resolve_cache_base(xdg(""), Some(OsString::new())),
1328 std::env::temp_dir()
1329 );
1330 }
1331
1332 #[test]
1333 fn cache_root_is_absolute_and_version_scoped() {
1334 let root = cargo_mutants_cache_root();
1335 assert!(
1336 root.ends_with(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}")),
1337 "version-scoped; got {root:?}"
1338 );
1339 assert!(
1340 root.to_string_lossy().contains("testing-conventions"),
1341 "tool-namespaced; got {root:?}"
1342 );
1343 assert!(
1345 root.is_absolute(),
1346 "expected an absolute path; got {root:?}"
1347 );
1348 }
1349
1350 #[test]
1351 fn install_argv_pins_the_version_and_isolates_the_root() {
1352 let argv: Vec<String> = install_argv(Path::new("/cache/cargo-mutants-27"))
1353 .iter()
1354 .map(|arg| arg.to_string_lossy().into_owned())
1355 .collect();
1356 assert_eq!(
1357 argv,
1358 vec![
1359 "install",
1360 "cargo-mutants",
1361 "--locked",
1362 "--version",
1363 CARGO_MUTANTS_VERSION,
1364 "--root",
1365 "/cache/cargo-mutants-27",
1366 ]
1367 );
1368 }
1369
1370 #[cfg(unix)]
1371 fn fake_output(code: i32, stderr: &str) -> Output {
1372 use std::os::unix::process::ExitStatusExt;
1373 Output {
1374 status: std::process::ExitStatus::from_raw(code << 8),
1375 stdout: Vec::new(),
1376 stderr: stderr.as_bytes().to_vec(),
1377 }
1378 }
1379
1380 #[cfg(unix)]
1381 #[test]
1382 fn run_install_succeeds_on_a_zero_exit() {
1383 let mut ran = false;
1384 run_install(Path::new("/cache/root"), |command| {
1385 ran = true;
1386 let argv: Vec<String> = command
1388 .get_args()
1389 .map(|arg| arg.to_string_lossy().into_owned())
1390 .collect();
1391 assert!(argv.contains(&CARGO_MUTANTS_VERSION.to_string()));
1392 Ok(fake_output(0, ""))
1393 })
1394 .unwrap();
1395 assert!(ran);
1396 }
1397
1398 #[cfg(unix)]
1399 #[test]
1400 fn run_install_reports_a_nonzero_exit_with_the_engine_output() {
1401 let err = run_install(Path::new("/cache/root"), |_| {
1402 Ok(fake_output(1, "error: could not compile cargo-mutants"))
1403 })
1404 .unwrap_err();
1405 assert!(
1406 err.to_string()
1407 .contains("failed to provision cargo-mutants")
1408 && err.to_string().contains("could not compile"),
1409 "got: {err}"
1410 );
1411 }
1412
1413 #[cfg(unix)]
1414 #[test]
1415 fn run_install_propagates_a_spawn_failure() {
1416 let err = run_install(Path::new("/cache/root"), |_| {
1417 Err(std::io::Error::new(
1418 std::io::ErrorKind::NotFound,
1419 "no cargo",
1420 ))
1421 })
1422 .unwrap_err();
1423 assert!(
1424 err.to_string().contains("is cargo installed?"),
1425 "got: {err}"
1426 );
1427 }
1428
1429 #[test]
1430 fn cargo_mutants_bin_name_matches_the_platform() {
1431 let name = cargo_mutants_bin_name();
1432 if cfg!(windows) {
1433 assert_eq!(name, "cargo-mutants.exe");
1434 } else {
1435 assert_eq!(name, "cargo-mutants");
1436 }
1437 }
1438}