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