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 = parse_base_diff(&read_base_diff(&path)?);
383 if !parsed.files.iter().any(|file| file.ends_with(".rs")) {
384 return Ok(Measurement::EngineNotRun);
385 }
386 base_diff = Some(parsed);
387 Some(path)
388 }
389 }
390 }
391 None => None,
392 };
393 let engine = ensure_cargo_mutants()?;
394 let run = run_cargo_mutants(&engine, root, &out.0, diff.as_deref(), features)?;
395 let outcomes = out.0.join("mutants.out").join("outcomes.json");
396 let json = match std::fs::read_to_string(&outcomes) {
400 Ok(json) => json,
401 Err(_) => {
402 if let Some(diff) = &base_diff {
403 let listed =
404 list_cargo_mutants(&engine, root, features, |command| command.output())?;
405 zero_mutant_verdict(&listed, diff, &run)?;
406 }
407 return Ok(Measurement::Tested {
408 count: 0,
409 survivors: Vec::new(),
410 });
411 }
412 };
413 let report = rebase_report_paths(parse_mutants_report(&json)?, prefix.as_deref());
414 let survivors = evaluate_scoped(
415 cargo_mutants_survivors(&report),
416 &mutated_lines(&report),
417 exempt,
418 exempt_lines,
419 )?;
420 Ok(Measurement::Tested {
421 count: conclusive_count(&report),
422 survivors,
423 })
424}
425
426fn one_line(replacement: &str) -> String {
429 let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
430 const MAX: usize = 60;
431 if flat.chars().count() > MAX {
432 format!("{}…", flat.chars().take(MAX).collect::<String>())
433 } else {
434 flat
435 }
436}
437
438pub fn measure_typescript(
442 root: &Path,
443 exempt: &[String],
444 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
445 base: Option<&str>,
446 adapter: &Path,
447) -> Result<Measurement> {
448 let package_root =
449 crate::tiers::package_root(root, "package.json").unwrap_or_else(|| root.to_path_buf());
450 let prefix = scan_prefix(root, &package_root);
451 let mutate = match base {
452 Some(base) => {
453 let ranges = mutate_ranges(root, base)?;
454 if ranges.is_empty() {
455 return Ok(Measurement::EngineNotRun);
456 }
457 Some(prefix_mutate_specs(ranges, prefix.as_deref()))
458 }
459 None => prefix.as_deref().map(scan_scoped_mutate_globs),
460 };
461 let test_files = prefix.as_deref().map(scan_scoped_test_file_globs);
462 let json = run_ts_adapter(
463 &package_root,
464 adapter,
465 mutate.as_deref(),
466 test_files.as_deref(),
467 )?;
468 let mutants = to_scan_relative(parse_normalized_results(&json)?, prefix.as_deref());
469 let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
470 Ok(Measurement::Tested {
471 count: normalized_conclusive_count(&mutants),
472 survivors,
473 })
474}
475
476fn scan_prefix(root: &Path, package_root: &Path) -> Option<String> {
479 let rel = root.strip_prefix(package_root).ok()?;
480 let parts: Vec<String> = rel
481 .components()
482 .map(|part| part.as_os_str().to_string_lossy().into_owned())
483 .collect();
484 if parts.is_empty() {
485 None
486 } else {
487 Some(parts.join("/"))
488 }
489}
490
491fn prefix_mutate_specs(specs: Vec<String>, prefix: Option<&str>) -> Vec<String> {
494 match prefix {
495 None => specs,
496 Some(prefix) => specs
497 .into_iter()
498 .map(|spec| format!("{prefix}/{spec}"))
499 .collect(),
500 }
501}
502
503fn scan_scoped_mutate_globs(prefix: &str) -> Vec<String> {
507 const EXTENSIONS: &str = "+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)";
508 vec![
509 format!("{prefix}/**/!(*.+(s|S)pec|*.+(t|T)est).{EXTENSIONS}"),
510 format!("!{prefix}/**/__tests__/**/*.{EXTENSIONS}"),
511 ]
512}
513
514fn scan_scoped_test_file_globs(prefix: &str) -> Vec<String> {
518 vec![format!("{prefix}/**")]
519}
520
521fn to_scan_relative(mutants: Vec<NormalizedMutant>, prefix: Option<&str>) -> Vec<NormalizedMutant> {
525 let Some(prefix) = prefix else {
526 return mutants;
527 };
528 let prefix = format!("{prefix}/");
529 mutants
530 .into_iter()
531 .filter_map(|mut mutant| {
532 mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
533 Some(mutant)
534 })
535 .collect()
536}
537
538fn adapter_cwd<'a>(root: &'a Path, engine: &str) -> Result<&'a Path> {
542 let cwd = if root.as_os_str().is_empty() {
543 Path::new(".")
544 } else {
545 root
546 };
547 if !cwd.is_dir() {
548 bail!(
549 "the {engine} mutation adapter's working directory `{}` is not a directory",
550 cwd.display()
551 );
552 }
553 Ok(cwd)
554}
555
556fn spawn_context(interpreter: &str, entry: &str, cwd: &Path) -> String {
560 format!(
561 "spawning `{interpreter} {entry}` in `{}` (is `{interpreter}` on PATH?)",
562 cwd.display()
563 )
564}
565
566fn run_ts_adapter(
570 package_root: &Path,
571 adapter: &Path,
572 mutate: Option<&[String]>,
573 test_files: Option<&[String]>,
574) -> Result<String> {
575 let out = AdapterOut::new();
576 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
577 let results = out.0.join("results.json");
578
579 let cwd = adapter_cwd(package_root, "TypeScript")?;
580
581 let mut command = Command::new("node");
582 command
583 .current_dir(cwd)
584 .arg(adapter)
585 .arg("--out")
586 .arg(&results);
587 if let Some(specs) = mutate {
588 command.arg("--mutate").arg(specs.join(","));
589 }
590 if let Some(globs) = test_files {
591 command.arg("--test-files").arg(globs.join(","));
592 }
593 let output =
594 command
595 .output()
596 .context(spawn_context("node", &adapter.display().to_string(), cwd))?;
597 if !output.status.success() {
598 bail!(
599 "the TypeScript mutation adapter failed in `{}`:\n{}{}",
600 cwd.display(),
601 String::from_utf8_lossy(&output.stdout),
602 String::from_utf8_lossy(&output.stderr),
603 );
604 }
605 read_adapter_results(&results, "TypeScript")
606}
607
608fn read_adapter_results(results: &Path, engine: &str) -> Result<String> {
610 std::fs::read_to_string(results).with_context(|| {
611 format!(
612 "reading the {engine} mutation adapter's results from `{}`",
613 results.display()
614 )
615 })
616}
617
618struct AdapterOut(PathBuf);
621
622impl AdapterOut {
623 fn new() -> Self {
624 static COUNTER: AtomicU64 = AtomicU64::new(0);
625 let name = format!(
626 "testing-conventions-ts-adapter-{}-{}",
627 std::process::id(),
628 COUNTER.fetch_add(1, Ordering::Relaxed),
629 );
630 AdapterOut(std::env::temp_dir().join(name))
631 }
632}
633
634impl Drop for AdapterOut {
635 fn drop(&mut self) {
636 let _ = std::fs::remove_dir_all(&self.0);
637 }
638}
639
640fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
644 let changed = crate::patch_coverage::changed_lines(root, base)?;
645 let mut specs = Vec::new();
646 for (file, lines) in changed {
647 if !is_mutatable_ts(&file) {
648 continue;
649 }
650 for (start, end) in contiguous_runs(&lines) {
651 specs.push(format!("{file}:{start}-{end}"));
652 }
653 }
654 Ok(specs)
655}
656
657fn is_mutatable_ts(file: &str) -> bool {
661 let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
662 .iter()
663 .any(|ext| file.ends_with(ext));
664 let is_decl = file.ends_with(".d.ts");
665 let is_test = file.contains(".test.") || file.contains(".spec.");
666 is_source && !is_decl && !is_test
667}
668
669fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
671 let mut runs: Vec<(u64, u64)> = Vec::new();
672 for &line in lines {
673 match runs.last_mut() {
674 Some(run) if run.1 + 1 == line => run.1 = line,
675 _ => runs.push((line, line)),
676 }
677 }
678 runs
679}
680
681pub fn measure_python(
685 root: &Path,
686 exempt: &[String],
687 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
688 base: Option<&str>,
689) -> Result<Measurement> {
690 let changed = match base {
691 Some(base) => Some(crate::patch_coverage::changed_lines(root, base)?),
692 None => None,
693 };
694 let modules: Vec<String> = match &changed {
695 None => Vec::new(),
696 Some(changed) => {
697 let modules: Vec<String> = changed
698 .keys()
699 .filter(|file| is_mutatable_py(file))
700 .cloned()
701 .collect();
702 if modules.is_empty() {
703 return Ok(Measurement::EngineNotRun);
704 }
705 modules
706 }
707 };
708 let json = run_py_adapter(root, &modules)?;
709 let mut mutants = parse_normalized_results(&json)?;
710 if let Some(changed) = &changed {
711 mutants.retain(|mutant| {
712 changed
713 .get(&mutant.file)
714 .is_some_and(|lines| lines.contains(&u64::from(mutant.line)))
715 });
716 }
717 let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
718 Ok(Measurement::Tested {
719 count: normalized_conclusive_count(&mutants),
720 survivors,
721 })
722}
723
724fn run_py_adapter(root: &Path, modules: &[String]) -> Result<String> {
728 let out = AdapterOut::new();
729 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
730 let results = out.0.join("results.json");
731
732 let cwd = adapter_cwd(root, "Python")?;
733
734 const ENTRY: &str = "-m testing_conventions.mutation.main";
735 let mut command = Command::new("python3");
736 command
737 .current_dir(cwd)
738 .args(["-m", "testing_conventions.mutation.main", "--out"])
739 .arg(&results)
740 .env("PYTHONDONTWRITEBYTECODE", "1");
741 for module in modules {
742 command.arg("--module").arg(module);
743 }
744 let output = command
745 .output()
746 .context(spawn_context("python3", ENTRY, cwd))?;
747 if !output.status.success() {
748 bail!(
749 "the Python mutation adapter failed in `{}`:\n{}{}",
750 cwd.display(),
751 String::from_utf8_lossy(&output.stdout),
752 String::from_utf8_lossy(&output.stderr),
753 );
754 }
755 read_adapter_results(&results, "Python")
756}
757
758fn is_mutatable_py(file: &str) -> bool {
761 if !file.ends_with(".py") {
762 return false;
763 }
764 let base = file.rsplit('/').next().unwrap_or(file);
765 !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
766}
767
768struct MutantsOut(PathBuf);
771
772impl MutantsOut {
773 fn new() -> Self {
774 static COUNTER: AtomicU64 = AtomicU64::new(0);
775 let name = format!(
776 "testing-conventions-mutants-{}-{}",
777 std::process::id(),
778 COUNTER.fetch_add(1, Ordering::Relaxed),
779 );
780 MutantsOut(std::env::temp_dir().join(name))
781 }
782}
783
784impl Drop for MutantsOut {
785 fn drop(&mut self) {
786 let _ = std::fs::remove_dir_all(&self.0);
787 }
788}
789
790fn cargo_workspace_root(root: &Path) -> Result<PathBuf> {
794 let output = Command::new("cargo")
795 .current_dir(root)
796 .args(["locate-project", "--workspace", "--message-format", "plain"])
797 .output()
798 .context("running `cargo locate-project` (is cargo installed?)")?;
799 if !output.status.success() {
800 bail!(
801 "cargo locate-project failed in `{}`: {}",
802 root.display(),
803 String::from_utf8_lossy(&output.stderr)
804 );
805 }
806 let manifest = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
807 manifest_dir(&manifest)
808}
809
810fn manifest_dir(manifest: &Path) -> Result<PathBuf> {
812 manifest.parent().map(Path::to_path_buf).with_context(|| {
813 format!(
814 "no parent dir for the workspace manifest `{}`",
815 manifest.display()
816 )
817 })
818}
819
820fn canonical_scan_prefix(root: &Path, workspace_root: &Path) -> Option<String> {
824 let root = root.canonicalize().ok()?;
825 let workspace_root = workspace_root.canonicalize().ok()?;
826 scan_prefix(&root, &workspace_root)
827}
828
829fn write_base_diff(
833 root: &Path,
834 workspace_root: &Path,
835 prefix: Option<&str>,
836 base: &str,
837 out: &MutantsOut,
838) -> Result<Option<PathBuf>> {
839 let range = format!("{base}...HEAD");
840 let (dir, args) = match prefix {
841 None => (root, vec!["diff", "--relative", &range]),
842 Some(prefix) => (
843 workspace_root,
844 vec!["diff", "--relative", &range, "--", prefix],
845 ),
846 };
847 let output = Command::new("git")
848 .current_dir(dir)
849 .args(&args)
850 .output()
851 .context("running `git diff` for `--base` (is git installed?)")?;
852 if !output.status.success() {
853 bail!(
854 "git diff {range} failed: {}",
855 String::from_utf8_lossy(&output.stderr)
856 );
857 }
858 if output.stdout.is_empty() {
859 return Ok(None);
860 }
861 std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
862 let path = out.0.join("base.diff");
863 std::fs::write(&path, &output.stdout).context("writing the base diff")?;
864 Ok(Some(path))
865}
866
867fn read_base_diff(path: &Path) -> Result<String> {
869 std::fs::read_to_string(path)
870 .with_context(|| format!("reading the written base diff `{}`", path.display()))
871}
872
873struct BaseDiff {
877 files: Vec<String>,
878 inserted: BTreeMap<String, BTreeSet<u32>>,
879}
880
881fn parse_base_diff(diff: &str) -> BaseDiff {
885 let mut files = Vec::new();
886 let mut inserted: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
887 let mut current: Option<String> = None;
888 let mut lines = diff.lines();
889 while let Some(line) = lines.next() {
890 if let Some(path) = line.strip_prefix("+++ ") {
891 current = (path != "/dev/null").then(|| {
892 let path = path.strip_prefix("b/").unwrap_or(path).to_string();
893 files.push(path.clone());
894 path
895 });
896 } else if let Some(header) = line.strip_prefix("@@ ") {
897 let Some((new_start, old_count, new_count)) = parse_hunk_header(header) else {
898 continue;
899 };
900 let mut new_line = new_start;
901 let (mut old_left, mut new_left) = (old_count, new_count);
902 while old_left > 0 || new_left > 0 {
903 let Some(line) = lines.next() else { break };
904 if line.starts_with('\\') {
905 } else if line.starts_with('+') {
908 if let Some(file) = ¤t {
909 inserted.entry(file.clone()).or_default().insert(new_line);
910 }
911 new_line += 1;
912 new_left = new_left.saturating_sub(1);
913 } else if line.starts_with('-') {
914 old_left = old_left.saturating_sub(1);
915 } else {
916 new_line += 1;
917 old_left = old_left.saturating_sub(1);
918 new_left = new_left.saturating_sub(1);
919 }
920 }
921 }
922 }
923 BaseDiff { files, inserted }
924}
925
926fn parse_hunk_header(header: &str) -> Option<(u32, u32, u32)> {
928 let mut parts = header.split(' ');
929 let (_, old_count) = parse_range(parts.next()?.strip_prefix('-')?)?;
930 let (new_start, new_count) = parse_range(parts.next()?.strip_prefix('+')?)?;
931 Some((new_start, old_count, new_count))
932}
933
934fn parse_range(range: &str) -> Option<(u32, u32)> {
936 match range.split_once(',') {
937 Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
938 None => Some((range.parse().ok()?, 1)),
939 }
940}
941
942fn rebase_report_paths(report: MutantsReport, prefix: Option<&str>) -> MutantsReport {
946 let Some(prefix) = prefix else {
947 return report;
948 };
949 let prefix = format!("{prefix}/");
950 MutantsReport {
951 outcomes: report
952 .outcomes
953 .into_iter()
954 .filter_map(|mut outcome| {
955 if let Scenario::Mutant(mutant) = &mut outcome.scenario {
956 mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
957 }
958 Some(outcome)
959 })
960 .collect(),
961 }
962}
963
964const CARGO_MUTANTS_VERSION: &str = "27.1.0";
967
968fn ensure_cargo_mutants() -> Result<PathBuf> {
972 provision_pinned(&cargo_mutants_cache_root(), execute)
973}
974
975fn provision_pinned(
977 root: &Path,
978 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
979) -> Result<PathBuf> {
980 let bin = root.join("bin").join(CARGO_MUTANTS_BIN_NAME);
981 let lock_path = root.join(".install.lock");
982 provision(&bin, &lock_path, || run_install(root, run))
983}
984
985fn execute(command: &mut Command) -> std::io::Result<Output> {
987 command.output()
988}
989
990const CARGO_MUTANTS_BIN_NAME: &str = if cfg!(windows) {
993 "cargo-mutants.exe"
994} else {
995 "cargo-mutants"
996};
997
998fn cargo_mutants_cache_root() -> PathBuf {
1002 cache_base()
1003 .join("testing-conventions")
1004 .join(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}"))
1005}
1006
1007fn cache_base() -> PathBuf {
1010 resolve_cache_base(std::env::var_os("XDG_CACHE_HOME"), std::env::var_os("HOME"))
1011}
1012
1013fn resolve_cache_base(xdg: Option<OsString>, home: Option<OsString>) -> PathBuf {
1016 if let Some(dir) = xdg.filter(|value| !value.is_empty()) {
1017 return PathBuf::from(dir);
1018 }
1019 if let Some(dir) = home.filter(|value| !value.is_empty()) {
1020 return PathBuf::from(dir).join(".cache");
1021 }
1022 std::env::temp_dir()
1023}
1024
1025fn provision(
1029 bin: &Path,
1030 lock_path: &Path,
1031 install: impl FnOnce() -> Result<()>,
1032) -> Result<PathBuf> {
1033 if bin.exists() {
1034 return Ok(bin.to_path_buf());
1035 }
1036 if let Some(parent) = lock_path.parent() {
1037 std::fs::create_dir_all(parent).context("creating the provisioning lock's parent dir")?;
1038 }
1039 let lock_file = std::fs::OpenOptions::new()
1040 .create(true)
1041 .truncate(false)
1042 .write(true)
1043 .open(lock_path)
1044 .context("opening the provisioning lock file")?;
1045 lock_file
1046 .lock()
1047 .context("acquiring the provisioning lock")?;
1048 if bin.exists() {
1050 return Ok(bin.to_path_buf());
1051 }
1052 install()?;
1053 if !bin.exists() {
1054 bail!(
1055 "provisioning reported success but cargo-mutants is not at `{}`",
1056 bin.display()
1057 );
1058 }
1059 Ok(bin.to_path_buf())
1060}
1061
1062fn install_argv(root: &Path) -> Vec<OsString> {
1066 vec![
1067 OsString::from("install"),
1068 OsString::from("cargo-mutants"),
1069 OsString::from("--locked"),
1070 OsString::from("--version"),
1071 OsString::from(CARGO_MUTANTS_VERSION),
1072 OsString::from("--root"),
1073 root.as_os_str().to_os_string(),
1074 ]
1075}
1076
1077fn run_install(
1081 root: &Path,
1082 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1083) -> Result<()> {
1084 let mut command = Command::new("cargo");
1085 command.args(install_argv(root));
1086 strip_llvm_cov_env(&mut command);
1087 let output = run(&mut command)
1088 .context("provisioning cargo-mutants via `cargo install` (is cargo installed?)")?;
1089 if !output.status.success() {
1090 bail!(
1091 "failed to provision cargo-mutants {CARGO_MUTANTS_VERSION}:\n{}{}",
1092 String::from_utf8_lossy(&output.stdout),
1093 String::from_utf8_lossy(&output.stderr),
1094 );
1095 }
1096 Ok(())
1097}
1098
1099fn strip_llvm_cov_env(command: &mut Command) {
1103 for var in [
1104 "RUSTFLAGS",
1105 "CARGO_ENCODED_RUSTFLAGS",
1106 "RUSTDOCFLAGS",
1107 "CARGO_ENCODED_RUSTDOCFLAGS",
1108 "LLVM_PROFILE_FILE",
1109 "CARGO_LLVM_COV",
1110 "CARGO_LLVM_COV_SHOW_ENV",
1111 "CARGO_LLVM_COV_TARGET_DIR",
1112 "CARGO_LLVM_COV_BUILD_DIR",
1113 "RUSTC_WRAPPER",
1114 "RUSTC_WORKSPACE_WRAPPER",
1115 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
1116 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
1117 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
1118 ] {
1119 command.env_remove(var);
1120 }
1121}
1122
1123fn run_cargo_mutants(
1127 engine: &Path,
1128 root: &Path,
1129 out: &Path,
1130 in_diff: Option<&Path>,
1131 features: &[String],
1132) -> Result<Output> {
1133 let mut command = Command::new(engine);
1134 command
1135 .current_dir(root)
1136 .args(mutants_argv(out, in_diff, features));
1137 strip_llvm_cov_env(&mut command);
1138 let output = command.output().context("running cargo-mutants")?;
1139 classify_mutants_exit(root, &output)?;
1140 Ok(output)
1141}
1142
1143fn zero_mutant_verdict(listed: &[MutantInfo], diff: &BaseDiff, run: &Output) -> Result<()> {
1147 let dropped: Vec<&MutantInfo> = listed
1148 .iter()
1149 .filter(|mutant| {
1150 diff.inserted.get(&mutant.file).is_some_and(|lines| {
1151 lines
1152 .range(mutant.span.start.line..=mutant.span.end.line)
1153 .next()
1154 .is_some()
1155 })
1156 })
1157 .collect();
1158 if dropped.is_empty() {
1159 return Ok(());
1160 }
1161 let sites: Vec<String> = dropped
1162 .iter()
1163 .map(|mutant| {
1164 format!(
1165 " {}:{}: {}",
1166 mutant.file,
1167 mutant.span.start.line,
1168 strip_embedded_location(&mutant.name)
1169 )
1170 })
1171 .collect();
1172 bail!(
1173 "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{}{}",
1174 dropped.len(),
1175 listed.len(),
1176 sites.join("\n"),
1177 String::from_utf8_lossy(&run.stdout),
1178 String::from_utf8_lossy(&run.stderr),
1179 )
1180}
1181
1182fn list_argv(features: &[String]) -> Vec<OsString> {
1186 let mut argv = vec![
1187 OsString::from("mutants"),
1188 OsString::from("--list"),
1189 OsString::from("--json"),
1190 ];
1191 if !features.is_empty() {
1192 argv.push(OsString::from("--features"));
1193 argv.push(OsString::from(features.join(",")));
1194 }
1195 argv
1196}
1197
1198fn list_cargo_mutants(
1202 engine: &Path,
1203 root: &Path,
1204 features: &[String],
1205 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1206) -> Result<Vec<MutantInfo>> {
1207 let mut command = Command::new(engine);
1208 command.current_dir(root).args(list_argv(features));
1209 strip_llvm_cov_env(&mut command);
1210 let output = run(&mut command).context("listing the crate's mutants with cargo-mutants")?;
1211 if !output.status.success() {
1212 bail!(
1213 "cargo-mutants --list failed in `{}`:\n{}{}",
1214 root.display(),
1215 String::from_utf8_lossy(&output.stdout),
1216 String::from_utf8_lossy(&output.stderr),
1217 );
1218 }
1219 parse_mutants_list(&String::from_utf8_lossy(&output.stdout))
1220}
1221
1222fn mutants_argv(out: &Path, in_diff: Option<&Path>, features: &[String]) -> Vec<OsString> {
1226 let mut argv = vec![
1227 OsString::from("mutants"),
1228 OsString::from("--output"),
1229 out.as_os_str().to_os_string(),
1230 ];
1231 if let Some(diff) = in_diff {
1232 argv.push(OsString::from("--in-diff"));
1233 argv.push(diff.as_os_str().to_os_string());
1234 }
1235 if !features.is_empty() {
1236 argv.push(OsString::from("--features"));
1237 argv.push(OsString::from(features.join(",")));
1238 }
1239 argv
1240}
1241
1242fn classify_mutants_exit(root: &Path, output: &Output) -> Result<()> {
1246 match output.status.code() {
1247 Some(0) | Some(2) | Some(3) => Ok(()),
1248 _ => bail!(
1249 "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
1250 root.display(),
1251 String::from_utf8_lossy(&output.stdout),
1252 String::from_utf8_lossy(&output.stderr),
1253 ),
1254 }
1255}
1256
1257#[cfg(test)]
1258mod tests {
1259 use super::*;
1260
1261 const NORMALIZED: &str = r#"[
1262 {"file": "src/a.ts", "line": 2, "status": "survived",
1263 "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
1264 {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
1265 {"file": "src/a.ts", "line": 9, "status": "killed",
1266 "mutator": "BooleanLiteral", "replacement": "false"},
1267 {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
1268 {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
1269 {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
1270 ]"#;
1271
1272 #[test]
1273 fn parses_the_normalized_schema() {
1274 let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
1275 assert_eq!(mutants.len(), 6);
1276 assert_eq!(mutants[0].status, MutantStatus::Survived);
1277 assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
1278 assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
1279 assert_eq!(mutants[1].replacement, None);
1280 }
1281
1282 #[test]
1283 fn normalized_survivors_are_survived_and_nocoverage_only() {
1284 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1285 let survivors = normalized_survivors(&mutants);
1286 assert_eq!(survivors.len(), 2);
1287 assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
1288 assert!(survivors[0].description.contains("ConditionalExpression"));
1289 assert!(survivors[0].description.contains("-> true"));
1290 assert_eq!(survivors[1].description, "ArithmeticOperator");
1291 }
1292
1293 #[test]
1294 fn normalized_mutated_lines_collects_only_viable_mutants() {
1295 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1296 assert_eq!(
1297 normalized_mutated_lines(&mutants),
1298 [2u32, 5, 9, 12]
1299 .into_iter()
1300 .map(|line| ("src/a.ts".to_string(), line))
1301 .collect()
1302 );
1303 }
1304
1305 #[test]
1306 fn normalized_conclusive_count_is_survived_killed_and_nocoverage() {
1307 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1308 assert_eq!(normalized_conclusive_count(&mutants), 3);
1309 assert_eq!(normalized_conclusive_count(&[]), 0);
1310 }
1311
1312 #[test]
1313 fn evaluate_normalized_reports_unexempted_survivors() {
1314 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1315 let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
1316 assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
1317 }
1318
1319 #[test]
1320 fn evaluate_normalized_drops_a_whole_file_exemption() {
1321 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1322 let kept =
1323 evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
1324 assert!(
1325 kept.is_empty(),
1326 "the whole-file exemption lifts both survivors"
1327 );
1328 }
1329
1330 #[test]
1331 fn evaluate_normalized_drops_a_line_scoped_exemption() {
1332 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1333 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
1334 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1335 assert_eq!(kept.len(), 1);
1336 assert_eq!(kept[0].line, 5);
1337 }
1338
1339 #[test]
1340 fn evaluate_normalized_rejects_exempting_a_caught_line() {
1341 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1342 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
1343 let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
1344 assert!(
1345 err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
1346 "got: {err}"
1347 );
1348 }
1349
1350 #[test]
1351 fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
1352 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1353 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
1354 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1355 assert_eq!(kept.len(), 2);
1356 }
1357
1358 const SAMPLE: &str = r#"{
1359 "outcomes": [
1360 {"scenario": "Baseline", "summary": "Success",
1361 "phase_results": []},
1362 {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
1363 "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
1364 "function": {"function_name": "is_positive"},
1365 "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
1366 "summary": "MissedMutant"},
1367 {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
1368 "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
1369 "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
1370 "summary": "CaughtMutant"}
1371 ],
1372 "total_mutants": 2
1373 }"#;
1374
1375 #[test]
1376 fn parses_the_outcomes_export() {
1377 let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
1378 assert_eq!(report.outcomes.len(), 3);
1379 assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
1380 }
1381
1382 #[test]
1383 fn collects_only_missed_mutants_as_survivors() {
1384 let report = parse_mutants_report(SAMPLE).unwrap();
1385 let survivors = unexplained_survivors(&report, &[]);
1386 assert_eq!(survivors.len(), 1);
1387 assert_eq!(survivors[0].file, "src/lib.rs");
1388 assert_eq!(survivors[0].line, 7);
1389 assert!(survivors[0].description.contains("replace > with =="));
1390 }
1391
1392 #[test]
1393 fn a_survivor_description_carries_no_location_prefix() {
1394 let report = parse_mutants_report(SAMPLE).unwrap();
1395 let survivors = unexplained_survivors(&report, &[]);
1396 assert_eq!(
1397 survivors[0].description, "replace > with == in is_positive",
1398 "the name's embedded `file:line:col:` prefix is stripped"
1399 );
1400 }
1401
1402 #[test]
1403 fn strip_embedded_location_removes_a_file_line_col_prefix() {
1404 assert_eq!(
1405 strip_embedded_location("src/lib.rs:7:5: replace > with == in is_positive"),
1406 "replace > with == in is_positive"
1407 );
1408 }
1409
1410 #[test]
1411 fn strip_embedded_location_keeps_a_name_without_one() {
1412 for name in [
1413 "replace add -> 0",
1414 "note: no location segment",
1415 "7:5: no file segment",
1416 "src/lib.rs:7:x: non-numeric column",
1417 "src/lib.rs:x:5: non-numeric line",
1418 ] {
1419 assert_eq!(strip_embedded_location(name), name);
1420 }
1421 }
1422
1423 #[test]
1424 fn conclusive_count_is_caught_plus_missed() {
1425 let report = parse_mutants_report(SAMPLE).unwrap();
1426 assert_eq!(conclusive_count(&report), 2);
1427 assert_eq!(conclusive_count(&MutantsReport { outcomes: vec![] }), 0);
1428 }
1429
1430 #[test]
1431 fn an_exemption_drops_a_survivor_in_that_file() {
1432 let report = parse_mutants_report(SAMPLE).unwrap();
1433 let exempt = vec!["src/lib.rs".to_string()];
1434 assert!(unexplained_survivors(&report, &exempt).is_empty());
1435 }
1436
1437 #[test]
1438 fn an_exemption_on_another_file_leaves_the_survivor() {
1439 let report = parse_mutants_report(SAMPLE).unwrap();
1440 let exempt = vec!["src/elsewhere.rs".to_string()];
1441 assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1442 }
1443
1444 const BASELINE_ONLY: &str = r#"{
1445 "outcomes": [
1446 {"scenario": "Baseline", "summary": "MissedMutant", "phase_results": []},
1447 {"scenario": "Baseline", "summary": "CaughtMutant", "phase_results": []}
1448 ],
1449 "total_mutants": 0
1450 }"#;
1451
1452 #[test]
1453 fn a_baseline_outcome_is_never_a_survivor() {
1454 let report = parse_mutants_report(BASELINE_ONLY).unwrap();
1455 assert!(unexplained_survivors(&report, &[]).is_empty());
1456 }
1457
1458 #[test]
1459 fn a_baseline_outcome_is_never_a_mutated_line() {
1460 let report = parse_mutants_report(BASELINE_ONLY).unwrap();
1461 assert!(mutated_lines(&report).is_empty());
1462 }
1463
1464 #[test]
1465 fn parse_base_diff_skips_a_malformed_hunk_header() {
1466 let diff = "\
1467diff --git a/src/lib.rs b/src/lib.rs
1468--- a/src/lib.rs
1469+++ b/src/lib.rs
1470@@ junk @@
1471";
1472 let parsed = parse_base_diff(diff);
1473 assert_eq!(parsed.files, vec!["src/lib.rs"]);
1474 assert!(parsed.inserted.is_empty());
1475 }
1476
1477 #[test]
1478 fn a_missing_base_diff_read_reports_its_path() {
1479 let missing = unique_tmp().join("base.diff");
1480 let err = read_base_diff(&missing).unwrap_err();
1481 let msg = format!("{err:#}");
1482 assert!(msg.contains("reading the written base diff"), "{msg}");
1483 }
1484
1485 #[test]
1486 fn a_missing_adapter_results_read_names_the_engine_and_path() {
1487 let missing = unique_tmp().join("results.json");
1488 let err = read_adapter_results(&missing, "TypeScript").unwrap_err();
1489 let msg = format!("{err:#}");
1490 assert!(
1491 msg.contains("TypeScript mutation adapter's results"),
1492 "{msg}"
1493 );
1494 }
1495
1496 #[test]
1497 fn manifest_dir_is_the_manifest_parent() {
1498 let dir = manifest_dir(Path::new("/w/Cargo.toml")).unwrap();
1499 assert_eq!(dir, Path::new("/w"));
1500 }
1501
1502 #[test]
1503 fn a_rootless_manifest_path_is_an_error() {
1504 let err = manifest_dir(Path::new("/")).unwrap_err();
1505 let msg = format!("{err:#}");
1506 assert!(msg.contains("no parent dir"), "{msg}");
1507 }
1508
1509 #[test]
1510 fn a_directory_outside_any_workspace_fails_locate_project() {
1511 let dir = unique_tmp();
1512 let err = cargo_workspace_root(&dir).unwrap_err();
1513 let msg = format!("{err:#}");
1514 assert!(msg.contains("cargo locate-project failed"), "{msg}");
1515 std::fs::remove_dir_all(&dir).ok();
1516 }
1517
1518 #[test]
1519 fn a_bad_base_ref_fails_the_base_diff() {
1520 let dir = unique_tmp();
1521 let init = Command::new("git")
1522 .current_dir(&dir)
1523 .args(["init", "-q"])
1524 .output()
1525 .unwrap();
1526 assert!(init.status.success());
1527 let out = MutantsOut::new();
1528 let err = write_base_diff(&dir, &dir, None, "tc-no-such-ref", &out).unwrap_err();
1529 let msg = format!("{err:#}");
1530 assert!(msg.contains("git diff"), "{msg}");
1531 std::fs::remove_dir_all(&dir).ok();
1532 }
1533
1534 #[test]
1535 fn a_python_adapter_failure_reports_the_adapter_output() {
1536 let dir = unique_tmp();
1537 let err = run_py_adapter(&dir, &[]).unwrap_err();
1538 let msg = format!("{err:#}");
1539 assert!(msg.contains("the Python mutation adapter failed"), "{msg}");
1540 std::fs::remove_dir_all(&dir).ok();
1541 }
1542
1543 #[test]
1544 fn rebase_report_paths_strips_the_workspace_prefix() {
1545 let report = parse_mutants_report(SAMPLE).unwrap();
1546 let prefixed = MutantsReport {
1547 outcomes: report
1548 .outcomes
1549 .iter()
1550 .cloned()
1551 .map(|mut outcome| {
1552 if let Scenario::Mutant(mutant) = &mut outcome.scenario {
1553 mutant.file = format!("member/{}", mutant.file);
1554 }
1555 outcome
1556 })
1557 .collect(),
1558 };
1559 let rebased = rebase_report_paths(prefixed, Some("member"));
1560 let survivors = unexplained_survivors(&rebased, &[]);
1561 assert_eq!(survivors.len(), 1);
1562 assert_eq!(survivors[0].file, "src/lib.rs");
1563 assert_eq!(rebased.outcomes.len(), 3);
1564 }
1565
1566 #[test]
1567 fn rebase_report_paths_drops_an_out_of_scope_mutant_and_keeps_none_identity() {
1568 let report = parse_mutants_report(SAMPLE).unwrap();
1569 let rebased = rebase_report_paths(report.clone(), Some("member"));
1570 assert_eq!(
1571 rebased.outcomes.len(),
1572 1,
1573 "only the pathless baseline outcome remains"
1574 );
1575 let unchanged = rebase_report_paths(report, None);
1576 assert_eq!(unchanged.outcomes.len(), 3);
1577 assert_eq!(unexplained_survivors(&unchanged, &[])[0].file, "src/lib.rs");
1578 }
1579
1580 #[test]
1581 fn adapter_cwd_normalises_the_empty_package_root_to_the_current_dir() {
1582 assert_eq!(
1586 adapter_cwd(Path::new(""), "TypeScript").unwrap(),
1587 Path::new(".")
1588 );
1589 assert_eq!(
1590 adapter_cwd(Path::new("src"), "TypeScript").unwrap(),
1591 Path::new("src")
1592 );
1593 }
1594
1595 #[test]
1596 fn adapter_cwd_rejects_a_directory_that_is_not_there() {
1597 let err = adapter_cwd(Path::new("no/such/dir"), "Python")
1600 .expect_err("a directory that is not there is an error");
1601 assert_eq!(
1602 err.to_string(),
1603 "the Python mutation adapter's working directory `no/such/dir` is not a directory"
1604 );
1605 }
1606
1607 #[test]
1608 fn spawn_context_names_the_interpreter_the_entry_and_the_working_directory() {
1609 assert_eq!(
1610 spawn_context("node", "/pkg/dist/mutation/main.js", Path::new("/pkg")),
1611 "spawning `node /pkg/dist/mutation/main.js` in `/pkg` (is `node` on PATH?)"
1612 );
1613 }
1614
1615 #[test]
1616 fn scan_prefix_is_the_scan_path_relative_to_the_package_root() {
1617 assert_eq!(
1618 scan_prefix(Path::new("/repo/pkg/src"), Path::new("/repo/pkg")),
1619 Some("src".to_string())
1620 );
1621 assert_eq!(
1622 scan_prefix(Path::new("/repo/pkg/src/nested"), Path::new("/repo/pkg")),
1623 Some("src/nested".to_string())
1624 );
1625 assert_eq!(
1626 scan_prefix(Path::new("/repo/pkg"), Path::new("/repo/pkg")),
1627 None
1628 );
1629 assert_eq!(
1630 scan_prefix(Path::new("pkg/src"), Path::new("pkg")),
1631 Some("src".to_string())
1632 );
1633 }
1634
1635 #[test]
1636 fn prefix_mutate_specs_rebases_diff_ranges_onto_the_package_root() {
1637 let specs = vec!["index.ts:8-11".to_string(), "a/b.ts:2-2".to_string()];
1638 assert_eq!(
1639 prefix_mutate_specs(specs.clone(), Some("src")),
1640 vec![
1641 "src/index.ts:8-11".to_string(),
1642 "src/a/b.ts:2-2".to_string()
1643 ]
1644 );
1645 assert_eq!(prefix_mutate_specs(specs.clone(), None), specs);
1646 }
1647
1648 #[test]
1649 fn scan_scoped_mutate_globs_mirror_strykers_default_under_the_scan_path() {
1650 assert_eq!(
1651 scan_scoped_mutate_globs("src"),
1652 vec![
1653 "src/**/!(*.+(s|S)pec|*.+(t|T)est).+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
1654 .to_string(),
1655 "!src/**/__tests__/**/*.+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
1656 .to_string(),
1657 ]
1658 );
1659 }
1660
1661 #[test]
1662 fn scan_scoped_test_file_globs_narrow_the_run_without_moving_the_runner_root() {
1663 assert_eq!(
1664 scan_scoped_test_file_globs("src"),
1665 vec!["src/**".to_string()]
1666 );
1667 assert_eq!(
1668 scan_scoped_test_file_globs("packages/core/src"),
1669 vec!["packages/core/src/**".to_string()]
1670 );
1671 }
1672
1673 #[test]
1674 fn to_scan_relative_strips_the_prefix_and_drops_out_of_scope_mutants() {
1675 let mutants = parse_normalized_results(
1676 r#"[
1677 {"file": "src/a.ts", "line": 2, "status": "survived", "mutator": "X"},
1678 {"file": "tests/e2e/t.ts", "line": 9, "status": "survived", "mutator": "X"}
1679 ]"#,
1680 )
1681 .unwrap();
1682 let rebased = to_scan_relative(mutants.clone(), Some("src"));
1683 assert_eq!(rebased.len(), 1, "the out-of-scan-path mutant is dropped");
1684 assert_eq!(rebased[0].file, "a.ts");
1685 let unchanged = to_scan_relative(mutants, None);
1686 assert_eq!(unchanged.len(), 2);
1687 assert_eq!(unchanged[0].file, "src/a.ts");
1688 }
1689
1690 #[test]
1691 fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
1692 assert!(is_mutatable_ts("src/index.ts"));
1693 assert!(is_mutatable_ts("src/util.tsx"));
1694 assert!(is_mutatable_ts("src/util.js"));
1695 assert!(!is_mutatable_ts("src/index.test.ts"));
1696 assert!(!is_mutatable_ts("src/index.spec.ts"));
1697 assert!(!is_mutatable_ts("src/types.d.ts"));
1698 assert!(!is_mutatable_ts("README.md"));
1699 }
1700
1701 #[test]
1702 fn contiguous_runs_collapses_adjacent_lines() {
1703 let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
1704 assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
1705 assert!(contiguous_runs(&BTreeSet::new()).is_empty());
1706 }
1707
1708 #[test]
1709 fn one_line_flattens_and_caps() {
1710 assert_eq!(one_line("a -\n b"), "a - b");
1711 let long = "x".repeat(80);
1712 let capped = one_line(&long);
1713 assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
1714 }
1715
1716 #[test]
1717 fn is_mutatable_py_keeps_sources_and_drops_tests() {
1718 assert!(is_mutatable_py("calc.py"));
1719 assert!(is_mutatable_py("pkg/util.py"));
1720 assert!(!is_mutatable_py("calc_test.py"));
1721 assert!(!is_mutatable_py("test_calc.py"));
1722 assert!(!is_mutatable_py("pkg/conftest.py"));
1723 assert!(!is_mutatable_py("README.md"));
1724 }
1725
1726 #[test]
1727 fn mutated_lines_collects_caught_and_missed() {
1728 let report = parse_mutants_report(SAMPLE).unwrap();
1729 assert_eq!(
1730 mutated_lines(&report),
1731 [
1732 ("src/lib.rs".to_string(), 7),
1733 ("src/other.rs".to_string(), 3)
1734 ]
1735 .into_iter()
1736 .collect()
1737 );
1738 }
1739
1740 #[test]
1741 fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1742 let report = parse_mutants_report(SAMPLE).unwrap();
1743 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1744 let kept = evaluate_scoped(
1745 cargo_mutants_survivors(&report),
1746 &mutated_lines(&report),
1747 &[],
1748 &line_scoped,
1749 )
1750 .unwrap();
1751 assert!(
1752 kept.is_empty(),
1753 "the src/lib.rs:7 survivor should be lifted"
1754 );
1755 }
1756
1757 #[test]
1758 fn evaluate_scoped_rejects_exempting_a_caught_line() {
1759 let report = parse_mutants_report(SAMPLE).unwrap();
1760 let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
1761 let err = evaluate_scoped(
1762 cargo_mutants_survivors(&report),
1763 &mutated_lines(&report),
1764 &[],
1765 &line_scoped,
1766 )
1767 .unwrap_err();
1768 assert!(
1769 err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
1770 "got: {err}"
1771 );
1772 }
1773
1774 #[test]
1775 fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
1776 let report = parse_mutants_report(SAMPLE).unwrap();
1777 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
1778 let kept = evaluate_scoped(
1779 cargo_mutants_survivors(&report),
1780 &mutated_lines(&report),
1781 &[],
1782 &line_scoped,
1783 )
1784 .unwrap();
1785 assert_eq!(kept.len(), 1);
1786 assert_eq!(kept[0].line, 7);
1787 }
1788
1789 #[test]
1790 fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1791 let report = parse_mutants_report(SAMPLE).unwrap();
1792 let kept = evaluate_scoped(
1793 cargo_mutants_survivors(&report),
1794 &mutated_lines(&report),
1795 &["src/lib.rs".to_string()],
1796 &BTreeMap::new(),
1797 )
1798 .unwrap();
1799 assert!(kept.is_empty());
1800 }
1801
1802 fn unique_tmp() -> PathBuf {
1803 static COUNTER: AtomicU64 = AtomicU64::new(0);
1804 let dir = std::env::temp_dir().join(format!(
1805 "tc-provision-test-{}-{}",
1806 std::process::id(),
1807 COUNTER.fetch_add(1, Ordering::Relaxed)
1808 ));
1809 std::fs::create_dir_all(&dir).unwrap();
1810 dir
1811 }
1812
1813 enum Install {
1814 MustNotRun,
1815 WritesNothing,
1816 WritesBin,
1817 Fails,
1818 CountsSleepsAndWritesBin(std::sync::Arc<AtomicU64>),
1819 }
1820
1821 fn write_bin(bin: &Path) {
1822 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1823 std::fs::write(bin, b"binary").unwrap();
1824 }
1825
1826 fn drive_provision(bin: &Path, lock: &Path, install: Install) -> Result<PathBuf> {
1827 provision(bin, lock, || match install {
1828 Install::MustNotRun => panic!("must not reinstall"),
1829 Install::WritesNothing => Ok(()),
1830 Install::WritesBin => {
1831 write_bin(bin);
1832 Ok(())
1833 }
1834 Install::Fails => bail!("install blew up"),
1835 Install::CountsSleepsAndWritesBin(count) => {
1836 count.fetch_add(1, Ordering::SeqCst);
1837 std::thread::sleep(std::time::Duration::from_millis(50));
1838 write_bin(bin);
1839 Ok(())
1840 }
1841 })
1842 }
1843
1844 #[test]
1845 fn provision_returns_an_existing_binary_without_installing() {
1846 let tmp = unique_tmp();
1847 let bin = tmp.join("bin").join("cargo-mutants");
1848 let lock = tmp.join(".install.lock");
1849 write_bin(&bin);
1850 let got = drive_provision(&bin, &lock, Install::MustNotRun).unwrap();
1851 assert_eq!(got, bin);
1852 std::fs::remove_dir_all(&tmp).unwrap();
1853 }
1854
1855 #[test]
1856 fn the_must_not_run_sentinel_panics_when_installation_runs() {
1857 let tmp = unique_tmp();
1858 std::fs::create_dir_all(&tmp).unwrap();
1859 let bin = tmp.join("bin").join("cargo-mutants");
1860 let lock = tmp.join(".install.lock");
1861 let panicked =
1862 std::panic::catch_unwind(|| drive_provision(&bin, &lock, Install::MustNotRun)).is_err();
1863 std::fs::remove_dir_all(&tmp).unwrap();
1864 assert!(panicked);
1865 }
1866
1867 #[test]
1868 fn provision_with_a_rootless_lock_path_fails_to_open_the_lock() {
1869 let bin = unique_tmp().join("bin").join("cargo-mutants");
1870 let err = drive_provision(&bin, Path::new("/"), Install::WritesNothing).unwrap_err();
1871 let msg = format!("{err:#}");
1872 assert!(msg.contains("opening the provisioning lock"), "{msg}");
1873 }
1874
1875 #[test]
1876 fn provision_installs_when_the_binary_is_absent() {
1877 let tmp = unique_tmp();
1878 let bin = tmp.join("bin").join("cargo-mutants");
1879 let lock = tmp.join(".install.lock");
1880 let got = drive_provision(&bin, &lock, Install::WritesBin).unwrap();
1881 assert_eq!(got, bin);
1882 assert_eq!(
1883 std::fs::read(&bin).unwrap(),
1884 b"binary",
1885 "an absent binary must be installed"
1886 );
1887 std::fs::remove_dir_all(&tmp).unwrap();
1888 }
1889
1890 #[test]
1891 fn provision_errors_when_install_produces_no_binary() {
1892 let tmp = unique_tmp();
1893 let bin = tmp.join("bin").join("cargo-mutants");
1894 let lock = tmp.join(".install.lock");
1895 let err = drive_provision(&bin, &lock, Install::WritesNothing).unwrap_err();
1896 assert!(
1897 err.to_string().contains("cargo-mutants is not at"),
1898 "got: {err}"
1899 );
1900 std::fs::remove_dir_all(&tmp).unwrap();
1901 }
1902
1903 #[test]
1904 fn provision_propagates_an_install_failure() {
1905 let tmp = unique_tmp();
1906 let bin = tmp.join("bin").join("cargo-mutants");
1907 let lock = tmp.join(".install.lock");
1908 let err = drive_provision(&bin, &lock, Install::Fails).unwrap_err();
1909 assert!(err.to_string().contains("install blew up"), "got: {err}");
1910 std::fs::remove_dir_all(&tmp).unwrap();
1911 }
1912
1913 #[test]
1914 fn provision_does_not_duplicate_the_install_under_concurrent_callers() {
1915 use std::sync::{Arc, Barrier};
1919 use std::thread;
1920
1921 let tmp = unique_tmp();
1922 let bin = tmp.join("bin").join("cargo-mutants");
1923 let lock = tmp.join(".install.lock");
1924 let install_count = Arc::new(AtomicU64::new(0));
1925 let barrier = Arc::new(Barrier::new(2));
1926
1927 let handles: Vec<_> = (0..2)
1928 .map(|_| {
1929 let bin = bin.clone();
1930 let lock = lock.clone();
1931 let install_count = Arc::clone(&install_count);
1932 let barrier = Arc::clone(&barrier);
1933 thread::spawn(move || {
1934 barrier.wait();
1935 drive_provision(
1936 &bin,
1937 &lock,
1938 Install::CountsSleepsAndWritesBin(install_count),
1939 )
1940 })
1941 })
1942 .collect();
1943
1944 for h in handles {
1945 h.join()
1946 .expect("provisioning thread must not panic")
1947 .unwrap();
1948 }
1949
1950 assert_eq!(
1951 install_count.load(Ordering::SeqCst),
1952 1,
1953 "two concurrent callers on a cold cache must share one install, not each run their own"
1954 );
1955 std::fs::remove_dir_all(&tmp).unwrap();
1956 }
1957
1958 #[test]
1959 fn resolve_cache_base_prefers_xdg_then_home_then_temp() {
1960 let xdg = |s: &str| Some(OsString::from(s));
1961 assert_eq!(
1962 resolve_cache_base(xdg("/xdg"), xdg("/home")),
1963 PathBuf::from("/xdg")
1964 );
1965 assert_eq!(
1966 resolve_cache_base(xdg(""), xdg("/home")),
1967 PathBuf::from("/home/.cache")
1968 );
1969 assert_eq!(
1970 resolve_cache_base(None, xdg("/home")),
1971 PathBuf::from("/home/.cache")
1972 );
1973 assert_eq!(resolve_cache_base(None, None), std::env::temp_dir());
1974 assert_eq!(
1975 resolve_cache_base(xdg(""), Some(OsString::new())),
1976 std::env::temp_dir()
1977 );
1978 }
1979
1980 #[test]
1981 fn cache_root_is_absolute_and_version_scoped() {
1982 let root = cargo_mutants_cache_root();
1983 assert!(
1984 root.ends_with(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}")),
1985 "version-scoped; got {root:?}"
1986 );
1987 assert!(
1988 root.to_string_lossy().contains("testing-conventions"),
1989 "tool-namespaced; got {root:?}"
1990 );
1991 assert!(
1992 root.is_absolute(),
1993 "expected an absolute path; got {root:?}"
1994 );
1995 }
1996
1997 #[test]
1998 fn install_argv_pins_the_version_and_isolates_the_root() {
1999 let argv: Vec<String> = install_argv(Path::new("/cache/cargo-mutants-27"))
2000 .iter()
2001 .map(|arg| arg.to_string_lossy().into_owned())
2002 .collect();
2003 assert_eq!(
2004 argv,
2005 vec![
2006 "install",
2007 "cargo-mutants",
2008 "--locked",
2009 "--version",
2010 CARGO_MUTANTS_VERSION,
2011 "--root",
2012 "/cache/cargo-mutants-27",
2013 ]
2014 );
2015 }
2016
2017 #[test]
2018 fn mutants_argv_enables_features_on_the_engine_itself() {
2019 let argv = |diff, features: &[&str]| -> Vec<String> {
2020 mutants_argv(
2021 Path::new("/out"),
2022 diff,
2023 &features.iter().map(|f| f.to_string()).collect::<Vec<_>>(),
2024 )
2025 .iter()
2026 .map(|arg| arg.to_string_lossy().into_owned())
2027 .collect()
2028 };
2029 assert_eq!(
2030 argv(None, &["cli", "boost"]),
2031 vec!["mutants", "--output", "/out", "--features", "cli,boost"]
2032 );
2033 assert_eq!(
2034 argv(Some(Path::new("/out/base.diff")), &["cli"]),
2035 vec![
2036 "mutants",
2037 "--output",
2038 "/out",
2039 "--in-diff",
2040 "/out/base.diff",
2041 "--features",
2042 "cli",
2043 ]
2044 );
2045 assert_eq!(argv(None, &[]), vec!["mutants", "--output", "/out"]);
2046 }
2047
2048 #[test]
2049 fn list_argv_mirrors_the_run_feature_selection() {
2050 let argv = |features: &[&str]| -> Vec<String> {
2051 list_argv(&features.iter().map(|f| f.to_string()).collect::<Vec<_>>())
2052 .iter()
2053 .map(|arg| arg.to_string_lossy().into_owned())
2054 .collect()
2055 };
2056 assert_eq!(argv(&[]), vec!["mutants", "--list", "--json"]);
2057 assert_eq!(
2058 argv(&["cli", "boost"]),
2059 vec!["mutants", "--list", "--json", "--features", "cli,boost"]
2060 );
2061 }
2062
2063 #[test]
2064 fn parse_base_diff_maps_inserted_lines_per_hunk() {
2065 let diff = "\
2066diff --git a/src/lib.rs b/src/lib.rs
2067--- a/src/lib.rs
2068+++ b/src/lib.rs
2069@@ -1,4 +1,5 @@
2070 fn a() {}
2071+fn b() {}
2072 fn c() {}
2073-fn d() {}
2074+fn e() {}
2075 fn f() {}
2076@@ -10,2 +11,4 @@
2077 tail
2078+one
2079+two
2080 more
2081";
2082 let parsed = parse_base_diff(diff);
2083 assert_eq!(parsed.files, vec!["src/lib.rs"]);
2084 assert_eq!(
2085 parsed.inserted.get("src/lib.rs"),
2086 Some(&BTreeSet::from([2, 4, 12, 13]))
2087 );
2088 }
2089
2090 #[test]
2091 fn parse_base_diff_leaves_a_deletion_only_file_without_inserted_lines() {
2092 let diff = "\
2093--- a/src/gone.rs
2094+++ b/src/gone.rs
2095@@ -5,2 +4,0 @@
2096-x
2097-y
2098";
2099 let parsed = parse_base_diff(diff);
2100 assert_eq!(parsed.files, vec!["src/gone.rs"]);
2101 assert!(parsed.inserted.is_empty());
2102 }
2103
2104 #[test]
2105 fn parse_base_diff_skips_a_deleted_file() {
2106 let diff = "\
2107--- a/src/dead.rs
2108+++ /dev/null
2109@@ -1,2 +0,0 @@
2110-a
2111-b
2112";
2113 let parsed = parse_base_diff(diff);
2114 assert!(parsed.files.is_empty());
2115 assert!(parsed.inserted.is_empty());
2116 }
2117
2118 #[test]
2119 fn parse_base_diff_consumes_hunk_bodies_by_count_so_content_never_reads_as_a_header() {
2120 let diff = "\
2123+++ b/notes.txt
2124@@ -1,1 +1,2 @@
2125 keep
2126++++ not a header
2127";
2128 let parsed = parse_base_diff(diff);
2129 assert_eq!(parsed.files, vec!["notes.txt"]);
2130 assert_eq!(parsed.inserted.get("notes.txt"), Some(&BTreeSet::from([2])));
2131 }
2132
2133 #[test]
2134 fn parse_base_diff_defaults_an_elided_hunk_count_to_one() {
2135 let diff = "\
2136+++ b/one.txt
2137@@ -1 +1 @@
2138-old
2139+new
2140";
2141 let parsed = parse_base_diff(diff);
2142 assert_eq!(parsed.inserted.get("one.txt"), Some(&BTreeSet::from([1])));
2143 }
2144
2145 #[test]
2146 fn parse_base_diff_skips_no_newline_annotations_mid_hunk() {
2147 let diff = "\
2148+++ b/n.txt
2149@@ -1 +1 @@
2150-old
2151\\ No newline at end of file
2152+new
2153\\ No newline at end of file
2154";
2155 let parsed = parse_base_diff(diff);
2156 assert_eq!(parsed.inserted.get("n.txt"), Some(&BTreeSet::from([1])));
2157 }
2158
2159 #[cfg(unix)]
2160 fn fake_output(code: i32, stderr: &str) -> Output {
2161 use std::os::unix::process::ExitStatusExt;
2162 Output {
2163 status: std::process::ExitStatus::from_raw(code << 8),
2164 stdout: Vec::new(),
2165 stderr: stderr.as_bytes().to_vec(),
2166 }
2167 }
2168
2169 #[cfg(unix)]
2170 enum FakeRun {
2171 AssertsVersionAndSucceeds,
2172 FailsWith(&'static str),
2173 SpawnError,
2174 }
2175
2176 #[cfg(unix)]
2177 fn drive_install(root: &Path, run: FakeRun) -> Result<()> {
2178 run_install(root, |command| match run {
2179 FakeRun::AssertsVersionAndSucceeds => {
2180 let argv: Vec<String> = command
2181 .get_args()
2182 .map(|arg| arg.to_string_lossy().into_owned())
2183 .collect();
2184 assert!(argv.contains(&CARGO_MUTANTS_VERSION.to_string()));
2185 Ok(fake_output(0, ""))
2186 }
2187 FakeRun::FailsWith(stderr) => Ok(fake_output(1, stderr)),
2188 FakeRun::SpawnError => Err(std::io::Error::new(
2189 std::io::ErrorKind::NotFound,
2190 "no cargo",
2191 )),
2192 })
2193 }
2194
2195 #[cfg(unix)]
2196 #[test]
2197 fn run_install_succeeds_on_a_zero_exit() {
2198 drive_install(Path::new("/cache/root"), FakeRun::AssertsVersionAndSucceeds).unwrap();
2199 }
2200
2201 #[cfg(unix)]
2202 #[test]
2203 fn run_install_reports_a_nonzero_exit_with_the_engine_output() {
2204 let err = drive_install(
2205 Path::new("/cache/root"),
2206 FakeRun::FailsWith("error: could not compile cargo-mutants"),
2207 )
2208 .unwrap_err();
2209 assert!(
2210 err.to_string()
2211 .contains("failed to provision cargo-mutants")
2212 && err.to_string().contains("could not compile"),
2213 "got: {err}"
2214 );
2215 }
2216
2217 #[cfg(unix)]
2218 #[test]
2219 fn run_install_propagates_a_spawn_failure() {
2220 let err = drive_install(Path::new("/cache/root"), FakeRun::SpawnError).unwrap_err();
2221 assert!(
2222 err.to_string().contains("is cargo installed?"),
2223 "got: {err}"
2224 );
2225 }
2226
2227 #[cfg(unix)]
2228 #[test]
2229 fn provision_pinned_installs_via_the_injected_runner() {
2230 let root = unique_tmp();
2231 let bin = root.join("bin").join(CARGO_MUTANTS_BIN_NAME);
2232 let expected = bin.clone();
2233 let got = provision_pinned(&root, |_| {
2234 write_bin(&bin);
2235 Ok(fake_output(0, ""))
2236 })
2237 .unwrap();
2238 assert_eq!(got, expected);
2239 std::fs::remove_dir_all(&root).unwrap();
2240 }
2241
2242 #[test]
2243 fn execute_surfaces_a_spawn_failure() {
2244 let err = execute(&mut Command::new("/nonexistent-tc-cargo")).unwrap_err();
2245 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
2246 }
2247
2248 #[cfg(unix)]
2249 fn fake_stdout(code: i32, stdout: &str) -> Output {
2250 use std::os::unix::process::ExitStatusExt;
2251 Output {
2252 status: std::process::ExitStatus::from_raw(code << 8),
2253 stdout: stdout.as_bytes().to_vec(),
2254 stderr: Vec::new(),
2255 }
2256 }
2257
2258 #[cfg(unix)]
2259 enum FakeList {
2260 AssertsArgvAndReturns(&'static str, Vec<&'static str>),
2261 FailsWith(&'static str),
2262 SpawnError,
2263 }
2264
2265 #[cfg(unix)]
2266 fn drive_list(features: &[String], run: FakeList) -> Result<Vec<MutantInfo>> {
2267 list_cargo_mutants(
2268 Path::new("/cache/bin/cargo-mutants"),
2269 Path::new("/crate"),
2270 features,
2271 |command| match run {
2272 FakeList::AssertsArgvAndReturns(json, expected) => {
2273 let argv: Vec<String> = command
2274 .get_args()
2275 .map(|arg| arg.to_string_lossy().into_owned())
2276 .collect();
2277 assert_eq!(argv, expected);
2278 assert_eq!(command.get_current_dir(), Some(Path::new("/crate")));
2279 Ok(fake_stdout(0, json))
2280 }
2281 FakeList::FailsWith(stderr) => Ok(fake_output(1, stderr)),
2282 FakeList::SpawnError => Err(std::io::Error::new(
2283 std::io::ErrorKind::NotFound,
2284 "no engine",
2285 )),
2286 },
2287 )
2288 }
2289
2290 #[cfg(unix)]
2291 #[test]
2292 fn list_cargo_mutants_parses_the_listing_from_a_clean_run() {
2293 let json = r#"[{"file": "src/lib.rs", "name": "replace add -> 0",
2294 "span": {"start": {"line": 3, "column": 1}, "end": {"line": 5, "column": 2}}}]"#;
2295 let listed = drive_list(
2296 &["cli".to_string()],
2297 FakeList::AssertsArgvAndReturns(
2298 json,
2299 vec!["mutants", "--list", "--json", "--features", "cli"],
2300 ),
2301 )
2302 .unwrap();
2303 assert_eq!(listed.len(), 1);
2304 assert_eq!(listed[0].file, "src/lib.rs");
2305 assert_eq!(listed[0].span.start.line, 3);
2306 assert_eq!(listed[0].span.end.line, 5);
2307 assert_eq!(listed[0].name, "replace add -> 0");
2308 }
2309
2310 #[cfg(unix)]
2311 #[test]
2312 fn list_cargo_mutants_reports_a_nonzero_exit_with_the_engine_output() {
2313 let err = drive_list(&[], FakeList::FailsWith("error: no such option")).unwrap_err();
2314 assert!(
2315 err.to_string().contains("cargo-mutants --list failed")
2316 && err.to_string().contains("no such option"),
2317 "got: {err}"
2318 );
2319 }
2320
2321 #[cfg(unix)]
2322 #[test]
2323 fn list_cargo_mutants_propagates_a_spawn_failure() {
2324 let err = drive_list(&[], FakeList::SpawnError).unwrap_err();
2325 assert!(
2326 err.to_string()
2327 .contains("listing the crate's mutants with cargo-mutants"),
2328 "got: {err}"
2329 );
2330 }
2331
2332 #[cfg(unix)]
2333 fn listed_mutant(file: &str, start: u32, end: u32, name: &str) -> MutantInfo {
2334 MutantInfo {
2335 file: file.to_string(),
2336 span: Span {
2337 start: LineCol { line: start },
2338 end: LineCol { line: end },
2339 },
2340 name: name.to_string(),
2341 }
2342 }
2343
2344 #[cfg(unix)]
2345 fn diff_with_inserted(file: &str, lines: &[u32]) -> BaseDiff {
2346 BaseDiff {
2347 files: vec![file.to_string()],
2348 inserted: BTreeMap::from([(file.to_string(), lines.iter().copied().collect())]),
2349 }
2350 }
2351
2352 #[cfg(unix)]
2353 #[test]
2354 fn zero_mutant_verdict_accepts_a_zero_with_no_mutant_on_the_inserted_lines() {
2355 let run = fake_output(0, "");
2356 zero_mutant_verdict(&[], &diff_with_inserted("src/lib.rs", &[5]), &run).unwrap();
2357 let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2358 zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[4, 9]), &run).unwrap();
2359 zero_mutant_verdict(&listed, &diff_with_inserted("src/other.rs", &[6]), &run).unwrap();
2360 }
2361
2362 #[cfg(unix)]
2363 #[test]
2364 fn zero_mutant_verdict_is_fatal_on_a_mutant_at_either_span_boundary() {
2365 let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2366 let run = fake_stdout(0, "0 mutants tested");
2367 for line in [5, 8] {
2368 let err =
2369 zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[line]), &run)
2370 .unwrap_err();
2371 let message = err.to_string();
2372 assert!(
2373 message.contains("1 of the crate's 1 mutant site(s)")
2374 && message.contains("src/lib.rs:5: replace add -> 0")
2375 && message.contains("0 mutants tested"),
2376 "got: {message}"
2377 );
2378 }
2379 }
2380
2381 #[cfg(unix)]
2382 #[test]
2383 fn zero_mutant_verdict_names_each_dropped_site_once() {
2384 let listed = [
2385 listed_mutant(
2386 "src/lib.rs",
2387 7,
2388 7,
2389 "src/lib.rs:7:7: replace > with == in is_positive",
2390 ),
2391 listed_mutant("src/lib.rs", 7, 7, "replace add -> 0"),
2392 ];
2393 let run = fake_stdout(0, "0 mutants tested");
2394 let message = zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[7]), &run)
2395 .unwrap_err()
2396 .to_string();
2397 assert!(
2398 message.contains(" src/lib.rs:7: replace > with == in is_positive"),
2399 "the name's embedded `file:line:col:` prefix is stripped; got: {message}"
2400 );
2401 assert!(
2402 !message.contains(": src/lib.rs:7:7:"),
2403 "a dropped site carries one location; got: {message}"
2404 );
2405 assert!(
2406 message.contains(" src/lib.rs:7: replace add -> 0"),
2407 "a name with no embedded location keeps its rendered location; got: {message}"
2408 );
2409 }
2410
2411 #[cfg(unix)]
2412 #[test]
2413 fn classify_mutants_exit_accepts_the_caught_and_survivor_exits() {
2414 classify_mutants_exit(Path::new("/crate"), &fake_output(0, "")).unwrap();
2415 classify_mutants_exit(Path::new("/crate"), &fake_output(2, "")).unwrap();
2416 }
2417
2418 #[cfg(unix)]
2419 #[test]
2420 fn classify_mutants_exit_accepts_a_timeout_exit_3() {
2421 classify_mutants_exit(Path::new("/crate"), &fake_output(3, ""))
2422 .expect("a timeout (exit 3) is inconclusive, not fatal");
2423 }
2424
2425 #[cfg(unix)]
2426 #[test]
2427 fn classify_mutants_exit_is_fatal_on_a_baseline_failure() {
2428 let err = classify_mutants_exit(Path::new("/crate"), &fake_output(4, "baseline broke"))
2429 .unwrap_err();
2430 assert!(
2431 err.to_string().contains("did not run cleanly")
2432 && err.to_string().contains("baseline broke"),
2433 "got: {err}"
2434 );
2435 }
2436
2437 #[test]
2438 fn cargo_mutants_bin_name_matches_the_platform() {
2439 #[cfg(windows)]
2440 assert_eq!(CARGO_MUTANTS_BIN_NAME, "cargo-mutants.exe");
2441 #[cfg(not(windows))]
2442 assert_eq!(CARGO_MUTANTS_BIN_NAME, "cargo-mutants");
2443 }
2444}