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) -> Result<Vec<Survivor>> {
343 let out = MutantsOut::new();
344 let diff = match base {
345 Some(base) => match write_base_diff(root, base, &out)? {
348 None => return Ok(Vec::new()),
349 Some(path) => Some(path),
350 },
351 None => None,
352 };
353 let engine = ensure_cargo_mutants()?;
354 run_cargo_mutants(&engine, root, &out.0, diff.as_deref())?;
355 let outcomes = out.0.join("mutants.out").join("outcomes.json");
356 let json = match std::fs::read_to_string(&outcomes) {
360 Ok(json) => json,
361 Err(_) => return Ok(Vec::new()),
362 };
363 let report = parse_mutants_report(&json)?;
364 evaluate_scoped(
365 cargo_mutants_survivors(&report),
366 &mutated_lines(&report),
367 exempt,
368 exempt_lines,
369 )
370}
371
372fn one_line(replacement: &str) -> String {
375 let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
376 const MAX: usize = 60;
377 if flat.chars().count() > MAX {
378 format!("{}…", flat.chars().take(MAX).collect::<String>())
379 } else {
380 flat
381 }
382}
383
384pub fn measure_typescript(
403 root: &Path,
404 exempt: &[String],
405 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
406 base: Option<&str>,
407 adapter: &Path,
408) -> Result<Vec<Survivor>> {
409 let mutate = match base {
410 Some(base) => {
411 let ranges = mutate_ranges(root, base)?;
412 if ranges.is_empty() {
414 return Ok(Vec::new());
415 }
416 Some(ranges)
417 }
418 None => None,
419 };
420 let json = run_ts_adapter(root, adapter, mutate.as_deref())?;
421 let mutants = parse_normalized_results(&json)?;
422 evaluate_normalized(&mutants, exempt, exempt_lines)
423}
424
425fn run_ts_adapter(root: &Path, adapter: &Path, mutate: Option<&[String]>) -> Result<String> {
436 let out = AdapterOut::new();
437 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
438 let results = out.0.join("results.json");
439
440 let mut command = Command::new("node");
441 command
442 .current_dir(root)
443 .arg(adapter)
444 .arg("--out")
445 .arg(&results);
446 if let Some(specs) = mutate {
447 command.arg("--mutate").arg(specs.join(","));
448 }
449 let output = command
450 .output()
451 .context("running the TypeScript mutation adapter (is `node` installed?)")?;
452 if !output.status.success() {
453 bail!(
454 "the TypeScript mutation adapter failed in `{}`:\n{}{}",
455 root.display(),
456 String::from_utf8_lossy(&output.stdout),
457 String::from_utf8_lossy(&output.stderr),
458 );
459 }
460 std::fs::read_to_string(&results).with_context(|| {
461 format!(
462 "reading the TypeScript mutation adapter's results from `{}`",
463 results.display()
464 )
465 })
466}
467
468struct AdapterOut(PathBuf);
471
472impl AdapterOut {
473 fn new() -> Self {
474 static COUNTER: AtomicU64 = AtomicU64::new(0);
475 let name = format!(
476 "testing-conventions-ts-adapter-{}-{}",
477 std::process::id(),
478 COUNTER.fetch_add(1, Ordering::Relaxed),
479 );
480 AdapterOut(std::env::temp_dir().join(name))
481 }
482}
483
484impl Drop for AdapterOut {
485 fn drop(&mut self) {
486 let _ = std::fs::remove_dir_all(&self.0);
487 }
488}
489
490fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
496 let changed = crate::patch_coverage::changed_lines(root, base)?;
497 let mut specs = Vec::new();
498 for (file, lines) in changed {
499 if !is_mutatable_ts(&file) {
500 continue;
501 }
502 for (start, end) in contiguous_runs(&lines) {
503 specs.push(format!("{file}:{start}-{end}"));
504 }
505 }
506 Ok(specs)
507}
508
509fn is_mutatable_ts(file: &str) -> bool {
513 let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
514 .iter()
515 .any(|ext| file.ends_with(ext));
516 let is_decl = file.ends_with(".d.ts");
517 let is_test = file.contains(".test.") || file.contains(".spec.");
518 is_source && !is_decl && !is_test
519}
520
521fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
523 let mut runs: Vec<(u64, u64)> = Vec::new();
524 for &line in lines {
525 match runs.last_mut() {
526 Some(run) if run.1 + 1 == line => run.1 = line,
527 _ => runs.push((line, line)),
528 }
529 }
530 runs
531}
532
533pub fn measure_python(
552 root: &Path,
553 exempt: &[String],
554 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
555 base: Option<&str>,
556) -> Result<Vec<Survivor>> {
557 let changed = match base {
558 Some(base) => Some(crate::patch_coverage::changed_lines(root, base)?),
559 None => None,
560 };
561 let modules: Vec<String> = match &changed {
562 None => Vec::new(),
563 Some(changed) => {
564 let modules: Vec<String> = changed
565 .keys()
566 .filter(|file| is_mutatable_py(file))
567 .cloned()
568 .collect();
569 if modules.is_empty() {
571 return Ok(Vec::new());
572 }
573 modules
574 }
575 };
576 let json = run_py_adapter(root, &modules)?;
577 let mut mutants = parse_normalized_results(&json)?;
578 if let Some(changed) = &changed {
579 mutants.retain(|mutant| {
581 changed
582 .get(&mutant.file)
583 .is_some_and(|lines| lines.contains(&u64::from(mutant.line)))
584 });
585 }
586 evaluate_normalized(&mutants, exempt, exempt_lines)
587}
588
589fn run_py_adapter(root: &Path, modules: &[String]) -> Result<String> {
597 let out = AdapterOut::new();
598 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
599 let results = out.0.join("results.json");
600
601 let mut command = Command::new("python3");
602 command
603 .current_dir(root)
604 .args(["-m", "testing_conventions.mutation.main", "--out"])
605 .arg(&results)
606 .env("PYTHONDONTWRITEBYTECODE", "1");
607 for module in modules {
608 command.arg("--module").arg(module);
609 }
610 let output = command
611 .output()
612 .context("running the Python mutation adapter (is `python3` installed?)")?;
613 if !output.status.success() {
614 bail!(
615 "the Python mutation adapter failed in `{}`:\n{}{}",
616 root.display(),
617 String::from_utf8_lossy(&output.stdout),
618 String::from_utf8_lossy(&output.stderr),
619 );
620 }
621 std::fs::read_to_string(&results).with_context(|| {
622 format!(
623 "reading the Python mutation adapter's results from `{}`",
624 results.display()
625 )
626 })
627}
628
629fn is_mutatable_py(file: &str) -> bool {
632 if !file.ends_with(".py") {
633 return false;
634 }
635 let base = file.rsplit('/').next().unwrap_or(file);
636 !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
637}
638
639struct MutantsOut(PathBuf);
642
643impl MutantsOut {
644 fn new() -> Self {
645 static COUNTER: AtomicU64 = AtomicU64::new(0);
646 let name = format!(
647 "testing-conventions-mutants-{}-{}",
648 std::process::id(),
649 COUNTER.fetch_add(1, Ordering::Relaxed),
650 );
651 MutantsOut(std::env::temp_dir().join(name))
652 }
653}
654
655impl Drop for MutantsOut {
656 fn drop(&mut self) {
657 let _ = std::fs::remove_dir_all(&self.0);
658 }
659}
660
661fn write_base_diff(root: &Path, base: &str, out: &MutantsOut) -> Result<Option<PathBuf>> {
670 let range = format!("{base}...HEAD");
671 let output = Command::new("git")
672 .current_dir(root)
673 .args(["diff", "--relative", &range])
674 .output()
675 .context("running `git diff` for `--base` (is git installed?)")?;
676 if !output.status.success() {
677 bail!(
678 "git diff {range} failed: {}",
679 String::from_utf8_lossy(&output.stderr)
680 );
681 }
682 if output.stdout.is_empty() {
683 return Ok(None);
684 }
685 std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
686 let path = out.0.join("base.diff");
687 std::fs::write(&path, &output.stdout).context("writing the base diff")?;
688 Ok(Some(path))
689}
690
691const CARGO_MUTANTS_VERSION: &str = "27.1.0";
694
695fn ensure_cargo_mutants() -> Result<PathBuf> {
705 let root = cargo_mutants_cache_root();
706 let bin = root.join("bin").join(cargo_mutants_bin_name());
707 provision(&bin, || run_install(&root, |command| command.output()))
708}
709
710fn cargo_mutants_bin_name() -> &'static str {
713 if cfg!(windows) {
714 "cargo-mutants.exe"
715 } else {
716 "cargo-mutants"
717 }
718}
719
720fn cargo_mutants_cache_root() -> PathBuf {
724 cache_base()
725 .join("testing-conventions")
726 .join(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}"))
727}
728
729fn cache_base() -> PathBuf {
732 resolve_cache_base(std::env::var_os("XDG_CACHE_HOME"), std::env::var_os("HOME"))
733}
734
735fn resolve_cache_base(xdg: Option<OsString>, home: Option<OsString>) -> PathBuf {
738 if let Some(dir) = xdg.filter(|value| !value.is_empty()) {
739 return PathBuf::from(dir);
740 }
741 if let Some(dir) = home.filter(|value| !value.is_empty()) {
742 return PathBuf::from(dir).join(".cache");
743 }
744 std::env::temp_dir()
745}
746
747fn provision(bin: &Path, install: impl FnOnce() -> Result<()>) -> Result<PathBuf> {
752 if bin.exists() {
753 return Ok(bin.to_path_buf());
754 }
755 install()?;
756 if !bin.exists() {
757 bail!(
758 "provisioning reported success but cargo-mutants is not at `{}`",
759 bin.display()
760 );
761 }
762 Ok(bin.to_path_buf())
763}
764
765fn install_argv(root: &Path) -> Vec<OsString> {
769 vec![
770 OsString::from("install"),
771 OsString::from("cargo-mutants"),
772 OsString::from("--locked"),
773 OsString::from("--version"),
774 OsString::from(CARGO_MUTANTS_VERSION),
775 OsString::from("--root"),
776 root.as_os_str().to_os_string(),
777 ]
778}
779
780fn run_install(
785 root: &Path,
786 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
787) -> Result<()> {
788 let mut command = Command::new("cargo");
789 command.args(install_argv(root));
790 strip_llvm_cov_env(&mut command);
791 let output = run(&mut command)
792 .context("provisioning cargo-mutants via `cargo install` (is cargo installed?)")?;
793 if !output.status.success() {
794 bail!(
795 "failed to provision cargo-mutants {CARGO_MUTANTS_VERSION}:\n{}{}",
796 String::from_utf8_lossy(&output.stdout),
797 String::from_utf8_lossy(&output.stderr),
798 );
799 }
800 Ok(())
801}
802
803fn strip_llvm_cov_env(command: &mut Command) {
807 for var in [
808 "RUSTFLAGS",
809 "CARGO_ENCODED_RUSTFLAGS",
810 "RUSTDOCFLAGS",
811 "CARGO_ENCODED_RUSTDOCFLAGS",
812 "LLVM_PROFILE_FILE",
813 "CARGO_LLVM_COV",
814 "CARGO_LLVM_COV_SHOW_ENV",
815 "CARGO_LLVM_COV_TARGET_DIR",
816 "CARGO_LLVM_COV_BUILD_DIR",
817 "RUSTC_WRAPPER",
818 "RUSTC_WORKSPACE_WRAPPER",
819 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
820 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
821 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
822 ] {
823 command.env_remove(var);
824 }
825}
826
827fn run_cargo_mutants(engine: &Path, root: &Path, out: &Path, in_diff: Option<&Path>) -> Result<()> {
836 let mut command = Command::new(engine);
837 command
838 .current_dir(root)
839 .arg("mutants")
840 .arg("--output")
841 .arg(out);
842 if let Some(diff) = in_diff {
843 command.arg("--in-diff").arg(diff);
844 }
845 strip_llvm_cov_env(&mut command);
846 let output = command.output().context("running cargo-mutants")?;
847 match output.status.code() {
848 Some(0) | Some(2) => Ok(()),
850 _ => bail!(
851 "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
852 root.display(),
853 String::from_utf8_lossy(&output.stdout),
854 String::from_utf8_lossy(&output.stderr),
855 ),
856 }
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862
863 const NORMALIZED: &str = r#"[
869 {"file": "src/a.ts", "line": 2, "status": "survived",
870 "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
871 {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
872 {"file": "src/a.ts", "line": 9, "status": "killed",
873 "mutator": "BooleanLiteral", "replacement": "false"},
874 {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
875 {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
876 {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
877 ]"#;
878
879 #[test]
880 fn parses_the_normalized_schema() {
881 let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
882 assert_eq!(mutants.len(), 6);
883 assert_eq!(mutants[0].status, MutantStatus::Survived);
884 assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
885 assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
886 assert_eq!(mutants[1].replacement, None);
887 }
888
889 #[test]
890 fn normalized_survivors_are_survived_and_nocoverage_only() {
891 let mutants = parse_normalized_results(NORMALIZED).unwrap();
892 let survivors = normalized_survivors(&mutants);
893 assert_eq!(survivors.len(), 2);
895 assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
896 assert!(survivors[0].description.contains("ConditionalExpression"));
898 assert!(survivors[0].description.contains("-> true"));
899 assert_eq!(survivors[1].description, "ArithmeticOperator");
900 }
901
902 #[test]
903 fn normalized_mutated_lines_collects_only_viable_mutants() {
904 let mutants = parse_normalized_results(NORMALIZED).unwrap();
905 assert_eq!(
908 normalized_mutated_lines(&mutants),
909 [2u32, 5, 9, 12]
910 .into_iter()
911 .map(|line| ("src/a.ts".to_string(), line))
912 .collect()
913 );
914 }
915
916 #[test]
917 fn evaluate_normalized_reports_unexempted_survivors() {
918 let mutants = parse_normalized_results(NORMALIZED).unwrap();
919 let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
920 assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
921 }
922
923 #[test]
924 fn evaluate_normalized_drops_a_whole_file_exemption() {
925 let mutants = parse_normalized_results(NORMALIZED).unwrap();
926 let kept =
927 evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
928 assert!(
929 kept.is_empty(),
930 "the whole-file exemption lifts both survivors"
931 );
932 }
933
934 #[test]
935 fn evaluate_normalized_drops_a_line_scoped_exemption() {
936 let mutants = parse_normalized_results(NORMALIZED).unwrap();
937 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
938 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
939 assert_eq!(kept.len(), 1);
941 assert_eq!(kept[0].line, 5);
942 }
943
944 #[test]
945 fn evaluate_normalized_rejects_exempting_a_caught_line() {
946 let mutants = parse_normalized_results(NORMALIZED).unwrap();
949 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
950 let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
951 assert!(
952 err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
953 "got: {err}"
954 );
955 }
956
957 #[test]
958 fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
959 let mutants = parse_normalized_results(NORMALIZED).unwrap();
962 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
963 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
964 assert_eq!(kept.len(), 2);
965 }
966
967 const SAMPLE: &str = r#"{
970 "outcomes": [
971 {"scenario": "Baseline", "summary": "Success",
972 "phase_results": []},
973 {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
974 "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
975 "function": {"function_name": "is_positive"},
976 "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
977 "summary": "MissedMutant"},
978 {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
979 "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
980 "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
981 "summary": "CaughtMutant"}
982 ],
983 "total_mutants": 2
984 }"#;
985
986 #[test]
987 fn parses_the_outcomes_export() {
988 let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
989 assert_eq!(report.outcomes.len(), 3);
990 assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
991 }
992
993 #[test]
994 fn collects_only_missed_mutants_as_survivors() {
995 let report = parse_mutants_report(SAMPLE).unwrap();
996 let survivors = unexplained_survivors(&report, &[]);
997 assert_eq!(survivors.len(), 1);
999 assert_eq!(survivors[0].file, "src/lib.rs");
1000 assert_eq!(survivors[0].line, 7);
1001 assert!(survivors[0].description.contains("replace > with =="));
1002 }
1003
1004 #[test]
1005 fn an_exemption_drops_a_survivor_in_that_file() {
1006 let report = parse_mutants_report(SAMPLE).unwrap();
1007 let exempt = vec!["src/lib.rs".to_string()];
1008 assert!(unexplained_survivors(&report, &exempt).is_empty());
1009 }
1010
1011 #[test]
1012 fn an_exemption_on_another_file_leaves_the_survivor() {
1013 let report = parse_mutants_report(SAMPLE).unwrap();
1014 let exempt = vec!["src/elsewhere.rs".to_string()];
1015 assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1016 }
1017
1018 #[test]
1019 fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
1020 assert!(is_mutatable_ts("src/index.ts"));
1021 assert!(is_mutatable_ts("src/util.tsx"));
1022 assert!(is_mutatable_ts("src/util.js"));
1023 assert!(!is_mutatable_ts("src/index.test.ts"));
1024 assert!(!is_mutatable_ts("src/index.spec.ts"));
1025 assert!(!is_mutatable_ts("src/types.d.ts"));
1026 assert!(!is_mutatable_ts("README.md"));
1027 }
1028
1029 #[test]
1030 fn contiguous_runs_collapses_adjacent_lines() {
1031 let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
1032 assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
1033 assert!(contiguous_runs(&BTreeSet::new()).is_empty());
1034 }
1035
1036 #[test]
1037 fn one_line_flattens_and_caps() {
1038 assert_eq!(one_line("a -\n b"), "a - b");
1039 let long = "x".repeat(80);
1040 let capped = one_line(&long);
1041 assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
1042 }
1043
1044 #[test]
1045 fn is_mutatable_py_keeps_sources_and_drops_tests() {
1046 assert!(is_mutatable_py("calc.py"));
1047 assert!(is_mutatable_py("pkg/util.py"));
1048 assert!(!is_mutatable_py("calc_test.py"));
1049 assert!(!is_mutatable_py("test_calc.py"));
1050 assert!(!is_mutatable_py("pkg/conftest.py"));
1051 assert!(!is_mutatable_py("README.md"));
1052 }
1053
1054 #[test]
1057 fn mutated_lines_collects_caught_and_missed() {
1058 let report = parse_mutants_report(SAMPLE).unwrap();
1061 assert_eq!(
1062 mutated_lines(&report),
1063 [
1064 ("src/lib.rs".to_string(), 7),
1065 ("src/other.rs".to_string(), 3)
1066 ]
1067 .into_iter()
1068 .collect()
1069 );
1070 }
1071
1072 #[test]
1073 fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1074 let report = parse_mutants_report(SAMPLE).unwrap();
1075 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1076 let kept = evaluate_scoped(
1077 cargo_mutants_survivors(&report),
1078 &mutated_lines(&report),
1079 &[],
1080 &line_scoped,
1081 )
1082 .unwrap();
1083 assert!(
1084 kept.is_empty(),
1085 "the src/lib.rs:7 survivor should be lifted"
1086 );
1087 }
1088
1089 #[test]
1090 fn evaluate_scoped_rejects_exempting_a_caught_line() {
1091 let report = parse_mutants_report(SAMPLE).unwrap();
1093 let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
1094 let err = evaluate_scoped(
1095 cargo_mutants_survivors(&report),
1096 &mutated_lines(&report),
1097 &[],
1098 &line_scoped,
1099 )
1100 .unwrap_err();
1101 assert!(
1102 err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
1103 "got: {err}"
1104 );
1105 }
1106
1107 #[test]
1108 fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
1109 let report = parse_mutants_report(SAMPLE).unwrap();
1112 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
1113 let kept = evaluate_scoped(
1114 cargo_mutants_survivors(&report),
1115 &mutated_lines(&report),
1116 &[],
1117 &line_scoped,
1118 )
1119 .unwrap();
1120 assert_eq!(kept.len(), 1);
1121 assert_eq!(kept[0].line, 7);
1122 }
1123
1124 #[test]
1125 fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1126 let report = parse_mutants_report(SAMPLE).unwrap();
1127 let kept = evaluate_scoped(
1128 cargo_mutants_survivors(&report),
1129 &mutated_lines(&report),
1130 &["src/lib.rs".to_string()],
1131 &BTreeMap::new(),
1132 )
1133 .unwrap();
1134 assert!(kept.is_empty());
1135 }
1136
1137 fn unique_tmp() -> PathBuf {
1140 static COUNTER: AtomicU64 = AtomicU64::new(0);
1141 let dir = std::env::temp_dir().join(format!(
1142 "tc-provision-test-{}-{}",
1143 std::process::id(),
1144 COUNTER.fetch_add(1, Ordering::Relaxed)
1145 ));
1146 std::fs::create_dir_all(&dir).unwrap();
1147 dir
1148 }
1149
1150 #[test]
1151 fn provision_returns_an_existing_binary_without_installing() {
1152 let tmp = unique_tmp();
1153 let bin = tmp.join("bin").join("cargo-mutants");
1154 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1155 std::fs::write(&bin, b"binary").unwrap();
1156 let mut installed = false;
1157 let got = provision(&bin, || {
1158 installed = true;
1159 Ok(())
1160 })
1161 .unwrap();
1162 assert_eq!(got, bin);
1163 assert!(!installed, "a present binary must not be reinstalled");
1164 std::fs::remove_dir_all(&tmp).unwrap();
1165 }
1166
1167 #[test]
1168 fn provision_installs_when_the_binary_is_absent() {
1169 let tmp = unique_tmp();
1170 let bin = tmp.join("bin").join("cargo-mutants");
1171 let mut installed = false;
1172 let got = provision(&bin, || {
1173 installed = true;
1174 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1175 std::fs::write(&bin, b"binary").unwrap();
1176 Ok(())
1177 })
1178 .unwrap();
1179 assert!(installed, "an absent binary must be installed");
1180 assert_eq!(got, bin);
1181 std::fs::remove_dir_all(&tmp).unwrap();
1182 }
1183
1184 #[test]
1185 fn provision_errors_when_install_produces_no_binary() {
1186 let tmp = unique_tmp();
1187 let bin = tmp.join("bin").join("cargo-mutants");
1188 let err = provision(&bin, || Ok(())).unwrap_err();
1189 assert!(
1190 err.to_string().contains("cargo-mutants is not at"),
1191 "got: {err}"
1192 );
1193 std::fs::remove_dir_all(&tmp).unwrap();
1194 }
1195
1196 #[test]
1197 fn provision_propagates_an_install_failure() {
1198 let tmp = unique_tmp();
1199 let bin = tmp.join("bin").join("cargo-mutants");
1200 let err = provision(&bin, || bail!("install blew up")).unwrap_err();
1201 assert!(err.to_string().contains("install blew up"), "got: {err}");
1202 std::fs::remove_dir_all(&tmp).unwrap();
1203 }
1204
1205 #[test]
1206 fn resolve_cache_base_prefers_xdg_then_home_then_temp() {
1207 let xdg = |s: &str| Some(OsString::from(s));
1208 assert_eq!(
1210 resolve_cache_base(xdg("/xdg"), xdg("/home")),
1211 PathBuf::from("/xdg")
1212 );
1213 assert_eq!(
1215 resolve_cache_base(xdg(""), xdg("/home")),
1216 PathBuf::from("/home/.cache")
1217 );
1218 assert_eq!(
1220 resolve_cache_base(None, xdg("/home")),
1221 PathBuf::from("/home/.cache")
1222 );
1223 assert_eq!(resolve_cache_base(None, None), std::env::temp_dir());
1225 assert_eq!(
1226 resolve_cache_base(xdg(""), Some(OsString::new())),
1227 std::env::temp_dir()
1228 );
1229 }
1230
1231 #[test]
1232 fn cache_root_is_absolute_and_version_scoped() {
1233 let root = cargo_mutants_cache_root();
1234 assert!(
1235 root.ends_with(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}")),
1236 "version-scoped; got {root:?}"
1237 );
1238 assert!(
1239 root.to_string_lossy().contains("testing-conventions"),
1240 "tool-namespaced; got {root:?}"
1241 );
1242 assert!(
1244 root.is_absolute(),
1245 "expected an absolute path; got {root:?}"
1246 );
1247 }
1248
1249 #[test]
1250 fn install_argv_pins_the_version_and_isolates_the_root() {
1251 let argv: Vec<String> = install_argv(Path::new("/cache/cargo-mutants-27"))
1252 .iter()
1253 .map(|arg| arg.to_string_lossy().into_owned())
1254 .collect();
1255 assert_eq!(
1256 argv,
1257 vec![
1258 "install",
1259 "cargo-mutants",
1260 "--locked",
1261 "--version",
1262 CARGO_MUTANTS_VERSION,
1263 "--root",
1264 "/cache/cargo-mutants-27",
1265 ]
1266 );
1267 }
1268
1269 #[cfg(unix)]
1270 fn fake_output(code: i32, stderr: &str) -> Output {
1271 use std::os::unix::process::ExitStatusExt;
1272 Output {
1273 status: std::process::ExitStatus::from_raw(code << 8),
1274 stdout: Vec::new(),
1275 stderr: stderr.as_bytes().to_vec(),
1276 }
1277 }
1278
1279 #[cfg(unix)]
1280 #[test]
1281 fn run_install_succeeds_on_a_zero_exit() {
1282 let mut ran = false;
1283 run_install(Path::new("/cache/root"), |command| {
1284 ran = true;
1285 let argv: Vec<String> = command
1287 .get_args()
1288 .map(|arg| arg.to_string_lossy().into_owned())
1289 .collect();
1290 assert!(argv.contains(&CARGO_MUTANTS_VERSION.to_string()));
1291 Ok(fake_output(0, ""))
1292 })
1293 .unwrap();
1294 assert!(ran);
1295 }
1296
1297 #[cfg(unix)]
1298 #[test]
1299 fn run_install_reports_a_nonzero_exit_with_the_engine_output() {
1300 let err = run_install(Path::new("/cache/root"), |_| {
1301 Ok(fake_output(1, "error: could not compile cargo-mutants"))
1302 })
1303 .unwrap_err();
1304 assert!(
1305 err.to_string()
1306 .contains("failed to provision cargo-mutants")
1307 && err.to_string().contains("could not compile"),
1308 "got: {err}"
1309 );
1310 }
1311
1312 #[cfg(unix)]
1313 #[test]
1314 fn run_install_propagates_a_spawn_failure() {
1315 let err = run_install(Path::new("/cache/root"), |_| {
1316 Err(std::io::Error::new(
1317 std::io::ErrorKind::NotFound,
1318 "no cargo",
1319 ))
1320 })
1321 .unwrap_err();
1322 assert!(
1323 err.to_string().contains("is cargo installed?"),
1324 "got: {err}"
1325 );
1326 }
1327
1328 #[test]
1329 fn cargo_mutants_bin_name_matches_the_platform() {
1330 let name = cargo_mutants_bin_name();
1331 if cfg!(windows) {
1332 assert_eq!(name, "cargo-mutants.exe");
1333 } else {
1334 assert_eq!(name, "cargo-mutants");
1335 }
1336 }
1337}