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