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
37#[derive(Debug, Clone, Deserialize)]
40pub struct MutantsReport {
41 pub outcomes: Vec<MutantOutcome>,
42}
43
44#[derive(Debug, Clone, Deserialize)]
48pub struct MutantOutcome {
49 pub summary: String,
50 pub scenario: Scenario,
51}
52
53#[derive(Debug, Clone, Deserialize)]
56pub enum Scenario {
57 Baseline,
58 Mutant(MutantInfo),
59}
60
61#[derive(Debug, Clone, Deserialize)]
65pub struct MutantInfo {
66 pub file: String,
67 pub span: Span,
68 pub name: String,
69}
70
71#[derive(Debug, Clone, Deserialize)]
73pub struct Span {
74 pub start: LineCol,
75}
76
77#[derive(Debug, Clone, Deserialize)]
79pub struct LineCol {
80 pub line: u32,
81}
82
83pub fn parse_mutants_report(json: &str) -> Result<MutantsReport> {
85 serde_json::from_str(json).context("parsing cargo-mutants outcomes.json")
86}
87
88pub fn unexplained_survivors(report: &MutantsReport, exempt: &[String]) -> Vec<Survivor> {
97 let survivors = report
98 .outcomes
99 .iter()
100 .filter_map(|outcome| {
101 if outcome.summary != "MissedMutant" {
102 return None;
103 }
104 let Scenario::Mutant(mutant) = &outcome.scenario else {
105 return None;
106 };
107 Some(Survivor {
108 file: mutant.file.clone(),
109 line: mutant.span.start.line,
110 description: mutant.name.clone(),
111 })
112 })
113 .collect();
114 evaluate(survivors, exempt)
115}
116
117pub fn evaluate(survivors: Vec<Survivor>, exempt: &[String]) -> Vec<Survivor> {
123 survivors
124 .into_iter()
125 .filter(|survivor| !exempt.iter().any(|path| path == &survivor.file))
126 .collect()
127}
128
129pub fn measure_rust(root: &Path, exempt: &[String], base: Option<&str>) -> Result<Vec<Survivor>> {
135 let out = MutantsOut::new();
136 let diff = match base {
137 Some(base) => match write_base_diff(root, base, &out)? {
140 None => return Ok(Vec::new()),
141 Some(path) => Some(path),
142 },
143 None => None,
144 };
145 run_cargo_mutants(root, &out.0, diff.as_deref())?;
146 let outcomes = out.0.join("mutants.out").join("outcomes.json");
147 let json = match std::fs::read_to_string(&outcomes) {
151 Ok(json) => json,
152 Err(_) => return Ok(Vec::new()),
153 };
154 let report = parse_mutants_report(&json)?;
155 Ok(unexplained_survivors(&report, exempt))
156}
157
158#[derive(Debug, Clone, Deserialize)]
162pub struct StrykerReport {
163 pub files: BTreeMap<String, StrykerFile>,
165}
166
167#[derive(Debug, Clone, Deserialize)]
169pub struct StrykerFile {
170 #[serde(default)]
171 pub mutants: Vec<StrykerMutant>,
172}
173
174#[derive(Debug, Clone, Deserialize)]
177#[serde(rename_all = "camelCase")]
178pub struct StrykerMutant {
179 pub mutator_name: String,
180 #[serde(default)]
181 pub replacement: Option<String>,
182 pub status: String,
183 pub location: StrykerLocation,
184}
185
186#[derive(Debug, Clone, Deserialize)]
189pub struct StrykerLocation {
190 pub start: LineCol,
191}
192
193pub fn parse_stryker_report(json: &str) -> Result<StrykerReport> {
195 serde_json::from_str(json).context("parsing Stryker mutation.json")
196}
197
198pub fn stryker_survivors(report: &StrykerReport) -> Vec<Survivor> {
205 let mut survivors = Vec::new();
206 for (file, contents) in &report.files {
207 for mutant in &contents.mutants {
208 if mutant.status != "Survived" && mutant.status != "NoCoverage" {
209 continue;
210 }
211 let description = match &mutant.replacement {
212 Some(replacement) => {
213 format!("{} (-> {})", mutant.mutator_name, one_line(replacement))
214 }
215 None => mutant.mutator_name.clone(),
216 };
217 survivors.push(Survivor {
218 file: file.clone(),
219 line: mutant.location.start.line,
220 description,
221 });
222 }
223 }
224 survivors
225}
226
227fn one_line(replacement: &str) -> String {
230 let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
231 const MAX: usize = 60;
232 if flat.chars().count() > MAX {
233 format!("{}…", flat.chars().take(MAX).collect::<String>())
234 } else {
235 flat
236 }
237}
238
239pub fn measure_typescript(
248 root: &Path,
249 exempt: &[String],
250 base: Option<&str>,
251) -> Result<Vec<Survivor>> {
252 let mutate = match base {
253 Some(base) => {
254 let ranges = mutate_ranges(root, base)?;
255 if ranges.is_empty() {
257 return Ok(Vec::new());
258 }
259 Some(ranges)
260 }
261 None => None,
262 };
263 let json = run_stryker(root, mutate.as_deref())?;
264 let report = parse_stryker_report(&json)?;
265 Ok(evaluate(stryker_survivors(&report), exempt))
266}
267
268fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
274 let changed = crate::patch_coverage::changed_lines(root, base)?;
275 let mut specs = Vec::new();
276 for (file, lines) in changed {
277 if !is_mutatable_ts(&file) {
278 continue;
279 }
280 for (start, end) in contiguous_runs(&lines) {
281 specs.push(format!("{file}:{start}-{end}"));
282 }
283 }
284 Ok(specs)
285}
286
287fn is_mutatable_ts(file: &str) -> bool {
291 let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
292 .iter()
293 .any(|ext| file.ends_with(ext));
294 let is_decl = file.ends_with(".d.ts");
295 let is_test = file.contains(".test.") || file.contains(".spec.");
296 is_source && !is_decl && !is_test
297}
298
299fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
301 let mut runs: Vec<(u64, u64)> = Vec::new();
302 for &line in lines {
303 match runs.last_mut() {
304 Some(run) if run.1 + 1 == line => run.1 = line,
305 _ => runs.push((line, line)),
306 }
307 }
308 runs
309}
310
311fn run_stryker(root: &Path, mutate: Option<&[String]>) -> Result<String> {
322 let report_path = root.join("reports").join("mutation").join("mutation.json");
323 let _cleanup = ReportCleanup(report_path.clone());
324 let _ = std::fs::remove_file(&report_path);
326
327 let mut command = Command::new("npx");
328 command
329 .current_dir(root)
330 .args(["--yes", "stryker", "run", "--reporters", "json"]);
331 if let Some(specs) = mutate {
332 command.arg("--mutate").arg(specs.join(","));
333 }
334 let output = command
335 .env("CI", "1")
336 .output()
337 .context("running `npx stryker run` (is @stryker-mutator/core installed?)")?;
338
339 std::fs::read_to_string(&report_path).map_err(|_| {
340 anyhow::anyhow!(
341 "Stryker produced no report in `{}` (did it run cleanly?):\n{}{}",
342 root.display(),
343 String::from_utf8_lossy(&output.stdout),
344 String::from_utf8_lossy(&output.stderr),
345 )
346 })
347}
348
349struct ReportCleanup(PathBuf);
352
353impl Drop for ReportCleanup {
354 fn drop(&mut self) {
355 let _ = std::fs::remove_file(&self.0);
356 if let Some(mutation_dir) = self.0.parent() {
357 let _ = std::fs::remove_dir(mutation_dir);
359 if let Some(reports_dir) = mutation_dir.parent() {
360 let _ = std::fs::remove_dir(reports_dir);
361 }
362 }
363 }
364}
365
366#[derive(Debug, Clone, Deserialize)]
369pub struct CosmicRayLine(pub CrWorkItem, pub Option<CrResult>);
370
371#[derive(Debug, Clone, Deserialize)]
374pub struct CrWorkItem {
375 pub mutations: Vec<CrMutation>,
376}
377
378#[derive(Debug, Clone, Deserialize)]
381pub struct CrMutation {
382 pub module_path: String,
383 pub operator_name: String,
384 pub start_pos: (u32, u32),
386 #[serde(default)]
387 pub definition_name: Option<String>,
388}
389
390#[derive(Debug, Clone, Deserialize)]
393pub struct CrResult {
394 #[serde(default)]
395 pub test_outcome: Option<String>,
396}
397
398pub fn parse_cosmic_ray_dump(dump: &str) -> Result<Vec<Survivor>> {
406 let mut survivors = Vec::new();
407 for line in dump.lines() {
408 if line.trim().is_empty() {
409 continue;
410 }
411 let CosmicRayLine(item, result) =
412 serde_json::from_str(line).context("parsing a cosmic-ray dump line")?;
413 let survived = matches!(result, Some(CrResult { test_outcome: Some(outcome) }) if outcome == "survived");
414 if !survived {
415 continue;
416 }
417 let Some(mutation) = item.mutations.first() else {
418 continue;
419 };
420 let definition = mutation.definition_name.as_deref().unwrap_or("<module>");
421 survivors.push(Survivor {
422 file: mutation.module_path.clone(),
423 line: mutation.start_pos.0,
424 description: format!("{} in {}", mutation.operator_name, definition),
425 });
426 }
427 Ok(survivors)
428}
429
430pub fn measure_python(root: &Path, exempt: &[String], base: Option<&str>) -> Result<Vec<Survivor>> {
441 let survivors = match base {
442 None => run_cosmic_ray(root, ".", &PY_TEST_EXCLUDES)?,
443 Some(base) => {
444 let changed = crate::patch_coverage::changed_lines(root, base)?;
445 let mut all = Vec::new();
446 for (file, lines) in &changed {
447 if !is_mutatable_py(file) {
448 continue;
449 }
450 for survivor in run_cosmic_ray(root, file, &[])? {
452 if lines.contains(&(survivor.line as u64)) {
453 all.push(survivor);
454 }
455 }
456 }
457 all
458 }
459 };
460 Ok(evaluate(survivors, exempt))
461}
462
463const PY_TEST_EXCLUDES: [&str; 3] = ["*_test.py", "test_*.py", "conftest.py"];
466
467fn is_mutatable_py(file: &str) -> bool {
470 if !file.ends_with(".py") {
471 return false;
472 }
473 let base = file.rsplit('/').next().unwrap_or(file);
474 !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
475}
476
477fn run_cosmic_ray(
483 root: &Path,
484 module_path: &str,
485 excluded_modules: &[&str],
486) -> Result<Vec<Survivor>> {
487 let dir = CosmicRayDir::new();
488 std::fs::create_dir_all(&dir.0).context("creating the cosmic-ray temp dir")?;
489 let config = dir.0.join("cr.toml");
490 let session = dir.0.join("session.sqlite");
491
492 let excludes = excluded_modules
493 .iter()
494 .map(|glob| format!("\"{glob}\""))
495 .collect::<Vec<_>>()
496 .join(", ");
497 std::fs::write(
498 &config,
499 format!(
500 "[cosmic-ray]\n\
501 module-path = \"{module_path}\"\n\
502 timeout = 30.0\n\
503 excluded-modules = [{excludes}]\n\
504 test-command = \"python3 -m pytest -q -p no:cacheprovider\"\n\
505 \n\
506 [cosmic-ray.distributor]\n\
507 name = \"local\"\n"
508 ),
509 )
510 .context("writing the cosmic-ray config")?;
511
512 let baseline = cosmic_ray(root, &["baseline", path_str(&config)])?;
515 if !baseline.status.success() {
516 bail!(
517 "the Python unit suite did not pass unmutated in `{}` (cosmic-ray baseline failed):\n{}{}",
518 root.display(),
519 String::from_utf8_lossy(&baseline.stdout),
520 String::from_utf8_lossy(&baseline.stderr),
521 );
522 }
523
524 let init = cosmic_ray(root, &["init", path_str(&config), path_str(&session)])?;
525 if !init.status.success() {
526 bail!(
527 "cosmic-ray init failed in `{}`:\n{}{}",
528 root.display(),
529 String::from_utf8_lossy(&init.stdout),
530 String::from_utf8_lossy(&init.stderr),
531 );
532 }
533 let exec = cosmic_ray(root, &["exec", path_str(&config), path_str(&session)])?;
534 if !exec.status.success() {
535 bail!(
536 "cosmic-ray exec failed in `{}`:\n{}{}",
537 root.display(),
538 String::from_utf8_lossy(&exec.stdout),
539 String::from_utf8_lossy(&exec.stderr),
540 );
541 }
542 let dump = cosmic_ray(root, &["dump", path_str(&session)])?;
543 if !dump.status.success() {
544 bail!(
545 "cosmic-ray dump failed in `{}`:\n{}",
546 root.display(),
547 String::from_utf8_lossy(&dump.stderr),
548 );
549 }
550 parse_cosmic_ray_dump(&String::from_utf8_lossy(&dump.stdout))
551}
552
553fn cosmic_ray(root: &Path, args: &[&str]) -> Result<std::process::Output> {
556 Command::new("cosmic-ray")
557 .current_dir(root)
558 .args(args)
559 .env("PYTHONDONTWRITEBYTECODE", "1")
560 .output()
561 .context("running `cosmic-ray` (is it installed?)")
562}
563
564fn path_str(path: &Path) -> &str {
565 path.to_str().expect("temp path is valid UTF-8")
566}
567
568struct CosmicRayDir(PathBuf);
571
572impl CosmicRayDir {
573 fn new() -> Self {
574 static COUNTER: AtomicU64 = AtomicU64::new(0);
575 let name = format!(
576 "testing-conventions-cosmic-ray-{}-{}",
577 std::process::id(),
578 COUNTER.fetch_add(1, Ordering::Relaxed),
579 );
580 CosmicRayDir(std::env::temp_dir().join(name))
581 }
582}
583
584impl Drop for CosmicRayDir {
585 fn drop(&mut self) {
586 let _ = std::fs::remove_dir_all(&self.0);
587 }
588}
589
590struct MutantsOut(PathBuf);
593
594impl MutantsOut {
595 fn new() -> Self {
596 static COUNTER: AtomicU64 = AtomicU64::new(0);
597 let name = format!(
598 "testing-conventions-mutants-{}-{}",
599 std::process::id(),
600 COUNTER.fetch_add(1, Ordering::Relaxed),
601 );
602 MutantsOut(std::env::temp_dir().join(name))
603 }
604}
605
606impl Drop for MutantsOut {
607 fn drop(&mut self) {
608 let _ = std::fs::remove_dir_all(&self.0);
609 }
610}
611
612fn write_base_diff(root: &Path, base: &str, out: &MutantsOut) -> Result<Option<PathBuf>> {
621 let range = format!("{base}...HEAD");
622 let output = Command::new("git")
623 .current_dir(root)
624 .args(["diff", "--relative", &range])
625 .output()
626 .context("running `git diff` for `--base` (is git installed?)")?;
627 if !output.status.success() {
628 bail!(
629 "git diff {range} failed: {}",
630 String::from_utf8_lossy(&output.stderr)
631 );
632 }
633 if output.stdout.is_empty() {
634 return Ok(None);
635 }
636 std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
637 let path = out.0.join("base.diff");
638 std::fs::write(&path, &output.stdout).context("writing the base diff")?;
639 Ok(Some(path))
640}
641
642fn run_cargo_mutants(root: &Path, out: &Path, in_diff: Option<&Path>) -> Result<()> {
651 let mut command = Command::new("cargo");
652 command
653 .current_dir(root)
654 .arg("mutants")
655 .arg("--output")
656 .arg(out);
657 if let Some(diff) = in_diff {
658 command.arg("--in-diff").arg(diff);
659 }
660 for var in [
661 "RUSTFLAGS",
662 "CARGO_ENCODED_RUSTFLAGS",
663 "RUSTDOCFLAGS",
664 "CARGO_ENCODED_RUSTDOCFLAGS",
665 "LLVM_PROFILE_FILE",
666 "CARGO_LLVM_COV",
667 "CARGO_LLVM_COV_SHOW_ENV",
668 "CARGO_LLVM_COV_TARGET_DIR",
669 "CARGO_LLVM_COV_BUILD_DIR",
670 "RUSTC_WRAPPER",
671 "RUSTC_WORKSPACE_WRAPPER",
672 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
673 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
674 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
675 ] {
676 command.env_remove(var);
677 }
678 let output = command
679 .output()
680 .context("running `cargo mutants` (is cargo-mutants installed?)")?;
681 match output.status.code() {
682 Some(0) | Some(2) => Ok(()),
684 _ => bail!(
685 "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
686 root.display(),
687 String::from_utf8_lossy(&output.stdout),
688 String::from_utf8_lossy(&output.stderr),
689 ),
690 }
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696
697 const SAMPLE: &str = r#"{
700 "outcomes": [
701 {"scenario": "Baseline", "summary": "Success",
702 "phase_results": []},
703 {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
704 "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
705 "function": {"function_name": "is_positive"},
706 "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
707 "summary": "MissedMutant"},
708 {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
709 "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
710 "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
711 "summary": "CaughtMutant"}
712 ],
713 "total_mutants": 2
714 }"#;
715
716 #[test]
717 fn parses_the_outcomes_export() {
718 let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
719 assert_eq!(report.outcomes.len(), 3);
720 assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
721 }
722
723 #[test]
724 fn collects_only_missed_mutants_as_survivors() {
725 let report = parse_mutants_report(SAMPLE).unwrap();
726 let survivors = unexplained_survivors(&report, &[]);
727 assert_eq!(survivors.len(), 1);
729 assert_eq!(survivors[0].file, "src/lib.rs");
730 assert_eq!(survivors[0].line, 7);
731 assert!(survivors[0].description.contains("replace > with =="));
732 }
733
734 #[test]
735 fn an_exemption_drops_a_survivor_in_that_file() {
736 let report = parse_mutants_report(SAMPLE).unwrap();
737 let exempt = vec!["src/lib.rs".to_string()];
738 assert!(unexplained_survivors(&report, &exempt).is_empty());
739 }
740
741 #[test]
742 fn an_exemption_on_another_file_leaves_the_survivor() {
743 let report = parse_mutants_report(SAMPLE).unwrap();
744 let exempt = vec!["src/elsewhere.rs".to_string()];
745 assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
746 }
747
748 const STRYKER_SAMPLE: &str = r#"{
751 "schemaVersion": "1.0",
752 "files": {
753 "src/index.ts": {
754 "language": "typescript",
755 "source": "...",
756 "mutants": [
757 {"id": "0", "mutatorName": "ConditionalExpression", "replacement": "true",
758 "status": "Survived", "coveredBy": ["t0"],
759 "location": {"start": {"line": 2, "column": 10}, "end": {"line": 2, "column": 15}}},
760 {"id": "1", "mutatorName": "ArithmeticOperator", "replacement": "a - b",
761 "status": "NoCoverage",
762 "location": {"start": {"line": 5, "column": 3}, "end": {"line": 5, "column": 8}}},
763 {"id": "2", "mutatorName": "BooleanLiteral", "replacement": "false",
764 "status": "Killed",
765 "location": {"start": {"line": 9, "column": 1}, "end": {"line": 9, "column": 6}}}
766 ]
767 }
768 }
769 }"#;
770
771 #[test]
772 fn parses_a_stryker_report() {
773 let report = parse_stryker_report(STRYKER_SAMPLE).expect("valid mutation.json");
774 assert_eq!(report.files["src/index.ts"].mutants.len(), 3);
775 }
776
777 #[test]
778 fn collects_survived_and_nocoverage_as_survivors() {
779 let report = parse_stryker_report(STRYKER_SAMPLE).unwrap();
780 let survivors = stryker_survivors(&report);
781 assert_eq!(survivors.len(), 2);
783 assert!(survivors.iter().all(|s| s.file == "src/index.ts"));
784 assert_eq!(survivors[0].line, 2);
785 assert!(survivors[0].description.contains("ConditionalExpression"));
786 assert!(survivors[0].description.contains("true"));
787 assert_eq!(survivors[1].line, 5);
788 }
789
790 #[test]
791 fn evaluate_drops_exempt_files_for_either_engine() {
792 let report = parse_stryker_report(STRYKER_SAMPLE).unwrap();
793 let survivors = stryker_survivors(&report);
794 let exempt = vec!["src/index.ts".to_string()];
795 assert!(evaluate(survivors, &exempt).is_empty());
796 }
797
798 #[test]
799 fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
800 assert!(is_mutatable_ts("src/index.ts"));
801 assert!(is_mutatable_ts("src/util.tsx"));
802 assert!(is_mutatable_ts("src/util.js"));
803 assert!(!is_mutatable_ts("src/index.test.ts"));
804 assert!(!is_mutatable_ts("src/index.spec.ts"));
805 assert!(!is_mutatable_ts("src/types.d.ts"));
806 assert!(!is_mutatable_ts("README.md"));
807 }
808
809 #[test]
810 fn contiguous_runs_collapses_adjacent_lines() {
811 let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
812 assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
813 assert!(contiguous_runs(&BTreeSet::new()).is_empty());
814 }
815
816 #[test]
817 fn one_line_flattens_and_caps() {
818 assert_eq!(one_line("a -\n b"), "a - b");
819 let long = "x".repeat(80);
820 let capped = one_line(&long);
821 assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
822 }
823
824 const COSMIC_RAY_DUMP: &str = concat!(
827 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"}]"#,
828 "\n",
829 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"}]"#,
830 "\n",
831 );
832
833 #[test]
834 fn collects_only_survived_cosmic_ray_mutants() {
835 let survivors = parse_cosmic_ray_dump(COSMIC_RAY_DUMP).expect("valid dump");
836 assert_eq!(survivors.len(), 1);
838 assert_eq!(survivors[0].file, "calc.py");
839 assert_eq!(survivors[0].line, 6);
840 assert!(survivors[0]
841 .description
842 .contains("ReplaceComparisonOperator"));
843 assert!(survivors[0].description.contains("is_positive"));
844 }
845
846 #[test]
847 fn an_unexecuted_cosmic_ray_item_is_not_a_survivor() {
848 let dump = r#"[{"mutations":[{"module_path":"calc.py","operator_name":"core/NumberReplacer","start_pos":[3,5],"end_pos":[3,6]}]},null]"#;
850 assert!(parse_cosmic_ray_dump(dump).unwrap().is_empty());
851 }
852
853 #[test]
854 fn is_mutatable_py_keeps_sources_and_drops_tests() {
855 assert!(is_mutatable_py("calc.py"));
856 assert!(is_mutatable_py("pkg/util.py"));
857 assert!(!is_mutatable_py("calc_test.py"));
858 assert!(!is_mutatable_py("test_calc.py"));
859 assert!(!is_mutatable_py("pkg/conftest.py"));
860 assert!(!is_mutatable_py("README.md"));
861 }
862}