1use std::collections::{BTreeMap, BTreeSet};
19use std::path::{Path, PathBuf};
20use std::process::Command;
21use std::sync::atomic::{AtomicU64, Ordering};
22
23use anyhow::{bail, Context, Result};
24use serde::Deserialize;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Survivor {
29 pub file: String,
31 pub line: u32,
33 pub description: String,
35}
36
37pub type MutatedLines = BTreeSet<(String, u32)>;
41
42type RunOutcome = (Vec<Survivor>, MutatedLines);
45
46#[derive(Debug, Clone, Deserialize)]
49pub struct MutantsReport {
50 pub outcomes: Vec<MutantOutcome>,
51}
52
53#[derive(Debug, Clone, Deserialize)]
57pub struct MutantOutcome {
58 pub summary: String,
59 pub scenario: Scenario,
60}
61
62#[derive(Debug, Clone, Deserialize)]
65pub enum Scenario {
66 Baseline,
67 Mutant(MutantInfo),
68}
69
70#[derive(Debug, Clone, Deserialize)]
74pub struct MutantInfo {
75 pub file: String,
76 pub span: Span,
77 pub name: String,
78}
79
80#[derive(Debug, Clone, Deserialize)]
82pub struct Span {
83 pub start: LineCol,
84}
85
86#[derive(Debug, Clone, Deserialize)]
88pub struct LineCol {
89 pub line: u32,
90}
91
92pub fn parse_mutants_report(json: &str) -> Result<MutantsReport> {
94 serde_json::from_str(json).context("parsing cargo-mutants outcomes.json")
95}
96
97pub fn unexplained_survivors(report: &MutantsReport, exempt: &[String]) -> Vec<Survivor> {
106 evaluate(cargo_mutants_survivors(report), exempt)
107}
108
109fn cargo_mutants_survivors(report: &MutantsReport) -> Vec<Survivor> {
113 report
114 .outcomes
115 .iter()
116 .filter_map(|outcome| {
117 if outcome.summary != "MissedMutant" {
118 return None;
119 }
120 let Scenario::Mutant(mutant) = &outcome.scenario else {
121 return None;
122 };
123 Some(Survivor {
124 file: mutant.file.clone(),
125 line: mutant.span.start.line,
126 description: mutant.name.clone(),
127 })
128 })
129 .collect()
130}
131
132pub fn mutated_lines(report: &MutantsReport) -> MutatedLines {
138 report
139 .outcomes
140 .iter()
141 .filter_map(|outcome| {
142 if outcome.summary != "CaughtMutant" && outcome.summary != "MissedMutant" {
143 return None;
144 }
145 let Scenario::Mutant(mutant) = &outcome.scenario else {
146 return None;
147 };
148 Some((mutant.file.clone(), mutant.span.start.line))
149 })
150 .collect()
151}
152
153pub fn evaluate(survivors: Vec<Survivor>, exempt: &[String]) -> Vec<Survivor> {
158 survivors
159 .into_iter()
160 .filter(|survivor| !exempt.iter().any(|path| path == &survivor.file))
161 .collect()
162}
163
164pub fn evaluate_scoped(
176 survivors: Vec<Survivor>,
177 mutated: &MutatedLines,
178 whole_file: &[String],
179 line_scoped: &BTreeMap<String, BTreeSet<u32>>,
180) -> Result<Vec<Survivor>> {
181 let mut over: Vec<String> = Vec::new();
182 for (file, lines) in line_scoped {
183 for &line in lines {
184 let has_survivor = survivors
185 .iter()
186 .any(|survivor| survivor.file == *file && survivor.line == line);
187 if has_survivor {
188 continue;
189 }
190 if mutated.contains(&(file.clone(), line)) {
191 over.push(format!("\n {file}:{line}"));
192 }
193 }
194 }
195 if !over.is_empty() {
196 bail!(
197 "a line-scoped mutation exemption may only list a line with a surviving mutant, but \
198 these had mutants that were all caught:{}",
199 over.concat()
200 );
201 }
202 Ok(survivors
203 .into_iter()
204 .filter(|survivor| {
205 let whole = whole_file.iter().any(|path| path == &survivor.file);
206 let line = line_scoped
207 .get(&survivor.file)
208 .is_some_and(|lines| lines.contains(&survivor.line));
209 !(whole || line)
210 })
211 .collect())
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
220#[serde(rename_all = "snake_case")]
221pub enum MutantStatus {
222 Survived,
224 Killed,
226 NoCoverage,
228 Timeout,
230 CompileError,
232 RuntimeError,
234}
235
236impl MutantStatus {
237 fn is_survivor(self) -> bool {
240 matches!(self, MutantStatus::Survived | MutantStatus::NoCoverage)
241 }
242
243 fn is_viable(self) -> bool {
248 matches!(
249 self,
250 MutantStatus::Survived
251 | MutantStatus::Killed
252 | MutantStatus::NoCoverage
253 | MutantStatus::Timeout
254 )
255 }
256}
257
258#[derive(Debug, Clone, Deserialize)]
261pub struct NormalizedMutant {
262 pub file: String,
264 pub line: u32,
266 pub status: MutantStatus,
268 pub mutator: String,
270 #[serde(default)]
272 pub replacement: Option<String>,
273}
274
275pub fn parse_normalized_results(json: &str) -> Result<Vec<NormalizedMutant>> {
278 serde_json::from_str(json).context("parsing normalized mutation results")
279}
280
281pub fn evaluate_normalized(
289 mutants: &[NormalizedMutant],
290 whole_file: &[String],
291 line_scoped: &BTreeMap<String, BTreeSet<u32>>,
292) -> Result<Vec<Survivor>> {
293 evaluate_scoped(
294 normalized_survivors(mutants),
295 &normalized_mutated_lines(mutants),
296 whole_file,
297 line_scoped,
298 )
299}
300
301fn normalized_survivors(mutants: &[NormalizedMutant]) -> Vec<Survivor> {
303 mutants
304 .iter()
305 .filter(|mutant| mutant.status.is_survivor())
306 .map(|mutant| Survivor {
307 file: mutant.file.clone(),
308 line: mutant.line,
309 description: describe_normalized(mutant),
310 })
311 .collect()
312}
313
314fn normalized_mutated_lines(mutants: &[NormalizedMutant]) -> MutatedLines {
317 mutants
318 .iter()
319 .filter(|mutant| mutant.status.is_viable())
320 .map(|mutant| (mutant.file.clone(), mutant.line))
321 .collect()
322}
323
324fn describe_normalized(mutant: &NormalizedMutant) -> String {
327 match &mutant.replacement {
328 Some(replacement) => format!("{} (-> {})", mutant.mutator, one_line(replacement)),
329 None => mutant.mutator.clone(),
330 }
331}
332
333pub fn measure_rust(
340 root: &Path,
341 exempt: &[String],
342 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
343 base: Option<&str>,
344) -> Result<Vec<Survivor>> {
345 let out = MutantsOut::new();
346 let diff = match base {
347 Some(base) => match write_base_diff(root, base, &out)? {
350 None => return Ok(Vec::new()),
351 Some(path) => Some(path),
352 },
353 None => None,
354 };
355 run_cargo_mutants(root, &out.0, diff.as_deref())?;
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
534#[derive(Debug, Clone, Deserialize)]
537pub struct CosmicRayLine(pub CrWorkItem, pub Option<CrResult>);
538
539#[derive(Debug, Clone, Deserialize)]
542pub struct CrWorkItem {
543 pub mutations: Vec<CrMutation>,
544}
545
546#[derive(Debug, Clone, Deserialize)]
549pub struct CrMutation {
550 pub module_path: String,
551 pub operator_name: String,
552 pub start_pos: (u32, u32),
554 #[serde(default)]
555 pub definition_name: Option<String>,
556}
557
558#[derive(Debug, Clone, Deserialize)]
561pub struct CrResult {
562 #[serde(default)]
563 pub test_outcome: Option<String>,
564}
565
566pub fn parse_cosmic_ray_dump(dump: &str) -> Result<Vec<Survivor>> {
574 let mut survivors = Vec::new();
575 for line in dump.lines() {
576 if line.trim().is_empty() {
577 continue;
578 }
579 let CosmicRayLine(item, result) =
580 serde_json::from_str(line).context("parsing a cosmic-ray dump line")?;
581 let survived = matches!(result, Some(CrResult { test_outcome: Some(outcome) }) if outcome == "survived");
582 if !survived {
583 continue;
584 }
585 let Some(mutation) = item.mutations.first() else {
586 continue;
587 };
588 let definition = mutation.definition_name.as_deref().unwrap_or("<module>");
589 survivors.push(Survivor {
590 file: mutation.module_path.clone(),
591 line: mutation.start_pos.0,
592 description: format!("{} in {}", mutation.operator_name, definition),
593 });
594 }
595 Ok(survivors)
596}
597
598pub fn measure_python(
609 root: &Path,
610 exempt: &[String],
611 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
612 base: Option<&str>,
613) -> Result<Vec<Survivor>> {
614 let (survivors, mutated) = match base {
615 None => run_cosmic_ray(root, ".", &PY_TEST_EXCLUDES)?,
616 Some(base) => {
617 let changed = crate::patch_coverage::changed_lines(root, base)?;
618 let mut all_survivors = Vec::new();
619 let mut all_mutated = BTreeSet::new();
620 for (file, lines) in &changed {
621 if !is_mutatable_py(file) {
622 continue;
623 }
624 let (survivors, mutated) = run_cosmic_ray(root, file, &[])?;
626 for survivor in survivors {
627 if lines.contains(&(survivor.line as u64)) {
628 all_survivors.push(survivor);
629 }
630 }
631 for (mutated_file, line) in mutated {
632 if lines.contains(&u64::from(line)) {
633 all_mutated.insert((mutated_file, line));
634 }
635 }
636 }
637 (all_survivors, all_mutated)
638 }
639 };
640 evaluate_scoped(survivors, &mutated, exempt, exempt_lines)
641}
642
643const PY_TEST_EXCLUDES: [&str; 3] = ["*_test.py", "test_*.py", "conftest.py"];
646
647fn is_mutatable_py(file: &str) -> bool {
650 if !file.ends_with(".py") {
651 return false;
652 }
653 let base = file.rsplit('/').next().unwrap_or(file);
654 !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
655}
656
657fn run_cosmic_ray(root: &Path, module_path: &str, excluded_modules: &[&str]) -> Result<RunOutcome> {
664 let dir = CosmicRayDir::new();
665 std::fs::create_dir_all(&dir.0).context("creating the cosmic-ray temp dir")?;
666 let config = dir.0.join("cr.toml");
667 let session = dir.0.join("session.sqlite");
668
669 let excludes = excluded_modules
670 .iter()
671 .map(|glob| format!("\"{glob}\""))
672 .collect::<Vec<_>>()
673 .join(", ");
674 std::fs::write(
675 &config,
676 format!(
677 "[cosmic-ray]\n\
678 module-path = \"{module_path}\"\n\
679 timeout = 30.0\n\
680 excluded-modules = [{excludes}]\n\
681 test-command = \"python3 -m pytest -q -p no:cacheprovider\"\n\
682 \n\
683 [cosmic-ray.distributor]\n\
684 name = \"local\"\n"
685 ),
686 )
687 .context("writing the cosmic-ray config")?;
688
689 let baseline = cosmic_ray(root, &["baseline", path_str(&config)])?;
692 if !baseline.status.success() {
693 bail!(
694 "the Python unit suite did not pass unmutated in `{}` (cosmic-ray baseline failed):\n{}{}",
695 root.display(),
696 String::from_utf8_lossy(&baseline.stdout),
697 String::from_utf8_lossy(&baseline.stderr),
698 );
699 }
700
701 let init = cosmic_ray(root, &["init", path_str(&config), path_str(&session)])?;
702 if !init.status.success() {
703 bail!(
704 "cosmic-ray init failed in `{}`:\n{}{}",
705 root.display(),
706 String::from_utf8_lossy(&init.stdout),
707 String::from_utf8_lossy(&init.stderr),
708 );
709 }
710 let exec = cosmic_ray(root, &["exec", path_str(&config), path_str(&session)])?;
711 if !exec.status.success() {
712 bail!(
713 "cosmic-ray exec failed in `{}`:\n{}{}",
714 root.display(),
715 String::from_utf8_lossy(&exec.stdout),
716 String::from_utf8_lossy(&exec.stderr),
717 );
718 }
719 let dump = cosmic_ray(root, &["dump", path_str(&session)])?;
720 if !dump.status.success() {
721 bail!(
722 "cosmic-ray dump failed in `{}`:\n{}",
723 root.display(),
724 String::from_utf8_lossy(&dump.stderr),
725 );
726 }
727 let stdout = String::from_utf8_lossy(&dump.stdout);
728 Ok((
729 parse_cosmic_ray_dump(&stdout)?,
730 cosmic_ray_mutated_lines(&stdout)?,
731 ))
732}
733
734pub fn cosmic_ray_mutated_lines(dump: &str) -> Result<MutatedLines> {
739 let mut mutated = BTreeSet::new();
740 for line in dump.lines() {
741 if line.trim().is_empty() {
742 continue;
743 }
744 let CosmicRayLine(item, result) =
745 serde_json::from_str(line).context("parsing a cosmic-ray dump line")?;
746 let outcome = result.and_then(|result| result.test_outcome);
747 if !matches!(outcome.as_deref(), Some("survived") | Some("killed")) {
748 continue;
749 }
750 if let Some(mutation) = item.mutations.first() {
751 mutated.insert((mutation.module_path.clone(), mutation.start_pos.0));
752 }
753 }
754 Ok(mutated)
755}
756
757fn cosmic_ray(root: &Path, args: &[&str]) -> Result<std::process::Output> {
760 Command::new("cosmic-ray")
761 .current_dir(root)
762 .args(args)
763 .env("PYTHONDONTWRITEBYTECODE", "1")
764 .output()
765 .context("running `cosmic-ray` (is it installed?)")
766}
767
768fn path_str(path: &Path) -> &str {
769 path.to_str().expect("temp path is valid UTF-8")
770}
771
772struct CosmicRayDir(PathBuf);
775
776impl CosmicRayDir {
777 fn new() -> Self {
778 static COUNTER: AtomicU64 = AtomicU64::new(0);
779 let name = format!(
780 "testing-conventions-cosmic-ray-{}-{}",
781 std::process::id(),
782 COUNTER.fetch_add(1, Ordering::Relaxed),
783 );
784 CosmicRayDir(std::env::temp_dir().join(name))
785 }
786}
787
788impl Drop for CosmicRayDir {
789 fn drop(&mut self) {
790 let _ = std::fs::remove_dir_all(&self.0);
791 }
792}
793
794struct MutantsOut(PathBuf);
797
798impl MutantsOut {
799 fn new() -> Self {
800 static COUNTER: AtomicU64 = AtomicU64::new(0);
801 let name = format!(
802 "testing-conventions-mutants-{}-{}",
803 std::process::id(),
804 COUNTER.fetch_add(1, Ordering::Relaxed),
805 );
806 MutantsOut(std::env::temp_dir().join(name))
807 }
808}
809
810impl Drop for MutantsOut {
811 fn drop(&mut self) {
812 let _ = std::fs::remove_dir_all(&self.0);
813 }
814}
815
816fn write_base_diff(root: &Path, base: &str, out: &MutantsOut) -> Result<Option<PathBuf>> {
825 let range = format!("{base}...HEAD");
826 let output = Command::new("git")
827 .current_dir(root)
828 .args(["diff", "--relative", &range])
829 .output()
830 .context("running `git diff` for `--base` (is git installed?)")?;
831 if !output.status.success() {
832 bail!(
833 "git diff {range} failed: {}",
834 String::from_utf8_lossy(&output.stderr)
835 );
836 }
837 if output.stdout.is_empty() {
838 return Ok(None);
839 }
840 std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
841 let path = out.0.join("base.diff");
842 std::fs::write(&path, &output.stdout).context("writing the base diff")?;
843 Ok(Some(path))
844}
845
846fn run_cargo_mutants(root: &Path, out: &Path, in_diff: Option<&Path>) -> Result<()> {
855 let mut command = Command::new("cargo");
856 command
857 .current_dir(root)
858 .arg("mutants")
859 .arg("--output")
860 .arg(out);
861 if let Some(diff) = in_diff {
862 command.arg("--in-diff").arg(diff);
863 }
864 for var in [
865 "RUSTFLAGS",
866 "CARGO_ENCODED_RUSTFLAGS",
867 "RUSTDOCFLAGS",
868 "CARGO_ENCODED_RUSTDOCFLAGS",
869 "LLVM_PROFILE_FILE",
870 "CARGO_LLVM_COV",
871 "CARGO_LLVM_COV_SHOW_ENV",
872 "CARGO_LLVM_COV_TARGET_DIR",
873 "CARGO_LLVM_COV_BUILD_DIR",
874 "RUSTC_WRAPPER",
875 "RUSTC_WORKSPACE_WRAPPER",
876 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
877 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
878 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
879 ] {
880 command.env_remove(var);
881 }
882 let output = command
883 .output()
884 .context("running `cargo mutants` (is cargo-mutants installed?)")?;
885 match output.status.code() {
886 Some(0) | Some(2) => Ok(()),
888 _ => bail!(
889 "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
890 root.display(),
891 String::from_utf8_lossy(&output.stdout),
892 String::from_utf8_lossy(&output.stderr),
893 ),
894 }
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900
901 const NORMALIZED: &str = r#"[
907 {"file": "src/a.ts", "line": 2, "status": "survived",
908 "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
909 {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
910 {"file": "src/a.ts", "line": 9, "status": "killed",
911 "mutator": "BooleanLiteral", "replacement": "false"},
912 {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
913 {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
914 {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
915 ]"#;
916
917 #[test]
918 fn parses_the_normalized_schema() {
919 let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
920 assert_eq!(mutants.len(), 6);
921 assert_eq!(mutants[0].status, MutantStatus::Survived);
922 assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
923 assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
924 assert_eq!(mutants[1].replacement, None);
925 }
926
927 #[test]
928 fn normalized_survivors_are_survived_and_nocoverage_only() {
929 let mutants = parse_normalized_results(NORMALIZED).unwrap();
930 let survivors = normalized_survivors(&mutants);
931 assert_eq!(survivors.len(), 2);
933 assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
934 assert!(survivors[0].description.contains("ConditionalExpression"));
936 assert!(survivors[0].description.contains("-> true"));
937 assert_eq!(survivors[1].description, "ArithmeticOperator");
938 }
939
940 #[test]
941 fn normalized_mutated_lines_collects_only_viable_mutants() {
942 let mutants = parse_normalized_results(NORMALIZED).unwrap();
943 assert_eq!(
946 normalized_mutated_lines(&mutants),
947 [2u32, 5, 9, 12]
948 .into_iter()
949 .map(|line| ("src/a.ts".to_string(), line))
950 .collect()
951 );
952 }
953
954 #[test]
955 fn evaluate_normalized_reports_unexempted_survivors() {
956 let mutants = parse_normalized_results(NORMALIZED).unwrap();
957 let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
958 assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
959 }
960
961 #[test]
962 fn evaluate_normalized_drops_a_whole_file_exemption() {
963 let mutants = parse_normalized_results(NORMALIZED).unwrap();
964 let kept =
965 evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
966 assert!(
967 kept.is_empty(),
968 "the whole-file exemption lifts both survivors"
969 );
970 }
971
972 #[test]
973 fn evaluate_normalized_drops_a_line_scoped_exemption() {
974 let mutants = parse_normalized_results(NORMALIZED).unwrap();
975 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
976 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
977 assert_eq!(kept.len(), 1);
979 assert_eq!(kept[0].line, 5);
980 }
981
982 #[test]
983 fn evaluate_normalized_rejects_exempting_a_caught_line() {
984 let mutants = parse_normalized_results(NORMALIZED).unwrap();
987 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
988 let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
989 assert!(
990 err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
991 "got: {err}"
992 );
993 }
994
995 #[test]
996 fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
997 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1000 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
1001 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1002 assert_eq!(kept.len(), 2);
1003 }
1004
1005 const SAMPLE: &str = r#"{
1008 "outcomes": [
1009 {"scenario": "Baseline", "summary": "Success",
1010 "phase_results": []},
1011 {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
1012 "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
1013 "function": {"function_name": "is_positive"},
1014 "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
1015 "summary": "MissedMutant"},
1016 {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
1017 "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
1018 "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
1019 "summary": "CaughtMutant"}
1020 ],
1021 "total_mutants": 2
1022 }"#;
1023
1024 #[test]
1025 fn parses_the_outcomes_export() {
1026 let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
1027 assert_eq!(report.outcomes.len(), 3);
1028 assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
1029 }
1030
1031 #[test]
1032 fn collects_only_missed_mutants_as_survivors() {
1033 let report = parse_mutants_report(SAMPLE).unwrap();
1034 let survivors = unexplained_survivors(&report, &[]);
1035 assert_eq!(survivors.len(), 1);
1037 assert_eq!(survivors[0].file, "src/lib.rs");
1038 assert_eq!(survivors[0].line, 7);
1039 assert!(survivors[0].description.contains("replace > with =="));
1040 }
1041
1042 #[test]
1043 fn an_exemption_drops_a_survivor_in_that_file() {
1044 let report = parse_mutants_report(SAMPLE).unwrap();
1045 let exempt = vec!["src/lib.rs".to_string()];
1046 assert!(unexplained_survivors(&report, &exempt).is_empty());
1047 }
1048
1049 #[test]
1050 fn an_exemption_on_another_file_leaves_the_survivor() {
1051 let report = parse_mutants_report(SAMPLE).unwrap();
1052 let exempt = vec!["src/elsewhere.rs".to_string()];
1053 assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1054 }
1055
1056 #[test]
1057 fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
1058 assert!(is_mutatable_ts("src/index.ts"));
1059 assert!(is_mutatable_ts("src/util.tsx"));
1060 assert!(is_mutatable_ts("src/util.js"));
1061 assert!(!is_mutatable_ts("src/index.test.ts"));
1062 assert!(!is_mutatable_ts("src/index.spec.ts"));
1063 assert!(!is_mutatable_ts("src/types.d.ts"));
1064 assert!(!is_mutatable_ts("README.md"));
1065 }
1066
1067 #[test]
1068 fn contiguous_runs_collapses_adjacent_lines() {
1069 let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
1070 assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
1071 assert!(contiguous_runs(&BTreeSet::new()).is_empty());
1072 }
1073
1074 #[test]
1075 fn one_line_flattens_and_caps() {
1076 assert_eq!(one_line("a -\n b"), "a - b");
1077 let long = "x".repeat(80);
1078 let capped = one_line(&long);
1079 assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
1080 }
1081
1082 const COSMIC_RAY_DUMP: &str = concat!(
1085 r#"[{"job_id":"a","mutations":[{"module_path":"calc.py","operator_name":"core/ReplaceComparisonOperator_Gt_NotEq","occurrence":0,"start_pos":[6,11],"end_pos":[6,12],"operator_args":{},"definition_name":"is_positive"}]},{"worker_outcome":"normal","test_outcome":"survived"}]"#,
1086 "\n",
1087 r#"[{"job_id":"b","mutations":[{"module_path":"calc.py","operator_name":"core/ReplaceBinaryOperator_Add_Div","occurrence":0,"start_pos":[2,13],"end_pos":[2,14],"operator_args":{},"definition_name":"add"}]},{"worker_outcome":"normal","test_outcome":"killed"}]"#,
1088 "\n",
1089 );
1090
1091 #[test]
1092 fn collects_only_survived_cosmic_ray_mutants() {
1093 let survivors = parse_cosmic_ray_dump(COSMIC_RAY_DUMP).expect("valid dump");
1094 assert_eq!(survivors.len(), 1);
1096 assert_eq!(survivors[0].file, "calc.py");
1097 assert_eq!(survivors[0].line, 6);
1098 assert!(survivors[0]
1099 .description
1100 .contains("ReplaceComparisonOperator"));
1101 assert!(survivors[0].description.contains("is_positive"));
1102 }
1103
1104 #[test]
1105 fn an_unexecuted_cosmic_ray_item_is_not_a_survivor() {
1106 let dump = r#"[{"mutations":[{"module_path":"calc.py","operator_name":"core/NumberReplacer","start_pos":[3,5],"end_pos":[3,6]}]},null]"#;
1108 assert!(parse_cosmic_ray_dump(dump).unwrap().is_empty());
1109 }
1110
1111 #[test]
1112 fn is_mutatable_py_keeps_sources_and_drops_tests() {
1113 assert!(is_mutatable_py("calc.py"));
1114 assert!(is_mutatable_py("pkg/util.py"));
1115 assert!(!is_mutatable_py("calc_test.py"));
1116 assert!(!is_mutatable_py("test_calc.py"));
1117 assert!(!is_mutatable_py("pkg/conftest.py"));
1118 assert!(!is_mutatable_py("README.md"));
1119 }
1120
1121 #[test]
1124 fn mutated_lines_collects_caught_and_missed() {
1125 let report = parse_mutants_report(SAMPLE).unwrap();
1128 assert_eq!(
1129 mutated_lines(&report),
1130 [
1131 ("src/lib.rs".to_string(), 7),
1132 ("src/other.rs".to_string(), 3)
1133 ]
1134 .into_iter()
1135 .collect()
1136 );
1137 }
1138
1139 #[test]
1140 fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1141 let report = parse_mutants_report(SAMPLE).unwrap();
1142 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1143 let kept = evaluate_scoped(
1144 cargo_mutants_survivors(&report),
1145 &mutated_lines(&report),
1146 &[],
1147 &line_scoped,
1148 )
1149 .unwrap();
1150 assert!(
1151 kept.is_empty(),
1152 "the src/lib.rs:7 survivor should be lifted"
1153 );
1154 }
1155
1156 #[test]
1157 fn evaluate_scoped_rejects_exempting_a_caught_line() {
1158 let report = parse_mutants_report(SAMPLE).unwrap();
1160 let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
1161 let err = evaluate_scoped(
1162 cargo_mutants_survivors(&report),
1163 &mutated_lines(&report),
1164 &[],
1165 &line_scoped,
1166 )
1167 .unwrap_err();
1168 assert!(
1169 err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
1170 "got: {err}"
1171 );
1172 }
1173
1174 #[test]
1175 fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
1176 let report = parse_mutants_report(SAMPLE).unwrap();
1179 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
1180 let kept = evaluate_scoped(
1181 cargo_mutants_survivors(&report),
1182 &mutated_lines(&report),
1183 &[],
1184 &line_scoped,
1185 )
1186 .unwrap();
1187 assert_eq!(kept.len(), 1);
1188 assert_eq!(kept[0].line, 7);
1189 }
1190
1191 #[test]
1192 fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1193 let report = parse_mutants_report(SAMPLE).unwrap();
1194 let kept = evaluate_scoped(
1195 cargo_mutants_survivors(&report),
1196 &mutated_lines(&report),
1197 &["src/lib.rs".to_string()],
1198 &BTreeMap::new(),
1199 )
1200 .unwrap();
1201 assert!(kept.is_empty());
1202 }
1203
1204 #[test]
1205 fn cosmic_ray_mutated_lines_collects_executed_mutants() {
1206 let mutated = cosmic_ray_mutated_lines(COSMIC_RAY_DUMP).unwrap();
1208 assert_eq!(
1209 mutated,
1210 [("calc.py".to_string(), 2), ("calc.py".to_string(), 6)]
1211 .into_iter()
1212 .collect()
1213 );
1214 }
1215}