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