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;
13use syn::spanned::Spanned;
14use syn::visit::{self, Visit};
15
16use crate::colocated_test::Language;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Survivor {
21 pub file: String,
24 pub line: u32,
26 pub description: String,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Measurement {
35 EngineNotRun,
37 Tested {
40 count: usize,
41 survivors: Vec<Survivor>,
42 },
43}
44
45pub type MutatedLines = BTreeSet<(String, u32)>;
49
50fn is_declaration_only(base: &Path, file: &str, language: Language) -> bool {
54 match std::fs::read_to_string(base.join(file)) {
55 Ok(source) => !language.is_subject(&source, Path::new(file)),
56 Err(_) => false,
57 }
58}
59
60fn is_hidden_from_tests(base: &Path, file: &str, line: u32) -> bool {
64 let source = std::fs::read_to_string(base.join(file)).unwrap_or_default();
65 lines_hidden_from_tests(&source).contains(&line)
66}
67
68fn lines_hidden_from_tests(source: &str) -> BTreeSet<u32> {
75 let Ok(ast) = syn::parse_file(source) else {
76 return BTreeSet::new();
77 };
78 let mut hidden = HiddenItems::default();
79 hidden.visit_file(&ast);
80 hidden.lines
81}
82
83#[derive(Default)]
86struct HiddenItems {
87 lines: BTreeSet<u32>,
88}
89
90impl HiddenItems {
91 fn gated(&mut self, attrs: &[syn::Attribute], node: &dyn Spanned) {
92 if !crate::isolation::has_cfg_not_test(attrs) {
93 return;
94 }
95 let span = node.span();
96 self.lines
97 .extend(span.start().line as u32..=span.end().line as u32);
98 }
99}
100
101impl<'ast> Visit<'ast> for HiddenItems {
102 fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
103 self.gated(&node.attrs, node);
104 visit::visit_item_fn(self, node);
105 }
106
107 fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
108 self.gated(&node.attrs, node);
109 visit::visit_item_mod(self, node);
110 }
111
112 fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
113 self.gated(&node.attrs, node);
114 visit::visit_item_impl(self, node);
115 }
116
117 fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
118 self.gated(&node.attrs, node);
119 visit::visit_impl_item_fn(self, node);
120 }
121}
122
123#[derive(Debug, Clone, Deserialize)]
126pub struct MutantsReport {
127 pub outcomes: Vec<MutantOutcome>,
128}
129
130#[derive(Debug, Clone, Deserialize)]
134pub struct MutantOutcome {
135 pub summary: String,
136 pub scenario: Scenario,
137}
138
139#[derive(Debug, Clone, Deserialize)]
142pub enum Scenario {
143 Baseline,
144 Mutant(MutantInfo),
145}
146
147#[derive(Debug, Clone, Deserialize)]
151pub struct MutantInfo {
152 pub file: String,
153 pub span: Span,
154 pub name: String,
155}
156
157#[derive(Debug, Clone, Deserialize)]
159pub struct Span {
160 pub start: LineCol,
161 pub end: LineCol,
162}
163
164#[derive(Debug, Clone, Deserialize)]
166pub struct LineCol {
167 pub line: u32,
168}
169
170pub fn parse_mutants_report(json: &str) -> Result<MutantsReport> {
172 serde_json::from_str(json).context("parsing cargo-mutants outcomes.json")
173}
174
175fn parse_mutants_list(json: &str) -> Result<Vec<MutantInfo>> {
178 serde_json::from_str(json).context("parsing the cargo-mutants mutant list")
179}
180
181pub fn unexplained_survivors(report: &MutantsReport, exempt: &[String]) -> Vec<Survivor> {
185 evaluate(cargo_mutants_survivors(report), exempt)
186}
187
188fn cargo_mutants_survivors(report: &MutantsReport) -> Vec<Survivor> {
192 report
193 .outcomes
194 .iter()
195 .filter_map(|outcome| {
196 if outcome.summary != "MissedMutant" {
197 return None;
198 }
199 let Scenario::Mutant(mutant) = &outcome.scenario else {
200 return None;
201 };
202 Some(Survivor {
203 file: mutant.file.clone(),
204 line: mutant.span.start.line,
205 description: strip_embedded_location(&mutant.name).to_string(),
206 })
207 })
208 .collect()
209}
210
211fn strip_embedded_location(name: &str) -> &str {
214 let Some((location, description)) = name.split_once(": ") else {
215 return name;
216 };
217 let mut parts = location.rsplitn(3, ':');
218 let numeric = |part: Option<&str>| part.is_some_and(|p| p.parse::<u32>().is_ok());
219 if numeric(parts.next()) && numeric(parts.next()) && parts.next().is_some() {
220 description
221 } else {
222 name
223 }
224}
225
226pub fn mutated_lines(report: &MutantsReport) -> MutatedLines {
230 report
231 .outcomes
232 .iter()
233 .filter_map(|outcome| {
234 if outcome.summary != "CaughtMutant" && outcome.summary != "MissedMutant" {
235 return None;
236 }
237 let Scenario::Mutant(mutant) = &outcome.scenario else {
238 return None;
239 };
240 Some((mutant.file.clone(), mutant.span.start.line))
241 })
242 .collect()
243}
244
245fn conclusive_count(report: &MutantsReport) -> usize {
249 report
250 .outcomes
251 .iter()
252 .filter(|outcome| outcome.summary == "CaughtMutant" || outcome.summary == "MissedMutant")
253 .count()
254}
255
256pub fn evaluate(survivors: Vec<Survivor>, exempt: &[String]) -> Vec<Survivor> {
259 survivors
260 .into_iter()
261 .filter(|survivor| !exempt.iter().any(|path| path == &survivor.file))
262 .collect()
263}
264
265pub fn evaluate_scoped(
269 survivors: Vec<Survivor>,
270 mutated: &MutatedLines,
271 whole_file: &[String],
272 line_scoped: &BTreeMap<String, BTreeSet<u32>>,
273) -> Result<Vec<Survivor>> {
274 let mut over: Vec<String> = Vec::new();
275 for (file, lines) in line_scoped {
276 for &line in lines {
277 let has_survivor = survivors
278 .iter()
279 .any(|survivor| survivor.file == *file && survivor.line == line);
280 if has_survivor {
281 continue;
282 }
283 if mutated.contains(&(file.clone(), line)) {
284 over.push(format!("\n {file}:{line}"));
285 }
286 }
287 }
288 if !over.is_empty() {
289 bail!(
290 "a line-scoped mutation exemption may only list a line with a surviving mutant, but \
291 these had mutants that were all caught:{}",
292 over.concat()
293 );
294 }
295 Ok(survivors
296 .into_iter()
297 .filter(|survivor| {
298 let whole = whole_file.iter().any(|path| path == &survivor.file);
299 let line = line_scoped
300 .get(&survivor.file)
301 .is_some_and(|lines| lines.contains(&survivor.line));
302 !(whole || line)
303 })
304 .collect())
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
311#[serde(rename_all = "snake_case")]
312pub enum MutantStatus {
313 Survived,
315 Killed,
317 NoCoverage,
319 Timeout,
321 CompileError,
323 RuntimeError,
325}
326
327impl MutantStatus {
328 fn is_survivor(self) -> bool {
331 matches!(self, MutantStatus::Survived | MutantStatus::NoCoverage)
332 }
333
334 fn is_viable(self) -> bool {
337 matches!(
338 self,
339 MutantStatus::Survived
340 | MutantStatus::Killed
341 | MutantStatus::NoCoverage
342 | MutantStatus::Timeout
343 )
344 }
345
346 fn is_conclusive(self) -> bool {
350 matches!(
351 self,
352 MutantStatus::Survived | MutantStatus::Killed | MutantStatus::NoCoverage
353 )
354 }
355}
356
357#[derive(Debug, Clone, Deserialize)]
360pub struct NormalizedMutant {
361 pub file: String,
363 pub line: u32,
365 pub status: MutantStatus,
367 pub mutator: String,
369 #[serde(default)]
371 pub replacement: Option<String>,
372}
373
374pub fn parse_normalized_results(json: &str) -> Result<Vec<NormalizedMutant>> {
377 serde_json::from_str(json).context("parsing normalized mutation results")
378}
379
380pub fn evaluate_normalized(
384 mutants: &[NormalizedMutant],
385 whole_file: &[String],
386 line_scoped: &BTreeMap<String, BTreeSet<u32>>,
387) -> Result<Vec<Survivor>> {
388 evaluate_scoped(
389 normalized_survivors(mutants),
390 &normalized_mutated_lines(mutants),
391 whole_file,
392 line_scoped,
393 )
394}
395
396fn normalized_survivors(mutants: &[NormalizedMutant]) -> Vec<Survivor> {
398 mutants
399 .iter()
400 .filter(|mutant| mutant.status.is_survivor())
401 .map(|mutant| Survivor {
402 file: mutant.file.clone(),
403 line: mutant.line,
404 description: describe_normalized(mutant),
405 })
406 .collect()
407}
408
409fn normalized_mutated_lines(mutants: &[NormalizedMutant]) -> MutatedLines {
412 mutants
413 .iter()
414 .filter(|mutant| mutant.status.is_viable())
415 .map(|mutant| (mutant.file.clone(), mutant.line))
416 .collect()
417}
418
419fn normalized_conclusive_count(mutants: &[NormalizedMutant]) -> usize {
422 mutants
423 .iter()
424 .filter(|mutant| mutant.status.is_conclusive())
425 .count()
426}
427
428fn describe_normalized(mutant: &NormalizedMutant) -> String {
431 match &mutant.replacement {
432 Some(replacement) => format!("{} (-> {})", mutant.mutator, one_line(replacement)),
433 None => mutant.mutator.clone(),
434 }
435}
436
437pub fn measure_rust(
441 root: &Path,
442 exempt: &[String],
443 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
444 base: Option<&str>,
445 features: &[String],
446) -> Result<Measurement> {
447 let out = MutantsOut::new();
448 let workspace_root = cargo_workspace_root(root)?;
452 let prefix = canonical_scan_prefix(root, &workspace_root);
453 let mut base_diff = None;
454 let diff = match base {
455 Some(base) => {
456 match write_base_diff(root, &workspace_root, prefix.as_deref(), base, &out)? {
457 None => return Ok(Measurement::EngineNotRun),
458 Some(path) => {
459 let parsed = parse_base_diff(&read_base_diff(&path)?);
460 if !parsed.files.iter().any(|file| file.ends_with(".rs")) {
461 return Ok(Measurement::EngineNotRun);
462 }
463 base_diff = Some(parsed);
464 Some(path)
465 }
466 }
467 }
468 None => None,
469 };
470 let engine = ensure_cargo_mutants()?;
471 let run = run_cargo_mutants(&engine, root, &out.0, diff.as_deref(), features)?;
472 let outcomes = out.0.join("mutants.out").join("outcomes.json");
473 let json = match std::fs::read_to_string(&outcomes) {
477 Ok(json) => json,
478 Err(_) => {
479 if let Some(diff) = &base_diff {
480 let listed: Vec<MutantInfo> =
481 list_cargo_mutants(&engine, root, features, |command| command.output())?
482 .into_iter()
483 .filter(|mutant| {
484 !is_declaration_only(&workspace_root, &mutant.file, Language::Rust)
485 && !is_hidden_from_tests(
486 &workspace_root,
487 &mutant.file,
488 mutant.span.start.line,
489 )
490 })
491 .collect();
492 zero_mutant_verdict(&listed, diff, &run)?;
493 }
494 return Ok(Measurement::Tested {
495 count: 0,
496 survivors: Vec::new(),
497 });
498 }
499 };
500 let mut report = rebase_report_paths(parse_mutants_report(&json)?, prefix.as_deref());
501 report.outcomes.retain(|outcome| match &outcome.scenario {
502 Scenario::Baseline => true,
503 Scenario::Mutant(mutant) => {
504 !is_declaration_only(root, &mutant.file, Language::Rust)
505 && !is_hidden_from_tests(root, &mutant.file, mutant.span.start.line)
506 }
507 });
508 let survivors = evaluate_scoped(
509 cargo_mutants_survivors(&report),
510 &mutated_lines(&report),
511 exempt,
512 exempt_lines,
513 )?;
514 Ok(Measurement::Tested {
515 count: conclusive_count(&report),
516 survivors,
517 })
518}
519
520fn one_line(replacement: &str) -> String {
523 let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
524 const MAX: usize = 60;
525 if flat.chars().count() > MAX {
526 format!("{}…", flat.chars().take(MAX).collect::<String>())
527 } else {
528 flat
529 }
530}
531
532pub fn measure_typescript(
536 root: &Path,
537 exempt: &[String],
538 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
539 base: Option<&str>,
540 adapter: &Path,
541) -> Result<Measurement> {
542 let package_root =
543 crate::tiers::package_root(root, "package.json").unwrap_or_else(|| root.to_path_buf());
544 let prefix = scan_prefix(root, &package_root);
545 let mutate = match base {
546 Some(base) => {
547 let ranges = mutate_ranges(root, base)?;
548 if ranges.is_empty() {
549 return Ok(Measurement::EngineNotRun);
550 }
551 Some(prefix_mutate_specs(ranges, prefix.as_deref()))
552 }
553 None => prefix.as_deref().map(scan_scoped_mutate_globs),
554 };
555 let test_files = prefix.as_deref().map(scan_scoped_test_file_globs);
556 let json = run_ts_adapter(
557 &package_root,
558 adapter,
559 mutate.as_deref(),
560 test_files.as_deref(),
561 )?;
562 let mut mutants = to_scan_relative(parse_normalized_results(&json)?, prefix.as_deref());
563 mutants.retain(|mutant| !is_declaration_only(root, &mutant.file, Language::TypeScript));
564 let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
565 Ok(Measurement::Tested {
566 count: normalized_conclusive_count(&mutants),
567 survivors,
568 })
569}
570
571fn scan_prefix(root: &Path, package_root: &Path) -> Option<String> {
574 let rel = root.strip_prefix(package_root).ok()?;
575 let parts: Vec<String> = rel
576 .components()
577 .map(|part| part.as_os_str().to_string_lossy().into_owned())
578 .collect();
579 if parts.is_empty() {
580 None
581 } else {
582 Some(parts.join("/"))
583 }
584}
585
586fn prefix_mutate_specs(specs: Vec<String>, prefix: Option<&str>) -> Vec<String> {
589 match prefix {
590 None => specs,
591 Some(prefix) => specs
592 .into_iter()
593 .map(|spec| format!("{prefix}/{spec}"))
594 .collect(),
595 }
596}
597
598fn scan_scoped_mutate_globs(prefix: &str) -> Vec<String> {
602 const EXTENSIONS: &str = "+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)";
603 vec![
604 format!("{prefix}/**/!(*.+(s|S)pec|*.+(t|T)est).{EXTENSIONS}"),
605 format!("!{prefix}/**/__tests__/**/*.{EXTENSIONS}"),
606 ]
607}
608
609fn scan_scoped_test_file_globs(prefix: &str) -> Vec<String> {
613 vec![format!("{prefix}/**")]
614}
615
616fn to_scan_relative(mutants: Vec<NormalizedMutant>, prefix: Option<&str>) -> Vec<NormalizedMutant> {
620 let Some(prefix) = prefix else {
621 return mutants;
622 };
623 let prefix = format!("{prefix}/");
624 mutants
625 .into_iter()
626 .filter_map(|mut mutant| {
627 mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
628 Some(mutant)
629 })
630 .collect()
631}
632
633fn adapter_cwd<'a>(root: &'a Path, engine: &str) -> Result<&'a Path> {
637 let cwd = if root.as_os_str().is_empty() {
638 Path::new(".")
639 } else {
640 root
641 };
642 if !cwd.is_dir() {
643 bail!(
644 "the {engine} mutation adapter's working directory `{}` is not a directory",
645 cwd.display()
646 );
647 }
648 Ok(cwd)
649}
650
651fn spawn_context(interpreter: &str, entry: &str, cwd: &Path) -> String {
655 format!(
656 "spawning `{interpreter} {entry}` in `{}` (is `{interpreter}` on PATH?)",
657 cwd.display()
658 )
659}
660
661fn run_ts_adapter(
665 package_root: &Path,
666 adapter: &Path,
667 mutate: Option<&[String]>,
668 test_files: Option<&[String]>,
669) -> Result<String> {
670 let out = AdapterOut::new();
671 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
672 let results = out.0.join("results.json");
673
674 let cwd = adapter_cwd(package_root, "TypeScript")?;
675
676 let mut command = Command::new("node");
677 command
678 .current_dir(cwd)
679 .arg(adapter)
680 .arg("--out")
681 .arg(&results);
682 if let Some(specs) = mutate {
683 command.arg("--mutate").arg(specs.join(","));
684 }
685 if let Some(globs) = test_files {
686 command.arg("--test-files").arg(globs.join(","));
687 }
688 let output =
689 command
690 .output()
691 .context(spawn_context("node", &adapter.display().to_string(), cwd))?;
692 if !output.status.success() {
693 bail!(
694 "the TypeScript mutation adapter failed in `{}`:\n{}{}",
695 cwd.display(),
696 String::from_utf8_lossy(&output.stdout),
697 String::from_utf8_lossy(&output.stderr),
698 );
699 }
700 read_adapter_results(&results, "TypeScript")
701}
702
703fn read_adapter_results(results: &Path, engine: &str) -> Result<String> {
705 std::fs::read_to_string(results).with_context(|| {
706 format!(
707 "reading the {engine} mutation adapter's results from `{}`",
708 results.display()
709 )
710 })
711}
712
713struct AdapterOut(PathBuf);
716
717impl AdapterOut {
718 fn new() -> Self {
719 static COUNTER: AtomicU64 = AtomicU64::new(0);
720 let name = format!(
721 "testing-conventions-ts-adapter-{}-{}",
722 std::process::id(),
723 COUNTER.fetch_add(1, Ordering::Relaxed),
724 );
725 AdapterOut(std::env::temp_dir().join(name))
726 }
727}
728
729impl Drop for AdapterOut {
730 fn drop(&mut self) {
731 let _ = std::fs::remove_dir_all(&self.0);
732 }
733}
734
735fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
739 let changed = crate::patch_coverage::changed_lines(root, base)?;
740 let mut specs = Vec::new();
741 for (file, lines) in changed {
742 if !is_mutatable_ts(&file) || is_declaration_only(root, &file, Language::TypeScript) {
743 continue;
744 }
745 for (start, end) in contiguous_runs(&lines) {
746 specs.push(format!("{file}:{start}-{end}"));
747 }
748 }
749 Ok(specs)
750}
751
752fn is_mutatable_ts(file: &str) -> bool {
756 let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
757 .iter()
758 .any(|ext| file.ends_with(ext));
759 let is_decl = file.ends_with(".d.ts");
760 let is_test = file.contains(".test.") || file.contains(".spec.");
761 is_source && !is_decl && !is_test
762}
763
764fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
766 let mut runs: Vec<(u64, u64)> = Vec::new();
767 for &line in lines {
768 match runs.last_mut() {
769 Some(run) if run.1 + 1 == line => run.1 = line,
770 _ => runs.push((line, line)),
771 }
772 }
773 runs
774}
775
776pub fn measure_python(
780 root: &Path,
781 exempt: &[String],
782 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
783 base: Option<&str>,
784) -> Result<Measurement> {
785 let changed = match base {
786 Some(base) => Some(crate::patch_coverage::changed_lines(root, base)?),
787 None => None,
788 };
789 let modules: Vec<String> = match &changed {
790 None => Vec::new(),
791 Some(changed) => {
792 let modules: Vec<String> = changed
793 .keys()
794 .filter(|file| is_mutatable_py(file))
795 .cloned()
796 .collect();
797 if modules.is_empty() {
798 return Ok(Measurement::EngineNotRun);
799 }
800 modules
801 }
802 };
803 let json = run_py_adapter(root, &modules)?;
804 let mut mutants = parse_normalized_results(&json)?;
805 if let Some(changed) = &changed {
806 mutants.retain(|mutant| {
807 changed
808 .get(&mutant.file)
809 .is_some_and(|lines| lines.contains(&u64::from(mutant.line)))
810 });
811 }
812 mutants.retain(|mutant| !is_declaration_only(root, &mutant.file, Language::Python));
813 let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
814 Ok(Measurement::Tested {
815 count: normalized_conclusive_count(&mutants),
816 survivors,
817 })
818}
819
820fn run_py_adapter(root: &Path, modules: &[String]) -> Result<String> {
824 let out = AdapterOut::new();
825 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
826 let results = out.0.join("results.json");
827
828 let cwd = adapter_cwd(root, "Python")?;
829
830 const ENTRY: &str = "-m testing_conventions.mutation.main";
831 let mut command = Command::new("python3");
832 command
833 .current_dir(cwd)
834 .args(["-m", "testing_conventions.mutation.main", "--out"])
835 .arg(&results)
836 .env("PYTHONDONTWRITEBYTECODE", "1");
837 for module in modules {
838 command.arg("--module").arg(module);
839 }
840 let output = command
841 .output()
842 .context(spawn_context("python3", ENTRY, cwd))?;
843 if !output.status.success() {
844 bail!(
845 "the Python mutation adapter failed in `{}`:\n{}{}",
846 cwd.display(),
847 String::from_utf8_lossy(&output.stdout),
848 String::from_utf8_lossy(&output.stderr),
849 );
850 }
851 read_adapter_results(&results, "Python")
852}
853
854fn is_mutatable_py(file: &str) -> bool {
857 if !file.ends_with(".py") {
858 return false;
859 }
860 let base = file.rsplit('/').next().unwrap_or(file);
861 !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
862}
863
864struct MutantsOut(PathBuf);
867
868impl MutantsOut {
869 fn new() -> Self {
870 static COUNTER: AtomicU64 = AtomicU64::new(0);
871 let name = format!(
872 "testing-conventions-mutants-{}-{}",
873 std::process::id(),
874 COUNTER.fetch_add(1, Ordering::Relaxed),
875 );
876 MutantsOut(std::env::temp_dir().join(name))
877 }
878}
879
880impl Drop for MutantsOut {
881 fn drop(&mut self) {
882 let _ = std::fs::remove_dir_all(&self.0);
883 }
884}
885
886fn cargo_workspace_root(root: &Path) -> Result<PathBuf> {
890 let output = Command::new("cargo")
891 .current_dir(root)
892 .args(["locate-project", "--workspace", "--message-format", "plain"])
893 .output()
894 .context("running `cargo locate-project` (is cargo installed?)")?;
895 if !output.status.success() {
896 bail!(
897 "cargo locate-project failed in `{}`: {}",
898 root.display(),
899 String::from_utf8_lossy(&output.stderr)
900 );
901 }
902 let manifest = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
903 manifest_dir(&manifest)
904}
905
906fn manifest_dir(manifest: &Path) -> Result<PathBuf> {
908 manifest.parent().map(Path::to_path_buf).with_context(|| {
909 format!(
910 "no parent dir for the workspace manifest `{}`",
911 manifest.display()
912 )
913 })
914}
915
916fn canonical_scan_prefix(root: &Path, workspace_root: &Path) -> Option<String> {
920 let root = root.canonicalize().ok()?;
921 let workspace_root = workspace_root.canonicalize().ok()?;
922 scan_prefix(&root, &workspace_root)
923}
924
925fn write_base_diff(
929 root: &Path,
930 workspace_root: &Path,
931 prefix: Option<&str>,
932 base: &str,
933 out: &MutantsOut,
934) -> Result<Option<PathBuf>> {
935 let range = format!("{base}...HEAD");
936 let (dir, args) = match prefix {
937 None => (root, vec!["diff", "--relative", &range]),
938 Some(prefix) => (
939 workspace_root,
940 vec!["diff", "--relative", &range, "--", prefix],
941 ),
942 };
943 let output = Command::new("git")
944 .current_dir(dir)
945 .args(&args)
946 .output()
947 .context("running `git diff` for `--base` (is git installed?)")?;
948 if !output.status.success() {
949 bail!(
950 "git diff {range} failed: {}",
951 String::from_utf8_lossy(&output.stderr)
952 );
953 }
954 if output.stdout.is_empty() {
955 return Ok(None);
956 }
957 std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
958 let path = out.0.join("base.diff");
959 std::fs::write(&path, &output.stdout).context("writing the base diff")?;
960 Ok(Some(path))
961}
962
963fn read_base_diff(path: &Path) -> Result<String> {
965 std::fs::read_to_string(path)
966 .with_context(|| format!("reading the written base diff `{}`", path.display()))
967}
968
969struct BaseDiff {
973 files: Vec<String>,
974 inserted: BTreeMap<String, BTreeSet<u32>>,
975}
976
977fn parse_base_diff(diff: &str) -> BaseDiff {
981 let mut files = Vec::new();
982 let mut inserted: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
983 let mut current: Option<String> = None;
984 let mut lines = diff.lines();
985 while let Some(line) = lines.next() {
986 if let Some(path) = line.strip_prefix("+++ ") {
987 current = (path != "/dev/null").then(|| {
988 let path = path.strip_prefix("b/").unwrap_or(path).to_string();
989 files.push(path.clone());
990 path
991 });
992 } else if let Some(header) = line.strip_prefix("@@ ") {
993 let Some((new_start, old_count, new_count)) = parse_hunk_header(header) else {
994 continue;
995 };
996 let mut new_line = new_start;
997 let (mut old_left, mut new_left) = (old_count, new_count);
998 while old_left > 0 || new_left > 0 {
999 let Some(line) = lines.next() else { break };
1000 if line.starts_with('\\') {
1001 } else if line.starts_with('+') {
1004 if let Some(file) = ¤t {
1005 inserted.entry(file.clone()).or_default().insert(new_line);
1006 }
1007 new_line += 1;
1008 new_left = new_left.saturating_sub(1);
1009 } else if line.starts_with('-') {
1010 old_left = old_left.saturating_sub(1);
1011 } else {
1012 new_line += 1;
1013 old_left = old_left.saturating_sub(1);
1014 new_left = new_left.saturating_sub(1);
1015 }
1016 }
1017 }
1018 }
1019 BaseDiff { files, inserted }
1020}
1021
1022fn parse_hunk_header(header: &str) -> Option<(u32, u32, u32)> {
1024 let mut parts = header.split(' ');
1025 let (_, old_count) = parse_range(parts.next()?.strip_prefix('-')?)?;
1026 let (new_start, new_count) = parse_range(parts.next()?.strip_prefix('+')?)?;
1027 Some((new_start, old_count, new_count))
1028}
1029
1030fn parse_range(range: &str) -> Option<(u32, u32)> {
1032 match range.split_once(',') {
1033 Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
1034 None => Some((range.parse().ok()?, 1)),
1035 }
1036}
1037
1038fn rebase_report_paths(report: MutantsReport, prefix: Option<&str>) -> MutantsReport {
1042 let Some(prefix) = prefix else {
1043 return report;
1044 };
1045 let prefix = format!("{prefix}/");
1046 MutantsReport {
1047 outcomes: report
1048 .outcomes
1049 .into_iter()
1050 .filter_map(|mut outcome| {
1051 if let Scenario::Mutant(mutant) = &mut outcome.scenario {
1052 mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
1053 }
1054 Some(outcome)
1055 })
1056 .collect(),
1057 }
1058}
1059
1060const CARGO_MUTANTS_VERSION: &str = "27.1.0";
1063
1064fn ensure_cargo_mutants() -> Result<PathBuf> {
1068 provision_pinned(&cargo_mutants_cache_root(), execute)
1069}
1070
1071fn provision_pinned(
1073 root: &Path,
1074 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1075) -> Result<PathBuf> {
1076 let bin = root.join("bin").join(CARGO_MUTANTS_BIN_NAME);
1077 let lock_path = root.join(".install.lock");
1078 provision(&bin, &lock_path, || run_install(root, run))
1079}
1080
1081fn execute(command: &mut Command) -> std::io::Result<Output> {
1083 command.output()
1084}
1085
1086const CARGO_MUTANTS_BIN_NAME: &str = if cfg!(windows) {
1089 "cargo-mutants.exe"
1090} else {
1091 "cargo-mutants"
1092};
1093
1094fn cargo_mutants_cache_root() -> PathBuf {
1098 cache_base()
1099 .join("testing-conventions")
1100 .join(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}"))
1101}
1102
1103fn cache_base() -> PathBuf {
1106 resolve_cache_base(std::env::var_os("XDG_CACHE_HOME"), std::env::var_os("HOME"))
1107}
1108
1109fn resolve_cache_base(xdg: Option<OsString>, home: Option<OsString>) -> PathBuf {
1112 if let Some(dir) = xdg.filter(|value| !value.is_empty()) {
1113 return PathBuf::from(dir);
1114 }
1115 if let Some(dir) = home.filter(|value| !value.is_empty()) {
1116 return PathBuf::from(dir).join(".cache");
1117 }
1118 std::env::temp_dir()
1119}
1120
1121fn provision(
1125 bin: &Path,
1126 lock_path: &Path,
1127 install: impl FnOnce() -> Result<()>,
1128) -> Result<PathBuf> {
1129 if bin.exists() {
1130 return Ok(bin.to_path_buf());
1131 }
1132 if let Some(parent) = lock_path.parent() {
1133 std::fs::create_dir_all(parent).context("creating the provisioning lock's parent dir")?;
1134 }
1135 let lock_file = std::fs::OpenOptions::new()
1136 .create(true)
1137 .truncate(false)
1138 .write(true)
1139 .open(lock_path)
1140 .context("opening the provisioning lock file")?;
1141 lock_file
1142 .lock()
1143 .context("acquiring the provisioning lock")?;
1144 if bin.exists() {
1146 return Ok(bin.to_path_buf());
1147 }
1148 install()?;
1149 if !bin.exists() {
1150 bail!(
1151 "provisioning reported success but cargo-mutants is not at `{}`",
1152 bin.display()
1153 );
1154 }
1155 Ok(bin.to_path_buf())
1156}
1157
1158fn install_argv(root: &Path) -> Vec<OsString> {
1162 vec![
1163 OsString::from("install"),
1164 OsString::from("cargo-mutants"),
1165 OsString::from("--locked"),
1166 OsString::from("--version"),
1167 OsString::from(CARGO_MUTANTS_VERSION),
1168 OsString::from("--root"),
1169 root.as_os_str().to_os_string(),
1170 ]
1171}
1172
1173fn run_install(
1177 root: &Path,
1178 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1179) -> Result<()> {
1180 let mut command = Command::new("cargo");
1181 command.args(install_argv(root));
1182 strip_llvm_cov_env(&mut command);
1183 let output = run(&mut command)
1184 .context("provisioning cargo-mutants via `cargo install` (is cargo installed?)")?;
1185 if !output.status.success() {
1186 bail!(
1187 "failed to provision cargo-mutants {CARGO_MUTANTS_VERSION}:\n{}{}",
1188 String::from_utf8_lossy(&output.stdout),
1189 String::from_utf8_lossy(&output.stderr),
1190 );
1191 }
1192 Ok(())
1193}
1194
1195fn strip_llvm_cov_env(command: &mut Command) {
1199 for var in [
1200 "RUSTFLAGS",
1201 "CARGO_ENCODED_RUSTFLAGS",
1202 "RUSTDOCFLAGS",
1203 "CARGO_ENCODED_RUSTDOCFLAGS",
1204 "LLVM_PROFILE_FILE",
1205 "CARGO_LLVM_COV",
1206 "CARGO_LLVM_COV_SHOW_ENV",
1207 "CARGO_LLVM_COV_TARGET_DIR",
1208 "CARGO_LLVM_COV_BUILD_DIR",
1209 "RUSTC_WRAPPER",
1210 "RUSTC_WORKSPACE_WRAPPER",
1211 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
1212 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
1213 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
1214 ] {
1215 command.env_remove(var);
1216 }
1217}
1218
1219fn run_cargo_mutants(
1223 engine: &Path,
1224 root: &Path,
1225 out: &Path,
1226 in_diff: Option<&Path>,
1227 features: &[String],
1228) -> Result<Output> {
1229 let mut command = Command::new(engine);
1230 command
1231 .current_dir(root)
1232 .args(mutants_argv(out, in_diff, features));
1233 strip_llvm_cov_env(&mut command);
1234 let output = command.output().context("running cargo-mutants")?;
1235 classify_mutants_exit(root, &output)?;
1236 Ok(output)
1237}
1238
1239fn zero_mutant_verdict(listed: &[MutantInfo], diff: &BaseDiff, run: &Output) -> Result<()> {
1243 let dropped: Vec<&MutantInfo> = listed
1244 .iter()
1245 .filter(|mutant| {
1246 diff.inserted.get(&mutant.file).is_some_and(|lines| {
1247 lines
1248 .range(mutant.span.start.line..=mutant.span.end.line)
1249 .next()
1250 .is_some()
1251 })
1252 })
1253 .collect();
1254 if dropped.is_empty() {
1255 return Ok(());
1256 }
1257 let sites: Vec<String> = dropped
1258 .iter()
1259 .map(|mutant| {
1260 format!(
1261 " {}:{}: {}",
1262 mutant.file,
1263 mutant.span.start.line,
1264 strip_embedded_location(&mutant.name)
1265 )
1266 })
1267 .collect();
1268 bail!(
1269 "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{}{}",
1270 dropped.len(),
1271 listed.len(),
1272 sites.join("\n"),
1273 String::from_utf8_lossy(&run.stdout),
1274 String::from_utf8_lossy(&run.stderr),
1275 )
1276}
1277
1278fn list_argv(features: &[String]) -> Vec<OsString> {
1282 let mut argv = vec![
1283 OsString::from("mutants"),
1284 OsString::from("--list"),
1285 OsString::from("--json"),
1286 ];
1287 if !features.is_empty() {
1288 argv.push(OsString::from("--features"));
1289 argv.push(OsString::from(features.join(",")));
1290 }
1291 argv
1292}
1293
1294fn list_cargo_mutants(
1298 engine: &Path,
1299 root: &Path,
1300 features: &[String],
1301 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1302) -> Result<Vec<MutantInfo>> {
1303 let mut command = Command::new(engine);
1304 command.current_dir(root).args(list_argv(features));
1305 strip_llvm_cov_env(&mut command);
1306 let output = run(&mut command).context("listing the crate's mutants with cargo-mutants")?;
1307 if !output.status.success() {
1308 bail!(
1309 "cargo-mutants --list failed in `{}`:\n{}{}",
1310 root.display(),
1311 String::from_utf8_lossy(&output.stdout),
1312 String::from_utf8_lossy(&output.stderr),
1313 );
1314 }
1315 parse_mutants_list(&String::from_utf8_lossy(&output.stdout))
1316}
1317
1318fn mutants_argv(out: &Path, in_diff: Option<&Path>, features: &[String]) -> Vec<OsString> {
1326 let mut argv = vec![
1327 OsString::from("mutants"),
1328 OsString::from("--output"),
1329 out.as_os_str().to_os_string(),
1330 OsString::from("--cargo-test-arg"),
1331 OsString::from("--lib"),
1332 OsString::from("--cargo-test-arg"),
1333 OsString::from("--bins"),
1334 ];
1335 if let Some(diff) = in_diff {
1336 argv.push(OsString::from("--in-diff"));
1337 argv.push(diff.as_os_str().to_os_string());
1338 }
1339 if !features.is_empty() {
1340 argv.push(OsString::from("--features"));
1341 argv.push(OsString::from(features.join(",")));
1342 }
1343 argv
1344}
1345
1346fn classify_mutants_exit(root: &Path, output: &Output) -> Result<()> {
1350 match output.status.code() {
1351 Some(0) | Some(2) | Some(3) => Ok(()),
1352 _ => bail!(
1353 "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
1354 root.display(),
1355 String::from_utf8_lossy(&output.stdout),
1356 String::from_utf8_lossy(&output.stderr),
1357 ),
1358 }
1359}
1360
1361#[cfg(test)]
1362mod tests {
1363 use super::*;
1364
1365 const NORMALIZED: &str = r#"[
1366 {"file": "src/a.ts", "line": 2, "status": "survived",
1367 "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
1368 {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
1369 {"file": "src/a.ts", "line": 9, "status": "killed",
1370 "mutator": "BooleanLiteral", "replacement": "false"},
1371 {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
1372 {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
1373 {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
1374 ]"#;
1375
1376 #[test]
1377 fn parses_the_normalized_schema() {
1378 let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
1379 assert_eq!(mutants.len(), 6);
1380 assert_eq!(mutants[0].status, MutantStatus::Survived);
1381 assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
1382 assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
1383 assert_eq!(mutants[1].replacement, None);
1384 }
1385
1386 #[test]
1387 fn normalized_survivors_are_survived_and_nocoverage_only() {
1388 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1389 let survivors = normalized_survivors(&mutants);
1390 assert_eq!(survivors.len(), 2);
1391 assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
1392 assert!(survivors[0].description.contains("ConditionalExpression"));
1393 assert!(survivors[0].description.contains("-> true"));
1394 assert_eq!(survivors[1].description, "ArithmeticOperator");
1395 }
1396
1397 #[test]
1398 fn normalized_mutated_lines_collects_only_viable_mutants() {
1399 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1400 assert_eq!(
1401 normalized_mutated_lines(&mutants),
1402 [2u32, 5, 9, 12]
1403 .into_iter()
1404 .map(|line| ("src/a.ts".to_string(), line))
1405 .collect()
1406 );
1407 }
1408
1409 #[test]
1410 fn normalized_conclusive_count_is_survived_killed_and_nocoverage() {
1411 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1412 assert_eq!(normalized_conclusive_count(&mutants), 3);
1413 assert_eq!(normalized_conclusive_count(&[]), 0);
1414 }
1415
1416 #[test]
1417 fn evaluate_normalized_reports_unexempted_survivors() {
1418 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1419 let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
1420 assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
1421 }
1422
1423 #[test]
1424 fn evaluate_normalized_drops_a_whole_file_exemption() {
1425 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1426 let kept =
1427 evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
1428 assert!(
1429 kept.is_empty(),
1430 "the whole-file exemption lifts both survivors"
1431 );
1432 }
1433
1434 #[test]
1435 fn evaluate_normalized_drops_a_line_scoped_exemption() {
1436 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1437 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
1438 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1439 assert_eq!(kept.len(), 1);
1440 assert_eq!(kept[0].line, 5);
1441 }
1442
1443 #[test]
1444 fn evaluate_normalized_rejects_exempting_a_caught_line() {
1445 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1446 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
1447 let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
1448 assert!(
1449 err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
1450 "got: {err}"
1451 );
1452 }
1453
1454 #[test]
1455 fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
1456 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1457 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
1458 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1459 assert_eq!(kept.len(), 2);
1460 }
1461
1462 const SAMPLE: &str = r#"{
1463 "outcomes": [
1464 {"scenario": "Baseline", "summary": "Success",
1465 "phase_results": []},
1466 {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
1467 "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
1468 "function": {"function_name": "is_positive"},
1469 "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
1470 "summary": "MissedMutant"},
1471 {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
1472 "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
1473 "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
1474 "summary": "CaughtMutant"}
1475 ],
1476 "total_mutants": 2
1477 }"#;
1478
1479 #[test]
1480 fn parses_the_outcomes_export() {
1481 let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
1482 assert_eq!(report.outcomes.len(), 3);
1483 assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
1484 }
1485
1486 #[test]
1487 fn collects_only_missed_mutants_as_survivors() {
1488 let report = parse_mutants_report(SAMPLE).unwrap();
1489 let survivors = unexplained_survivors(&report, &[]);
1490 assert_eq!(survivors.len(), 1);
1491 assert_eq!(survivors[0].file, "src/lib.rs");
1492 assert_eq!(survivors[0].line, 7);
1493 assert!(survivors[0].description.contains("replace > with =="));
1494 }
1495
1496 #[test]
1497 fn a_survivor_description_carries_no_location_prefix() {
1498 let report = parse_mutants_report(SAMPLE).unwrap();
1499 let survivors = unexplained_survivors(&report, &[]);
1500 assert_eq!(
1501 survivors[0].description, "replace > with == in is_positive",
1502 "the name's embedded `file:line:col:` prefix is stripped"
1503 );
1504 }
1505
1506 #[test]
1507 fn strip_embedded_location_removes_a_file_line_col_prefix() {
1508 assert_eq!(
1509 strip_embedded_location("src/lib.rs:7:5: replace > with == in is_positive"),
1510 "replace > with == in is_positive"
1511 );
1512 }
1513
1514 #[test]
1515 fn strip_embedded_location_keeps_a_name_without_one() {
1516 for name in [
1517 "replace add -> 0",
1518 "note: no location segment",
1519 "7:5: no file segment",
1520 "src/lib.rs:7:x: non-numeric column",
1521 "src/lib.rs:x:5: non-numeric line",
1522 ] {
1523 assert_eq!(strip_embedded_location(name), name);
1524 }
1525 }
1526
1527 #[test]
1528 fn conclusive_count_is_caught_plus_missed() {
1529 let report = parse_mutants_report(SAMPLE).unwrap();
1530 assert_eq!(conclusive_count(&report), 2);
1531 assert_eq!(conclusive_count(&MutantsReport { outcomes: vec![] }), 0);
1532 }
1533
1534 #[test]
1535 fn an_exemption_drops_a_survivor_in_that_file() {
1536 let report = parse_mutants_report(SAMPLE).unwrap();
1537 let exempt = vec!["src/lib.rs".to_string()];
1538 assert!(unexplained_survivors(&report, &exempt).is_empty());
1539 }
1540
1541 #[test]
1542 fn an_exemption_on_another_file_leaves_the_survivor() {
1543 let report = parse_mutants_report(SAMPLE).unwrap();
1544 let exempt = vec!["src/elsewhere.rs".to_string()];
1545 assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1546 }
1547
1548 const BASELINE_ONLY: &str = r#"{
1549 "outcomes": [
1550 {"scenario": "Baseline", "summary": "MissedMutant", "phase_results": []},
1551 {"scenario": "Baseline", "summary": "CaughtMutant", "phase_results": []}
1552 ],
1553 "total_mutants": 0
1554 }"#;
1555
1556 #[test]
1557 fn a_baseline_outcome_is_never_a_survivor() {
1558 let report = parse_mutants_report(BASELINE_ONLY).unwrap();
1559 assert!(unexplained_survivors(&report, &[]).is_empty());
1560 }
1561
1562 #[test]
1563 fn a_baseline_outcome_is_never_a_mutated_line() {
1564 let report = parse_mutants_report(BASELINE_ONLY).unwrap();
1565 assert!(mutated_lines(&report).is_empty());
1566 }
1567
1568 #[test]
1569 fn parse_base_diff_skips_a_malformed_hunk_header() {
1570 let diff = "\
1571diff --git a/src/lib.rs b/src/lib.rs
1572--- a/src/lib.rs
1573+++ b/src/lib.rs
1574@@ junk @@
1575";
1576 let parsed = parse_base_diff(diff);
1577 assert_eq!(parsed.files, vec!["src/lib.rs"]);
1578 assert!(parsed.inserted.is_empty());
1579 }
1580
1581 #[test]
1582 fn a_missing_base_diff_read_reports_its_path() {
1583 let missing = unique_tmp().join("base.diff");
1584 let err = read_base_diff(&missing).unwrap_err();
1585 let msg = format!("{err:#}");
1586 assert!(msg.contains("reading the written base diff"), "{msg}");
1587 }
1588
1589 #[test]
1590 fn a_missing_adapter_results_read_names_the_engine_and_path() {
1591 let missing = unique_tmp().join("results.json");
1592 let err = read_adapter_results(&missing, "TypeScript").unwrap_err();
1593 let msg = format!("{err:#}");
1594 assert!(
1595 msg.contains("TypeScript mutation adapter's results"),
1596 "{msg}"
1597 );
1598 }
1599
1600 #[test]
1601 fn manifest_dir_is_the_manifest_parent() {
1602 let dir = manifest_dir(Path::new("/w/Cargo.toml")).unwrap();
1603 assert_eq!(dir, Path::new("/w"));
1604 }
1605
1606 #[test]
1607 fn a_rootless_manifest_path_is_an_error() {
1608 let err = manifest_dir(Path::new("/")).unwrap_err();
1609 let msg = format!("{err:#}");
1610 assert!(msg.contains("no parent dir"), "{msg}");
1611 }
1612
1613 #[test]
1614 fn a_directory_outside_any_workspace_fails_locate_project() {
1615 let dir = unique_tmp();
1616 let err = cargo_workspace_root(&dir).unwrap_err();
1617 let msg = format!("{err:#}");
1618 assert!(msg.contains("cargo locate-project failed"), "{msg}");
1619 std::fs::remove_dir_all(&dir).ok();
1620 }
1621
1622 #[test]
1623 fn a_bad_base_ref_fails_the_base_diff() {
1624 let dir = unique_tmp();
1625 let init = Command::new("git")
1626 .current_dir(&dir)
1627 .args(["init", "-q"])
1628 .output()
1629 .unwrap();
1630 assert!(init.status.success());
1631 let out = MutantsOut::new();
1632 let err = write_base_diff(&dir, &dir, None, "tc-no-such-ref", &out).unwrap_err();
1633 let msg = format!("{err:#}");
1634 assert!(msg.contains("git diff"), "{msg}");
1635 std::fs::remove_dir_all(&dir).ok();
1636 }
1637
1638 #[test]
1639 fn a_python_adapter_failure_reports_the_adapter_output() {
1640 let dir = unique_tmp();
1641 let err = run_py_adapter(&dir, &[]).unwrap_err();
1642 let msg = format!("{err:#}");
1643 assert!(msg.contains("the Python mutation adapter failed"), "{msg}");
1644 std::fs::remove_dir_all(&dir).ok();
1645 }
1646
1647 #[test]
1648 fn a_cfg_not_test_function_hides_its_own_lines_and_no_others() {
1649 let source = "\
1650#[cfg(not(test))]
1651pub fn main() -> u8 {
1652 run()
1653}
1654
1655fn run() -> u8 {
1656 1
1657}
1658";
1659 assert_eq!(
1660 lines_hidden_from_tests(source),
1661 BTreeSet::from([1, 2, 3, 4])
1662 );
1663 }
1664
1665 #[test]
1666 fn a_gated_module_hides_everything_inside_it() {
1667 let source = "\
1668#[cfg(not(test))]
1669mod real {
1670 pub fn go() -> u8 {
1671 1
1672 }
1673}
1674";
1675 assert_eq!(
1676 lines_hidden_from_tests(source),
1677 BTreeSet::from([1, 2, 3, 4, 5, 6])
1678 );
1679 }
1680
1681 #[test]
1682 fn a_gated_method_hides_only_that_method() {
1683 let source = "\
1684impl Runner {
1685 #[cfg(not(test))]
1686 fn go(&self) -> u8 {
1687 1
1688 }
1689
1690 fn stay(&self) -> u8 {
1691 2
1692 }
1693}
1694";
1695 assert_eq!(
1696 lines_hidden_from_tests(source),
1697 BTreeSet::from([2, 3, 4, 5])
1698 );
1699 }
1700
1701 #[test]
1702 fn an_ungated_file_hides_nothing() {
1703 let source = "#[cfg(test)]\nmod tests {\n fn t() {}\n}\n\nfn go() -> u8 {\n 1\n}\n";
1704
1705 assert!(lines_hidden_from_tests(source).is_empty());
1706 }
1707
1708 #[test]
1709 fn unparseable_source_hides_nothing() {
1710 assert!(lines_hidden_from_tests("fn go( {").is_empty());
1711 }
1712
1713 fn hides_under(attr: &str) -> bool {
1715 !lines_hidden_from_tests(&format!("{attr}\nfn go() -> u8 {{\n 1\n}}\n")).is_empty()
1716 }
1717
1718 #[test]
1719 fn a_gate_no_test_build_can_satisfy_hides_the_item() {
1720 assert!(hides_under("#[cfg(not(test))]"));
1721 assert!(hides_under("#[cfg(all(not(test), unix))]"));
1722 assert!(hides_under("#[cfg(not(any(test, unix)))]"));
1723 assert!(hides_under("#[cfg(any())]"));
1724 assert!(hides_under("#[cfg(not(all()))]"));
1725 }
1726
1727 #[test]
1728 fn a_gate_a_test_build_can_still_satisfy_hides_nothing() {
1729 assert!(!hides_under("#[cfg(test)]"));
1730 assert!(!hides_under("#[cfg(unix)]"));
1731 assert!(!hides_under("#[cfg(feature = \"x\")]"));
1732 assert!(!hides_under("#[cfg(any(not(test), unix))]"));
1733 assert!(!hides_under("#[cfg(not(not(test)))]"));
1734 assert!(!hides_under("#[cfg(all())]"));
1735 assert!(!hides_under("#[inline]"));
1736 }
1737
1738 #[test]
1739 fn a_gate_resting_on_a_condition_we_cannot_decide_hides_nothing() {
1740 assert!(!hides_under("#[cfg(not(unix))]"));
1741 assert!(!hides_under("#[cfg(all(test, unix))]"));
1742 }
1743
1744 #[test]
1745 fn a_malformed_gate_hides_nothing() {
1746 assert!(!hides_under("#[cfg(not())]"));
1747 assert!(!hides_under("#[cfg(nope(test))]"));
1748 assert!(!hides_under("#[cfg(not(test), unix)]"));
1749 }
1750
1751 #[test]
1752 fn is_declaration_only_is_true_for_a_const_only_rust_file() {
1753 let dir = unique_tmp();
1754 std::fs::write(
1755 dir.join("settings.rs"),
1756 "pub const TIMEOUT: u64 = 30 * 60;\n",
1757 )
1758 .unwrap();
1759 assert!(is_declaration_only(&dir, "settings.rs", Language::Rust));
1760 std::fs::remove_dir_all(&dir).ok();
1761 }
1762
1763 #[test]
1764 fn is_declaration_only_is_false_for_a_rust_file_with_a_function() {
1765 let dir = unique_tmp();
1766 std::fs::write(dir.join("lib.rs"), "pub fn run() {}\n").unwrap();
1767 assert!(!is_declaration_only(&dir, "lib.rs", Language::Rust));
1768 std::fs::remove_dir_all(&dir).ok();
1769 }
1770
1771 #[test]
1772 fn is_declaration_only_covers_python_and_typescript_too() {
1773 let dir = unique_tmp();
1774 std::fs::write(dir.join("settings.py"), "TIMEOUT = 30 * 60\n").unwrap();
1775 std::fs::write(dir.join("settings.ts"), "export const TIMEOUT = 30 * 60;\n").unwrap();
1776 assert!(is_declaration_only(&dir, "settings.py", Language::Python));
1777 assert!(is_declaration_only(
1778 &dir,
1779 "settings.ts",
1780 Language::TypeScript
1781 ));
1782 std::fs::remove_dir_all(&dir).ok();
1783 }
1784
1785 #[test]
1786 fn is_declaration_only_is_false_for_an_unreadable_file() {
1787 let dir = unique_tmp();
1788 assert!(!is_declaration_only(&dir, "missing.rs", Language::Rust));
1789 std::fs::remove_dir_all(&dir).ok();
1790 }
1791
1792 #[test]
1793 fn rebase_report_paths_strips_the_workspace_prefix() {
1794 let report = parse_mutants_report(SAMPLE).unwrap();
1795 let prefixed = MutantsReport {
1796 outcomes: report
1797 .outcomes
1798 .iter()
1799 .cloned()
1800 .map(|mut outcome| {
1801 if let Scenario::Mutant(mutant) = &mut outcome.scenario {
1802 mutant.file = format!("member/{}", mutant.file);
1803 }
1804 outcome
1805 })
1806 .collect(),
1807 };
1808 let rebased = rebase_report_paths(prefixed, Some("member"));
1809 let survivors = unexplained_survivors(&rebased, &[]);
1810 assert_eq!(survivors.len(), 1);
1811 assert_eq!(survivors[0].file, "src/lib.rs");
1812 assert_eq!(rebased.outcomes.len(), 3);
1813 }
1814
1815 #[test]
1816 fn rebase_report_paths_drops_an_out_of_scope_mutant_and_keeps_none_identity() {
1817 let report = parse_mutants_report(SAMPLE).unwrap();
1818 let rebased = rebase_report_paths(report.clone(), Some("member"));
1819 assert_eq!(
1820 rebased.outcomes.len(),
1821 1,
1822 "only the pathless baseline outcome remains"
1823 );
1824 let unchanged = rebase_report_paths(report, None);
1825 assert_eq!(unchanged.outcomes.len(), 3);
1826 assert_eq!(unexplained_survivors(&unchanged, &[])[0].file, "src/lib.rs");
1827 }
1828
1829 #[test]
1830 fn adapter_cwd_normalises_the_empty_package_root_to_the_current_dir() {
1831 assert_eq!(
1835 adapter_cwd(Path::new(""), "TypeScript").unwrap(),
1836 Path::new(".")
1837 );
1838 assert_eq!(
1839 adapter_cwd(Path::new("src"), "TypeScript").unwrap(),
1840 Path::new("src")
1841 );
1842 }
1843
1844 #[test]
1845 fn adapter_cwd_rejects_a_directory_that_is_not_there() {
1846 let err = adapter_cwd(Path::new("no/such/dir"), "Python")
1849 .expect_err("a directory that is not there is an error");
1850 assert_eq!(
1851 err.to_string(),
1852 "the Python mutation adapter's working directory `no/such/dir` is not a directory"
1853 );
1854 }
1855
1856 #[test]
1857 fn spawn_context_names_the_interpreter_the_entry_and_the_working_directory() {
1858 assert_eq!(
1859 spawn_context("node", "/pkg/dist/mutation/main.js", Path::new("/pkg")),
1860 "spawning `node /pkg/dist/mutation/main.js` in `/pkg` (is `node` on PATH?)"
1861 );
1862 }
1863
1864 #[test]
1865 fn scan_prefix_is_the_scan_path_relative_to_the_package_root() {
1866 assert_eq!(
1867 scan_prefix(Path::new("/repo/pkg/src"), Path::new("/repo/pkg")),
1868 Some("src".to_string())
1869 );
1870 assert_eq!(
1871 scan_prefix(Path::new("/repo/pkg/src/nested"), Path::new("/repo/pkg")),
1872 Some("src/nested".to_string())
1873 );
1874 assert_eq!(
1875 scan_prefix(Path::new("/repo/pkg"), Path::new("/repo/pkg")),
1876 None
1877 );
1878 assert_eq!(
1879 scan_prefix(Path::new("pkg/src"), Path::new("pkg")),
1880 Some("src".to_string())
1881 );
1882 }
1883
1884 #[test]
1885 fn prefix_mutate_specs_rebases_diff_ranges_onto_the_package_root() {
1886 let specs = vec!["index.ts:8-11".to_string(), "a/b.ts:2-2".to_string()];
1887 assert_eq!(
1888 prefix_mutate_specs(specs.clone(), Some("src")),
1889 vec![
1890 "src/index.ts:8-11".to_string(),
1891 "src/a/b.ts:2-2".to_string()
1892 ]
1893 );
1894 assert_eq!(prefix_mutate_specs(specs.clone(), None), specs);
1895 }
1896
1897 #[test]
1898 fn scan_scoped_mutate_globs_mirror_strykers_default_under_the_scan_path() {
1899 assert_eq!(
1900 scan_scoped_mutate_globs("src"),
1901 vec![
1902 "src/**/!(*.+(s|S)pec|*.+(t|T)est).+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
1903 .to_string(),
1904 "!src/**/__tests__/**/*.+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
1905 .to_string(),
1906 ]
1907 );
1908 }
1909
1910 #[test]
1911 fn scan_scoped_test_file_globs_narrow_the_run_without_moving_the_runner_root() {
1912 assert_eq!(
1913 scan_scoped_test_file_globs("src"),
1914 vec!["src/**".to_string()]
1915 );
1916 assert_eq!(
1917 scan_scoped_test_file_globs("packages/core/src"),
1918 vec!["packages/core/src/**".to_string()]
1919 );
1920 }
1921
1922 #[test]
1923 fn to_scan_relative_strips_the_prefix_and_drops_out_of_scope_mutants() {
1924 let mutants = parse_normalized_results(
1925 r#"[
1926 {"file": "src/a.ts", "line": 2, "status": "survived", "mutator": "X"},
1927 {"file": "tests/e2e/t.ts", "line": 9, "status": "survived", "mutator": "X"}
1928 ]"#,
1929 )
1930 .unwrap();
1931 let rebased = to_scan_relative(mutants.clone(), Some("src"));
1932 assert_eq!(rebased.len(), 1, "the out-of-scan-path mutant is dropped");
1933 assert_eq!(rebased[0].file, "a.ts");
1934 let unchanged = to_scan_relative(mutants, None);
1935 assert_eq!(unchanged.len(), 2);
1936 assert_eq!(unchanged[0].file, "src/a.ts");
1937 }
1938
1939 #[test]
1940 fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
1941 assert!(is_mutatable_ts("src/index.ts"));
1942 assert!(is_mutatable_ts("src/util.tsx"));
1943 assert!(is_mutatable_ts("src/util.js"));
1944 assert!(!is_mutatable_ts("src/index.test.ts"));
1945 assert!(!is_mutatable_ts("src/index.spec.ts"));
1946 assert!(!is_mutatable_ts("src/types.d.ts"));
1947 assert!(!is_mutatable_ts("README.md"));
1948 }
1949
1950 #[test]
1951 fn contiguous_runs_collapses_adjacent_lines() {
1952 let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
1953 assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
1954 assert!(contiguous_runs(&BTreeSet::new()).is_empty());
1955 }
1956
1957 #[test]
1958 fn one_line_flattens_and_caps() {
1959 assert_eq!(one_line("a -\n b"), "a - b");
1960 let long = "x".repeat(80);
1961 let capped = one_line(&long);
1962 assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
1963 }
1964
1965 #[test]
1966 fn is_mutatable_py_keeps_sources_and_drops_tests() {
1967 assert!(is_mutatable_py("calc.py"));
1968 assert!(is_mutatable_py("pkg/util.py"));
1969 assert!(!is_mutatable_py("calc_test.py"));
1970 assert!(!is_mutatable_py("test_calc.py"));
1971 assert!(!is_mutatable_py("pkg/conftest.py"));
1972 assert!(!is_mutatable_py("README.md"));
1973 }
1974
1975 #[test]
1976 fn mutated_lines_collects_caught_and_missed() {
1977 let report = parse_mutants_report(SAMPLE).unwrap();
1978 assert_eq!(
1979 mutated_lines(&report),
1980 [
1981 ("src/lib.rs".to_string(), 7),
1982 ("src/other.rs".to_string(), 3)
1983 ]
1984 .into_iter()
1985 .collect()
1986 );
1987 }
1988
1989 #[test]
1990 fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1991 let report = parse_mutants_report(SAMPLE).unwrap();
1992 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1993 let kept = evaluate_scoped(
1994 cargo_mutants_survivors(&report),
1995 &mutated_lines(&report),
1996 &[],
1997 &line_scoped,
1998 )
1999 .unwrap();
2000 assert!(
2001 kept.is_empty(),
2002 "the src/lib.rs:7 survivor should be lifted"
2003 );
2004 }
2005
2006 #[test]
2007 fn evaluate_scoped_rejects_exempting_a_caught_line() {
2008 let report = parse_mutants_report(SAMPLE).unwrap();
2009 let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
2010 let err = evaluate_scoped(
2011 cargo_mutants_survivors(&report),
2012 &mutated_lines(&report),
2013 &[],
2014 &line_scoped,
2015 )
2016 .unwrap_err();
2017 assert!(
2018 err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
2019 "got: {err}"
2020 );
2021 }
2022
2023 #[test]
2024 fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
2025 let report = parse_mutants_report(SAMPLE).unwrap();
2026 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
2027 let kept = evaluate_scoped(
2028 cargo_mutants_survivors(&report),
2029 &mutated_lines(&report),
2030 &[],
2031 &line_scoped,
2032 )
2033 .unwrap();
2034 assert_eq!(kept.len(), 1);
2035 assert_eq!(kept[0].line, 7);
2036 }
2037
2038 #[test]
2039 fn evaluate_scoped_still_honors_a_whole_file_exemption() {
2040 let report = parse_mutants_report(SAMPLE).unwrap();
2041 let kept = evaluate_scoped(
2042 cargo_mutants_survivors(&report),
2043 &mutated_lines(&report),
2044 &["src/lib.rs".to_string()],
2045 &BTreeMap::new(),
2046 )
2047 .unwrap();
2048 assert!(kept.is_empty());
2049 }
2050
2051 fn unique_tmp() -> PathBuf {
2052 static COUNTER: AtomicU64 = AtomicU64::new(0);
2053 let dir = std::env::temp_dir().join(format!(
2054 "tc-provision-test-{}-{}",
2055 std::process::id(),
2056 COUNTER.fetch_add(1, Ordering::Relaxed)
2057 ));
2058 std::fs::create_dir_all(&dir).unwrap();
2059 dir
2060 }
2061
2062 enum Install {
2063 MustNotRun,
2064 WritesNothing,
2065 WritesBin,
2066 Fails,
2067 CountsSleepsAndWritesBin(std::sync::Arc<AtomicU64>),
2068 }
2069
2070 fn write_bin(bin: &Path) {
2071 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
2072 std::fs::write(bin, b"binary").unwrap();
2073 }
2074
2075 fn drive_provision(bin: &Path, lock: &Path, install: Install) -> Result<PathBuf> {
2076 provision(bin, lock, || match install {
2077 Install::MustNotRun => panic!("must not reinstall"),
2078 Install::WritesNothing => Ok(()),
2079 Install::WritesBin => {
2080 write_bin(bin);
2081 Ok(())
2082 }
2083 Install::Fails => bail!("install blew up"),
2084 Install::CountsSleepsAndWritesBin(count) => {
2085 count.fetch_add(1, Ordering::SeqCst);
2086 std::thread::sleep(std::time::Duration::from_millis(50));
2087 write_bin(bin);
2088 Ok(())
2089 }
2090 })
2091 }
2092
2093 #[test]
2094 fn provision_returns_an_existing_binary_without_installing() {
2095 let tmp = unique_tmp();
2096 let bin = tmp.join("bin").join("cargo-mutants");
2097 let lock = tmp.join(".install.lock");
2098 write_bin(&bin);
2099 let got = drive_provision(&bin, &lock, Install::MustNotRun).unwrap();
2100 assert_eq!(got, bin);
2101 std::fs::remove_dir_all(&tmp).unwrap();
2102 }
2103
2104 #[test]
2105 fn the_must_not_run_sentinel_panics_when_installation_runs() {
2106 let tmp = unique_tmp();
2107 std::fs::create_dir_all(&tmp).unwrap();
2108 let bin = tmp.join("bin").join("cargo-mutants");
2109 let lock = tmp.join(".install.lock");
2110 let panicked =
2111 std::panic::catch_unwind(|| drive_provision(&bin, &lock, Install::MustNotRun)).is_err();
2112 std::fs::remove_dir_all(&tmp).unwrap();
2113 assert!(panicked);
2114 }
2115
2116 #[test]
2117 fn provision_with_a_rootless_lock_path_fails_to_open_the_lock() {
2118 let bin = unique_tmp().join("bin").join("cargo-mutants");
2119 let err = drive_provision(&bin, Path::new("/"), Install::WritesNothing).unwrap_err();
2120 let msg = format!("{err:#}");
2121 assert!(msg.contains("opening the provisioning lock"), "{msg}");
2122 }
2123
2124 #[test]
2125 fn provision_installs_when_the_binary_is_absent() {
2126 let tmp = unique_tmp();
2127 let bin = tmp.join("bin").join("cargo-mutants");
2128 let lock = tmp.join(".install.lock");
2129 let got = drive_provision(&bin, &lock, Install::WritesBin).unwrap();
2130 assert_eq!(got, bin);
2131 assert_eq!(
2132 std::fs::read(&bin).unwrap(),
2133 b"binary",
2134 "an absent binary must be installed"
2135 );
2136 std::fs::remove_dir_all(&tmp).unwrap();
2137 }
2138
2139 #[test]
2140 fn provision_errors_when_install_produces_no_binary() {
2141 let tmp = unique_tmp();
2142 let bin = tmp.join("bin").join("cargo-mutants");
2143 let lock = tmp.join(".install.lock");
2144 let err = drive_provision(&bin, &lock, Install::WritesNothing).unwrap_err();
2145 assert!(
2146 err.to_string().contains("cargo-mutants is not at"),
2147 "got: {err}"
2148 );
2149 std::fs::remove_dir_all(&tmp).unwrap();
2150 }
2151
2152 #[test]
2153 fn provision_propagates_an_install_failure() {
2154 let tmp = unique_tmp();
2155 let bin = tmp.join("bin").join("cargo-mutants");
2156 let lock = tmp.join(".install.lock");
2157 let err = drive_provision(&bin, &lock, Install::Fails).unwrap_err();
2158 assert!(err.to_string().contains("install blew up"), "got: {err}");
2159 std::fs::remove_dir_all(&tmp).unwrap();
2160 }
2161
2162 #[test]
2163 fn provision_does_not_duplicate_the_install_under_concurrent_callers() {
2164 use std::sync::{Arc, Barrier};
2168 use std::thread;
2169
2170 let tmp = unique_tmp();
2171 let bin = tmp.join("bin").join("cargo-mutants");
2172 let lock = tmp.join(".install.lock");
2173 let install_count = Arc::new(AtomicU64::new(0));
2174 let barrier = Arc::new(Barrier::new(2));
2175
2176 let handles: Vec<_> = (0..2)
2177 .map(|_| {
2178 let bin = bin.clone();
2179 let lock = lock.clone();
2180 let install_count = Arc::clone(&install_count);
2181 let barrier = Arc::clone(&barrier);
2182 thread::spawn(move || {
2183 barrier.wait();
2184 drive_provision(
2185 &bin,
2186 &lock,
2187 Install::CountsSleepsAndWritesBin(install_count),
2188 )
2189 })
2190 })
2191 .collect();
2192
2193 for h in handles {
2194 h.join()
2195 .expect("provisioning thread must not panic")
2196 .unwrap();
2197 }
2198
2199 assert_eq!(
2200 install_count.load(Ordering::SeqCst),
2201 1,
2202 "two concurrent callers on a cold cache must share one install, not each run their own"
2203 );
2204 std::fs::remove_dir_all(&tmp).unwrap();
2205 }
2206
2207 #[test]
2208 fn resolve_cache_base_prefers_xdg_then_home_then_temp() {
2209 let xdg = |s: &str| Some(OsString::from(s));
2210 assert_eq!(
2211 resolve_cache_base(xdg("/xdg"), xdg("/home")),
2212 PathBuf::from("/xdg")
2213 );
2214 assert_eq!(
2215 resolve_cache_base(xdg(""), xdg("/home")),
2216 PathBuf::from("/home/.cache")
2217 );
2218 assert_eq!(
2219 resolve_cache_base(None, xdg("/home")),
2220 PathBuf::from("/home/.cache")
2221 );
2222 assert_eq!(resolve_cache_base(None, None), std::env::temp_dir());
2223 assert_eq!(
2224 resolve_cache_base(xdg(""), Some(OsString::new())),
2225 std::env::temp_dir()
2226 );
2227 }
2228
2229 #[test]
2230 fn cache_root_is_absolute_and_version_scoped() {
2231 let root = cargo_mutants_cache_root();
2232 assert!(
2233 root.ends_with(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}")),
2234 "version-scoped; got {root:?}"
2235 );
2236 assert!(
2237 root.to_string_lossy().contains("testing-conventions"),
2238 "tool-namespaced; got {root:?}"
2239 );
2240 assert!(
2241 root.is_absolute(),
2242 "expected an absolute path; got {root:?}"
2243 );
2244 }
2245
2246 #[test]
2247 fn install_argv_pins_the_version_and_isolates_the_root() {
2248 let argv: Vec<String> = install_argv(Path::new("/cache/cargo-mutants-27"))
2249 .iter()
2250 .map(|arg| arg.to_string_lossy().into_owned())
2251 .collect();
2252 assert_eq!(
2253 argv,
2254 vec![
2255 "install",
2256 "cargo-mutants",
2257 "--locked",
2258 "--version",
2259 CARGO_MUTANTS_VERSION,
2260 "--root",
2261 "/cache/cargo-mutants-27",
2262 ]
2263 );
2264 }
2265
2266 #[test]
2267 fn mutants_argv_enables_features_on_the_engine_itself() {
2268 let argv = |diff, features: &[&str]| -> Vec<String> {
2269 mutants_argv(
2270 Path::new("/out"),
2271 diff,
2272 &features.iter().map(|f| f.to_string()).collect::<Vec<_>>(),
2273 )
2274 .iter()
2275 .map(|arg| arg.to_string_lossy().into_owned())
2276 .collect()
2277 };
2278 assert_eq!(
2279 argv(None, &["cli", "boost"]),
2280 vec![
2281 "mutants",
2282 "--output",
2283 "/out",
2284 "--cargo-test-arg",
2285 "--lib",
2286 "--cargo-test-arg",
2287 "--bins",
2288 "--features",
2289 "cli,boost"
2290 ]
2291 );
2292 assert_eq!(
2293 argv(Some(Path::new("/out/base.diff")), &["cli"]),
2294 vec![
2295 "mutants",
2296 "--output",
2297 "/out",
2298 "--cargo-test-arg",
2299 "--lib",
2300 "--cargo-test-arg",
2301 "--bins",
2302 "--in-diff",
2303 "/out/base.diff",
2304 "--features",
2305 "cli",
2306 ]
2307 );
2308 assert_eq!(
2309 argv(None, &[]),
2310 vec![
2311 "mutants",
2312 "--output",
2313 "/out",
2314 "--cargo-test-arg",
2315 "--lib",
2316 "--cargo-test-arg",
2317 "--bins"
2318 ]
2319 );
2320 }
2321
2322 #[test]
2323 fn list_argv_mirrors_the_run_feature_selection() {
2324 let argv = |features: &[&str]| -> Vec<String> {
2325 list_argv(&features.iter().map(|f| f.to_string()).collect::<Vec<_>>())
2326 .iter()
2327 .map(|arg| arg.to_string_lossy().into_owned())
2328 .collect()
2329 };
2330 assert_eq!(argv(&[]), vec!["mutants", "--list", "--json"]);
2331 assert_eq!(
2332 argv(&["cli", "boost"]),
2333 vec!["mutants", "--list", "--json", "--features", "cli,boost"]
2334 );
2335 }
2336
2337 #[test]
2338 fn parse_base_diff_maps_inserted_lines_per_hunk() {
2339 let diff = "\
2340diff --git a/src/lib.rs b/src/lib.rs
2341--- a/src/lib.rs
2342+++ b/src/lib.rs
2343@@ -1,4 +1,5 @@
2344 fn a() {}
2345+fn b() {}
2346 fn c() {}
2347-fn d() {}
2348+fn e() {}
2349 fn f() {}
2350@@ -10,2 +11,4 @@
2351 tail
2352+one
2353+two
2354 more
2355";
2356 let parsed = parse_base_diff(diff);
2357 assert_eq!(parsed.files, vec!["src/lib.rs"]);
2358 assert_eq!(
2359 parsed.inserted.get("src/lib.rs"),
2360 Some(&BTreeSet::from([2, 4, 12, 13]))
2361 );
2362 }
2363
2364 #[test]
2365 fn parse_base_diff_leaves_a_deletion_only_file_without_inserted_lines() {
2366 let diff = "\
2367--- a/src/gone.rs
2368+++ b/src/gone.rs
2369@@ -5,2 +4,0 @@
2370-x
2371-y
2372";
2373 let parsed = parse_base_diff(diff);
2374 assert_eq!(parsed.files, vec!["src/gone.rs"]);
2375 assert!(parsed.inserted.is_empty());
2376 }
2377
2378 #[test]
2379 fn parse_base_diff_skips_a_deleted_file() {
2380 let diff = "\
2381--- a/src/dead.rs
2382+++ /dev/null
2383@@ -1,2 +0,0 @@
2384-a
2385-b
2386";
2387 let parsed = parse_base_diff(diff);
2388 assert!(parsed.files.is_empty());
2389 assert!(parsed.inserted.is_empty());
2390 }
2391
2392 #[test]
2393 fn parse_base_diff_consumes_hunk_bodies_by_count_so_content_never_reads_as_a_header() {
2394 let diff = "\
2397+++ b/notes.txt
2398@@ -1,1 +1,2 @@
2399 keep
2400++++ not a header
2401";
2402 let parsed = parse_base_diff(diff);
2403 assert_eq!(parsed.files, vec!["notes.txt"]);
2404 assert_eq!(parsed.inserted.get("notes.txt"), Some(&BTreeSet::from([2])));
2405 }
2406
2407 #[test]
2408 fn parse_base_diff_defaults_an_elided_hunk_count_to_one() {
2409 let diff = "\
2410+++ b/one.txt
2411@@ -1 +1 @@
2412-old
2413+new
2414";
2415 let parsed = parse_base_diff(diff);
2416 assert_eq!(parsed.inserted.get("one.txt"), Some(&BTreeSet::from([1])));
2417 }
2418
2419 #[test]
2420 fn parse_base_diff_skips_no_newline_annotations_mid_hunk() {
2421 let diff = "\
2422+++ b/n.txt
2423@@ -1 +1 @@
2424-old
2425\\ No newline at end of file
2426+new
2427\\ No newline at end of file
2428";
2429 let parsed = parse_base_diff(diff);
2430 assert_eq!(parsed.inserted.get("n.txt"), Some(&BTreeSet::from([1])));
2431 }
2432
2433 #[cfg(unix)]
2434 fn fake_output(code: i32, stderr: &str) -> Output {
2435 use std::os::unix::process::ExitStatusExt;
2436 Output {
2437 status: std::process::ExitStatus::from_raw(code << 8),
2438 stdout: Vec::new(),
2439 stderr: stderr.as_bytes().to_vec(),
2440 }
2441 }
2442
2443 #[cfg(unix)]
2444 enum FakeRun {
2445 AssertsVersionAndSucceeds,
2446 FailsWith(&'static str),
2447 SpawnError,
2448 }
2449
2450 #[cfg(unix)]
2451 fn drive_install(root: &Path, run: FakeRun) -> Result<()> {
2452 run_install(root, |command| match run {
2453 FakeRun::AssertsVersionAndSucceeds => {
2454 let argv: Vec<String> = command
2455 .get_args()
2456 .map(|arg| arg.to_string_lossy().into_owned())
2457 .collect();
2458 assert!(argv.contains(&CARGO_MUTANTS_VERSION.to_string()));
2459 Ok(fake_output(0, ""))
2460 }
2461 FakeRun::FailsWith(stderr) => Ok(fake_output(1, stderr)),
2462 FakeRun::SpawnError => Err(std::io::Error::new(
2463 std::io::ErrorKind::NotFound,
2464 "no cargo",
2465 )),
2466 })
2467 }
2468
2469 #[cfg(unix)]
2470 #[test]
2471 fn run_install_succeeds_on_a_zero_exit() {
2472 drive_install(Path::new("/cache/root"), FakeRun::AssertsVersionAndSucceeds).unwrap();
2473 }
2474
2475 #[cfg(unix)]
2476 #[test]
2477 fn run_install_reports_a_nonzero_exit_with_the_engine_output() {
2478 let err = drive_install(
2479 Path::new("/cache/root"),
2480 FakeRun::FailsWith("error: could not compile cargo-mutants"),
2481 )
2482 .unwrap_err();
2483 assert!(
2484 err.to_string()
2485 .contains("failed to provision cargo-mutants")
2486 && err.to_string().contains("could not compile"),
2487 "got: {err}"
2488 );
2489 }
2490
2491 #[cfg(unix)]
2492 #[test]
2493 fn run_install_propagates_a_spawn_failure() {
2494 let err = drive_install(Path::new("/cache/root"), FakeRun::SpawnError).unwrap_err();
2495 assert!(
2496 err.to_string().contains("is cargo installed?"),
2497 "got: {err}"
2498 );
2499 }
2500
2501 #[cfg(unix)]
2502 #[test]
2503 fn provision_pinned_installs_via_the_injected_runner() {
2504 let root = unique_tmp();
2505 let bin = root.join("bin").join(CARGO_MUTANTS_BIN_NAME);
2506 let expected = bin.clone();
2507 let got = provision_pinned(&root, |_| {
2508 write_bin(&bin);
2509 Ok(fake_output(0, ""))
2510 })
2511 .unwrap();
2512 assert_eq!(got, expected);
2513 std::fs::remove_dir_all(&root).unwrap();
2514 }
2515
2516 #[test]
2517 fn execute_surfaces_a_spawn_failure() {
2518 let err = execute(&mut Command::new("/nonexistent-tc-cargo")).unwrap_err();
2519 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
2520 }
2521
2522 #[cfg(unix)]
2523 fn fake_stdout(code: i32, stdout: &str) -> Output {
2524 use std::os::unix::process::ExitStatusExt;
2525 Output {
2526 status: std::process::ExitStatus::from_raw(code << 8),
2527 stdout: stdout.as_bytes().to_vec(),
2528 stderr: Vec::new(),
2529 }
2530 }
2531
2532 #[cfg(unix)]
2533 enum FakeList {
2534 AssertsArgvAndReturns(&'static str, Vec<&'static str>),
2535 FailsWith(&'static str),
2536 SpawnError,
2537 }
2538
2539 #[cfg(unix)]
2540 fn drive_list(features: &[String], run: FakeList) -> Result<Vec<MutantInfo>> {
2541 list_cargo_mutants(
2542 Path::new("/cache/bin/cargo-mutants"),
2543 Path::new("/crate"),
2544 features,
2545 |command| match run {
2546 FakeList::AssertsArgvAndReturns(json, expected) => {
2547 let argv: Vec<String> = command
2548 .get_args()
2549 .map(|arg| arg.to_string_lossy().into_owned())
2550 .collect();
2551 assert_eq!(argv, expected);
2552 assert_eq!(command.get_current_dir(), Some(Path::new("/crate")));
2553 Ok(fake_stdout(0, json))
2554 }
2555 FakeList::FailsWith(stderr) => Ok(fake_output(1, stderr)),
2556 FakeList::SpawnError => Err(std::io::Error::new(
2557 std::io::ErrorKind::NotFound,
2558 "no engine",
2559 )),
2560 },
2561 )
2562 }
2563
2564 #[cfg(unix)]
2565 #[test]
2566 fn list_cargo_mutants_parses_the_listing_from_a_clean_run() {
2567 let json = r#"[{"file": "src/lib.rs", "name": "replace add -> 0",
2568 "span": {"start": {"line": 3, "column": 1}, "end": {"line": 5, "column": 2}}}]"#;
2569 let listed = drive_list(
2570 &["cli".to_string()],
2571 FakeList::AssertsArgvAndReturns(
2572 json,
2573 vec!["mutants", "--list", "--json", "--features", "cli"],
2574 ),
2575 )
2576 .unwrap();
2577 assert_eq!(listed.len(), 1);
2578 assert_eq!(listed[0].file, "src/lib.rs");
2579 assert_eq!(listed[0].span.start.line, 3);
2580 assert_eq!(listed[0].span.end.line, 5);
2581 assert_eq!(listed[0].name, "replace add -> 0");
2582 }
2583
2584 #[cfg(unix)]
2585 #[test]
2586 fn list_cargo_mutants_reports_a_nonzero_exit_with_the_engine_output() {
2587 let err = drive_list(&[], FakeList::FailsWith("error: no such option")).unwrap_err();
2588 assert!(
2589 err.to_string().contains("cargo-mutants --list failed")
2590 && err.to_string().contains("no such option"),
2591 "got: {err}"
2592 );
2593 }
2594
2595 #[cfg(unix)]
2596 #[test]
2597 fn list_cargo_mutants_propagates_a_spawn_failure() {
2598 let err = drive_list(&[], FakeList::SpawnError).unwrap_err();
2599 assert!(
2600 err.to_string()
2601 .contains("listing the crate's mutants with cargo-mutants"),
2602 "got: {err}"
2603 );
2604 }
2605
2606 #[cfg(unix)]
2607 fn listed_mutant(file: &str, start: u32, end: u32, name: &str) -> MutantInfo {
2608 MutantInfo {
2609 file: file.to_string(),
2610 span: Span {
2611 start: LineCol { line: start },
2612 end: LineCol { line: end },
2613 },
2614 name: name.to_string(),
2615 }
2616 }
2617
2618 #[cfg(unix)]
2619 fn diff_with_inserted(file: &str, lines: &[u32]) -> BaseDiff {
2620 BaseDiff {
2621 files: vec![file.to_string()],
2622 inserted: BTreeMap::from([(file.to_string(), lines.iter().copied().collect())]),
2623 }
2624 }
2625
2626 #[cfg(unix)]
2627 #[test]
2628 fn zero_mutant_verdict_accepts_a_zero_with_no_mutant_on_the_inserted_lines() {
2629 let run = fake_output(0, "");
2630 zero_mutant_verdict(&[], &diff_with_inserted("src/lib.rs", &[5]), &run).unwrap();
2631 let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2632 zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[4, 9]), &run).unwrap();
2633 zero_mutant_verdict(&listed, &diff_with_inserted("src/other.rs", &[6]), &run).unwrap();
2634 }
2635
2636 #[cfg(unix)]
2637 #[test]
2638 fn zero_mutant_verdict_is_fatal_on_a_mutant_at_either_span_boundary() {
2639 let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2640 let run = fake_stdout(0, "0 mutants tested");
2641 for line in [5, 8] {
2642 let err =
2643 zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[line]), &run)
2644 .unwrap_err();
2645 let message = err.to_string();
2646 assert!(
2647 message.contains("1 of the crate's 1 mutant site(s)")
2648 && message.contains("src/lib.rs:5: replace add -> 0")
2649 && message.contains("0 mutants tested"),
2650 "got: {message}"
2651 );
2652 }
2653 }
2654
2655 #[cfg(unix)]
2656 #[test]
2657 fn zero_mutant_verdict_names_each_dropped_site_once() {
2658 let listed = [
2659 listed_mutant(
2660 "src/lib.rs",
2661 7,
2662 7,
2663 "src/lib.rs:7:7: replace > with == in is_positive",
2664 ),
2665 listed_mutant("src/lib.rs", 7, 7, "replace add -> 0"),
2666 ];
2667 let run = fake_stdout(0, "0 mutants tested");
2668 let message = zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[7]), &run)
2669 .unwrap_err()
2670 .to_string();
2671 assert!(
2672 message.contains(" src/lib.rs:7: replace > with == in is_positive"),
2673 "the name's embedded `file:line:col:` prefix is stripped; got: {message}"
2674 );
2675 assert!(
2676 !message.contains(": src/lib.rs:7:7:"),
2677 "a dropped site carries one location; got: {message}"
2678 );
2679 assert!(
2680 message.contains(" src/lib.rs:7: replace add -> 0"),
2681 "a name with no embedded location keeps its rendered location; got: {message}"
2682 );
2683 }
2684
2685 #[cfg(unix)]
2686 #[test]
2687 fn classify_mutants_exit_accepts_the_caught_and_survivor_exits() {
2688 classify_mutants_exit(Path::new("/crate"), &fake_output(0, "")).unwrap();
2689 classify_mutants_exit(Path::new("/crate"), &fake_output(2, "")).unwrap();
2690 }
2691
2692 #[cfg(unix)]
2693 #[test]
2694 fn classify_mutants_exit_accepts_a_timeout_exit_3() {
2695 classify_mutants_exit(Path::new("/crate"), &fake_output(3, ""))
2696 .expect("a timeout (exit 3) is inconclusive, not fatal");
2697 }
2698
2699 #[cfg(unix)]
2700 #[test]
2701 fn classify_mutants_exit_is_fatal_on_a_baseline_failure() {
2702 let err = classify_mutants_exit(Path::new("/crate"), &fake_output(4, "baseline broke"))
2703 .unwrap_err();
2704 assert!(
2705 err.to_string().contains("did not run cleanly")
2706 && err.to_string().contains("baseline broke"),
2707 "got: {err}"
2708 );
2709 }
2710
2711 #[test]
2712 fn cargo_mutants_bin_name_matches_the_platform() {
2713 #[cfg(windows)]
2714 assert_eq!(CARGO_MUTANTS_BIN_NAME, "cargo-mutants.exe");
2715 #[cfg(not(windows))]
2716 assert_eq!(CARGO_MUTANTS_BIN_NAME, "cargo-mutants");
2717 }
2718}