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
42#[derive(Debug, Clone, Deserialize)]
45pub struct MutantsReport {
46 pub outcomes: Vec<MutantOutcome>,
47}
48
49#[derive(Debug, Clone, Deserialize)]
53pub struct MutantOutcome {
54 pub summary: String,
55 pub scenario: Scenario,
56}
57
58#[derive(Debug, Clone, Deserialize)]
61pub enum Scenario {
62 Baseline,
63 Mutant(MutantInfo),
64}
65
66#[derive(Debug, Clone, Deserialize)]
70pub struct MutantInfo {
71 pub file: String,
72 pub span: Span,
73 pub name: String,
74}
75
76#[derive(Debug, Clone, Deserialize)]
78pub struct Span {
79 pub start: LineCol,
80}
81
82#[derive(Debug, Clone, Deserialize)]
84pub struct LineCol {
85 pub line: u32,
86}
87
88pub fn parse_mutants_report(json: &str) -> Result<MutantsReport> {
90 serde_json::from_str(json).context("parsing cargo-mutants outcomes.json")
91}
92
93pub fn unexplained_survivors(report: &MutantsReport, exempt: &[String]) -> Vec<Survivor> {
102 evaluate(cargo_mutants_survivors(report), exempt)
103}
104
105fn cargo_mutants_survivors(report: &MutantsReport) -> Vec<Survivor> {
109 report
110 .outcomes
111 .iter()
112 .filter_map(|outcome| {
113 if outcome.summary != "MissedMutant" {
114 return None;
115 }
116 let Scenario::Mutant(mutant) = &outcome.scenario else {
117 return None;
118 };
119 Some(Survivor {
120 file: mutant.file.clone(),
121 line: mutant.span.start.line,
122 description: mutant.name.clone(),
123 })
124 })
125 .collect()
126}
127
128pub fn mutated_lines(report: &MutantsReport) -> MutatedLines {
134 report
135 .outcomes
136 .iter()
137 .filter_map(|outcome| {
138 if outcome.summary != "CaughtMutant" && outcome.summary != "MissedMutant" {
139 return None;
140 }
141 let Scenario::Mutant(mutant) = &outcome.scenario else {
142 return None;
143 };
144 Some((mutant.file.clone(), mutant.span.start.line))
145 })
146 .collect()
147}
148
149pub fn evaluate(survivors: Vec<Survivor>, exempt: &[String]) -> Vec<Survivor> {
154 survivors
155 .into_iter()
156 .filter(|survivor| !exempt.iter().any(|path| path == &survivor.file))
157 .collect()
158}
159
160pub fn evaluate_scoped(
172 survivors: Vec<Survivor>,
173 mutated: &MutatedLines,
174 whole_file: &[String],
175 line_scoped: &BTreeMap<String, BTreeSet<u32>>,
176) -> Result<Vec<Survivor>> {
177 let mut over: Vec<String> = Vec::new();
178 for (file, lines) in line_scoped {
179 for &line in lines {
180 let has_survivor = survivors
181 .iter()
182 .any(|survivor| survivor.file == *file && survivor.line == line);
183 if has_survivor {
184 continue;
185 }
186 if mutated.contains(&(file.clone(), line)) {
187 over.push(format!("\n {file}:{line}"));
188 }
189 }
190 }
191 if !over.is_empty() {
192 bail!(
193 "a line-scoped mutation exemption may only list a line with a surviving mutant, but \
194 these had mutants that were all caught:{}",
195 over.concat()
196 );
197 }
198 Ok(survivors
199 .into_iter()
200 .filter(|survivor| {
201 let whole = whole_file.iter().any(|path| path == &survivor.file);
202 let line = line_scoped
203 .get(&survivor.file)
204 .is_some_and(|lines| lines.contains(&survivor.line));
205 !(whole || line)
206 })
207 .collect())
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
216#[serde(rename_all = "snake_case")]
217pub enum MutantStatus {
218 Survived,
220 Killed,
222 NoCoverage,
224 Timeout,
226 CompileError,
228 RuntimeError,
230}
231
232impl MutantStatus {
233 fn is_survivor(self) -> bool {
236 matches!(self, MutantStatus::Survived | MutantStatus::NoCoverage)
237 }
238
239 fn is_viable(self) -> bool {
244 matches!(
245 self,
246 MutantStatus::Survived
247 | MutantStatus::Killed
248 | MutantStatus::NoCoverage
249 | MutantStatus::Timeout
250 )
251 }
252}
253
254#[derive(Debug, Clone, Deserialize)]
257pub struct NormalizedMutant {
258 pub file: String,
260 pub line: u32,
262 pub status: MutantStatus,
264 pub mutator: String,
266 #[serde(default)]
268 pub replacement: Option<String>,
269}
270
271pub fn parse_normalized_results(json: &str) -> Result<Vec<NormalizedMutant>> {
274 serde_json::from_str(json).context("parsing normalized mutation results")
275}
276
277pub fn evaluate_normalized(
285 mutants: &[NormalizedMutant],
286 whole_file: &[String],
287 line_scoped: &BTreeMap<String, BTreeSet<u32>>,
288) -> Result<Vec<Survivor>> {
289 evaluate_scoped(
290 normalized_survivors(mutants),
291 &normalized_mutated_lines(mutants),
292 whole_file,
293 line_scoped,
294 )
295}
296
297fn normalized_survivors(mutants: &[NormalizedMutant]) -> Vec<Survivor> {
299 mutants
300 .iter()
301 .filter(|mutant| mutant.status.is_survivor())
302 .map(|mutant| Survivor {
303 file: mutant.file.clone(),
304 line: mutant.line,
305 description: describe_normalized(mutant),
306 })
307 .collect()
308}
309
310fn normalized_mutated_lines(mutants: &[NormalizedMutant]) -> MutatedLines {
313 mutants
314 .iter()
315 .filter(|mutant| mutant.status.is_viable())
316 .map(|mutant| (mutant.file.clone(), mutant.line))
317 .collect()
318}
319
320fn describe_normalized(mutant: &NormalizedMutant) -> String {
323 match &mutant.replacement {
324 Some(replacement) => format!("{} (-> {})", mutant.mutator, one_line(replacement)),
325 None => mutant.mutator.clone(),
326 }
327}
328
329pub fn measure_rust(
336 root: &Path,
337 exempt: &[String],
338 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
339 base: Option<&str>,
340) -> Result<Vec<Survivor>> {
341 let out = MutantsOut::new();
342 let diff = match base {
343 Some(base) => match write_base_diff(root, base, &out)? {
346 None => return Ok(Vec::new()),
347 Some(path) => Some(path),
348 },
349 None => None,
350 };
351 run_cargo_mutants(root, &out.0, diff.as_deref())?;
352 let outcomes = out.0.join("mutants.out").join("outcomes.json");
353 let json = match std::fs::read_to_string(&outcomes) {
357 Ok(json) => json,
358 Err(_) => return Ok(Vec::new()),
359 };
360 let report = parse_mutants_report(&json)?;
361 evaluate_scoped(
362 cargo_mutants_survivors(&report),
363 &mutated_lines(&report),
364 exempt,
365 exempt_lines,
366 )
367}
368
369fn one_line(replacement: &str) -> String {
372 let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
373 const MAX: usize = 60;
374 if flat.chars().count() > MAX {
375 format!("{}…", flat.chars().take(MAX).collect::<String>())
376 } else {
377 flat
378 }
379}
380
381pub fn measure_typescript(
400 root: &Path,
401 exempt: &[String],
402 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
403 base: Option<&str>,
404 adapter: &Path,
405) -> Result<Vec<Survivor>> {
406 let mutate = match base {
407 Some(base) => {
408 let ranges = mutate_ranges(root, base)?;
409 if ranges.is_empty() {
411 return Ok(Vec::new());
412 }
413 Some(ranges)
414 }
415 None => None,
416 };
417 let json = run_ts_adapter(root, adapter, mutate.as_deref())?;
418 let mutants = parse_normalized_results(&json)?;
419 evaluate_normalized(&mutants, exempt, exempt_lines)
420}
421
422fn run_ts_adapter(root: &Path, adapter: &Path, mutate: Option<&[String]>) -> Result<String> {
433 let out = AdapterOut::new();
434 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
435 let results = out.0.join("results.json");
436
437 let mut command = Command::new("node");
438 command
439 .current_dir(root)
440 .arg(adapter)
441 .arg("--out")
442 .arg(&results);
443 if let Some(specs) = mutate {
444 command.arg("--mutate").arg(specs.join(","));
445 }
446 let output = command
447 .output()
448 .context("running the TypeScript mutation adapter (is `node` installed?)")?;
449 if !output.status.success() {
450 bail!(
451 "the TypeScript mutation adapter failed in `{}`:\n{}{}",
452 root.display(),
453 String::from_utf8_lossy(&output.stdout),
454 String::from_utf8_lossy(&output.stderr),
455 );
456 }
457 std::fs::read_to_string(&results).with_context(|| {
458 format!(
459 "reading the TypeScript mutation adapter's results from `{}`",
460 results.display()
461 )
462 })
463}
464
465struct AdapterOut(PathBuf);
468
469impl AdapterOut {
470 fn new() -> Self {
471 static COUNTER: AtomicU64 = AtomicU64::new(0);
472 let name = format!(
473 "testing-conventions-ts-adapter-{}-{}",
474 std::process::id(),
475 COUNTER.fetch_add(1, Ordering::Relaxed),
476 );
477 AdapterOut(std::env::temp_dir().join(name))
478 }
479}
480
481impl Drop for AdapterOut {
482 fn drop(&mut self) {
483 let _ = std::fs::remove_dir_all(&self.0);
484 }
485}
486
487fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
493 let changed = crate::patch_coverage::changed_lines(root, base)?;
494 let mut specs = Vec::new();
495 for (file, lines) in changed {
496 if !is_mutatable_ts(&file) {
497 continue;
498 }
499 for (start, end) in contiguous_runs(&lines) {
500 specs.push(format!("{file}:{start}-{end}"));
501 }
502 }
503 Ok(specs)
504}
505
506fn is_mutatable_ts(file: &str) -> bool {
510 let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
511 .iter()
512 .any(|ext| file.ends_with(ext));
513 let is_decl = file.ends_with(".d.ts");
514 let is_test = file.contains(".test.") || file.contains(".spec.");
515 is_source && !is_decl && !is_test
516}
517
518fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
520 let mut runs: Vec<(u64, u64)> = Vec::new();
521 for &line in lines {
522 match runs.last_mut() {
523 Some(run) if run.1 + 1 == line => run.1 = line,
524 _ => runs.push((line, line)),
525 }
526 }
527 runs
528}
529
530pub fn measure_python(
549 root: &Path,
550 exempt: &[String],
551 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
552 base: Option<&str>,
553) -> Result<Vec<Survivor>> {
554 let changed = match base {
555 Some(base) => Some(crate::patch_coverage::changed_lines(root, base)?),
556 None => None,
557 };
558 let modules: Vec<String> = match &changed {
559 None => Vec::new(),
560 Some(changed) => {
561 let modules: Vec<String> = changed
562 .keys()
563 .filter(|file| is_mutatable_py(file))
564 .cloned()
565 .collect();
566 if modules.is_empty() {
568 return Ok(Vec::new());
569 }
570 modules
571 }
572 };
573 let json = run_py_adapter(root, &modules)?;
574 let mut mutants = parse_normalized_results(&json)?;
575 if let Some(changed) = &changed {
576 mutants.retain(|mutant| {
578 changed
579 .get(&mutant.file)
580 .is_some_and(|lines| lines.contains(&u64::from(mutant.line)))
581 });
582 }
583 evaluate_normalized(&mutants, exempt, exempt_lines)
584}
585
586fn run_py_adapter(root: &Path, modules: &[String]) -> Result<String> {
594 let out = AdapterOut::new();
595 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
596 let results = out.0.join("results.json");
597
598 let mut command = Command::new("python3");
599 command
600 .current_dir(root)
601 .args(["-m", "testing_conventions.mutation.main", "--out"])
602 .arg(&results)
603 .env("PYTHONDONTWRITEBYTECODE", "1");
604 for module in modules {
605 command.arg("--module").arg(module);
606 }
607 let output = command
608 .output()
609 .context("running the Python mutation adapter (is `python3` installed?)")?;
610 if !output.status.success() {
611 bail!(
612 "the Python mutation adapter failed in `{}`:\n{}{}",
613 root.display(),
614 String::from_utf8_lossy(&output.stdout),
615 String::from_utf8_lossy(&output.stderr),
616 );
617 }
618 std::fs::read_to_string(&results).with_context(|| {
619 format!(
620 "reading the Python mutation adapter's results from `{}`",
621 results.display()
622 )
623 })
624}
625
626fn is_mutatable_py(file: &str) -> bool {
629 if !file.ends_with(".py") {
630 return false;
631 }
632 let base = file.rsplit('/').next().unwrap_or(file);
633 !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
634}
635
636struct MutantsOut(PathBuf);
639
640impl MutantsOut {
641 fn new() -> Self {
642 static COUNTER: AtomicU64 = AtomicU64::new(0);
643 let name = format!(
644 "testing-conventions-mutants-{}-{}",
645 std::process::id(),
646 COUNTER.fetch_add(1, Ordering::Relaxed),
647 );
648 MutantsOut(std::env::temp_dir().join(name))
649 }
650}
651
652impl Drop for MutantsOut {
653 fn drop(&mut self) {
654 let _ = std::fs::remove_dir_all(&self.0);
655 }
656}
657
658fn write_base_diff(root: &Path, base: &str, out: &MutantsOut) -> Result<Option<PathBuf>> {
667 let range = format!("{base}...HEAD");
668 let output = Command::new("git")
669 .current_dir(root)
670 .args(["diff", "--relative", &range])
671 .output()
672 .context("running `git diff` for `--base` (is git installed?)")?;
673 if !output.status.success() {
674 bail!(
675 "git diff {range} failed: {}",
676 String::from_utf8_lossy(&output.stderr)
677 );
678 }
679 if output.stdout.is_empty() {
680 return Ok(None);
681 }
682 std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
683 let path = out.0.join("base.diff");
684 std::fs::write(&path, &output.stdout).context("writing the base diff")?;
685 Ok(Some(path))
686}
687
688fn run_cargo_mutants(root: &Path, out: &Path, in_diff: Option<&Path>) -> Result<()> {
697 let mut command = Command::new("cargo");
698 command
699 .current_dir(root)
700 .arg("mutants")
701 .arg("--output")
702 .arg(out);
703 if let Some(diff) = in_diff {
704 command.arg("--in-diff").arg(diff);
705 }
706 for var in [
707 "RUSTFLAGS",
708 "CARGO_ENCODED_RUSTFLAGS",
709 "RUSTDOCFLAGS",
710 "CARGO_ENCODED_RUSTDOCFLAGS",
711 "LLVM_PROFILE_FILE",
712 "CARGO_LLVM_COV",
713 "CARGO_LLVM_COV_SHOW_ENV",
714 "CARGO_LLVM_COV_TARGET_DIR",
715 "CARGO_LLVM_COV_BUILD_DIR",
716 "RUSTC_WRAPPER",
717 "RUSTC_WORKSPACE_WRAPPER",
718 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
719 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
720 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
721 ] {
722 command.env_remove(var);
723 }
724 let output = command
725 .output()
726 .context("running `cargo mutants` (is cargo-mutants installed?)")?;
727 match output.status.code() {
728 Some(0) | Some(2) => Ok(()),
730 _ => bail!(
731 "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
732 root.display(),
733 String::from_utf8_lossy(&output.stdout),
734 String::from_utf8_lossy(&output.stderr),
735 ),
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742
743 const NORMALIZED: &str = r#"[
749 {"file": "src/a.ts", "line": 2, "status": "survived",
750 "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
751 {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
752 {"file": "src/a.ts", "line": 9, "status": "killed",
753 "mutator": "BooleanLiteral", "replacement": "false"},
754 {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
755 {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
756 {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
757 ]"#;
758
759 #[test]
760 fn parses_the_normalized_schema() {
761 let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
762 assert_eq!(mutants.len(), 6);
763 assert_eq!(mutants[0].status, MutantStatus::Survived);
764 assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
765 assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
766 assert_eq!(mutants[1].replacement, None);
767 }
768
769 #[test]
770 fn normalized_survivors_are_survived_and_nocoverage_only() {
771 let mutants = parse_normalized_results(NORMALIZED).unwrap();
772 let survivors = normalized_survivors(&mutants);
773 assert_eq!(survivors.len(), 2);
775 assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
776 assert!(survivors[0].description.contains("ConditionalExpression"));
778 assert!(survivors[0].description.contains("-> true"));
779 assert_eq!(survivors[1].description, "ArithmeticOperator");
780 }
781
782 #[test]
783 fn normalized_mutated_lines_collects_only_viable_mutants() {
784 let mutants = parse_normalized_results(NORMALIZED).unwrap();
785 assert_eq!(
788 normalized_mutated_lines(&mutants),
789 [2u32, 5, 9, 12]
790 .into_iter()
791 .map(|line| ("src/a.ts".to_string(), line))
792 .collect()
793 );
794 }
795
796 #[test]
797 fn evaluate_normalized_reports_unexempted_survivors() {
798 let mutants = parse_normalized_results(NORMALIZED).unwrap();
799 let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
800 assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
801 }
802
803 #[test]
804 fn evaluate_normalized_drops_a_whole_file_exemption() {
805 let mutants = parse_normalized_results(NORMALIZED).unwrap();
806 let kept =
807 evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
808 assert!(
809 kept.is_empty(),
810 "the whole-file exemption lifts both survivors"
811 );
812 }
813
814 #[test]
815 fn evaluate_normalized_drops_a_line_scoped_exemption() {
816 let mutants = parse_normalized_results(NORMALIZED).unwrap();
817 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
818 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
819 assert_eq!(kept.len(), 1);
821 assert_eq!(kept[0].line, 5);
822 }
823
824 #[test]
825 fn evaluate_normalized_rejects_exempting_a_caught_line() {
826 let mutants = parse_normalized_results(NORMALIZED).unwrap();
829 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
830 let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
831 assert!(
832 err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
833 "got: {err}"
834 );
835 }
836
837 #[test]
838 fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
839 let mutants = parse_normalized_results(NORMALIZED).unwrap();
842 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
843 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
844 assert_eq!(kept.len(), 2);
845 }
846
847 const SAMPLE: &str = r#"{
850 "outcomes": [
851 {"scenario": "Baseline", "summary": "Success",
852 "phase_results": []},
853 {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
854 "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
855 "function": {"function_name": "is_positive"},
856 "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
857 "summary": "MissedMutant"},
858 {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
859 "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
860 "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
861 "summary": "CaughtMutant"}
862 ],
863 "total_mutants": 2
864 }"#;
865
866 #[test]
867 fn parses_the_outcomes_export() {
868 let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
869 assert_eq!(report.outcomes.len(), 3);
870 assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
871 }
872
873 #[test]
874 fn collects_only_missed_mutants_as_survivors() {
875 let report = parse_mutants_report(SAMPLE).unwrap();
876 let survivors = unexplained_survivors(&report, &[]);
877 assert_eq!(survivors.len(), 1);
879 assert_eq!(survivors[0].file, "src/lib.rs");
880 assert_eq!(survivors[0].line, 7);
881 assert!(survivors[0].description.contains("replace > with =="));
882 }
883
884 #[test]
885 fn an_exemption_drops_a_survivor_in_that_file() {
886 let report = parse_mutants_report(SAMPLE).unwrap();
887 let exempt = vec!["src/lib.rs".to_string()];
888 assert!(unexplained_survivors(&report, &exempt).is_empty());
889 }
890
891 #[test]
892 fn an_exemption_on_another_file_leaves_the_survivor() {
893 let report = parse_mutants_report(SAMPLE).unwrap();
894 let exempt = vec!["src/elsewhere.rs".to_string()];
895 assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
896 }
897
898 #[test]
899 fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
900 assert!(is_mutatable_ts("src/index.ts"));
901 assert!(is_mutatable_ts("src/util.tsx"));
902 assert!(is_mutatable_ts("src/util.js"));
903 assert!(!is_mutatable_ts("src/index.test.ts"));
904 assert!(!is_mutatable_ts("src/index.spec.ts"));
905 assert!(!is_mutatable_ts("src/types.d.ts"));
906 assert!(!is_mutatable_ts("README.md"));
907 }
908
909 #[test]
910 fn contiguous_runs_collapses_adjacent_lines() {
911 let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
912 assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
913 assert!(contiguous_runs(&BTreeSet::new()).is_empty());
914 }
915
916 #[test]
917 fn one_line_flattens_and_caps() {
918 assert_eq!(one_line("a -\n b"), "a - b");
919 let long = "x".repeat(80);
920 let capped = one_line(&long);
921 assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
922 }
923
924 #[test]
925 fn is_mutatable_py_keeps_sources_and_drops_tests() {
926 assert!(is_mutatable_py("calc.py"));
927 assert!(is_mutatable_py("pkg/util.py"));
928 assert!(!is_mutatable_py("calc_test.py"));
929 assert!(!is_mutatable_py("test_calc.py"));
930 assert!(!is_mutatable_py("pkg/conftest.py"));
931 assert!(!is_mutatable_py("README.md"));
932 }
933
934 #[test]
937 fn mutated_lines_collects_caught_and_missed() {
938 let report = parse_mutants_report(SAMPLE).unwrap();
941 assert_eq!(
942 mutated_lines(&report),
943 [
944 ("src/lib.rs".to_string(), 7),
945 ("src/other.rs".to_string(), 3)
946 ]
947 .into_iter()
948 .collect()
949 );
950 }
951
952 #[test]
953 fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
954 let report = parse_mutants_report(SAMPLE).unwrap();
955 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
956 let kept = evaluate_scoped(
957 cargo_mutants_survivors(&report),
958 &mutated_lines(&report),
959 &[],
960 &line_scoped,
961 )
962 .unwrap();
963 assert!(
964 kept.is_empty(),
965 "the src/lib.rs:7 survivor should be lifted"
966 );
967 }
968
969 #[test]
970 fn evaluate_scoped_rejects_exempting_a_caught_line() {
971 let report = parse_mutants_report(SAMPLE).unwrap();
973 let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
974 let err = evaluate_scoped(
975 cargo_mutants_survivors(&report),
976 &mutated_lines(&report),
977 &[],
978 &line_scoped,
979 )
980 .unwrap_err();
981 assert!(
982 err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
983 "got: {err}"
984 );
985 }
986
987 #[test]
988 fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
989 let report = parse_mutants_report(SAMPLE).unwrap();
992 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
993 let kept = evaluate_scoped(
994 cargo_mutants_survivors(&report),
995 &mutated_lines(&report),
996 &[],
997 &line_scoped,
998 )
999 .unwrap();
1000 assert_eq!(kept.len(), 1);
1001 assert_eq!(kept[0].line, 7);
1002 }
1003
1004 #[test]
1005 fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1006 let report = parse_mutants_report(SAMPLE).unwrap();
1007 let kept = evaluate_scoped(
1008 cargo_mutants_survivors(&report),
1009 &mutated_lines(&report),
1010 &["src/lib.rs".to_string()],
1011 &BTreeMap::new(),
1012 )
1013 .unwrap();
1014 assert!(kept.is_empty());
1015 }
1016}