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
214pub fn measure_rust(
221 root: &Path,
222 exempt: &[String],
223 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
224 base: Option<&str>,
225) -> Result<Vec<Survivor>> {
226 let out = MutantsOut::new();
227 let diff = match base {
228 Some(base) => match write_base_diff(root, base, &out)? {
231 None => return Ok(Vec::new()),
232 Some(path) => Some(path),
233 },
234 None => None,
235 };
236 run_cargo_mutants(root, &out.0, diff.as_deref())?;
237 let outcomes = out.0.join("mutants.out").join("outcomes.json");
238 let json = match std::fs::read_to_string(&outcomes) {
242 Ok(json) => json,
243 Err(_) => return Ok(Vec::new()),
244 };
245 let report = parse_mutants_report(&json)?;
246 evaluate_scoped(
247 cargo_mutants_survivors(&report),
248 &mutated_lines(&report),
249 exempt,
250 exempt_lines,
251 )
252}
253
254#[derive(Debug, Clone, Deserialize)]
258pub struct StrykerReport {
259 pub files: BTreeMap<String, StrykerFile>,
261}
262
263#[derive(Debug, Clone, Deserialize)]
265pub struct StrykerFile {
266 #[serde(default)]
267 pub mutants: Vec<StrykerMutant>,
268}
269
270#[derive(Debug, Clone, Deserialize)]
273#[serde(rename_all = "camelCase")]
274pub struct StrykerMutant {
275 pub mutator_name: String,
276 #[serde(default)]
277 pub replacement: Option<String>,
278 pub status: String,
279 pub location: StrykerLocation,
280}
281
282#[derive(Debug, Clone, Deserialize)]
285pub struct StrykerLocation {
286 pub start: LineCol,
287}
288
289pub fn parse_stryker_report(json: &str) -> Result<StrykerReport> {
291 serde_json::from_str(json).context("parsing Stryker mutation.json")
292}
293
294pub fn stryker_survivors(report: &StrykerReport) -> Vec<Survivor> {
301 let mut survivors = Vec::new();
302 for (file, contents) in &report.files {
303 for mutant in &contents.mutants {
304 if mutant.status != "Survived" && mutant.status != "NoCoverage" {
305 continue;
306 }
307 let description = match &mutant.replacement {
308 Some(replacement) => {
309 format!("{} (-> {})", mutant.mutator_name, one_line(replacement))
310 }
311 None => mutant.mutator_name.clone(),
312 };
313 survivors.push(Survivor {
314 file: file.clone(),
315 line: mutant.location.start.line,
316 description,
317 });
318 }
319 }
320 survivors
321}
322
323fn one_line(replacement: &str) -> String {
326 let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
327 const MAX: usize = 60;
328 if flat.chars().count() > MAX {
329 format!("{}…", flat.chars().take(MAX).collect::<String>())
330 } else {
331 flat
332 }
333}
334
335pub fn measure_typescript(
345 root: &Path,
346 exempt: &[String],
347 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
348 base: Option<&str>,
349) -> Result<Vec<Survivor>> {
350 let mutate = match base {
351 Some(base) => {
352 let ranges = mutate_ranges(root, base)?;
353 if ranges.is_empty() {
355 return Ok(Vec::new());
356 }
357 Some(ranges)
358 }
359 None => None,
360 };
361 let json = run_stryker(root, mutate.as_deref())?;
362 let report = parse_stryker_report(&json)?;
363 evaluate_scoped(
364 stryker_survivors(&report),
365 &stryker_mutated_lines(&report),
366 exempt,
367 exempt_lines,
368 )
369}
370
371fn stryker_mutated_lines(report: &StrykerReport) -> MutatedLines {
376 let mut mutated = BTreeSet::new();
377 for (file, contents) in &report.files {
378 for mutant in &contents.mutants {
379 if matches!(
380 mutant.status.as_str(),
381 "Killed" | "Survived" | "NoCoverage" | "Timeout"
382 ) {
383 mutated.insert((file.clone(), mutant.location.start.line));
384 }
385 }
386 }
387 mutated
388}
389
390fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
396 let changed = crate::patch_coverage::changed_lines(root, base)?;
397 let mut specs = Vec::new();
398 for (file, lines) in changed {
399 if !is_mutatable_ts(&file) {
400 continue;
401 }
402 for (start, end) in contiguous_runs(&lines) {
403 specs.push(format!("{file}:{start}-{end}"));
404 }
405 }
406 Ok(specs)
407}
408
409fn is_mutatable_ts(file: &str) -> bool {
413 let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
414 .iter()
415 .any(|ext| file.ends_with(ext));
416 let is_decl = file.ends_with(".d.ts");
417 let is_test = file.contains(".test.") || file.contains(".spec.");
418 is_source && !is_decl && !is_test
419}
420
421fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
423 let mut runs: Vec<(u64, u64)> = Vec::new();
424 for &line in lines {
425 match runs.last_mut() {
426 Some(run) if run.1 + 1 == line => run.1 = line,
427 _ => runs.push((line, line)),
428 }
429 }
430 runs
431}
432
433fn run_stryker(root: &Path, mutate: Option<&[String]>) -> Result<String> {
444 let report_path = root.join("reports").join("mutation").join("mutation.json");
445 let _cleanup = ReportCleanup(report_path.clone());
446 let _ = std::fs::remove_file(&report_path);
448
449 let mut command = Command::new("npx");
450 command
451 .current_dir(root)
452 .args(["--no-install", "stryker", "run", "--reporters", "json"]);
459 if let Some(specs) = mutate {
460 command.arg("--mutate").arg(specs.join(","));
461 }
462 let output = command
463 .env("CI", "1")
464 .output()
465 .context("running `npx --no-install stryker run`")?;
466
467 std::fs::read_to_string(&report_path).map_err(|_| {
468 anyhow::anyhow!(
469 "Stryker produced no report in `{}`. The rule runs the project's own Stryker via \
470 `npx --no-install` and never downloads it, so `@stryker-mutator/core` (plus a \
471 test-runner plugin) must be installed in the project. Stryker output:\n{}{}",
472 root.display(),
473 String::from_utf8_lossy(&output.stdout),
474 String::from_utf8_lossy(&output.stderr),
475 )
476 })
477}
478
479struct ReportCleanup(PathBuf);
482
483impl Drop for ReportCleanup {
484 fn drop(&mut self) {
485 let _ = std::fs::remove_file(&self.0);
486 if let Some(mutation_dir) = self.0.parent() {
487 let _ = std::fs::remove_dir(mutation_dir);
489 if let Some(reports_dir) = mutation_dir.parent() {
490 let _ = std::fs::remove_dir(reports_dir);
491 }
492 }
493 }
494}
495
496#[derive(Debug, Clone, Deserialize)]
499pub struct CosmicRayLine(pub CrWorkItem, pub Option<CrResult>);
500
501#[derive(Debug, Clone, Deserialize)]
504pub struct CrWorkItem {
505 pub mutations: Vec<CrMutation>,
506}
507
508#[derive(Debug, Clone, Deserialize)]
511pub struct CrMutation {
512 pub module_path: String,
513 pub operator_name: String,
514 pub start_pos: (u32, u32),
516 #[serde(default)]
517 pub definition_name: Option<String>,
518}
519
520#[derive(Debug, Clone, Deserialize)]
523pub struct CrResult {
524 #[serde(default)]
525 pub test_outcome: Option<String>,
526}
527
528pub fn parse_cosmic_ray_dump(dump: &str) -> Result<Vec<Survivor>> {
536 let mut survivors = Vec::new();
537 for line in dump.lines() {
538 if line.trim().is_empty() {
539 continue;
540 }
541 let CosmicRayLine(item, result) =
542 serde_json::from_str(line).context("parsing a cosmic-ray dump line")?;
543 let survived = matches!(result, Some(CrResult { test_outcome: Some(outcome) }) if outcome == "survived");
544 if !survived {
545 continue;
546 }
547 let Some(mutation) = item.mutations.first() else {
548 continue;
549 };
550 let definition = mutation.definition_name.as_deref().unwrap_or("<module>");
551 survivors.push(Survivor {
552 file: mutation.module_path.clone(),
553 line: mutation.start_pos.0,
554 description: format!("{} in {}", mutation.operator_name, definition),
555 });
556 }
557 Ok(survivors)
558}
559
560pub fn measure_python(
571 root: &Path,
572 exempt: &[String],
573 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
574 base: Option<&str>,
575) -> Result<Vec<Survivor>> {
576 let (survivors, mutated) = match base {
577 None => run_cosmic_ray(root, ".", &PY_TEST_EXCLUDES)?,
578 Some(base) => {
579 let changed = crate::patch_coverage::changed_lines(root, base)?;
580 let mut all_survivors = Vec::new();
581 let mut all_mutated = BTreeSet::new();
582 for (file, lines) in &changed {
583 if !is_mutatable_py(file) {
584 continue;
585 }
586 let (survivors, mutated) = run_cosmic_ray(root, file, &[])?;
588 for survivor in survivors {
589 if lines.contains(&(survivor.line as u64)) {
590 all_survivors.push(survivor);
591 }
592 }
593 for (mutated_file, line) in mutated {
594 if lines.contains(&u64::from(line)) {
595 all_mutated.insert((mutated_file, line));
596 }
597 }
598 }
599 (all_survivors, all_mutated)
600 }
601 };
602 evaluate_scoped(survivors, &mutated, exempt, exempt_lines)
603}
604
605const PY_TEST_EXCLUDES: [&str; 3] = ["*_test.py", "test_*.py", "conftest.py"];
608
609fn is_mutatable_py(file: &str) -> bool {
612 if !file.ends_with(".py") {
613 return false;
614 }
615 let base = file.rsplit('/').next().unwrap_or(file);
616 !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
617}
618
619fn run_cosmic_ray(root: &Path, module_path: &str, excluded_modules: &[&str]) -> Result<RunOutcome> {
626 let dir = CosmicRayDir::new();
627 std::fs::create_dir_all(&dir.0).context("creating the cosmic-ray temp dir")?;
628 let config = dir.0.join("cr.toml");
629 let session = dir.0.join("session.sqlite");
630
631 let excludes = excluded_modules
632 .iter()
633 .map(|glob| format!("\"{glob}\""))
634 .collect::<Vec<_>>()
635 .join(", ");
636 std::fs::write(
637 &config,
638 format!(
639 "[cosmic-ray]\n\
640 module-path = \"{module_path}\"\n\
641 timeout = 30.0\n\
642 excluded-modules = [{excludes}]\n\
643 test-command = \"python3 -m pytest -q -p no:cacheprovider\"\n\
644 \n\
645 [cosmic-ray.distributor]\n\
646 name = \"local\"\n"
647 ),
648 )
649 .context("writing the cosmic-ray config")?;
650
651 let baseline = cosmic_ray(root, &["baseline", path_str(&config)])?;
654 if !baseline.status.success() {
655 bail!(
656 "the Python unit suite did not pass unmutated in `{}` (cosmic-ray baseline failed):\n{}{}",
657 root.display(),
658 String::from_utf8_lossy(&baseline.stdout),
659 String::from_utf8_lossy(&baseline.stderr),
660 );
661 }
662
663 let init = cosmic_ray(root, &["init", path_str(&config), path_str(&session)])?;
664 if !init.status.success() {
665 bail!(
666 "cosmic-ray init failed in `{}`:\n{}{}",
667 root.display(),
668 String::from_utf8_lossy(&init.stdout),
669 String::from_utf8_lossy(&init.stderr),
670 );
671 }
672 let exec = cosmic_ray(root, &["exec", path_str(&config), path_str(&session)])?;
673 if !exec.status.success() {
674 bail!(
675 "cosmic-ray exec failed in `{}`:\n{}{}",
676 root.display(),
677 String::from_utf8_lossy(&exec.stdout),
678 String::from_utf8_lossy(&exec.stderr),
679 );
680 }
681 let dump = cosmic_ray(root, &["dump", path_str(&session)])?;
682 if !dump.status.success() {
683 bail!(
684 "cosmic-ray dump failed in `{}`:\n{}",
685 root.display(),
686 String::from_utf8_lossy(&dump.stderr),
687 );
688 }
689 let stdout = String::from_utf8_lossy(&dump.stdout);
690 Ok((
691 parse_cosmic_ray_dump(&stdout)?,
692 cosmic_ray_mutated_lines(&stdout)?,
693 ))
694}
695
696pub fn cosmic_ray_mutated_lines(dump: &str) -> Result<MutatedLines> {
701 let mut mutated = BTreeSet::new();
702 for line in dump.lines() {
703 if line.trim().is_empty() {
704 continue;
705 }
706 let CosmicRayLine(item, result) =
707 serde_json::from_str(line).context("parsing a cosmic-ray dump line")?;
708 let outcome = result.and_then(|result| result.test_outcome);
709 if !matches!(outcome.as_deref(), Some("survived") | Some("killed")) {
710 continue;
711 }
712 if let Some(mutation) = item.mutations.first() {
713 mutated.insert((mutation.module_path.clone(), mutation.start_pos.0));
714 }
715 }
716 Ok(mutated)
717}
718
719fn cosmic_ray(root: &Path, args: &[&str]) -> Result<std::process::Output> {
722 Command::new("cosmic-ray")
723 .current_dir(root)
724 .args(args)
725 .env("PYTHONDONTWRITEBYTECODE", "1")
726 .output()
727 .context("running `cosmic-ray` (is it installed?)")
728}
729
730fn path_str(path: &Path) -> &str {
731 path.to_str().expect("temp path is valid UTF-8")
732}
733
734struct CosmicRayDir(PathBuf);
737
738impl CosmicRayDir {
739 fn new() -> Self {
740 static COUNTER: AtomicU64 = AtomicU64::new(0);
741 let name = format!(
742 "testing-conventions-cosmic-ray-{}-{}",
743 std::process::id(),
744 COUNTER.fetch_add(1, Ordering::Relaxed),
745 );
746 CosmicRayDir(std::env::temp_dir().join(name))
747 }
748}
749
750impl Drop for CosmicRayDir {
751 fn drop(&mut self) {
752 let _ = std::fs::remove_dir_all(&self.0);
753 }
754}
755
756struct MutantsOut(PathBuf);
759
760impl MutantsOut {
761 fn new() -> Self {
762 static COUNTER: AtomicU64 = AtomicU64::new(0);
763 let name = format!(
764 "testing-conventions-mutants-{}-{}",
765 std::process::id(),
766 COUNTER.fetch_add(1, Ordering::Relaxed),
767 );
768 MutantsOut(std::env::temp_dir().join(name))
769 }
770}
771
772impl Drop for MutantsOut {
773 fn drop(&mut self) {
774 let _ = std::fs::remove_dir_all(&self.0);
775 }
776}
777
778fn write_base_diff(root: &Path, base: &str, out: &MutantsOut) -> Result<Option<PathBuf>> {
787 let range = format!("{base}...HEAD");
788 let output = Command::new("git")
789 .current_dir(root)
790 .args(["diff", "--relative", &range])
791 .output()
792 .context("running `git diff` for `--base` (is git installed?)")?;
793 if !output.status.success() {
794 bail!(
795 "git diff {range} failed: {}",
796 String::from_utf8_lossy(&output.stderr)
797 );
798 }
799 if output.stdout.is_empty() {
800 return Ok(None);
801 }
802 std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
803 let path = out.0.join("base.diff");
804 std::fs::write(&path, &output.stdout).context("writing the base diff")?;
805 Ok(Some(path))
806}
807
808fn run_cargo_mutants(root: &Path, out: &Path, in_diff: Option<&Path>) -> Result<()> {
817 let mut command = Command::new("cargo");
818 command
819 .current_dir(root)
820 .arg("mutants")
821 .arg("--output")
822 .arg(out);
823 if let Some(diff) = in_diff {
824 command.arg("--in-diff").arg(diff);
825 }
826 for var in [
827 "RUSTFLAGS",
828 "CARGO_ENCODED_RUSTFLAGS",
829 "RUSTDOCFLAGS",
830 "CARGO_ENCODED_RUSTDOCFLAGS",
831 "LLVM_PROFILE_FILE",
832 "CARGO_LLVM_COV",
833 "CARGO_LLVM_COV_SHOW_ENV",
834 "CARGO_LLVM_COV_TARGET_DIR",
835 "CARGO_LLVM_COV_BUILD_DIR",
836 "RUSTC_WRAPPER",
837 "RUSTC_WORKSPACE_WRAPPER",
838 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
839 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
840 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
841 ] {
842 command.env_remove(var);
843 }
844 let output = command
845 .output()
846 .context("running `cargo mutants` (is cargo-mutants installed?)")?;
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 SAMPLE: &str = r#"{
866 "outcomes": [
867 {"scenario": "Baseline", "summary": "Success",
868 "phase_results": []},
869 {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
870 "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
871 "function": {"function_name": "is_positive"},
872 "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
873 "summary": "MissedMutant"},
874 {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
875 "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
876 "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
877 "summary": "CaughtMutant"}
878 ],
879 "total_mutants": 2
880 }"#;
881
882 #[test]
883 fn parses_the_outcomes_export() {
884 let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
885 assert_eq!(report.outcomes.len(), 3);
886 assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
887 }
888
889 #[test]
890 fn collects_only_missed_mutants_as_survivors() {
891 let report = parse_mutants_report(SAMPLE).unwrap();
892 let survivors = unexplained_survivors(&report, &[]);
893 assert_eq!(survivors.len(), 1);
895 assert_eq!(survivors[0].file, "src/lib.rs");
896 assert_eq!(survivors[0].line, 7);
897 assert!(survivors[0].description.contains("replace > with =="));
898 }
899
900 #[test]
901 fn an_exemption_drops_a_survivor_in_that_file() {
902 let report = parse_mutants_report(SAMPLE).unwrap();
903 let exempt = vec!["src/lib.rs".to_string()];
904 assert!(unexplained_survivors(&report, &exempt).is_empty());
905 }
906
907 #[test]
908 fn an_exemption_on_another_file_leaves_the_survivor() {
909 let report = parse_mutants_report(SAMPLE).unwrap();
910 let exempt = vec!["src/elsewhere.rs".to_string()];
911 assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
912 }
913
914 const STRYKER_SAMPLE: &str = r#"{
917 "schemaVersion": "1.0",
918 "files": {
919 "src/index.ts": {
920 "language": "typescript",
921 "source": "...",
922 "mutants": [
923 {"id": "0", "mutatorName": "ConditionalExpression", "replacement": "true",
924 "status": "Survived", "coveredBy": ["t0"],
925 "location": {"start": {"line": 2, "column": 10}, "end": {"line": 2, "column": 15}}},
926 {"id": "1", "mutatorName": "ArithmeticOperator", "replacement": "a - b",
927 "status": "NoCoverage",
928 "location": {"start": {"line": 5, "column": 3}, "end": {"line": 5, "column": 8}}},
929 {"id": "2", "mutatorName": "BooleanLiteral", "replacement": "false",
930 "status": "Killed",
931 "location": {"start": {"line": 9, "column": 1}, "end": {"line": 9, "column": 6}}}
932 ]
933 }
934 }
935 }"#;
936
937 #[test]
938 fn parses_a_stryker_report() {
939 let report = parse_stryker_report(STRYKER_SAMPLE).expect("valid mutation.json");
940 assert_eq!(report.files["src/index.ts"].mutants.len(), 3);
941 }
942
943 #[test]
944 fn collects_survived_and_nocoverage_as_survivors() {
945 let report = parse_stryker_report(STRYKER_SAMPLE).unwrap();
946 let survivors = stryker_survivors(&report);
947 assert_eq!(survivors.len(), 2);
949 assert!(survivors.iter().all(|s| s.file == "src/index.ts"));
950 assert_eq!(survivors[0].line, 2);
951 assert!(survivors[0].description.contains("ConditionalExpression"));
952 assert!(survivors[0].description.contains("true"));
953 assert_eq!(survivors[1].line, 5);
954 }
955
956 #[test]
957 fn evaluate_drops_exempt_files_for_either_engine() {
958 let report = parse_stryker_report(STRYKER_SAMPLE).unwrap();
959 let survivors = stryker_survivors(&report);
960 let exempt = vec!["src/index.ts".to_string()];
961 assert!(evaluate(survivors, &exempt).is_empty());
962 }
963
964 #[test]
965 fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
966 assert!(is_mutatable_ts("src/index.ts"));
967 assert!(is_mutatable_ts("src/util.tsx"));
968 assert!(is_mutatable_ts("src/util.js"));
969 assert!(!is_mutatable_ts("src/index.test.ts"));
970 assert!(!is_mutatable_ts("src/index.spec.ts"));
971 assert!(!is_mutatable_ts("src/types.d.ts"));
972 assert!(!is_mutatable_ts("README.md"));
973 }
974
975 #[test]
976 fn contiguous_runs_collapses_adjacent_lines() {
977 let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
978 assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
979 assert!(contiguous_runs(&BTreeSet::new()).is_empty());
980 }
981
982 #[test]
983 fn one_line_flattens_and_caps() {
984 assert_eq!(one_line("a -\n b"), "a - b");
985 let long = "x".repeat(80);
986 let capped = one_line(&long);
987 assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
988 }
989
990 const COSMIC_RAY_DUMP: &str = concat!(
993 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"}]"#,
994 "\n",
995 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"}]"#,
996 "\n",
997 );
998
999 #[test]
1000 fn collects_only_survived_cosmic_ray_mutants() {
1001 let survivors = parse_cosmic_ray_dump(COSMIC_RAY_DUMP).expect("valid dump");
1002 assert_eq!(survivors.len(), 1);
1004 assert_eq!(survivors[0].file, "calc.py");
1005 assert_eq!(survivors[0].line, 6);
1006 assert!(survivors[0]
1007 .description
1008 .contains("ReplaceComparisonOperator"));
1009 assert!(survivors[0].description.contains("is_positive"));
1010 }
1011
1012 #[test]
1013 fn an_unexecuted_cosmic_ray_item_is_not_a_survivor() {
1014 let dump = r#"[{"mutations":[{"module_path":"calc.py","operator_name":"core/NumberReplacer","start_pos":[3,5],"end_pos":[3,6]}]},null]"#;
1016 assert!(parse_cosmic_ray_dump(dump).unwrap().is_empty());
1017 }
1018
1019 #[test]
1020 fn is_mutatable_py_keeps_sources_and_drops_tests() {
1021 assert!(is_mutatable_py("calc.py"));
1022 assert!(is_mutatable_py("pkg/util.py"));
1023 assert!(!is_mutatable_py("calc_test.py"));
1024 assert!(!is_mutatable_py("test_calc.py"));
1025 assert!(!is_mutatable_py("pkg/conftest.py"));
1026 assert!(!is_mutatable_py("README.md"));
1027 }
1028
1029 #[test]
1032 fn mutated_lines_collects_caught_and_missed() {
1033 let report = parse_mutants_report(SAMPLE).unwrap();
1036 assert_eq!(
1037 mutated_lines(&report),
1038 [
1039 ("src/lib.rs".to_string(), 7),
1040 ("src/other.rs".to_string(), 3)
1041 ]
1042 .into_iter()
1043 .collect()
1044 );
1045 }
1046
1047 #[test]
1048 fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1049 let report = parse_mutants_report(SAMPLE).unwrap();
1050 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1051 let kept = evaluate_scoped(
1052 cargo_mutants_survivors(&report),
1053 &mutated_lines(&report),
1054 &[],
1055 &line_scoped,
1056 )
1057 .unwrap();
1058 assert!(
1059 kept.is_empty(),
1060 "the src/lib.rs:7 survivor should be lifted"
1061 );
1062 }
1063
1064 #[test]
1065 fn evaluate_scoped_rejects_exempting_a_caught_line() {
1066 let report = parse_mutants_report(SAMPLE).unwrap();
1068 let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
1069 let err = evaluate_scoped(
1070 cargo_mutants_survivors(&report),
1071 &mutated_lines(&report),
1072 &[],
1073 &line_scoped,
1074 )
1075 .unwrap_err();
1076 assert!(
1077 err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
1078 "got: {err}"
1079 );
1080 }
1081
1082 #[test]
1083 fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
1084 let report = parse_mutants_report(SAMPLE).unwrap();
1087 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
1088 let kept = evaluate_scoped(
1089 cargo_mutants_survivors(&report),
1090 &mutated_lines(&report),
1091 &[],
1092 &line_scoped,
1093 )
1094 .unwrap();
1095 assert_eq!(kept.len(), 1);
1096 assert_eq!(kept[0].line, 7);
1097 }
1098
1099 #[test]
1100 fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1101 let report = parse_mutants_report(SAMPLE).unwrap();
1102 let kept = evaluate_scoped(
1103 cargo_mutants_survivors(&report),
1104 &mutated_lines(&report),
1105 &["src/lib.rs".to_string()],
1106 &BTreeMap::new(),
1107 )
1108 .unwrap();
1109 assert!(kept.is_empty());
1110 }
1111
1112 #[test]
1113 fn stryker_mutated_lines_collects_every_viable_mutant() {
1114 let report = parse_stryker_report(STRYKER_SAMPLE).unwrap();
1116 assert_eq!(
1117 stryker_mutated_lines(&report),
1118 [
1119 ("src/index.ts".to_string(), 2),
1120 ("src/index.ts".to_string(), 5),
1121 ("src/index.ts".to_string(), 9),
1122 ]
1123 .into_iter()
1124 .collect()
1125 );
1126 }
1127
1128 #[test]
1129 fn cosmic_ray_mutated_lines_collects_executed_mutants() {
1130 let mutated = cosmic_ray_mutated_lines(COSMIC_RAY_DUMP).unwrap();
1132 assert_eq!(
1133 mutated,
1134 [("calc.py".to_string(), 2), ("calc.py".to_string(), 6)]
1135 .into_iter()
1136 .collect()
1137 );
1138 }
1139}