1use std::collections::{BTreeMap, BTreeSet};
6use std::ffi::OsString;
7use std::path::{Path, PathBuf};
8use std::process::{Command, Output};
9use std::sync::atomic::{AtomicU64, Ordering};
10
11use anyhow::{bail, Context, Result};
12use serde::Deserialize;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Survivor {
17 pub file: String,
20 pub line: u32,
22 pub description: String,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Measurement {
31 EngineNotRun,
33 Tested {
36 count: usize,
37 survivors: Vec<Survivor>,
38 },
39}
40
41pub type MutatedLines = BTreeSet<(String, u32)>;
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 pub end: LineCol,
85}
86
87#[derive(Debug, Clone, Deserialize)]
89pub struct LineCol {
90 pub line: u32,
91}
92
93pub fn parse_mutants_report(json: &str) -> Result<MutantsReport> {
95 serde_json::from_str(json).context("parsing cargo-mutants outcomes.json")
96}
97
98fn parse_mutants_list(json: &str) -> Result<Vec<MutantInfo>> {
101 serde_json::from_str(json).context("parsing the cargo-mutants mutant list")
102}
103
104pub fn unexplained_survivors(report: &MutantsReport, exempt: &[String]) -> Vec<Survivor> {
108 evaluate(cargo_mutants_survivors(report), exempt)
109}
110
111fn cargo_mutants_survivors(report: &MutantsReport) -> Vec<Survivor> {
115 report
116 .outcomes
117 .iter()
118 .filter_map(|outcome| {
119 if outcome.summary != "MissedMutant" {
120 return None;
121 }
122 let Scenario::Mutant(mutant) = &outcome.scenario else {
123 return None;
124 };
125 Some(Survivor {
126 file: mutant.file.clone(),
127 line: mutant.span.start.line,
128 description: strip_embedded_location(&mutant.name).to_string(),
129 })
130 })
131 .collect()
132}
133
134fn strip_embedded_location(name: &str) -> &str {
137 let Some((location, description)) = name.split_once(": ") else {
138 return name;
139 };
140 let mut parts = location.rsplitn(3, ':');
141 let numeric = |part: Option<&str>| part.is_some_and(|p| p.parse::<u32>().is_ok());
142 if numeric(parts.next()) && numeric(parts.next()) && parts.next().is_some() {
143 description
144 } else {
145 name
146 }
147}
148
149pub fn mutated_lines(report: &MutantsReport) -> MutatedLines {
153 report
154 .outcomes
155 .iter()
156 .filter_map(|outcome| {
157 if outcome.summary != "CaughtMutant" && outcome.summary != "MissedMutant" {
158 return None;
159 }
160 let Scenario::Mutant(mutant) = &outcome.scenario else {
161 return None;
162 };
163 Some((mutant.file.clone(), mutant.span.start.line))
164 })
165 .collect()
166}
167
168fn conclusive_count(report: &MutantsReport) -> usize {
172 report
173 .outcomes
174 .iter()
175 .filter(|outcome| outcome.summary == "CaughtMutant" || outcome.summary == "MissedMutant")
176 .count()
177}
178
179pub fn evaluate(survivors: Vec<Survivor>, exempt: &[String]) -> Vec<Survivor> {
182 survivors
183 .into_iter()
184 .filter(|survivor| !exempt.iter().any(|path| path == &survivor.file))
185 .collect()
186}
187
188pub fn evaluate_scoped(
192 survivors: Vec<Survivor>,
193 mutated: &MutatedLines,
194 whole_file: &[String],
195 line_scoped: &BTreeMap<String, BTreeSet<u32>>,
196) -> Result<Vec<Survivor>> {
197 let mut over: Vec<String> = Vec::new();
198 for (file, lines) in line_scoped {
199 for &line in lines {
200 let has_survivor = survivors
201 .iter()
202 .any(|survivor| survivor.file == *file && survivor.line == line);
203 if has_survivor {
204 continue;
205 }
206 if mutated.contains(&(file.clone(), line)) {
207 over.push(format!("\n {file}:{line}"));
208 }
209 }
210 }
211 if !over.is_empty() {
212 bail!(
213 "a line-scoped mutation exemption may only list a line with a surviving mutant, but \
214 these had mutants that were all caught:{}",
215 over.concat()
216 );
217 }
218 Ok(survivors
219 .into_iter()
220 .filter(|survivor| {
221 let whole = whole_file.iter().any(|path| path == &survivor.file);
222 let line = line_scoped
223 .get(&survivor.file)
224 .is_some_and(|lines| lines.contains(&survivor.line));
225 !(whole || line)
226 })
227 .collect())
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum MutantStatus {
236 Survived,
238 Killed,
240 NoCoverage,
242 Timeout,
244 CompileError,
246 RuntimeError,
248}
249
250impl MutantStatus {
251 fn is_survivor(self) -> bool {
254 matches!(self, MutantStatus::Survived | MutantStatus::NoCoverage)
255 }
256
257 fn is_viable(self) -> bool {
260 matches!(
261 self,
262 MutantStatus::Survived
263 | MutantStatus::Killed
264 | MutantStatus::NoCoverage
265 | MutantStatus::Timeout
266 )
267 }
268
269 fn is_conclusive(self) -> bool {
273 matches!(
274 self,
275 MutantStatus::Survived | MutantStatus::Killed | MutantStatus::NoCoverage
276 )
277 }
278}
279
280#[derive(Debug, Clone, Deserialize)]
283pub struct NormalizedMutant {
284 pub file: String,
286 pub line: u32,
288 pub status: MutantStatus,
290 pub mutator: String,
292 #[serde(default)]
294 pub replacement: Option<String>,
295}
296
297pub fn parse_normalized_results(json: &str) -> Result<Vec<NormalizedMutant>> {
300 serde_json::from_str(json).context("parsing normalized mutation results")
301}
302
303pub fn evaluate_normalized(
307 mutants: &[NormalizedMutant],
308 whole_file: &[String],
309 line_scoped: &BTreeMap<String, BTreeSet<u32>>,
310) -> Result<Vec<Survivor>> {
311 evaluate_scoped(
312 normalized_survivors(mutants),
313 &normalized_mutated_lines(mutants),
314 whole_file,
315 line_scoped,
316 )
317}
318
319fn normalized_survivors(mutants: &[NormalizedMutant]) -> Vec<Survivor> {
321 mutants
322 .iter()
323 .filter(|mutant| mutant.status.is_survivor())
324 .map(|mutant| Survivor {
325 file: mutant.file.clone(),
326 line: mutant.line,
327 description: describe_normalized(mutant),
328 })
329 .collect()
330}
331
332fn normalized_mutated_lines(mutants: &[NormalizedMutant]) -> MutatedLines {
335 mutants
336 .iter()
337 .filter(|mutant| mutant.status.is_viable())
338 .map(|mutant| (mutant.file.clone(), mutant.line))
339 .collect()
340}
341
342fn normalized_conclusive_count(mutants: &[NormalizedMutant]) -> usize {
345 mutants
346 .iter()
347 .filter(|mutant| mutant.status.is_conclusive())
348 .count()
349}
350
351fn describe_normalized(mutant: &NormalizedMutant) -> String {
354 match &mutant.replacement {
355 Some(replacement) => format!("{} (-> {})", mutant.mutator, one_line(replacement)),
356 None => mutant.mutator.clone(),
357 }
358}
359
360pub fn measure_rust(
364 root: &Path,
365 exempt: &[String],
366 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
367 base: Option<&str>,
368 features: &[String],
369) -> Result<Measurement> {
370 let out = MutantsOut::new();
371 let workspace_root = cargo_workspace_root(root)?;
375 let prefix = canonical_scan_prefix(root, &workspace_root);
376 let mut base_diff = None;
377 let diff = match base {
378 Some(base) => {
379 match write_base_diff(root, &workspace_root, prefix.as_deref(), base, &out)? {
380 None => return Ok(Measurement::EngineNotRun),
381 Some(path) => {
382 let parsed =
383 parse_base_diff(&std::fs::read_to_string(&path).with_context(|| {
384 format!("reading the written base diff `{}`", path.display())
385 })?);
386 if !parsed.files.iter().any(|file| file.ends_with(".rs")) {
387 return Ok(Measurement::EngineNotRun);
388 }
389 base_diff = Some(parsed);
390 Some(path)
391 }
392 }
393 }
394 None => None,
395 };
396 let engine = ensure_cargo_mutants()?;
397 let run = run_cargo_mutants(&engine, root, &out.0, diff.as_deref(), features)?;
398 let outcomes = out.0.join("mutants.out").join("outcomes.json");
399 let json = match std::fs::read_to_string(&outcomes) {
403 Ok(json) => json,
404 Err(_) => {
405 if let Some(diff) = &base_diff {
406 let listed =
407 list_cargo_mutants(&engine, root, features, |command| command.output())?;
408 zero_mutant_verdict(&listed, diff, &run)?;
409 }
410 return Ok(Measurement::Tested {
411 count: 0,
412 survivors: Vec::new(),
413 });
414 }
415 };
416 let report = rebase_report_paths(parse_mutants_report(&json)?, prefix.as_deref());
417 let survivors = evaluate_scoped(
418 cargo_mutants_survivors(&report),
419 &mutated_lines(&report),
420 exempt,
421 exempt_lines,
422 )?;
423 Ok(Measurement::Tested {
424 count: conclusive_count(&report),
425 survivors,
426 })
427}
428
429fn one_line(replacement: &str) -> String {
432 let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
433 const MAX: usize = 60;
434 if flat.chars().count() > MAX {
435 format!("{}…", flat.chars().take(MAX).collect::<String>())
436 } else {
437 flat
438 }
439}
440
441pub fn measure_typescript(
445 root: &Path,
446 exempt: &[String],
447 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
448 base: Option<&str>,
449 adapter: &Path,
450) -> Result<Measurement> {
451 let package_root =
452 crate::tiers::package_root(root, "package.json").unwrap_or_else(|| root.to_path_buf());
453 let prefix = scan_prefix(root, &package_root);
454 let mutate = match base {
455 Some(base) => {
456 let ranges = mutate_ranges(root, base)?;
457 if ranges.is_empty() {
458 return Ok(Measurement::EngineNotRun);
459 }
460 Some(prefix_mutate_specs(ranges, prefix.as_deref()))
461 }
462 None => prefix.as_deref().map(scan_scoped_mutate_globs),
463 };
464 let test_files = prefix.as_deref().map(scan_scoped_test_file_globs);
465 let json = run_ts_adapter(
466 &package_root,
467 adapter,
468 mutate.as_deref(),
469 test_files.as_deref(),
470 )?;
471 let mutants = to_scan_relative(parse_normalized_results(&json)?, prefix.as_deref());
472 let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
473 Ok(Measurement::Tested {
474 count: normalized_conclusive_count(&mutants),
475 survivors,
476 })
477}
478
479fn scan_prefix(root: &Path, package_root: &Path) -> Option<String> {
482 let rel = root.strip_prefix(package_root).ok()?;
483 let parts: Vec<String> = rel
484 .components()
485 .map(|part| part.as_os_str().to_string_lossy().into_owned())
486 .collect();
487 if parts.is_empty() {
488 None
489 } else {
490 Some(parts.join("/"))
491 }
492}
493
494fn prefix_mutate_specs(specs: Vec<String>, prefix: Option<&str>) -> Vec<String> {
497 match prefix {
498 None => specs,
499 Some(prefix) => specs
500 .into_iter()
501 .map(|spec| format!("{prefix}/{spec}"))
502 .collect(),
503 }
504}
505
506fn scan_scoped_mutate_globs(prefix: &str) -> Vec<String> {
510 const EXTENSIONS: &str = "+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)";
511 vec![
512 format!("{prefix}/**/!(*.+(s|S)pec|*.+(t|T)est).{EXTENSIONS}"),
513 format!("!{prefix}/**/__tests__/**/*.{EXTENSIONS}"),
514 ]
515}
516
517fn scan_scoped_test_file_globs(prefix: &str) -> Vec<String> {
521 vec![format!("{prefix}/**")]
522}
523
524fn to_scan_relative(mutants: Vec<NormalizedMutant>, prefix: Option<&str>) -> Vec<NormalizedMutant> {
528 let Some(prefix) = prefix else {
529 return mutants;
530 };
531 let prefix = format!("{prefix}/");
532 mutants
533 .into_iter()
534 .filter_map(|mut mutant| {
535 mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
536 Some(mutant)
537 })
538 .collect()
539}
540
541fn adapter_cwd<'a>(root: &'a Path, engine: &str) -> Result<&'a Path> {
545 let cwd = if root.as_os_str().is_empty() {
546 Path::new(".")
547 } else {
548 root
549 };
550 if !cwd.is_dir() {
551 bail!(
552 "the {engine} mutation adapter's working directory `{}` is not a directory",
553 cwd.display()
554 );
555 }
556 Ok(cwd)
557}
558
559fn spawn_context(interpreter: &str, entry: &str, cwd: &Path) -> String {
563 format!(
564 "spawning `{interpreter} {entry}` in `{}` (is `{interpreter}` on PATH?)",
565 cwd.display()
566 )
567}
568
569fn run_ts_adapter(
573 package_root: &Path,
574 adapter: &Path,
575 mutate: Option<&[String]>,
576 test_files: Option<&[String]>,
577) -> Result<String> {
578 let out = AdapterOut::new();
579 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
580 let results = out.0.join("results.json");
581
582 let cwd = adapter_cwd(package_root, "TypeScript")?;
583
584 let mut command = Command::new("node");
585 command
586 .current_dir(cwd)
587 .arg(adapter)
588 .arg("--out")
589 .arg(&results);
590 if let Some(specs) = mutate {
591 command.arg("--mutate").arg(specs.join(","));
592 }
593 if let Some(globs) = test_files {
594 command.arg("--test-files").arg(globs.join(","));
595 }
596 let output = command
597 .output()
598 .with_context(|| spawn_context("node", &adapter.display().to_string(), cwd))?;
599 if !output.status.success() {
600 bail!(
601 "the TypeScript mutation adapter failed in `{}`:\n{}{}",
602 cwd.display(),
603 String::from_utf8_lossy(&output.stdout),
604 String::from_utf8_lossy(&output.stderr),
605 );
606 }
607 std::fs::read_to_string(&results).with_context(|| {
608 format!(
609 "reading the TypeScript mutation adapter's results from `{}`",
610 results.display()
611 )
612 })
613}
614
615struct AdapterOut(PathBuf);
618
619impl AdapterOut {
620 fn new() -> Self {
621 static COUNTER: AtomicU64 = AtomicU64::new(0);
622 let name = format!(
623 "testing-conventions-ts-adapter-{}-{}",
624 std::process::id(),
625 COUNTER.fetch_add(1, Ordering::Relaxed),
626 );
627 AdapterOut(std::env::temp_dir().join(name))
628 }
629}
630
631impl Drop for AdapterOut {
632 fn drop(&mut self) {
633 let _ = std::fs::remove_dir_all(&self.0);
634 }
635}
636
637fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
641 let changed = crate::patch_coverage::changed_lines(root, base)?;
642 let mut specs = Vec::new();
643 for (file, lines) in changed {
644 if !is_mutatable_ts(&file) {
645 continue;
646 }
647 for (start, end) in contiguous_runs(&lines) {
648 specs.push(format!("{file}:{start}-{end}"));
649 }
650 }
651 Ok(specs)
652}
653
654fn is_mutatable_ts(file: &str) -> bool {
658 let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
659 .iter()
660 .any(|ext| file.ends_with(ext));
661 let is_decl = file.ends_with(".d.ts");
662 let is_test = file.contains(".test.") || file.contains(".spec.");
663 is_source && !is_decl && !is_test
664}
665
666fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
668 let mut runs: Vec<(u64, u64)> = Vec::new();
669 for &line in lines {
670 match runs.last_mut() {
671 Some(run) if run.1 + 1 == line => run.1 = line,
672 _ => runs.push((line, line)),
673 }
674 }
675 runs
676}
677
678pub fn measure_python(
682 root: &Path,
683 exempt: &[String],
684 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
685 base: Option<&str>,
686) -> Result<Measurement> {
687 let changed = match base {
688 Some(base) => Some(crate::patch_coverage::changed_lines(root, base)?),
689 None => None,
690 };
691 let modules: Vec<String> = match &changed {
692 None => Vec::new(),
693 Some(changed) => {
694 let modules: Vec<String> = changed
695 .keys()
696 .filter(|file| is_mutatable_py(file))
697 .cloned()
698 .collect();
699 if modules.is_empty() {
700 return Ok(Measurement::EngineNotRun);
701 }
702 modules
703 }
704 };
705 let json = run_py_adapter(root, &modules)?;
706 let mut mutants = parse_normalized_results(&json)?;
707 if let Some(changed) = &changed {
708 mutants.retain(|mutant| {
709 changed
710 .get(&mutant.file)
711 .is_some_and(|lines| lines.contains(&u64::from(mutant.line)))
712 });
713 }
714 let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
715 Ok(Measurement::Tested {
716 count: normalized_conclusive_count(&mutants),
717 survivors,
718 })
719}
720
721fn run_py_adapter(root: &Path, modules: &[String]) -> Result<String> {
725 let out = AdapterOut::new();
726 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
727 let results = out.0.join("results.json");
728
729 let cwd = adapter_cwd(root, "Python")?;
730
731 const ENTRY: &str = "-m testing_conventions.mutation.main";
732 let mut command = Command::new("python3");
733 command
734 .current_dir(cwd)
735 .args(["-m", "testing_conventions.mutation.main", "--out"])
736 .arg(&results)
737 .env("PYTHONDONTWRITEBYTECODE", "1");
738 for module in modules {
739 command.arg("--module").arg(module);
740 }
741 let output = command
742 .output()
743 .with_context(|| spawn_context("python3", ENTRY, cwd))?;
744 if !output.status.success() {
745 bail!(
746 "the Python mutation adapter failed in `{}`:\n{}{}",
747 cwd.display(),
748 String::from_utf8_lossy(&output.stdout),
749 String::from_utf8_lossy(&output.stderr),
750 );
751 }
752 std::fs::read_to_string(&results).with_context(|| {
753 format!(
754 "reading the Python mutation adapter's results from `{}`",
755 results.display()
756 )
757 })
758}
759
760fn is_mutatable_py(file: &str) -> bool {
763 if !file.ends_with(".py") {
764 return false;
765 }
766 let base = file.rsplit('/').next().unwrap_or(file);
767 !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
768}
769
770struct MutantsOut(PathBuf);
773
774impl MutantsOut {
775 fn new() -> Self {
776 static COUNTER: AtomicU64 = AtomicU64::new(0);
777 let name = format!(
778 "testing-conventions-mutants-{}-{}",
779 std::process::id(),
780 COUNTER.fetch_add(1, Ordering::Relaxed),
781 );
782 MutantsOut(std::env::temp_dir().join(name))
783 }
784}
785
786impl Drop for MutantsOut {
787 fn drop(&mut self) {
788 let _ = std::fs::remove_dir_all(&self.0);
789 }
790}
791
792fn cargo_workspace_root(root: &Path) -> Result<PathBuf> {
796 let output = Command::new("cargo")
797 .current_dir(root)
798 .args(["locate-project", "--workspace", "--message-format", "plain"])
799 .output()
800 .context("running `cargo locate-project` (is cargo installed?)")?;
801 if !output.status.success() {
802 bail!(
803 "cargo locate-project failed in `{}`: {}",
804 root.display(),
805 String::from_utf8_lossy(&output.stderr)
806 );
807 }
808 let manifest = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
809 manifest.parent().map(Path::to_path_buf).with_context(|| {
810 format!(
811 "no parent dir for the workspace manifest `{}`",
812 manifest.display()
813 )
814 })
815}
816
817fn canonical_scan_prefix(root: &Path, workspace_root: &Path) -> Option<String> {
821 let root = root.canonicalize().ok()?;
822 let workspace_root = workspace_root.canonicalize().ok()?;
823 scan_prefix(&root, &workspace_root)
824}
825
826fn write_base_diff(
830 root: &Path,
831 workspace_root: &Path,
832 prefix: Option<&str>,
833 base: &str,
834 out: &MutantsOut,
835) -> Result<Option<PathBuf>> {
836 let range = format!("{base}...HEAD");
837 let (dir, args) = match prefix {
838 None => (root, vec!["diff", "--relative", &range]),
839 Some(prefix) => (
840 workspace_root,
841 vec!["diff", "--relative", &range, "--", prefix],
842 ),
843 };
844 let output = Command::new("git")
845 .current_dir(dir)
846 .args(&args)
847 .output()
848 .context("running `git diff` for `--base` (is git installed?)")?;
849 if !output.status.success() {
850 bail!(
851 "git diff {range} failed: {}",
852 String::from_utf8_lossy(&output.stderr)
853 );
854 }
855 if output.stdout.is_empty() {
856 return Ok(None);
857 }
858 std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
859 let path = out.0.join("base.diff");
860 std::fs::write(&path, &output.stdout).context("writing the base diff")?;
861 Ok(Some(path))
862}
863
864struct BaseDiff {
868 files: Vec<String>,
869 inserted: BTreeMap<String, BTreeSet<u32>>,
870}
871
872fn parse_base_diff(diff: &str) -> BaseDiff {
876 let mut files = Vec::new();
877 let mut inserted: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
878 let mut current: Option<String> = None;
879 let mut lines = diff.lines();
880 while let Some(line) = lines.next() {
881 if let Some(path) = line.strip_prefix("+++ ") {
882 current = (path != "/dev/null").then(|| {
883 let path = path.strip_prefix("b/").unwrap_or(path).to_string();
884 files.push(path.clone());
885 path
886 });
887 } else if let Some(header) = line.strip_prefix("@@ ") {
888 let Some((new_start, old_count, new_count)) = parse_hunk_header(header) else {
889 continue;
890 };
891 let mut new_line = new_start;
892 let (mut old_left, mut new_left) = (old_count, new_count);
893 while old_left > 0 || new_left > 0 {
894 let Some(line) = lines.next() else { break };
895 if line.starts_with('\\') {
896 } else if line.starts_with('+') {
899 if let Some(file) = ¤t {
900 inserted.entry(file.clone()).or_default().insert(new_line);
901 }
902 new_line += 1;
903 new_left = new_left.saturating_sub(1);
904 } else if line.starts_with('-') {
905 old_left = old_left.saturating_sub(1);
906 } else {
907 new_line += 1;
908 old_left = old_left.saturating_sub(1);
909 new_left = new_left.saturating_sub(1);
910 }
911 }
912 }
913 }
914 BaseDiff { files, inserted }
915}
916
917fn parse_hunk_header(header: &str) -> Option<(u32, u32, u32)> {
919 let mut parts = header.split(' ');
920 let (_, old_count) = parse_range(parts.next()?.strip_prefix('-')?)?;
921 let (new_start, new_count) = parse_range(parts.next()?.strip_prefix('+')?)?;
922 Some((new_start, old_count, new_count))
923}
924
925fn parse_range(range: &str) -> Option<(u32, u32)> {
927 match range.split_once(',') {
928 Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
929 None => Some((range.parse().ok()?, 1)),
930 }
931}
932
933fn rebase_report_paths(report: MutantsReport, prefix: Option<&str>) -> MutantsReport {
937 let Some(prefix) = prefix else {
938 return report;
939 };
940 let prefix = format!("{prefix}/");
941 MutantsReport {
942 outcomes: report
943 .outcomes
944 .into_iter()
945 .filter_map(|mut outcome| {
946 if let Scenario::Mutant(mutant) = &mut outcome.scenario {
947 mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
948 }
949 Some(outcome)
950 })
951 .collect(),
952 }
953}
954
955const CARGO_MUTANTS_VERSION: &str = "27.1.0";
958
959fn ensure_cargo_mutants() -> Result<PathBuf> {
963 let root = cargo_mutants_cache_root();
964 let bin = root.join("bin").join(cargo_mutants_bin_name());
965 let lock_path = root.join(".install.lock");
966 provision(&bin, &lock_path, || {
967 run_install(&root, |command| command.output())
968 })
969}
970
971fn cargo_mutants_bin_name() -> &'static str {
974 if cfg!(windows) {
975 "cargo-mutants.exe"
976 } else {
977 "cargo-mutants"
978 }
979}
980
981fn cargo_mutants_cache_root() -> PathBuf {
985 cache_base()
986 .join("testing-conventions")
987 .join(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}"))
988}
989
990fn cache_base() -> PathBuf {
993 resolve_cache_base(std::env::var_os("XDG_CACHE_HOME"), std::env::var_os("HOME"))
994}
995
996fn resolve_cache_base(xdg: Option<OsString>, home: Option<OsString>) -> PathBuf {
999 if let Some(dir) = xdg.filter(|value| !value.is_empty()) {
1000 return PathBuf::from(dir);
1001 }
1002 if let Some(dir) = home.filter(|value| !value.is_empty()) {
1003 return PathBuf::from(dir).join(".cache");
1004 }
1005 std::env::temp_dir()
1006}
1007
1008fn provision(
1012 bin: &Path,
1013 lock_path: &Path,
1014 install: impl FnOnce() -> Result<()>,
1015) -> Result<PathBuf> {
1016 if bin.exists() {
1017 return Ok(bin.to_path_buf());
1018 }
1019 if let Some(parent) = lock_path.parent() {
1020 std::fs::create_dir_all(parent).context("creating the provisioning lock's parent dir")?;
1021 }
1022 let lock_file = std::fs::OpenOptions::new()
1023 .create(true)
1024 .truncate(false)
1025 .write(true)
1026 .open(lock_path)
1027 .context("opening the provisioning lock file")?;
1028 lock_file
1029 .lock()
1030 .context("acquiring the provisioning lock")?;
1031 if bin.exists() {
1033 return Ok(bin.to_path_buf());
1034 }
1035 install()?;
1036 if !bin.exists() {
1037 bail!(
1038 "provisioning reported success but cargo-mutants is not at `{}`",
1039 bin.display()
1040 );
1041 }
1042 Ok(bin.to_path_buf())
1043}
1044
1045fn install_argv(root: &Path) -> Vec<OsString> {
1049 vec![
1050 OsString::from("install"),
1051 OsString::from("cargo-mutants"),
1052 OsString::from("--locked"),
1053 OsString::from("--version"),
1054 OsString::from(CARGO_MUTANTS_VERSION),
1055 OsString::from("--root"),
1056 root.as_os_str().to_os_string(),
1057 ]
1058}
1059
1060fn run_install(
1064 root: &Path,
1065 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1066) -> Result<()> {
1067 let mut command = Command::new("cargo");
1068 command.args(install_argv(root));
1069 strip_llvm_cov_env(&mut command);
1070 let output = run(&mut command)
1071 .context("provisioning cargo-mutants via `cargo install` (is cargo installed?)")?;
1072 if !output.status.success() {
1073 bail!(
1074 "failed to provision cargo-mutants {CARGO_MUTANTS_VERSION}:\n{}{}",
1075 String::from_utf8_lossy(&output.stdout),
1076 String::from_utf8_lossy(&output.stderr),
1077 );
1078 }
1079 Ok(())
1080}
1081
1082fn strip_llvm_cov_env(command: &mut Command) {
1086 for var in [
1087 "RUSTFLAGS",
1088 "CARGO_ENCODED_RUSTFLAGS",
1089 "RUSTDOCFLAGS",
1090 "CARGO_ENCODED_RUSTDOCFLAGS",
1091 "LLVM_PROFILE_FILE",
1092 "CARGO_LLVM_COV",
1093 "CARGO_LLVM_COV_SHOW_ENV",
1094 "CARGO_LLVM_COV_TARGET_DIR",
1095 "CARGO_LLVM_COV_BUILD_DIR",
1096 "RUSTC_WRAPPER",
1097 "RUSTC_WORKSPACE_WRAPPER",
1098 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
1099 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
1100 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
1101 ] {
1102 command.env_remove(var);
1103 }
1104}
1105
1106fn run_cargo_mutants(
1110 engine: &Path,
1111 root: &Path,
1112 out: &Path,
1113 in_diff: Option<&Path>,
1114 features: &[String],
1115) -> Result<Output> {
1116 let mut command = Command::new(engine);
1117 command
1118 .current_dir(root)
1119 .args(mutants_argv(out, in_diff, features));
1120 strip_llvm_cov_env(&mut command);
1121 let output = command.output().context("running cargo-mutants")?;
1122 classify_mutants_exit(root, &output)?;
1123 Ok(output)
1124}
1125
1126fn zero_mutant_verdict(listed: &[MutantInfo], diff: &BaseDiff, run: &Output) -> Result<()> {
1130 let dropped: Vec<&MutantInfo> = listed
1131 .iter()
1132 .filter(|mutant| {
1133 diff.inserted.get(&mutant.file).is_some_and(|lines| {
1134 lines
1135 .range(mutant.span.start.line..=mutant.span.end.line)
1136 .next()
1137 .is_some()
1138 })
1139 })
1140 .collect();
1141 if dropped.is_empty() {
1142 return Ok(());
1143 }
1144 let sites: Vec<String> = dropped
1145 .iter()
1146 .map(|mutant| {
1147 format!(
1148 " {}:{}: {}",
1149 mutant.file,
1150 mutant.span.start.line,
1151 strip_embedded_location(&mutant.name)
1152 )
1153 })
1154 .collect();
1155 bail!(
1156 "cargo-mutants tested no mutants, but {} of the crate's {} mutant site(s) sit on the diff's inserted lines — the changed-line filter dropped real mutants:\n{}\nengine output:\n{}{}",
1157 dropped.len(),
1158 listed.len(),
1159 sites.join("\n"),
1160 String::from_utf8_lossy(&run.stdout),
1161 String::from_utf8_lossy(&run.stderr),
1162 )
1163}
1164
1165fn list_argv(features: &[String]) -> Vec<OsString> {
1169 let mut argv = vec![
1170 OsString::from("mutants"),
1171 OsString::from("--list"),
1172 OsString::from("--json"),
1173 ];
1174 if !features.is_empty() {
1175 argv.push(OsString::from("--features"));
1176 argv.push(OsString::from(features.join(",")));
1177 }
1178 argv
1179}
1180
1181fn list_cargo_mutants(
1185 engine: &Path,
1186 root: &Path,
1187 features: &[String],
1188 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1189) -> Result<Vec<MutantInfo>> {
1190 let mut command = Command::new(engine);
1191 command.current_dir(root).args(list_argv(features));
1192 strip_llvm_cov_env(&mut command);
1193 let output = run(&mut command).context("listing the crate's mutants with cargo-mutants")?;
1194 if !output.status.success() {
1195 bail!(
1196 "cargo-mutants --list failed in `{}`:\n{}{}",
1197 root.display(),
1198 String::from_utf8_lossy(&output.stdout),
1199 String::from_utf8_lossy(&output.stderr),
1200 );
1201 }
1202 parse_mutants_list(&String::from_utf8_lossy(&output.stdout))
1203}
1204
1205fn mutants_argv(out: &Path, in_diff: Option<&Path>, features: &[String]) -> Vec<OsString> {
1209 let mut argv = vec![
1210 OsString::from("mutants"),
1211 OsString::from("--output"),
1212 out.as_os_str().to_os_string(),
1213 ];
1214 if let Some(diff) = in_diff {
1215 argv.push(OsString::from("--in-diff"));
1216 argv.push(diff.as_os_str().to_os_string());
1217 }
1218 if !features.is_empty() {
1219 argv.push(OsString::from("--features"));
1220 argv.push(OsString::from(features.join(",")));
1221 }
1222 argv
1223}
1224
1225fn classify_mutants_exit(root: &Path, output: &Output) -> Result<()> {
1229 match output.status.code() {
1230 Some(0) | Some(2) | Some(3) => Ok(()),
1231 _ => bail!(
1232 "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
1233 root.display(),
1234 String::from_utf8_lossy(&output.stdout),
1235 String::from_utf8_lossy(&output.stderr),
1236 ),
1237 }
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242 use super::*;
1243
1244 const NORMALIZED: &str = r#"[
1245 {"file": "src/a.ts", "line": 2, "status": "survived",
1246 "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
1247 {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
1248 {"file": "src/a.ts", "line": 9, "status": "killed",
1249 "mutator": "BooleanLiteral", "replacement": "false"},
1250 {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
1251 {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
1252 {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
1253 ]"#;
1254
1255 #[test]
1256 fn parses_the_normalized_schema() {
1257 let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
1258 assert_eq!(mutants.len(), 6);
1259 assert_eq!(mutants[0].status, MutantStatus::Survived);
1260 assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
1261 assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
1262 assert_eq!(mutants[1].replacement, None);
1263 }
1264
1265 #[test]
1266 fn normalized_survivors_are_survived_and_nocoverage_only() {
1267 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1268 let survivors = normalized_survivors(&mutants);
1269 assert_eq!(survivors.len(), 2);
1270 assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
1271 assert!(survivors[0].description.contains("ConditionalExpression"));
1272 assert!(survivors[0].description.contains("-> true"));
1273 assert_eq!(survivors[1].description, "ArithmeticOperator");
1274 }
1275
1276 #[test]
1277 fn normalized_mutated_lines_collects_only_viable_mutants() {
1278 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1279 assert_eq!(
1280 normalized_mutated_lines(&mutants),
1281 [2u32, 5, 9, 12]
1282 .into_iter()
1283 .map(|line| ("src/a.ts".to_string(), line))
1284 .collect()
1285 );
1286 }
1287
1288 #[test]
1289 fn normalized_conclusive_count_is_survived_killed_and_nocoverage() {
1290 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1291 assert_eq!(normalized_conclusive_count(&mutants), 3);
1292 assert_eq!(normalized_conclusive_count(&[]), 0);
1293 }
1294
1295 #[test]
1296 fn evaluate_normalized_reports_unexempted_survivors() {
1297 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1298 let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
1299 assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
1300 }
1301
1302 #[test]
1303 fn evaluate_normalized_drops_a_whole_file_exemption() {
1304 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1305 let kept =
1306 evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
1307 assert!(
1308 kept.is_empty(),
1309 "the whole-file exemption lifts both survivors"
1310 );
1311 }
1312
1313 #[test]
1314 fn evaluate_normalized_drops_a_line_scoped_exemption() {
1315 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1316 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
1317 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1318 assert_eq!(kept.len(), 1);
1319 assert_eq!(kept[0].line, 5);
1320 }
1321
1322 #[test]
1323 fn evaluate_normalized_rejects_exempting_a_caught_line() {
1324 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1325 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
1326 let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
1327 assert!(
1328 err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
1329 "got: {err}"
1330 );
1331 }
1332
1333 #[test]
1334 fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
1335 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1336 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
1337 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1338 assert_eq!(kept.len(), 2);
1339 }
1340
1341 const SAMPLE: &str = r#"{
1342 "outcomes": [
1343 {"scenario": "Baseline", "summary": "Success",
1344 "phase_results": []},
1345 {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
1346 "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
1347 "function": {"function_name": "is_positive"},
1348 "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
1349 "summary": "MissedMutant"},
1350 {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
1351 "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
1352 "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
1353 "summary": "CaughtMutant"}
1354 ],
1355 "total_mutants": 2
1356 }"#;
1357
1358 #[test]
1359 fn parses_the_outcomes_export() {
1360 let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
1361 assert_eq!(report.outcomes.len(), 3);
1362 assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
1363 }
1364
1365 #[test]
1366 fn collects_only_missed_mutants_as_survivors() {
1367 let report = parse_mutants_report(SAMPLE).unwrap();
1368 let survivors = unexplained_survivors(&report, &[]);
1369 assert_eq!(survivors.len(), 1);
1370 assert_eq!(survivors[0].file, "src/lib.rs");
1371 assert_eq!(survivors[0].line, 7);
1372 assert!(survivors[0].description.contains("replace > with =="));
1373 }
1374
1375 #[test]
1376 fn a_survivor_description_carries_no_location_prefix() {
1377 let report = parse_mutants_report(SAMPLE).unwrap();
1378 let survivors = unexplained_survivors(&report, &[]);
1379 assert_eq!(
1380 survivors[0].description, "replace > with == in is_positive",
1381 "the name's embedded `file:line:col:` prefix is stripped"
1382 );
1383 }
1384
1385 #[test]
1386 fn strip_embedded_location_removes_a_file_line_col_prefix() {
1387 assert_eq!(
1388 strip_embedded_location("src/lib.rs:7:5: replace > with == in is_positive"),
1389 "replace > with == in is_positive"
1390 );
1391 }
1392
1393 #[test]
1394 fn strip_embedded_location_keeps_a_name_without_one() {
1395 for name in [
1396 "replace add -> 0",
1397 "note: no location segment",
1398 "7:5: no file segment",
1399 "src/lib.rs:7:x: non-numeric column",
1400 "src/lib.rs:x:5: non-numeric line",
1401 ] {
1402 assert_eq!(strip_embedded_location(name), name);
1403 }
1404 }
1405
1406 #[test]
1407 fn conclusive_count_is_caught_plus_missed() {
1408 let report = parse_mutants_report(SAMPLE).unwrap();
1409 assert_eq!(conclusive_count(&report), 2);
1410 assert_eq!(conclusive_count(&MutantsReport { outcomes: vec![] }), 0);
1411 }
1412
1413 #[test]
1414 fn an_exemption_drops_a_survivor_in_that_file() {
1415 let report = parse_mutants_report(SAMPLE).unwrap();
1416 let exempt = vec!["src/lib.rs".to_string()];
1417 assert!(unexplained_survivors(&report, &exempt).is_empty());
1418 }
1419
1420 #[test]
1421 fn an_exemption_on_another_file_leaves_the_survivor() {
1422 let report = parse_mutants_report(SAMPLE).unwrap();
1423 let exempt = vec!["src/elsewhere.rs".to_string()];
1424 assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1425 }
1426
1427 #[test]
1428 fn rebase_report_paths_strips_the_workspace_prefix() {
1429 let report = parse_mutants_report(SAMPLE).unwrap();
1430 let prefixed = MutantsReport {
1431 outcomes: report
1432 .outcomes
1433 .iter()
1434 .cloned()
1435 .map(|mut outcome| {
1436 if let Scenario::Mutant(mutant) = &mut outcome.scenario {
1437 mutant.file = format!("member/{}", mutant.file);
1438 }
1439 outcome
1440 })
1441 .collect(),
1442 };
1443 let rebased = rebase_report_paths(prefixed, Some("member"));
1444 let survivors = unexplained_survivors(&rebased, &[]);
1445 assert_eq!(survivors.len(), 1);
1446 assert_eq!(survivors[0].file, "src/lib.rs");
1447 assert_eq!(rebased.outcomes.len(), 3);
1448 }
1449
1450 #[test]
1451 fn rebase_report_paths_drops_an_out_of_scope_mutant_and_keeps_none_identity() {
1452 let report = parse_mutants_report(SAMPLE).unwrap();
1453 let rebased = rebase_report_paths(report.clone(), Some("member"));
1454 assert_eq!(
1455 rebased.outcomes.len(),
1456 1,
1457 "only the pathless baseline outcome remains"
1458 );
1459 let unchanged = rebase_report_paths(report, None);
1460 assert_eq!(unchanged.outcomes.len(), 3);
1461 assert_eq!(unexplained_survivors(&unchanged, &[])[0].file, "src/lib.rs");
1462 }
1463
1464 #[test]
1465 fn adapter_cwd_normalises_the_empty_package_root_to_the_current_dir() {
1466 assert_eq!(
1470 adapter_cwd(Path::new(""), "TypeScript").unwrap(),
1471 Path::new(".")
1472 );
1473 assert_eq!(
1474 adapter_cwd(Path::new("src"), "TypeScript").unwrap(),
1475 Path::new("src")
1476 );
1477 }
1478
1479 #[test]
1480 fn adapter_cwd_rejects_a_directory_that_is_not_there() {
1481 let err = adapter_cwd(Path::new("no/such/dir"), "Python")
1484 .expect_err("a directory that is not there is an error");
1485 assert_eq!(
1486 err.to_string(),
1487 "the Python mutation adapter's working directory `no/such/dir` is not a directory"
1488 );
1489 }
1490
1491 #[test]
1492 fn spawn_context_names_the_interpreter_the_entry_and_the_working_directory() {
1493 assert_eq!(
1494 spawn_context("node", "/pkg/dist/mutation/main.js", Path::new("/pkg")),
1495 "spawning `node /pkg/dist/mutation/main.js` in `/pkg` (is `node` on PATH?)"
1496 );
1497 }
1498
1499 #[test]
1500 fn scan_prefix_is_the_scan_path_relative_to_the_package_root() {
1501 assert_eq!(
1502 scan_prefix(Path::new("/repo/pkg/src"), Path::new("/repo/pkg")),
1503 Some("src".to_string())
1504 );
1505 assert_eq!(
1506 scan_prefix(Path::new("/repo/pkg/src/nested"), Path::new("/repo/pkg")),
1507 Some("src/nested".to_string())
1508 );
1509 assert_eq!(
1510 scan_prefix(Path::new("/repo/pkg"), Path::new("/repo/pkg")),
1511 None
1512 );
1513 assert_eq!(
1514 scan_prefix(Path::new("pkg/src"), Path::new("pkg")),
1515 Some("src".to_string())
1516 );
1517 }
1518
1519 #[test]
1520 fn prefix_mutate_specs_rebases_diff_ranges_onto_the_package_root() {
1521 let specs = vec!["index.ts:8-11".to_string(), "a/b.ts:2-2".to_string()];
1522 assert_eq!(
1523 prefix_mutate_specs(specs.clone(), Some("src")),
1524 vec![
1525 "src/index.ts:8-11".to_string(),
1526 "src/a/b.ts:2-2".to_string()
1527 ]
1528 );
1529 assert_eq!(prefix_mutate_specs(specs.clone(), None), specs);
1530 }
1531
1532 #[test]
1533 fn scan_scoped_mutate_globs_mirror_strykers_default_under_the_scan_path() {
1534 assert_eq!(
1535 scan_scoped_mutate_globs("src"),
1536 vec![
1537 "src/**/!(*.+(s|S)pec|*.+(t|T)est).+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
1538 .to_string(),
1539 "!src/**/__tests__/**/*.+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
1540 .to_string(),
1541 ]
1542 );
1543 }
1544
1545 #[test]
1546 fn scan_scoped_test_file_globs_narrow_the_run_without_moving_the_runner_root() {
1547 assert_eq!(
1548 scan_scoped_test_file_globs("src"),
1549 vec!["src/**".to_string()]
1550 );
1551 assert_eq!(
1552 scan_scoped_test_file_globs("packages/core/src"),
1553 vec!["packages/core/src/**".to_string()]
1554 );
1555 }
1556
1557 #[test]
1558 fn to_scan_relative_strips_the_prefix_and_drops_out_of_scope_mutants() {
1559 let mutants = parse_normalized_results(
1560 r#"[
1561 {"file": "src/a.ts", "line": 2, "status": "survived", "mutator": "X"},
1562 {"file": "tests/e2e/t.ts", "line": 9, "status": "survived", "mutator": "X"}
1563 ]"#,
1564 )
1565 .unwrap();
1566 let rebased = to_scan_relative(mutants.clone(), Some("src"));
1567 assert_eq!(rebased.len(), 1, "the out-of-scan-path mutant is dropped");
1568 assert_eq!(rebased[0].file, "a.ts");
1569 let unchanged = to_scan_relative(mutants, None);
1570 assert_eq!(unchanged.len(), 2);
1571 assert_eq!(unchanged[0].file, "src/a.ts");
1572 }
1573
1574 #[test]
1575 fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
1576 assert!(is_mutatable_ts("src/index.ts"));
1577 assert!(is_mutatable_ts("src/util.tsx"));
1578 assert!(is_mutatable_ts("src/util.js"));
1579 assert!(!is_mutatable_ts("src/index.test.ts"));
1580 assert!(!is_mutatable_ts("src/index.spec.ts"));
1581 assert!(!is_mutatable_ts("src/types.d.ts"));
1582 assert!(!is_mutatable_ts("README.md"));
1583 }
1584
1585 #[test]
1586 fn contiguous_runs_collapses_adjacent_lines() {
1587 let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
1588 assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
1589 assert!(contiguous_runs(&BTreeSet::new()).is_empty());
1590 }
1591
1592 #[test]
1593 fn one_line_flattens_and_caps() {
1594 assert_eq!(one_line("a -\n b"), "a - b");
1595 let long = "x".repeat(80);
1596 let capped = one_line(&long);
1597 assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
1598 }
1599
1600 #[test]
1601 fn is_mutatable_py_keeps_sources_and_drops_tests() {
1602 assert!(is_mutatable_py("calc.py"));
1603 assert!(is_mutatable_py("pkg/util.py"));
1604 assert!(!is_mutatable_py("calc_test.py"));
1605 assert!(!is_mutatable_py("test_calc.py"));
1606 assert!(!is_mutatable_py("pkg/conftest.py"));
1607 assert!(!is_mutatable_py("README.md"));
1608 }
1609
1610 #[test]
1611 fn mutated_lines_collects_caught_and_missed() {
1612 let report = parse_mutants_report(SAMPLE).unwrap();
1613 assert_eq!(
1614 mutated_lines(&report),
1615 [
1616 ("src/lib.rs".to_string(), 7),
1617 ("src/other.rs".to_string(), 3)
1618 ]
1619 .into_iter()
1620 .collect()
1621 );
1622 }
1623
1624 #[test]
1625 fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1626 let report = parse_mutants_report(SAMPLE).unwrap();
1627 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1628 let kept = evaluate_scoped(
1629 cargo_mutants_survivors(&report),
1630 &mutated_lines(&report),
1631 &[],
1632 &line_scoped,
1633 )
1634 .unwrap();
1635 assert!(
1636 kept.is_empty(),
1637 "the src/lib.rs:7 survivor should be lifted"
1638 );
1639 }
1640
1641 #[test]
1642 fn evaluate_scoped_rejects_exempting_a_caught_line() {
1643 let report = parse_mutants_report(SAMPLE).unwrap();
1644 let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
1645 let err = evaluate_scoped(
1646 cargo_mutants_survivors(&report),
1647 &mutated_lines(&report),
1648 &[],
1649 &line_scoped,
1650 )
1651 .unwrap_err();
1652 assert!(
1653 err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
1654 "got: {err}"
1655 );
1656 }
1657
1658 #[test]
1659 fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
1660 let report = parse_mutants_report(SAMPLE).unwrap();
1661 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
1662 let kept = evaluate_scoped(
1663 cargo_mutants_survivors(&report),
1664 &mutated_lines(&report),
1665 &[],
1666 &line_scoped,
1667 )
1668 .unwrap();
1669 assert_eq!(kept.len(), 1);
1670 assert_eq!(kept[0].line, 7);
1671 }
1672
1673 #[test]
1674 fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1675 let report = parse_mutants_report(SAMPLE).unwrap();
1676 let kept = evaluate_scoped(
1677 cargo_mutants_survivors(&report),
1678 &mutated_lines(&report),
1679 &["src/lib.rs".to_string()],
1680 &BTreeMap::new(),
1681 )
1682 .unwrap();
1683 assert!(kept.is_empty());
1684 }
1685
1686 fn unique_tmp() -> PathBuf {
1687 static COUNTER: AtomicU64 = AtomicU64::new(0);
1688 let dir = std::env::temp_dir().join(format!(
1689 "tc-provision-test-{}-{}",
1690 std::process::id(),
1691 COUNTER.fetch_add(1, Ordering::Relaxed)
1692 ));
1693 std::fs::create_dir_all(&dir).unwrap();
1694 dir
1695 }
1696
1697 #[test]
1698 fn provision_returns_an_existing_binary_without_installing() {
1699 let tmp = unique_tmp();
1700 let bin = tmp.join("bin").join("cargo-mutants");
1701 let lock = tmp.join(".install.lock");
1702 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1703 std::fs::write(&bin, b"binary").unwrap();
1704 let mut installed = false;
1705 let got = provision(&bin, &lock, || {
1706 installed = true;
1707 Ok(())
1708 })
1709 .unwrap();
1710 assert_eq!(got, bin);
1711 assert!(!installed, "a present binary must not be reinstalled");
1712 std::fs::remove_dir_all(&tmp).unwrap();
1713 }
1714
1715 #[test]
1716 fn provision_installs_when_the_binary_is_absent() {
1717 let tmp = unique_tmp();
1718 let bin = tmp.join("bin").join("cargo-mutants");
1719 let lock = tmp.join(".install.lock");
1720 let mut installed = false;
1721 let got = provision(&bin, &lock, || {
1722 installed = true;
1723 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1724 std::fs::write(&bin, b"binary").unwrap();
1725 Ok(())
1726 })
1727 .unwrap();
1728 assert!(installed, "an absent binary must be installed");
1729 assert_eq!(got, bin);
1730 std::fs::remove_dir_all(&tmp).unwrap();
1731 }
1732
1733 #[test]
1734 fn provision_errors_when_install_produces_no_binary() {
1735 let tmp = unique_tmp();
1736 let bin = tmp.join("bin").join("cargo-mutants");
1737 let lock = tmp.join(".install.lock");
1738 let err = provision(&bin, &lock, || Ok(())).unwrap_err();
1739 assert!(
1740 err.to_string().contains("cargo-mutants is not at"),
1741 "got: {err}"
1742 );
1743 std::fs::remove_dir_all(&tmp).unwrap();
1744 }
1745
1746 #[test]
1747 fn provision_propagates_an_install_failure() {
1748 let tmp = unique_tmp();
1749 let bin = tmp.join("bin").join("cargo-mutants");
1750 let lock = tmp.join(".install.lock");
1751 let err = provision(&bin, &lock, || bail!("install blew up")).unwrap_err();
1752 assert!(err.to_string().contains("install blew up"), "got: {err}");
1753 std::fs::remove_dir_all(&tmp).unwrap();
1754 }
1755
1756 #[test]
1757 fn provision_does_not_duplicate_the_install_under_concurrent_callers() {
1758 use std::sync::{Arc, Barrier};
1762 use std::thread;
1763 use std::time::Duration;
1764
1765 let tmp = unique_tmp();
1766 let bin = tmp.join("bin").join("cargo-mutants");
1767 let lock = tmp.join(".install.lock");
1768 let install_count = Arc::new(AtomicU64::new(0));
1769 let barrier = Arc::new(Barrier::new(2));
1770
1771 let handles: Vec<_> = (0..2)
1772 .map(|_| {
1773 let bin = bin.clone();
1774 let lock = lock.clone();
1775 let install_count = Arc::clone(&install_count);
1776 let barrier = Arc::clone(&barrier);
1777 thread::spawn(move || {
1778 barrier.wait();
1779 provision(&bin, &lock, || {
1780 install_count.fetch_add(1, Ordering::SeqCst);
1781 thread::sleep(Duration::from_millis(50));
1782 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1783 std::fs::write(&bin, b"binary").unwrap();
1784 Ok(())
1785 })
1786 })
1787 })
1788 .collect();
1789
1790 for h in handles {
1791 h.join()
1792 .expect("provisioning thread must not panic")
1793 .unwrap();
1794 }
1795
1796 assert_eq!(
1797 install_count.load(Ordering::SeqCst),
1798 1,
1799 "two concurrent callers on a cold cache must share one install, not each run their own"
1800 );
1801 std::fs::remove_dir_all(&tmp).unwrap();
1802 }
1803
1804 #[test]
1805 fn resolve_cache_base_prefers_xdg_then_home_then_temp() {
1806 let xdg = |s: &str| Some(OsString::from(s));
1807 assert_eq!(
1808 resolve_cache_base(xdg("/xdg"), xdg("/home")),
1809 PathBuf::from("/xdg")
1810 );
1811 assert_eq!(
1812 resolve_cache_base(xdg(""), xdg("/home")),
1813 PathBuf::from("/home/.cache")
1814 );
1815 assert_eq!(
1816 resolve_cache_base(None, xdg("/home")),
1817 PathBuf::from("/home/.cache")
1818 );
1819 assert_eq!(resolve_cache_base(None, None), std::env::temp_dir());
1820 assert_eq!(
1821 resolve_cache_base(xdg(""), Some(OsString::new())),
1822 std::env::temp_dir()
1823 );
1824 }
1825
1826 #[test]
1827 fn cache_root_is_absolute_and_version_scoped() {
1828 let root = cargo_mutants_cache_root();
1829 assert!(
1830 root.ends_with(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}")),
1831 "version-scoped; got {root:?}"
1832 );
1833 assert!(
1834 root.to_string_lossy().contains("testing-conventions"),
1835 "tool-namespaced; got {root:?}"
1836 );
1837 assert!(
1838 root.is_absolute(),
1839 "expected an absolute path; got {root:?}"
1840 );
1841 }
1842
1843 #[test]
1844 fn install_argv_pins_the_version_and_isolates_the_root() {
1845 let argv: Vec<String> = install_argv(Path::new("/cache/cargo-mutants-27"))
1846 .iter()
1847 .map(|arg| arg.to_string_lossy().into_owned())
1848 .collect();
1849 assert_eq!(
1850 argv,
1851 vec![
1852 "install",
1853 "cargo-mutants",
1854 "--locked",
1855 "--version",
1856 CARGO_MUTANTS_VERSION,
1857 "--root",
1858 "/cache/cargo-mutants-27",
1859 ]
1860 );
1861 }
1862
1863 #[test]
1864 fn mutants_argv_enables_features_on_the_engine_itself() {
1865 let argv = |diff, features: &[&str]| -> Vec<String> {
1866 mutants_argv(
1867 Path::new("/out"),
1868 diff,
1869 &features.iter().map(|f| f.to_string()).collect::<Vec<_>>(),
1870 )
1871 .iter()
1872 .map(|arg| arg.to_string_lossy().into_owned())
1873 .collect()
1874 };
1875 assert_eq!(
1876 argv(None, &["cli", "boost"]),
1877 vec!["mutants", "--output", "/out", "--features", "cli,boost"]
1878 );
1879 assert_eq!(
1880 argv(Some(Path::new("/out/base.diff")), &["cli"]),
1881 vec![
1882 "mutants",
1883 "--output",
1884 "/out",
1885 "--in-diff",
1886 "/out/base.diff",
1887 "--features",
1888 "cli",
1889 ]
1890 );
1891 assert_eq!(argv(None, &[]), vec!["mutants", "--output", "/out"]);
1892 }
1893
1894 #[test]
1895 fn list_argv_mirrors_the_run_feature_selection() {
1896 let argv = |features: &[&str]| -> Vec<String> {
1897 list_argv(&features.iter().map(|f| f.to_string()).collect::<Vec<_>>())
1898 .iter()
1899 .map(|arg| arg.to_string_lossy().into_owned())
1900 .collect()
1901 };
1902 assert_eq!(argv(&[]), vec!["mutants", "--list", "--json"]);
1903 assert_eq!(
1904 argv(&["cli", "boost"]),
1905 vec!["mutants", "--list", "--json", "--features", "cli,boost"]
1906 );
1907 }
1908
1909 #[test]
1910 fn parse_base_diff_maps_inserted_lines_per_hunk() {
1911 let diff = "\
1912diff --git a/src/lib.rs b/src/lib.rs
1913--- a/src/lib.rs
1914+++ b/src/lib.rs
1915@@ -1,4 +1,5 @@
1916 fn a() {}
1917+fn b() {}
1918 fn c() {}
1919-fn d() {}
1920+fn e() {}
1921 fn f() {}
1922@@ -10,2 +11,4 @@
1923 tail
1924+one
1925+two
1926 more
1927";
1928 let parsed = parse_base_diff(diff);
1929 assert_eq!(parsed.files, vec!["src/lib.rs"]);
1930 assert_eq!(
1931 parsed.inserted.get("src/lib.rs"),
1932 Some(&BTreeSet::from([2, 4, 12, 13]))
1933 );
1934 }
1935
1936 #[test]
1937 fn parse_base_diff_leaves_a_deletion_only_file_without_inserted_lines() {
1938 let diff = "\
1939--- a/src/gone.rs
1940+++ b/src/gone.rs
1941@@ -5,2 +4,0 @@
1942-x
1943-y
1944";
1945 let parsed = parse_base_diff(diff);
1946 assert_eq!(parsed.files, vec!["src/gone.rs"]);
1947 assert!(parsed.inserted.is_empty());
1948 }
1949
1950 #[test]
1951 fn parse_base_diff_skips_a_deleted_file() {
1952 let diff = "\
1953--- a/src/dead.rs
1954+++ /dev/null
1955@@ -1,2 +0,0 @@
1956-a
1957-b
1958";
1959 let parsed = parse_base_diff(diff);
1960 assert!(parsed.files.is_empty());
1961 assert!(parsed.inserted.is_empty());
1962 }
1963
1964 #[test]
1965 fn parse_base_diff_consumes_hunk_bodies_by_count_so_content_never_reads_as_a_header() {
1966 let diff = "\
1969+++ b/notes.txt
1970@@ -1,1 +1,2 @@
1971 keep
1972++++ not a header
1973";
1974 let parsed = parse_base_diff(diff);
1975 assert_eq!(parsed.files, vec!["notes.txt"]);
1976 assert_eq!(parsed.inserted.get("notes.txt"), Some(&BTreeSet::from([2])));
1977 }
1978
1979 #[test]
1980 fn parse_base_diff_defaults_an_elided_hunk_count_to_one() {
1981 let diff = "\
1982+++ b/one.txt
1983@@ -1 +1 @@
1984-old
1985+new
1986";
1987 let parsed = parse_base_diff(diff);
1988 assert_eq!(parsed.inserted.get("one.txt"), Some(&BTreeSet::from([1])));
1989 }
1990
1991 #[test]
1992 fn parse_base_diff_skips_no_newline_annotations_mid_hunk() {
1993 let diff = "\
1994+++ b/n.txt
1995@@ -1 +1 @@
1996-old
1997\\ No newline at end of file
1998+new
1999\\ No newline at end of file
2000";
2001 let parsed = parse_base_diff(diff);
2002 assert_eq!(parsed.inserted.get("n.txt"), Some(&BTreeSet::from([1])));
2003 }
2004
2005 #[cfg(unix)]
2006 fn fake_output(code: i32, stderr: &str) -> Output {
2007 use std::os::unix::process::ExitStatusExt;
2008 Output {
2009 status: std::process::ExitStatus::from_raw(code << 8),
2010 stdout: Vec::new(),
2011 stderr: stderr.as_bytes().to_vec(),
2012 }
2013 }
2014
2015 #[cfg(unix)]
2016 #[test]
2017 fn run_install_succeeds_on_a_zero_exit() {
2018 let mut ran = false;
2019 run_install(Path::new("/cache/root"), |command| {
2020 ran = true;
2021 let argv: Vec<String> = command
2022 .get_args()
2023 .map(|arg| arg.to_string_lossy().into_owned())
2024 .collect();
2025 assert!(argv.contains(&CARGO_MUTANTS_VERSION.to_string()));
2026 Ok(fake_output(0, ""))
2027 })
2028 .unwrap();
2029 assert!(ran);
2030 }
2031
2032 #[cfg(unix)]
2033 #[test]
2034 fn run_install_reports_a_nonzero_exit_with_the_engine_output() {
2035 let err = run_install(Path::new("/cache/root"), |_| {
2036 Ok(fake_output(1, "error: could not compile cargo-mutants"))
2037 })
2038 .unwrap_err();
2039 assert!(
2040 err.to_string()
2041 .contains("failed to provision cargo-mutants")
2042 && err.to_string().contains("could not compile"),
2043 "got: {err}"
2044 );
2045 }
2046
2047 #[cfg(unix)]
2048 #[test]
2049 fn run_install_propagates_a_spawn_failure() {
2050 let err = run_install(Path::new("/cache/root"), |_| {
2051 Err(std::io::Error::new(
2052 std::io::ErrorKind::NotFound,
2053 "no cargo",
2054 ))
2055 })
2056 .unwrap_err();
2057 assert!(
2058 err.to_string().contains("is cargo installed?"),
2059 "got: {err}"
2060 );
2061 }
2062
2063 #[cfg(unix)]
2064 fn fake_stdout(code: i32, stdout: &str) -> Output {
2065 use std::os::unix::process::ExitStatusExt;
2066 Output {
2067 status: std::process::ExitStatus::from_raw(code << 8),
2068 stdout: stdout.as_bytes().to_vec(),
2069 stderr: Vec::new(),
2070 }
2071 }
2072
2073 #[cfg(unix)]
2074 #[test]
2075 fn list_cargo_mutants_parses_the_listing_from_a_clean_run() {
2076 let json = r#"[{"file": "src/lib.rs", "name": "replace add -> 0",
2077 "span": {"start": {"line": 3, "column": 1}, "end": {"line": 5, "column": 2}}}]"#;
2078 let listed = list_cargo_mutants(
2079 Path::new("/cache/bin/cargo-mutants"),
2080 Path::new("/crate"),
2081 &["cli".to_string()],
2082 |command| {
2083 let argv: Vec<String> = command
2084 .get_args()
2085 .map(|arg| arg.to_string_lossy().into_owned())
2086 .collect();
2087 assert_eq!(
2088 argv,
2089 vec!["mutants", "--list", "--json", "--features", "cli"]
2090 );
2091 assert_eq!(command.get_current_dir(), Some(Path::new("/crate")));
2092 Ok(fake_stdout(0, json))
2093 },
2094 )
2095 .unwrap();
2096 assert_eq!(listed.len(), 1);
2097 assert_eq!(listed[0].file, "src/lib.rs");
2098 assert_eq!(listed[0].span.start.line, 3);
2099 assert_eq!(listed[0].span.end.line, 5);
2100 assert_eq!(listed[0].name, "replace add -> 0");
2101 }
2102
2103 #[cfg(unix)]
2104 #[test]
2105 fn list_cargo_mutants_reports_a_nonzero_exit_with_the_engine_output() {
2106 let err = list_cargo_mutants(
2107 Path::new("/cache/bin/cargo-mutants"),
2108 Path::new("/crate"),
2109 &[],
2110 |_| Ok(fake_output(1, "error: no such option")),
2111 )
2112 .unwrap_err();
2113 assert!(
2114 err.to_string().contains("cargo-mutants --list failed")
2115 && err.to_string().contains("no such option"),
2116 "got: {err}"
2117 );
2118 }
2119
2120 #[cfg(unix)]
2121 #[test]
2122 fn list_cargo_mutants_propagates_a_spawn_failure() {
2123 let err = list_cargo_mutants(
2124 Path::new("/cache/bin/cargo-mutants"),
2125 Path::new("/crate"),
2126 &[],
2127 |_| {
2128 Err(std::io::Error::new(
2129 std::io::ErrorKind::NotFound,
2130 "no engine",
2131 ))
2132 },
2133 )
2134 .unwrap_err();
2135 assert!(
2136 err.to_string()
2137 .contains("listing the crate's mutants with cargo-mutants"),
2138 "got: {err}"
2139 );
2140 }
2141
2142 #[cfg(unix)]
2143 fn listed_mutant(file: &str, start: u32, end: u32, name: &str) -> MutantInfo {
2144 MutantInfo {
2145 file: file.to_string(),
2146 span: Span {
2147 start: LineCol { line: start },
2148 end: LineCol { line: end },
2149 },
2150 name: name.to_string(),
2151 }
2152 }
2153
2154 #[cfg(unix)]
2155 fn diff_with_inserted(file: &str, lines: &[u32]) -> BaseDiff {
2156 BaseDiff {
2157 files: vec![file.to_string()],
2158 inserted: BTreeMap::from([(file.to_string(), lines.iter().copied().collect())]),
2159 }
2160 }
2161
2162 #[cfg(unix)]
2163 #[test]
2164 fn zero_mutant_verdict_accepts_a_zero_with_no_mutant_on_the_inserted_lines() {
2165 let run = fake_output(0, "");
2166 zero_mutant_verdict(&[], &diff_with_inserted("src/lib.rs", &[5]), &run).unwrap();
2167 let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2168 zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[4, 9]), &run).unwrap();
2169 zero_mutant_verdict(&listed, &diff_with_inserted("src/other.rs", &[6]), &run).unwrap();
2170 }
2171
2172 #[cfg(unix)]
2173 #[test]
2174 fn zero_mutant_verdict_is_fatal_on_a_mutant_at_either_span_boundary() {
2175 let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2176 let run = fake_stdout(0, "0 mutants tested");
2177 for line in [5, 8] {
2178 let err =
2179 zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[line]), &run)
2180 .unwrap_err();
2181 let message = err.to_string();
2182 assert!(
2183 message.contains("1 of the crate's 1 mutant site(s)")
2184 && message.contains("src/lib.rs:5: replace add -> 0")
2185 && message.contains("0 mutants tested"),
2186 "got: {message}"
2187 );
2188 }
2189 }
2190
2191 #[cfg(unix)]
2192 #[test]
2193 fn zero_mutant_verdict_names_each_dropped_site_once() {
2194 let listed = [
2195 listed_mutant(
2196 "src/lib.rs",
2197 7,
2198 7,
2199 "src/lib.rs:7:7: replace > with == in is_positive",
2200 ),
2201 listed_mutant("src/lib.rs", 7, 7, "replace add -> 0"),
2202 ];
2203 let run = fake_stdout(0, "0 mutants tested");
2204 let message = zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[7]), &run)
2205 .unwrap_err()
2206 .to_string();
2207 assert!(
2208 message.contains(" src/lib.rs:7: replace > with == in is_positive"),
2209 "the name's embedded `file:line:col:` prefix is stripped; got: {message}"
2210 );
2211 assert!(
2212 !message.contains(": src/lib.rs:7:7:"),
2213 "a dropped site carries one location; got: {message}"
2214 );
2215 assert!(
2216 message.contains(" src/lib.rs:7: replace add -> 0"),
2217 "a name with no embedded location keeps its rendered location; got: {message}"
2218 );
2219 }
2220
2221 #[cfg(unix)]
2222 #[test]
2223 fn classify_mutants_exit_accepts_the_caught_and_survivor_exits() {
2224 classify_mutants_exit(Path::new("/crate"), &fake_output(0, "")).unwrap();
2225 classify_mutants_exit(Path::new("/crate"), &fake_output(2, "")).unwrap();
2226 }
2227
2228 #[cfg(unix)]
2229 #[test]
2230 fn classify_mutants_exit_accepts_a_timeout_exit_3() {
2231 classify_mutants_exit(Path::new("/crate"), &fake_output(3, ""))
2232 .expect("a timeout (exit 3) is inconclusive, not fatal");
2233 }
2234
2235 #[cfg(unix)]
2236 #[test]
2237 fn classify_mutants_exit_is_fatal_on_a_baseline_failure() {
2238 let err = classify_mutants_exit(Path::new("/crate"), &fake_output(4, "baseline broke"))
2239 .unwrap_err();
2240 assert!(
2241 err.to_string().contains("did not run cleanly")
2242 && err.to_string().contains("baseline broke"),
2243 "got: {err}"
2244 );
2245 }
2246
2247 #[test]
2248 fn cargo_mutants_bin_name_matches_the_platform() {
2249 let name = cargo_mutants_bin_name();
2250 if cfg!(windows) {
2251 assert_eq!(name, "cargo-mutants.exe");
2252 } else {
2253 assert_eq!(name, "cargo-mutants");
2254 }
2255 }
2256}