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) => Some(write_base_diff(root, base, &out)?),
138 None => None,
139 };
140 run_cargo_mutants(root, &out.0, diff.as_deref())?;
141 let outcomes = out.0.join("mutants.out").join("outcomes.json");
142 let json = std::fs::read_to_string(&outcomes).with_context(|| {
143 format!(
144 "reading cargo-mutants outcomes at `{}` — the run wrote none",
145 outcomes.display()
146 )
147 })?;
148 let report = parse_mutants_report(&json)?;
149 Ok(unexplained_survivors(&report, exempt))
150}
151
152#[derive(Debug, Clone, Deserialize)]
156pub struct StrykerReport {
157 pub files: BTreeMap<String, StrykerFile>,
159}
160
161#[derive(Debug, Clone, Deserialize)]
163pub struct StrykerFile {
164 #[serde(default)]
165 pub mutants: Vec<StrykerMutant>,
166}
167
168#[derive(Debug, Clone, Deserialize)]
171#[serde(rename_all = "camelCase")]
172pub struct StrykerMutant {
173 pub mutator_name: String,
174 #[serde(default)]
175 pub replacement: Option<String>,
176 pub status: String,
177 pub location: StrykerLocation,
178}
179
180#[derive(Debug, Clone, Deserialize)]
183pub struct StrykerLocation {
184 pub start: LineCol,
185}
186
187pub fn parse_stryker_report(json: &str) -> Result<StrykerReport> {
189 serde_json::from_str(json).context("parsing Stryker mutation.json")
190}
191
192pub fn stryker_survivors(report: &StrykerReport) -> Vec<Survivor> {
199 let mut survivors = Vec::new();
200 for (file, contents) in &report.files {
201 for mutant in &contents.mutants {
202 if mutant.status != "Survived" && mutant.status != "NoCoverage" {
203 continue;
204 }
205 let description = match &mutant.replacement {
206 Some(replacement) => {
207 format!("{} (-> {})", mutant.mutator_name, one_line(replacement))
208 }
209 None => mutant.mutator_name.clone(),
210 };
211 survivors.push(Survivor {
212 file: file.clone(),
213 line: mutant.location.start.line,
214 description,
215 });
216 }
217 }
218 survivors
219}
220
221fn one_line(replacement: &str) -> String {
224 let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
225 const MAX: usize = 60;
226 if flat.chars().count() > MAX {
227 format!("{}…", flat.chars().take(MAX).collect::<String>())
228 } else {
229 flat
230 }
231}
232
233pub fn measure_typescript(
242 root: &Path,
243 exempt: &[String],
244 base: Option<&str>,
245) -> Result<Vec<Survivor>> {
246 let mutate = match base {
247 Some(base) => {
248 let ranges = mutate_ranges(root, base)?;
249 if ranges.is_empty() {
251 return Ok(Vec::new());
252 }
253 Some(ranges)
254 }
255 None => None,
256 };
257 let json = run_stryker(root, mutate.as_deref())?;
258 let report = parse_stryker_report(&json)?;
259 Ok(evaluate(stryker_survivors(&report), exempt))
260}
261
262fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
268 let changed = crate::patch_coverage::changed_lines(root, base)?;
269 let mut specs = Vec::new();
270 for (file, lines) in changed {
271 if !is_mutatable_ts(&file) {
272 continue;
273 }
274 for (start, end) in contiguous_runs(&lines) {
275 specs.push(format!("{file}:{start}-{end}"));
276 }
277 }
278 Ok(specs)
279}
280
281fn is_mutatable_ts(file: &str) -> bool {
285 let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
286 .iter()
287 .any(|ext| file.ends_with(ext));
288 let is_decl = file.ends_with(".d.ts");
289 let is_test = file.contains(".test.") || file.contains(".spec.");
290 is_source && !is_decl && !is_test
291}
292
293fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
295 let mut runs: Vec<(u64, u64)> = Vec::new();
296 for &line in lines {
297 match runs.last_mut() {
298 Some(run) if run.1 + 1 == line => run.1 = line,
299 _ => runs.push((line, line)),
300 }
301 }
302 runs
303}
304
305fn run_stryker(root: &Path, mutate: Option<&[String]>) -> Result<String> {
316 let report_path = root.join("reports").join("mutation").join("mutation.json");
317 let _cleanup = ReportCleanup(report_path.clone());
318 let _ = std::fs::remove_file(&report_path);
320
321 let mut command = Command::new("npx");
322 command
323 .current_dir(root)
324 .args(["--yes", "stryker", "run", "--reporters", "json"]);
325 if let Some(specs) = mutate {
326 command.arg("--mutate").arg(specs.join(","));
327 }
328 let output = command
329 .env("CI", "1")
330 .output()
331 .context("running `npx stryker run` (is @stryker-mutator/core installed?)")?;
332
333 std::fs::read_to_string(&report_path).map_err(|_| {
334 anyhow::anyhow!(
335 "Stryker produced no report in `{}` (did it run cleanly?):\n{}{}",
336 root.display(),
337 String::from_utf8_lossy(&output.stdout),
338 String::from_utf8_lossy(&output.stderr),
339 )
340 })
341}
342
343struct ReportCleanup(PathBuf);
346
347impl Drop for ReportCleanup {
348 fn drop(&mut self) {
349 let _ = std::fs::remove_file(&self.0);
350 if let Some(mutation_dir) = self.0.parent() {
351 let _ = std::fs::remove_dir(mutation_dir);
353 if let Some(reports_dir) = mutation_dir.parent() {
354 let _ = std::fs::remove_dir(reports_dir);
355 }
356 }
357 }
358}
359
360#[derive(Debug, Clone, Deserialize)]
363pub struct CosmicRayLine(pub CrWorkItem, pub Option<CrResult>);
364
365#[derive(Debug, Clone, Deserialize)]
368pub struct CrWorkItem {
369 pub mutations: Vec<CrMutation>,
370}
371
372#[derive(Debug, Clone, Deserialize)]
375pub struct CrMutation {
376 pub module_path: String,
377 pub operator_name: String,
378 pub start_pos: (u32, u32),
380 #[serde(default)]
381 pub definition_name: Option<String>,
382}
383
384#[derive(Debug, Clone, Deserialize)]
387pub struct CrResult {
388 #[serde(default)]
389 pub test_outcome: Option<String>,
390}
391
392pub fn parse_cosmic_ray_dump(dump: &str) -> Result<Vec<Survivor>> {
400 let mut survivors = Vec::new();
401 for line in dump.lines() {
402 if line.trim().is_empty() {
403 continue;
404 }
405 let CosmicRayLine(item, result) =
406 serde_json::from_str(line).context("parsing a cosmic-ray dump line")?;
407 let survived = matches!(result, Some(CrResult { test_outcome: Some(outcome) }) if outcome == "survived");
408 if !survived {
409 continue;
410 }
411 let Some(mutation) = item.mutations.first() else {
412 continue;
413 };
414 let definition = mutation.definition_name.as_deref().unwrap_or("<module>");
415 survivors.push(Survivor {
416 file: mutation.module_path.clone(),
417 line: mutation.start_pos.0,
418 description: format!("{} in {}", mutation.operator_name, definition),
419 });
420 }
421 Ok(survivors)
422}
423
424pub fn measure_python(root: &Path, exempt: &[String], base: Option<&str>) -> Result<Vec<Survivor>> {
435 let survivors = match base {
436 None => run_cosmic_ray(root, ".", &PY_TEST_EXCLUDES)?,
437 Some(base) => {
438 let changed = crate::patch_coverage::changed_lines(root, base)?;
439 let mut all = Vec::new();
440 for (file, lines) in &changed {
441 if !is_mutatable_py(file) {
442 continue;
443 }
444 for survivor in run_cosmic_ray(root, file, &[])? {
446 if lines.contains(&(survivor.line as u64)) {
447 all.push(survivor);
448 }
449 }
450 }
451 all
452 }
453 };
454 Ok(evaluate(survivors, exempt))
455}
456
457const PY_TEST_EXCLUDES: [&str; 3] = ["*_test.py", "test_*.py", "conftest.py"];
460
461fn is_mutatable_py(file: &str) -> bool {
464 if !file.ends_with(".py") {
465 return false;
466 }
467 let base = file.rsplit('/').next().unwrap_or(file);
468 !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
469}
470
471fn run_cosmic_ray(
477 root: &Path,
478 module_path: &str,
479 excluded_modules: &[&str],
480) -> Result<Vec<Survivor>> {
481 let dir = CosmicRayDir::new();
482 std::fs::create_dir_all(&dir.0).context("creating the cosmic-ray temp dir")?;
483 let config = dir.0.join("cr.toml");
484 let session = dir.0.join("session.sqlite");
485
486 let excludes = excluded_modules
487 .iter()
488 .map(|glob| format!("\"{glob}\""))
489 .collect::<Vec<_>>()
490 .join(", ");
491 std::fs::write(
492 &config,
493 format!(
494 "[cosmic-ray]\n\
495 module-path = \"{module_path}\"\n\
496 timeout = 30.0\n\
497 excluded-modules = [{excludes}]\n\
498 test-command = \"python3 -m pytest -q -p no:cacheprovider\"\n\
499 \n\
500 [cosmic-ray.distributor]\n\
501 name = \"local\"\n"
502 ),
503 )
504 .context("writing the cosmic-ray config")?;
505
506 let baseline = cosmic_ray(root, &["baseline", path_str(&config)])?;
509 if !baseline.status.success() {
510 bail!(
511 "the Python unit suite did not pass unmutated in `{}` (cosmic-ray baseline failed):\n{}{}",
512 root.display(),
513 String::from_utf8_lossy(&baseline.stdout),
514 String::from_utf8_lossy(&baseline.stderr),
515 );
516 }
517
518 let init = cosmic_ray(root, &["init", path_str(&config), path_str(&session)])?;
519 if !init.status.success() {
520 bail!(
521 "cosmic-ray init failed in `{}`:\n{}{}",
522 root.display(),
523 String::from_utf8_lossy(&init.stdout),
524 String::from_utf8_lossy(&init.stderr),
525 );
526 }
527 let exec = cosmic_ray(root, &["exec", path_str(&config), path_str(&session)])?;
528 if !exec.status.success() {
529 bail!(
530 "cosmic-ray exec failed in `{}`:\n{}{}",
531 root.display(),
532 String::from_utf8_lossy(&exec.stdout),
533 String::from_utf8_lossy(&exec.stderr),
534 );
535 }
536 let dump = cosmic_ray(root, &["dump", path_str(&session)])?;
537 if !dump.status.success() {
538 bail!(
539 "cosmic-ray dump failed in `{}`:\n{}",
540 root.display(),
541 String::from_utf8_lossy(&dump.stderr),
542 );
543 }
544 parse_cosmic_ray_dump(&String::from_utf8_lossy(&dump.stdout))
545}
546
547fn cosmic_ray(root: &Path, args: &[&str]) -> Result<std::process::Output> {
550 Command::new("cosmic-ray")
551 .current_dir(root)
552 .args(args)
553 .env("PYTHONDONTWRITEBYTECODE", "1")
554 .output()
555 .context("running `cosmic-ray` (is it installed?)")
556}
557
558fn path_str(path: &Path) -> &str {
559 path.to_str().expect("temp path is valid UTF-8")
560}
561
562struct CosmicRayDir(PathBuf);
565
566impl CosmicRayDir {
567 fn new() -> Self {
568 static COUNTER: AtomicU64 = AtomicU64::new(0);
569 let name = format!(
570 "testing-conventions-cosmic-ray-{}-{}",
571 std::process::id(),
572 COUNTER.fetch_add(1, Ordering::Relaxed),
573 );
574 CosmicRayDir(std::env::temp_dir().join(name))
575 }
576}
577
578impl Drop for CosmicRayDir {
579 fn drop(&mut self) {
580 let _ = std::fs::remove_dir_all(&self.0);
581 }
582}
583
584struct MutantsOut(PathBuf);
587
588impl MutantsOut {
589 fn new() -> Self {
590 static COUNTER: AtomicU64 = AtomicU64::new(0);
591 let name = format!(
592 "testing-conventions-mutants-{}-{}",
593 std::process::id(),
594 COUNTER.fetch_add(1, Ordering::Relaxed),
595 );
596 MutantsOut(std::env::temp_dir().join(name))
597 }
598}
599
600impl Drop for MutantsOut {
601 fn drop(&mut self) {
602 let _ = std::fs::remove_dir_all(&self.0);
603 }
604}
605
606fn write_base_diff(root: &Path, base: &str, out: &MutantsOut) -> Result<PathBuf> {
608 let range = format!("{base}...HEAD");
609 let output = Command::new("git")
610 .current_dir(root)
611 .args(["diff", &range])
612 .output()
613 .context("running `git diff` for `--base` (is git installed?)")?;
614 if !output.status.success() {
615 bail!(
616 "git diff {range} failed: {}",
617 String::from_utf8_lossy(&output.stderr)
618 );
619 }
620 std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
621 let path = out.0.join("base.diff");
622 std::fs::write(&path, &output.stdout).context("writing the base diff")?;
623 Ok(path)
624}
625
626fn run_cargo_mutants(root: &Path, out: &Path, in_diff: Option<&Path>) -> Result<()> {
635 let mut command = Command::new("cargo");
636 command
637 .current_dir(root)
638 .arg("mutants")
639 .arg("--output")
640 .arg(out);
641 if let Some(diff) = in_diff {
642 command.arg("--in-diff").arg(diff);
643 }
644 for var in [
645 "RUSTFLAGS",
646 "CARGO_ENCODED_RUSTFLAGS",
647 "RUSTDOCFLAGS",
648 "CARGO_ENCODED_RUSTDOCFLAGS",
649 "LLVM_PROFILE_FILE",
650 "CARGO_LLVM_COV",
651 "CARGO_LLVM_COV_SHOW_ENV",
652 "CARGO_LLVM_COV_TARGET_DIR",
653 "CARGO_LLVM_COV_BUILD_DIR",
654 "RUSTC_WRAPPER",
655 "RUSTC_WORKSPACE_WRAPPER",
656 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
657 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
658 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
659 ] {
660 command.env_remove(var);
661 }
662 let output = command
663 .output()
664 .context("running `cargo mutants` (is cargo-mutants installed?)")?;
665 match output.status.code() {
666 Some(0) | Some(2) => Ok(()),
668 _ => bail!(
669 "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
670 root.display(),
671 String::from_utf8_lossy(&output.stdout),
672 String::from_utf8_lossy(&output.stderr),
673 ),
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680
681 const SAMPLE: &str = r#"{
684 "outcomes": [
685 {"scenario": "Baseline", "summary": "Success",
686 "phase_results": []},
687 {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
688 "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
689 "function": {"function_name": "is_positive"},
690 "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
691 "summary": "MissedMutant"},
692 {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
693 "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
694 "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
695 "summary": "CaughtMutant"}
696 ],
697 "total_mutants": 2
698 }"#;
699
700 #[test]
701 fn parses_the_outcomes_export() {
702 let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
703 assert_eq!(report.outcomes.len(), 3);
704 assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
705 }
706
707 #[test]
708 fn collects_only_missed_mutants_as_survivors() {
709 let report = parse_mutants_report(SAMPLE).unwrap();
710 let survivors = unexplained_survivors(&report, &[]);
711 assert_eq!(survivors.len(), 1);
713 assert_eq!(survivors[0].file, "src/lib.rs");
714 assert_eq!(survivors[0].line, 7);
715 assert!(survivors[0].description.contains("replace > with =="));
716 }
717
718 #[test]
719 fn an_exemption_drops_a_survivor_in_that_file() {
720 let report = parse_mutants_report(SAMPLE).unwrap();
721 let exempt = vec!["src/lib.rs".to_string()];
722 assert!(unexplained_survivors(&report, &exempt).is_empty());
723 }
724
725 #[test]
726 fn an_exemption_on_another_file_leaves_the_survivor() {
727 let report = parse_mutants_report(SAMPLE).unwrap();
728 let exempt = vec!["src/elsewhere.rs".to_string()];
729 assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
730 }
731
732 const STRYKER_SAMPLE: &str = r#"{
735 "schemaVersion": "1.0",
736 "files": {
737 "src/index.ts": {
738 "language": "typescript",
739 "source": "...",
740 "mutants": [
741 {"id": "0", "mutatorName": "ConditionalExpression", "replacement": "true",
742 "status": "Survived", "coveredBy": ["t0"],
743 "location": {"start": {"line": 2, "column": 10}, "end": {"line": 2, "column": 15}}},
744 {"id": "1", "mutatorName": "ArithmeticOperator", "replacement": "a - b",
745 "status": "NoCoverage",
746 "location": {"start": {"line": 5, "column": 3}, "end": {"line": 5, "column": 8}}},
747 {"id": "2", "mutatorName": "BooleanLiteral", "replacement": "false",
748 "status": "Killed",
749 "location": {"start": {"line": 9, "column": 1}, "end": {"line": 9, "column": 6}}}
750 ]
751 }
752 }
753 }"#;
754
755 #[test]
756 fn parses_a_stryker_report() {
757 let report = parse_stryker_report(STRYKER_SAMPLE).expect("valid mutation.json");
758 assert_eq!(report.files["src/index.ts"].mutants.len(), 3);
759 }
760
761 #[test]
762 fn collects_survived_and_nocoverage_as_survivors() {
763 let report = parse_stryker_report(STRYKER_SAMPLE).unwrap();
764 let survivors = stryker_survivors(&report);
765 assert_eq!(survivors.len(), 2);
767 assert!(survivors.iter().all(|s| s.file == "src/index.ts"));
768 assert_eq!(survivors[0].line, 2);
769 assert!(survivors[0].description.contains("ConditionalExpression"));
770 assert!(survivors[0].description.contains("true"));
771 assert_eq!(survivors[1].line, 5);
772 }
773
774 #[test]
775 fn evaluate_drops_exempt_files_for_either_engine() {
776 let report = parse_stryker_report(STRYKER_SAMPLE).unwrap();
777 let survivors = stryker_survivors(&report);
778 let exempt = vec!["src/index.ts".to_string()];
779 assert!(evaluate(survivors, &exempt).is_empty());
780 }
781
782 #[test]
783 fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
784 assert!(is_mutatable_ts("src/index.ts"));
785 assert!(is_mutatable_ts("src/util.tsx"));
786 assert!(is_mutatable_ts("src/util.js"));
787 assert!(!is_mutatable_ts("src/index.test.ts"));
788 assert!(!is_mutatable_ts("src/index.spec.ts"));
789 assert!(!is_mutatable_ts("src/types.d.ts"));
790 assert!(!is_mutatable_ts("README.md"));
791 }
792
793 #[test]
794 fn contiguous_runs_collapses_adjacent_lines() {
795 let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
796 assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
797 assert!(contiguous_runs(&BTreeSet::new()).is_empty());
798 }
799
800 #[test]
801 fn one_line_flattens_and_caps() {
802 assert_eq!(one_line("a -\n b"), "a - b");
803 let long = "x".repeat(80);
804 let capped = one_line(&long);
805 assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
806 }
807
808 const COSMIC_RAY_DUMP: &str = concat!(
811 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"}]"#,
812 "\n",
813 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"}]"#,
814 "\n",
815 );
816
817 #[test]
818 fn collects_only_survived_cosmic_ray_mutants() {
819 let survivors = parse_cosmic_ray_dump(COSMIC_RAY_DUMP).expect("valid dump");
820 assert_eq!(survivors.len(), 1);
822 assert_eq!(survivors[0].file, "calc.py");
823 assert_eq!(survivors[0].line, 6);
824 assert!(survivors[0]
825 .description
826 .contains("ReplaceComparisonOperator"));
827 assert!(survivors[0].description.contains("is_positive"));
828 }
829
830 #[test]
831 fn an_unexecuted_cosmic_ray_item_is_not_a_survivor() {
832 let dump = r#"[{"mutations":[{"module_path":"calc.py","operator_name":"core/NumberReplacer","start_pos":[3,5],"end_pos":[3,6]}]},null]"#;
834 assert!(parse_cosmic_ray_dump(dump).unwrap().is_empty());
835 }
836
837 #[test]
838 fn is_mutatable_py_keeps_sources_and_drops_tests() {
839 assert!(is_mutatable_py("calc.py"));
840 assert!(is_mutatable_py("pkg/util.py"));
841 assert!(!is_mutatable_py("calc_test.py"));
842 assert!(!is_mutatable_py("test_calc.py"));
843 assert!(!is_mutatable_py("pkg/conftest.py"));
844 assert!(!is_mutatable_py("README.md"));
845 }
846}