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
373#[derive(Debug, Clone, Deserialize)]
377pub struct StrykerReport {
378 pub files: BTreeMap<String, StrykerFile>,
380}
381
382#[derive(Debug, Clone, Deserialize)]
384pub struct StrykerFile {
385 #[serde(default)]
386 pub mutants: Vec<StrykerMutant>,
387}
388
389#[derive(Debug, Clone, Deserialize)]
392#[serde(rename_all = "camelCase")]
393pub struct StrykerMutant {
394 pub mutator_name: String,
395 #[serde(default)]
396 pub replacement: Option<String>,
397 pub status: String,
398 pub location: StrykerLocation,
399}
400
401#[derive(Debug, Clone, Deserialize)]
404pub struct StrykerLocation {
405 pub start: LineCol,
406}
407
408pub fn parse_stryker_report(json: &str) -> Result<StrykerReport> {
410 serde_json::from_str(json).context("parsing Stryker mutation.json")
411}
412
413pub fn stryker_survivors(report: &StrykerReport) -> Vec<Survivor> {
420 let mut survivors = Vec::new();
421 for (file, contents) in &report.files {
422 for mutant in &contents.mutants {
423 if mutant.status != "Survived" && mutant.status != "NoCoverage" {
424 continue;
425 }
426 let description = match &mutant.replacement {
427 Some(replacement) => {
428 format!("{} (-> {})", mutant.mutator_name, one_line(replacement))
429 }
430 None => mutant.mutator_name.clone(),
431 };
432 survivors.push(Survivor {
433 file: file.clone(),
434 line: mutant.location.start.line,
435 description,
436 });
437 }
438 }
439 survivors
440}
441
442fn one_line(replacement: &str) -> String {
445 let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
446 const MAX: usize = 60;
447 if flat.chars().count() > MAX {
448 format!("{}…", flat.chars().take(MAX).collect::<String>())
449 } else {
450 flat
451 }
452}
453
454pub fn measure_typescript(
464 root: &Path,
465 exempt: &[String],
466 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
467 base: Option<&str>,
468) -> Result<Vec<Survivor>> {
469 let mutate = match base {
470 Some(base) => {
471 let ranges = mutate_ranges(root, base)?;
472 if ranges.is_empty() {
474 return Ok(Vec::new());
475 }
476 Some(ranges)
477 }
478 None => None,
479 };
480 let json = run_stryker(root, mutate.as_deref())?;
481 let report = parse_stryker_report(&json)?;
482 evaluate_scoped(
483 stryker_survivors(&report),
484 &stryker_mutated_lines(&report),
485 exempt,
486 exempt_lines,
487 )
488}
489
490fn stryker_mutated_lines(report: &StrykerReport) -> MutatedLines {
495 let mut mutated = BTreeSet::new();
496 for (file, contents) in &report.files {
497 for mutant in &contents.mutants {
498 if matches!(
499 mutant.status.as_str(),
500 "Killed" | "Survived" | "NoCoverage" | "Timeout"
501 ) {
502 mutated.insert((file.clone(), mutant.location.start.line));
503 }
504 }
505 }
506 mutated
507}
508
509fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
515 let changed = crate::patch_coverage::changed_lines(root, base)?;
516 let mut specs = Vec::new();
517 for (file, lines) in changed {
518 if !is_mutatable_ts(&file) {
519 continue;
520 }
521 for (start, end) in contiguous_runs(&lines) {
522 specs.push(format!("{file}:{start}-{end}"));
523 }
524 }
525 Ok(specs)
526}
527
528fn is_mutatable_ts(file: &str) -> bool {
532 let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
533 .iter()
534 .any(|ext| file.ends_with(ext));
535 let is_decl = file.ends_with(".d.ts");
536 let is_test = file.contains(".test.") || file.contains(".spec.");
537 is_source && !is_decl && !is_test
538}
539
540fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
542 let mut runs: Vec<(u64, u64)> = Vec::new();
543 for &line in lines {
544 match runs.last_mut() {
545 Some(run) if run.1 + 1 == line => run.1 = line,
546 _ => runs.push((line, line)),
547 }
548 }
549 runs
550}
551
552fn run_stryker(root: &Path, mutate: Option<&[String]>) -> Result<String> {
563 let report_path = root.join("reports").join("mutation").join("mutation.json");
564 let _cleanup = ReportCleanup(report_path.clone());
565 let _ = std::fs::remove_file(&report_path);
567
568 let mut command = Command::new("npx");
569 command
570 .current_dir(root)
571 .args(["--no-install", "stryker", "run", "--reporters", "json"]);
578 if let Some(specs) = mutate {
579 command.arg("--mutate").arg(specs.join(","));
580 }
581 let output = command
582 .env("CI", "1")
583 .output()
584 .context("running `npx --no-install stryker run`")?;
585
586 std::fs::read_to_string(&report_path).map_err(|_| {
587 anyhow::anyhow!(
588 "Stryker produced no report in `{}`. The rule runs the project's own Stryker via \
589 `npx --no-install` and never downloads it, so `@stryker-mutator/core` (plus a \
590 test-runner plugin) must be installed in the project. Stryker output:\n{}{}",
591 root.display(),
592 String::from_utf8_lossy(&output.stdout),
593 String::from_utf8_lossy(&output.stderr),
594 )
595 })
596}
597
598struct ReportCleanup(PathBuf);
601
602impl Drop for ReportCleanup {
603 fn drop(&mut self) {
604 let _ = std::fs::remove_file(&self.0);
605 if let Some(mutation_dir) = self.0.parent() {
606 let _ = std::fs::remove_dir(mutation_dir);
608 if let Some(reports_dir) = mutation_dir.parent() {
609 let _ = std::fs::remove_dir(reports_dir);
610 }
611 }
612 }
613}
614
615#[derive(Debug, Clone, Deserialize)]
618pub struct CosmicRayLine(pub CrWorkItem, pub Option<CrResult>);
619
620#[derive(Debug, Clone, Deserialize)]
623pub struct CrWorkItem {
624 pub mutations: Vec<CrMutation>,
625}
626
627#[derive(Debug, Clone, Deserialize)]
630pub struct CrMutation {
631 pub module_path: String,
632 pub operator_name: String,
633 pub start_pos: (u32, u32),
635 #[serde(default)]
636 pub definition_name: Option<String>,
637}
638
639#[derive(Debug, Clone, Deserialize)]
642pub struct CrResult {
643 #[serde(default)]
644 pub test_outcome: Option<String>,
645}
646
647pub fn parse_cosmic_ray_dump(dump: &str) -> Result<Vec<Survivor>> {
655 let mut survivors = Vec::new();
656 for line in dump.lines() {
657 if line.trim().is_empty() {
658 continue;
659 }
660 let CosmicRayLine(item, result) =
661 serde_json::from_str(line).context("parsing a cosmic-ray dump line")?;
662 let survived = matches!(result, Some(CrResult { test_outcome: Some(outcome) }) if outcome == "survived");
663 if !survived {
664 continue;
665 }
666 let Some(mutation) = item.mutations.first() else {
667 continue;
668 };
669 let definition = mutation.definition_name.as_deref().unwrap_or("<module>");
670 survivors.push(Survivor {
671 file: mutation.module_path.clone(),
672 line: mutation.start_pos.0,
673 description: format!("{} in {}", mutation.operator_name, definition),
674 });
675 }
676 Ok(survivors)
677}
678
679pub fn measure_python(
690 root: &Path,
691 exempt: &[String],
692 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
693 base: Option<&str>,
694) -> Result<Vec<Survivor>> {
695 let (survivors, mutated) = match base {
696 None => run_cosmic_ray(root, ".", &PY_TEST_EXCLUDES)?,
697 Some(base) => {
698 let changed = crate::patch_coverage::changed_lines(root, base)?;
699 let mut all_survivors = Vec::new();
700 let mut all_mutated = BTreeSet::new();
701 for (file, lines) in &changed {
702 if !is_mutatable_py(file) {
703 continue;
704 }
705 let (survivors, mutated) = run_cosmic_ray(root, file, &[])?;
707 for survivor in survivors {
708 if lines.contains(&(survivor.line as u64)) {
709 all_survivors.push(survivor);
710 }
711 }
712 for (mutated_file, line) in mutated {
713 if lines.contains(&u64::from(line)) {
714 all_mutated.insert((mutated_file, line));
715 }
716 }
717 }
718 (all_survivors, all_mutated)
719 }
720 };
721 evaluate_scoped(survivors, &mutated, exempt, exempt_lines)
722}
723
724const PY_TEST_EXCLUDES: [&str; 3] = ["*_test.py", "test_*.py", "conftest.py"];
727
728fn is_mutatable_py(file: &str) -> bool {
731 if !file.ends_with(".py") {
732 return false;
733 }
734 let base = file.rsplit('/').next().unwrap_or(file);
735 !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
736}
737
738fn run_cosmic_ray(root: &Path, module_path: &str, excluded_modules: &[&str]) -> Result<RunOutcome> {
745 let dir = CosmicRayDir::new();
746 std::fs::create_dir_all(&dir.0).context("creating the cosmic-ray temp dir")?;
747 let config = dir.0.join("cr.toml");
748 let session = dir.0.join("session.sqlite");
749
750 let excludes = excluded_modules
751 .iter()
752 .map(|glob| format!("\"{glob}\""))
753 .collect::<Vec<_>>()
754 .join(", ");
755 std::fs::write(
756 &config,
757 format!(
758 "[cosmic-ray]\n\
759 module-path = \"{module_path}\"\n\
760 timeout = 30.0\n\
761 excluded-modules = [{excludes}]\n\
762 test-command = \"python3 -m pytest -q -p no:cacheprovider\"\n\
763 \n\
764 [cosmic-ray.distributor]\n\
765 name = \"local\"\n"
766 ),
767 )
768 .context("writing the cosmic-ray config")?;
769
770 let baseline = cosmic_ray(root, &["baseline", path_str(&config)])?;
773 if !baseline.status.success() {
774 bail!(
775 "the Python unit suite did not pass unmutated in `{}` (cosmic-ray baseline failed):\n{}{}",
776 root.display(),
777 String::from_utf8_lossy(&baseline.stdout),
778 String::from_utf8_lossy(&baseline.stderr),
779 );
780 }
781
782 let init = cosmic_ray(root, &["init", path_str(&config), path_str(&session)])?;
783 if !init.status.success() {
784 bail!(
785 "cosmic-ray init failed in `{}`:\n{}{}",
786 root.display(),
787 String::from_utf8_lossy(&init.stdout),
788 String::from_utf8_lossy(&init.stderr),
789 );
790 }
791 let exec = cosmic_ray(root, &["exec", path_str(&config), path_str(&session)])?;
792 if !exec.status.success() {
793 bail!(
794 "cosmic-ray exec failed in `{}`:\n{}{}",
795 root.display(),
796 String::from_utf8_lossy(&exec.stdout),
797 String::from_utf8_lossy(&exec.stderr),
798 );
799 }
800 let dump = cosmic_ray(root, &["dump", path_str(&session)])?;
801 if !dump.status.success() {
802 bail!(
803 "cosmic-ray dump failed in `{}`:\n{}",
804 root.display(),
805 String::from_utf8_lossy(&dump.stderr),
806 );
807 }
808 let stdout = String::from_utf8_lossy(&dump.stdout);
809 Ok((
810 parse_cosmic_ray_dump(&stdout)?,
811 cosmic_ray_mutated_lines(&stdout)?,
812 ))
813}
814
815pub fn cosmic_ray_mutated_lines(dump: &str) -> Result<MutatedLines> {
820 let mut mutated = BTreeSet::new();
821 for line in dump.lines() {
822 if line.trim().is_empty() {
823 continue;
824 }
825 let CosmicRayLine(item, result) =
826 serde_json::from_str(line).context("parsing a cosmic-ray dump line")?;
827 let outcome = result.and_then(|result| result.test_outcome);
828 if !matches!(outcome.as_deref(), Some("survived") | Some("killed")) {
829 continue;
830 }
831 if let Some(mutation) = item.mutations.first() {
832 mutated.insert((mutation.module_path.clone(), mutation.start_pos.0));
833 }
834 }
835 Ok(mutated)
836}
837
838fn cosmic_ray(root: &Path, args: &[&str]) -> Result<std::process::Output> {
841 Command::new("cosmic-ray")
842 .current_dir(root)
843 .args(args)
844 .env("PYTHONDONTWRITEBYTECODE", "1")
845 .output()
846 .context("running `cosmic-ray` (is it installed?)")
847}
848
849fn path_str(path: &Path) -> &str {
850 path.to_str().expect("temp path is valid UTF-8")
851}
852
853struct CosmicRayDir(PathBuf);
856
857impl CosmicRayDir {
858 fn new() -> Self {
859 static COUNTER: AtomicU64 = AtomicU64::new(0);
860 let name = format!(
861 "testing-conventions-cosmic-ray-{}-{}",
862 std::process::id(),
863 COUNTER.fetch_add(1, Ordering::Relaxed),
864 );
865 CosmicRayDir(std::env::temp_dir().join(name))
866 }
867}
868
869impl Drop for CosmicRayDir {
870 fn drop(&mut self) {
871 let _ = std::fs::remove_dir_all(&self.0);
872 }
873}
874
875struct MutantsOut(PathBuf);
878
879impl MutantsOut {
880 fn new() -> Self {
881 static COUNTER: AtomicU64 = AtomicU64::new(0);
882 let name = format!(
883 "testing-conventions-mutants-{}-{}",
884 std::process::id(),
885 COUNTER.fetch_add(1, Ordering::Relaxed),
886 );
887 MutantsOut(std::env::temp_dir().join(name))
888 }
889}
890
891impl Drop for MutantsOut {
892 fn drop(&mut self) {
893 let _ = std::fs::remove_dir_all(&self.0);
894 }
895}
896
897fn write_base_diff(root: &Path, base: &str, out: &MutantsOut) -> Result<Option<PathBuf>> {
906 let range = format!("{base}...HEAD");
907 let output = Command::new("git")
908 .current_dir(root)
909 .args(["diff", "--relative", &range])
910 .output()
911 .context("running `git diff` for `--base` (is git installed?)")?;
912 if !output.status.success() {
913 bail!(
914 "git diff {range} failed: {}",
915 String::from_utf8_lossy(&output.stderr)
916 );
917 }
918 if output.stdout.is_empty() {
919 return Ok(None);
920 }
921 std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
922 let path = out.0.join("base.diff");
923 std::fs::write(&path, &output.stdout).context("writing the base diff")?;
924 Ok(Some(path))
925}
926
927fn run_cargo_mutants(root: &Path, out: &Path, in_diff: Option<&Path>) -> Result<()> {
936 let mut command = Command::new("cargo");
937 command
938 .current_dir(root)
939 .arg("mutants")
940 .arg("--output")
941 .arg(out);
942 if let Some(diff) = in_diff {
943 command.arg("--in-diff").arg(diff);
944 }
945 for var in [
946 "RUSTFLAGS",
947 "CARGO_ENCODED_RUSTFLAGS",
948 "RUSTDOCFLAGS",
949 "CARGO_ENCODED_RUSTDOCFLAGS",
950 "LLVM_PROFILE_FILE",
951 "CARGO_LLVM_COV",
952 "CARGO_LLVM_COV_SHOW_ENV",
953 "CARGO_LLVM_COV_TARGET_DIR",
954 "CARGO_LLVM_COV_BUILD_DIR",
955 "RUSTC_WRAPPER",
956 "RUSTC_WORKSPACE_WRAPPER",
957 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
958 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
959 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
960 ] {
961 command.env_remove(var);
962 }
963 let output = command
964 .output()
965 .context("running `cargo mutants` (is cargo-mutants installed?)")?;
966 match output.status.code() {
967 Some(0) | Some(2) => Ok(()),
969 _ => bail!(
970 "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
971 root.display(),
972 String::from_utf8_lossy(&output.stdout),
973 String::from_utf8_lossy(&output.stderr),
974 ),
975 }
976}
977
978#[cfg(test)]
979mod tests {
980 use super::*;
981
982 const NORMALIZED: &str = r#"[
988 {"file": "src/a.ts", "line": 2, "status": "survived",
989 "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
990 {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
991 {"file": "src/a.ts", "line": 9, "status": "killed",
992 "mutator": "BooleanLiteral", "replacement": "false"},
993 {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
994 {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
995 {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
996 ]"#;
997
998 #[test]
999 fn parses_the_normalized_schema() {
1000 let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
1001 assert_eq!(mutants.len(), 6);
1002 assert_eq!(mutants[0].status, MutantStatus::Survived);
1003 assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
1004 assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
1005 assert_eq!(mutants[1].replacement, None);
1006 }
1007
1008 #[test]
1009 fn normalized_survivors_are_survived_and_nocoverage_only() {
1010 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1011 let survivors = normalized_survivors(&mutants);
1012 assert_eq!(survivors.len(), 2);
1014 assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
1015 assert!(survivors[0].description.contains("ConditionalExpression"));
1017 assert!(survivors[0].description.contains("-> true"));
1018 assert_eq!(survivors[1].description, "ArithmeticOperator");
1019 }
1020
1021 #[test]
1022 fn normalized_mutated_lines_collects_only_viable_mutants() {
1023 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1024 assert_eq!(
1027 normalized_mutated_lines(&mutants),
1028 [2u32, 5, 9, 12]
1029 .into_iter()
1030 .map(|line| ("src/a.ts".to_string(), line))
1031 .collect()
1032 );
1033 }
1034
1035 #[test]
1036 fn evaluate_normalized_reports_unexempted_survivors() {
1037 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1038 let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
1039 assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
1040 }
1041
1042 #[test]
1043 fn evaluate_normalized_drops_a_whole_file_exemption() {
1044 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1045 let kept =
1046 evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
1047 assert!(
1048 kept.is_empty(),
1049 "the whole-file exemption lifts both survivors"
1050 );
1051 }
1052
1053 #[test]
1054 fn evaluate_normalized_drops_a_line_scoped_exemption() {
1055 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1056 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
1057 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1058 assert_eq!(kept.len(), 1);
1060 assert_eq!(kept[0].line, 5);
1061 }
1062
1063 #[test]
1064 fn evaluate_normalized_rejects_exempting_a_caught_line() {
1065 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1068 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
1069 let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
1070 assert!(
1071 err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
1072 "got: {err}"
1073 );
1074 }
1075
1076 #[test]
1077 fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
1078 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1081 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
1082 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1083 assert_eq!(kept.len(), 2);
1084 }
1085
1086 const SAMPLE: &str = r#"{
1089 "outcomes": [
1090 {"scenario": "Baseline", "summary": "Success",
1091 "phase_results": []},
1092 {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
1093 "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
1094 "function": {"function_name": "is_positive"},
1095 "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
1096 "summary": "MissedMutant"},
1097 {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
1098 "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
1099 "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
1100 "summary": "CaughtMutant"}
1101 ],
1102 "total_mutants": 2
1103 }"#;
1104
1105 #[test]
1106 fn parses_the_outcomes_export() {
1107 let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
1108 assert_eq!(report.outcomes.len(), 3);
1109 assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
1110 }
1111
1112 #[test]
1113 fn collects_only_missed_mutants_as_survivors() {
1114 let report = parse_mutants_report(SAMPLE).unwrap();
1115 let survivors = unexplained_survivors(&report, &[]);
1116 assert_eq!(survivors.len(), 1);
1118 assert_eq!(survivors[0].file, "src/lib.rs");
1119 assert_eq!(survivors[0].line, 7);
1120 assert!(survivors[0].description.contains("replace > with =="));
1121 }
1122
1123 #[test]
1124 fn an_exemption_drops_a_survivor_in_that_file() {
1125 let report = parse_mutants_report(SAMPLE).unwrap();
1126 let exempt = vec!["src/lib.rs".to_string()];
1127 assert!(unexplained_survivors(&report, &exempt).is_empty());
1128 }
1129
1130 #[test]
1131 fn an_exemption_on_another_file_leaves_the_survivor() {
1132 let report = parse_mutants_report(SAMPLE).unwrap();
1133 let exempt = vec!["src/elsewhere.rs".to_string()];
1134 assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1135 }
1136
1137 const STRYKER_SAMPLE: &str = r#"{
1140 "schemaVersion": "1.0",
1141 "files": {
1142 "src/index.ts": {
1143 "language": "typescript",
1144 "source": "...",
1145 "mutants": [
1146 {"id": "0", "mutatorName": "ConditionalExpression", "replacement": "true",
1147 "status": "Survived", "coveredBy": ["t0"],
1148 "location": {"start": {"line": 2, "column": 10}, "end": {"line": 2, "column": 15}}},
1149 {"id": "1", "mutatorName": "ArithmeticOperator", "replacement": "a - b",
1150 "status": "NoCoverage",
1151 "location": {"start": {"line": 5, "column": 3}, "end": {"line": 5, "column": 8}}},
1152 {"id": "2", "mutatorName": "BooleanLiteral", "replacement": "false",
1153 "status": "Killed",
1154 "location": {"start": {"line": 9, "column": 1}, "end": {"line": 9, "column": 6}}}
1155 ]
1156 }
1157 }
1158 }"#;
1159
1160 #[test]
1161 fn parses_a_stryker_report() {
1162 let report = parse_stryker_report(STRYKER_SAMPLE).expect("valid mutation.json");
1163 assert_eq!(report.files["src/index.ts"].mutants.len(), 3);
1164 }
1165
1166 #[test]
1167 fn collects_survived_and_nocoverage_as_survivors() {
1168 let report = parse_stryker_report(STRYKER_SAMPLE).unwrap();
1169 let survivors = stryker_survivors(&report);
1170 assert_eq!(survivors.len(), 2);
1172 assert!(survivors.iter().all(|s| s.file == "src/index.ts"));
1173 assert_eq!(survivors[0].line, 2);
1174 assert!(survivors[0].description.contains("ConditionalExpression"));
1175 assert!(survivors[0].description.contains("true"));
1176 assert_eq!(survivors[1].line, 5);
1177 }
1178
1179 #[test]
1180 fn evaluate_drops_exempt_files_for_either_engine() {
1181 let report = parse_stryker_report(STRYKER_SAMPLE).unwrap();
1182 let survivors = stryker_survivors(&report);
1183 let exempt = vec!["src/index.ts".to_string()];
1184 assert!(evaluate(survivors, &exempt).is_empty());
1185 }
1186
1187 #[test]
1188 fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
1189 assert!(is_mutatable_ts("src/index.ts"));
1190 assert!(is_mutatable_ts("src/util.tsx"));
1191 assert!(is_mutatable_ts("src/util.js"));
1192 assert!(!is_mutatable_ts("src/index.test.ts"));
1193 assert!(!is_mutatable_ts("src/index.spec.ts"));
1194 assert!(!is_mutatable_ts("src/types.d.ts"));
1195 assert!(!is_mutatable_ts("README.md"));
1196 }
1197
1198 #[test]
1199 fn contiguous_runs_collapses_adjacent_lines() {
1200 let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
1201 assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
1202 assert!(contiguous_runs(&BTreeSet::new()).is_empty());
1203 }
1204
1205 #[test]
1206 fn one_line_flattens_and_caps() {
1207 assert_eq!(one_line("a -\n b"), "a - b");
1208 let long = "x".repeat(80);
1209 let capped = one_line(&long);
1210 assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
1211 }
1212
1213 const COSMIC_RAY_DUMP: &str = concat!(
1216 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"}]"#,
1217 "\n",
1218 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"}]"#,
1219 "\n",
1220 );
1221
1222 #[test]
1223 fn collects_only_survived_cosmic_ray_mutants() {
1224 let survivors = parse_cosmic_ray_dump(COSMIC_RAY_DUMP).expect("valid dump");
1225 assert_eq!(survivors.len(), 1);
1227 assert_eq!(survivors[0].file, "calc.py");
1228 assert_eq!(survivors[0].line, 6);
1229 assert!(survivors[0]
1230 .description
1231 .contains("ReplaceComparisonOperator"));
1232 assert!(survivors[0].description.contains("is_positive"));
1233 }
1234
1235 #[test]
1236 fn an_unexecuted_cosmic_ray_item_is_not_a_survivor() {
1237 let dump = r#"[{"mutations":[{"module_path":"calc.py","operator_name":"core/NumberReplacer","start_pos":[3,5],"end_pos":[3,6]}]},null]"#;
1239 assert!(parse_cosmic_ray_dump(dump).unwrap().is_empty());
1240 }
1241
1242 #[test]
1243 fn is_mutatable_py_keeps_sources_and_drops_tests() {
1244 assert!(is_mutatable_py("calc.py"));
1245 assert!(is_mutatable_py("pkg/util.py"));
1246 assert!(!is_mutatable_py("calc_test.py"));
1247 assert!(!is_mutatable_py("test_calc.py"));
1248 assert!(!is_mutatable_py("pkg/conftest.py"));
1249 assert!(!is_mutatable_py("README.md"));
1250 }
1251
1252 #[test]
1255 fn mutated_lines_collects_caught_and_missed() {
1256 let report = parse_mutants_report(SAMPLE).unwrap();
1259 assert_eq!(
1260 mutated_lines(&report),
1261 [
1262 ("src/lib.rs".to_string(), 7),
1263 ("src/other.rs".to_string(), 3)
1264 ]
1265 .into_iter()
1266 .collect()
1267 );
1268 }
1269
1270 #[test]
1271 fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1272 let report = parse_mutants_report(SAMPLE).unwrap();
1273 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1274 let kept = evaluate_scoped(
1275 cargo_mutants_survivors(&report),
1276 &mutated_lines(&report),
1277 &[],
1278 &line_scoped,
1279 )
1280 .unwrap();
1281 assert!(
1282 kept.is_empty(),
1283 "the src/lib.rs:7 survivor should be lifted"
1284 );
1285 }
1286
1287 #[test]
1288 fn evaluate_scoped_rejects_exempting_a_caught_line() {
1289 let report = parse_mutants_report(SAMPLE).unwrap();
1291 let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
1292 let err = evaluate_scoped(
1293 cargo_mutants_survivors(&report),
1294 &mutated_lines(&report),
1295 &[],
1296 &line_scoped,
1297 )
1298 .unwrap_err();
1299 assert!(
1300 err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
1301 "got: {err}"
1302 );
1303 }
1304
1305 #[test]
1306 fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
1307 let report = parse_mutants_report(SAMPLE).unwrap();
1310 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
1311 let kept = evaluate_scoped(
1312 cargo_mutants_survivors(&report),
1313 &mutated_lines(&report),
1314 &[],
1315 &line_scoped,
1316 )
1317 .unwrap();
1318 assert_eq!(kept.len(), 1);
1319 assert_eq!(kept[0].line, 7);
1320 }
1321
1322 #[test]
1323 fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1324 let report = parse_mutants_report(SAMPLE).unwrap();
1325 let kept = evaluate_scoped(
1326 cargo_mutants_survivors(&report),
1327 &mutated_lines(&report),
1328 &["src/lib.rs".to_string()],
1329 &BTreeMap::new(),
1330 )
1331 .unwrap();
1332 assert!(kept.is_empty());
1333 }
1334
1335 #[test]
1336 fn stryker_mutated_lines_collects_every_viable_mutant() {
1337 let report = parse_stryker_report(STRYKER_SAMPLE).unwrap();
1339 assert_eq!(
1340 stryker_mutated_lines(&report),
1341 [
1342 ("src/index.ts".to_string(), 2),
1343 ("src/index.ts".to_string(), 5),
1344 ("src/index.ts".to_string(), 9),
1345 ]
1346 .into_iter()
1347 .collect()
1348 );
1349 }
1350
1351 #[test]
1352 fn cosmic_ray_mutated_lines_collects_executed_mutants() {
1353 let mutated = cosmic_ray_mutated_lines(COSMIC_RAY_DUMP).unwrap();
1355 assert_eq!(
1356 mutated,
1357 [("calc.py".to_string(), 2), ("calc.py".to_string(), 6)]
1358 .into_iter()
1359 .collect()
1360 );
1361 }
1362}