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> {
1263 let mut argv = vec![
1264 OsString::from("mutants"),
1265 OsString::from("--output"),
1266 out.as_os_str().to_os_string(),
1267 OsString::from("--cargo-test-arg"),
1268 OsString::from("--lib"),
1269 ];
1270 if let Some(diff) = in_diff {
1271 argv.push(OsString::from("--in-diff"));
1272 argv.push(diff.as_os_str().to_os_string());
1273 }
1274 if !features.is_empty() {
1275 argv.push(OsString::from("--features"));
1276 argv.push(OsString::from(features.join(",")));
1277 }
1278 argv
1279}
1280
1281fn classify_mutants_exit(root: &Path, output: &Output) -> Result<()> {
1285 match output.status.code() {
1286 Some(0) | Some(2) | Some(3) => Ok(()),
1287 _ => bail!(
1288 "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
1289 root.display(),
1290 String::from_utf8_lossy(&output.stdout),
1291 String::from_utf8_lossy(&output.stderr),
1292 ),
1293 }
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298 use super::*;
1299
1300 const NORMALIZED: &str = r#"[
1301 {"file": "src/a.ts", "line": 2, "status": "survived",
1302 "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
1303 {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
1304 {"file": "src/a.ts", "line": 9, "status": "killed",
1305 "mutator": "BooleanLiteral", "replacement": "false"},
1306 {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
1307 {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
1308 {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
1309 ]"#;
1310
1311 #[test]
1312 fn parses_the_normalized_schema() {
1313 let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
1314 assert_eq!(mutants.len(), 6);
1315 assert_eq!(mutants[0].status, MutantStatus::Survived);
1316 assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
1317 assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
1318 assert_eq!(mutants[1].replacement, None);
1319 }
1320
1321 #[test]
1322 fn normalized_survivors_are_survived_and_nocoverage_only() {
1323 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1324 let survivors = normalized_survivors(&mutants);
1325 assert_eq!(survivors.len(), 2);
1326 assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
1327 assert!(survivors[0].description.contains("ConditionalExpression"));
1328 assert!(survivors[0].description.contains("-> true"));
1329 assert_eq!(survivors[1].description, "ArithmeticOperator");
1330 }
1331
1332 #[test]
1333 fn normalized_mutated_lines_collects_only_viable_mutants() {
1334 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1335 assert_eq!(
1336 normalized_mutated_lines(&mutants),
1337 [2u32, 5, 9, 12]
1338 .into_iter()
1339 .map(|line| ("src/a.ts".to_string(), line))
1340 .collect()
1341 );
1342 }
1343
1344 #[test]
1345 fn normalized_conclusive_count_is_survived_killed_and_nocoverage() {
1346 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1347 assert_eq!(normalized_conclusive_count(&mutants), 3);
1348 assert_eq!(normalized_conclusive_count(&[]), 0);
1349 }
1350
1351 #[test]
1352 fn evaluate_normalized_reports_unexempted_survivors() {
1353 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1354 let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
1355 assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
1356 }
1357
1358 #[test]
1359 fn evaluate_normalized_drops_a_whole_file_exemption() {
1360 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1361 let kept =
1362 evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
1363 assert!(
1364 kept.is_empty(),
1365 "the whole-file exemption lifts both survivors"
1366 );
1367 }
1368
1369 #[test]
1370 fn evaluate_normalized_drops_a_line_scoped_exemption() {
1371 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1372 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
1373 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1374 assert_eq!(kept.len(), 1);
1375 assert_eq!(kept[0].line, 5);
1376 }
1377
1378 #[test]
1379 fn evaluate_normalized_rejects_exempting_a_caught_line() {
1380 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1381 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
1382 let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
1383 assert!(
1384 err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
1385 "got: {err}"
1386 );
1387 }
1388
1389 #[test]
1390 fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
1391 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1392 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
1393 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1394 assert_eq!(kept.len(), 2);
1395 }
1396
1397 const SAMPLE: &str = r#"{
1398 "outcomes": [
1399 {"scenario": "Baseline", "summary": "Success",
1400 "phase_results": []},
1401 {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
1402 "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
1403 "function": {"function_name": "is_positive"},
1404 "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
1405 "summary": "MissedMutant"},
1406 {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
1407 "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
1408 "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
1409 "summary": "CaughtMutant"}
1410 ],
1411 "total_mutants": 2
1412 }"#;
1413
1414 #[test]
1415 fn parses_the_outcomes_export() {
1416 let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
1417 assert_eq!(report.outcomes.len(), 3);
1418 assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
1419 }
1420
1421 #[test]
1422 fn collects_only_missed_mutants_as_survivors() {
1423 let report = parse_mutants_report(SAMPLE).unwrap();
1424 let survivors = unexplained_survivors(&report, &[]);
1425 assert_eq!(survivors.len(), 1);
1426 assert_eq!(survivors[0].file, "src/lib.rs");
1427 assert_eq!(survivors[0].line, 7);
1428 assert!(survivors[0].description.contains("replace > with =="));
1429 }
1430
1431 #[test]
1432 fn a_survivor_description_carries_no_location_prefix() {
1433 let report = parse_mutants_report(SAMPLE).unwrap();
1434 let survivors = unexplained_survivors(&report, &[]);
1435 assert_eq!(
1436 survivors[0].description, "replace > with == in is_positive",
1437 "the name's embedded `file:line:col:` prefix is stripped"
1438 );
1439 }
1440
1441 #[test]
1442 fn strip_embedded_location_removes_a_file_line_col_prefix() {
1443 assert_eq!(
1444 strip_embedded_location("src/lib.rs:7:5: replace > with == in is_positive"),
1445 "replace > with == in is_positive"
1446 );
1447 }
1448
1449 #[test]
1450 fn strip_embedded_location_keeps_a_name_without_one() {
1451 for name in [
1452 "replace add -> 0",
1453 "note: no location segment",
1454 "7:5: no file segment",
1455 "src/lib.rs:7:x: non-numeric column",
1456 "src/lib.rs:x:5: non-numeric line",
1457 ] {
1458 assert_eq!(strip_embedded_location(name), name);
1459 }
1460 }
1461
1462 #[test]
1463 fn conclusive_count_is_caught_plus_missed() {
1464 let report = parse_mutants_report(SAMPLE).unwrap();
1465 assert_eq!(conclusive_count(&report), 2);
1466 assert_eq!(conclusive_count(&MutantsReport { outcomes: vec![] }), 0);
1467 }
1468
1469 #[test]
1470 fn an_exemption_drops_a_survivor_in_that_file() {
1471 let report = parse_mutants_report(SAMPLE).unwrap();
1472 let exempt = vec!["src/lib.rs".to_string()];
1473 assert!(unexplained_survivors(&report, &exempt).is_empty());
1474 }
1475
1476 #[test]
1477 fn an_exemption_on_another_file_leaves_the_survivor() {
1478 let report = parse_mutants_report(SAMPLE).unwrap();
1479 let exempt = vec!["src/elsewhere.rs".to_string()];
1480 assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1481 }
1482
1483 const BASELINE_ONLY: &str = r#"{
1484 "outcomes": [
1485 {"scenario": "Baseline", "summary": "MissedMutant", "phase_results": []},
1486 {"scenario": "Baseline", "summary": "CaughtMutant", "phase_results": []}
1487 ],
1488 "total_mutants": 0
1489 }"#;
1490
1491 #[test]
1492 fn a_baseline_outcome_is_never_a_survivor() {
1493 let report = parse_mutants_report(BASELINE_ONLY).unwrap();
1494 assert!(unexplained_survivors(&report, &[]).is_empty());
1495 }
1496
1497 #[test]
1498 fn a_baseline_outcome_is_never_a_mutated_line() {
1499 let report = parse_mutants_report(BASELINE_ONLY).unwrap();
1500 assert!(mutated_lines(&report).is_empty());
1501 }
1502
1503 #[test]
1504 fn parse_base_diff_skips_a_malformed_hunk_header() {
1505 let diff = "\
1506diff --git a/src/lib.rs b/src/lib.rs
1507--- a/src/lib.rs
1508+++ b/src/lib.rs
1509@@ junk @@
1510";
1511 let parsed = parse_base_diff(diff);
1512 assert_eq!(parsed.files, vec!["src/lib.rs"]);
1513 assert!(parsed.inserted.is_empty());
1514 }
1515
1516 #[test]
1517 fn a_missing_base_diff_read_reports_its_path() {
1518 let missing = unique_tmp().join("base.diff");
1519 let err = read_base_diff(&missing).unwrap_err();
1520 let msg = format!("{err:#}");
1521 assert!(msg.contains("reading the written base diff"), "{msg}");
1522 }
1523
1524 #[test]
1525 fn a_missing_adapter_results_read_names_the_engine_and_path() {
1526 let missing = unique_tmp().join("results.json");
1527 let err = read_adapter_results(&missing, "TypeScript").unwrap_err();
1528 let msg = format!("{err:#}");
1529 assert!(
1530 msg.contains("TypeScript mutation adapter's results"),
1531 "{msg}"
1532 );
1533 }
1534
1535 #[test]
1536 fn manifest_dir_is_the_manifest_parent() {
1537 let dir = manifest_dir(Path::new("/w/Cargo.toml")).unwrap();
1538 assert_eq!(dir, Path::new("/w"));
1539 }
1540
1541 #[test]
1542 fn a_rootless_manifest_path_is_an_error() {
1543 let err = manifest_dir(Path::new("/")).unwrap_err();
1544 let msg = format!("{err:#}");
1545 assert!(msg.contains("no parent dir"), "{msg}");
1546 }
1547
1548 #[test]
1549 fn a_directory_outside_any_workspace_fails_locate_project() {
1550 let dir = unique_tmp();
1551 let err = cargo_workspace_root(&dir).unwrap_err();
1552 let msg = format!("{err:#}");
1553 assert!(msg.contains("cargo locate-project failed"), "{msg}");
1554 std::fs::remove_dir_all(&dir).ok();
1555 }
1556
1557 #[test]
1558 fn a_bad_base_ref_fails_the_base_diff() {
1559 let dir = unique_tmp();
1560 let init = Command::new("git")
1561 .current_dir(&dir)
1562 .args(["init", "-q"])
1563 .output()
1564 .unwrap();
1565 assert!(init.status.success());
1566 let out = MutantsOut::new();
1567 let err = write_base_diff(&dir, &dir, None, "tc-no-such-ref", &out).unwrap_err();
1568 let msg = format!("{err:#}");
1569 assert!(msg.contains("git diff"), "{msg}");
1570 std::fs::remove_dir_all(&dir).ok();
1571 }
1572
1573 #[test]
1574 fn a_python_adapter_failure_reports_the_adapter_output() {
1575 let dir = unique_tmp();
1576 let err = run_py_adapter(&dir, &[]).unwrap_err();
1577 let msg = format!("{err:#}");
1578 assert!(msg.contains("the Python mutation adapter failed"), "{msg}");
1579 std::fs::remove_dir_all(&dir).ok();
1580 }
1581
1582 #[test]
1583 fn is_declaration_only_is_true_for_a_const_only_rust_file() {
1584 let dir = unique_tmp();
1585 std::fs::write(
1586 dir.join("settings.rs"),
1587 "pub const TIMEOUT: u64 = 30 * 60;\n",
1588 )
1589 .unwrap();
1590 assert!(is_declaration_only(&dir, "settings.rs", Language::Rust));
1591 std::fs::remove_dir_all(&dir).ok();
1592 }
1593
1594 #[test]
1595 fn is_declaration_only_is_false_for_a_rust_file_with_a_function() {
1596 let dir = unique_tmp();
1597 std::fs::write(dir.join("lib.rs"), "pub fn run() {}\n").unwrap();
1598 assert!(!is_declaration_only(&dir, "lib.rs", Language::Rust));
1599 std::fs::remove_dir_all(&dir).ok();
1600 }
1601
1602 #[test]
1603 fn is_declaration_only_covers_python_and_typescript_too() {
1604 let dir = unique_tmp();
1605 std::fs::write(dir.join("settings.py"), "TIMEOUT = 30 * 60\n").unwrap();
1606 std::fs::write(dir.join("settings.ts"), "export const TIMEOUT = 30 * 60;\n").unwrap();
1607 assert!(is_declaration_only(&dir, "settings.py", Language::Python));
1608 assert!(is_declaration_only(
1609 &dir,
1610 "settings.ts",
1611 Language::TypeScript
1612 ));
1613 std::fs::remove_dir_all(&dir).ok();
1614 }
1615
1616 #[test]
1617 fn is_declaration_only_is_false_for_an_unreadable_file() {
1618 let dir = unique_tmp();
1619 assert!(!is_declaration_only(&dir, "missing.rs", Language::Rust));
1620 std::fs::remove_dir_all(&dir).ok();
1621 }
1622
1623 #[test]
1624 fn rebase_report_paths_strips_the_workspace_prefix() {
1625 let report = parse_mutants_report(SAMPLE).unwrap();
1626 let prefixed = MutantsReport {
1627 outcomes: report
1628 .outcomes
1629 .iter()
1630 .cloned()
1631 .map(|mut outcome| {
1632 if let Scenario::Mutant(mutant) = &mut outcome.scenario {
1633 mutant.file = format!("member/{}", mutant.file);
1634 }
1635 outcome
1636 })
1637 .collect(),
1638 };
1639 let rebased = rebase_report_paths(prefixed, Some("member"));
1640 let survivors = unexplained_survivors(&rebased, &[]);
1641 assert_eq!(survivors.len(), 1);
1642 assert_eq!(survivors[0].file, "src/lib.rs");
1643 assert_eq!(rebased.outcomes.len(), 3);
1644 }
1645
1646 #[test]
1647 fn rebase_report_paths_drops_an_out_of_scope_mutant_and_keeps_none_identity() {
1648 let report = parse_mutants_report(SAMPLE).unwrap();
1649 let rebased = rebase_report_paths(report.clone(), Some("member"));
1650 assert_eq!(
1651 rebased.outcomes.len(),
1652 1,
1653 "only the pathless baseline outcome remains"
1654 );
1655 let unchanged = rebase_report_paths(report, None);
1656 assert_eq!(unchanged.outcomes.len(), 3);
1657 assert_eq!(unexplained_survivors(&unchanged, &[])[0].file, "src/lib.rs");
1658 }
1659
1660 #[test]
1661 fn adapter_cwd_normalises_the_empty_package_root_to_the_current_dir() {
1662 assert_eq!(
1666 adapter_cwd(Path::new(""), "TypeScript").unwrap(),
1667 Path::new(".")
1668 );
1669 assert_eq!(
1670 adapter_cwd(Path::new("src"), "TypeScript").unwrap(),
1671 Path::new("src")
1672 );
1673 }
1674
1675 #[test]
1676 fn adapter_cwd_rejects_a_directory_that_is_not_there() {
1677 let err = adapter_cwd(Path::new("no/such/dir"), "Python")
1680 .expect_err("a directory that is not there is an error");
1681 assert_eq!(
1682 err.to_string(),
1683 "the Python mutation adapter's working directory `no/such/dir` is not a directory"
1684 );
1685 }
1686
1687 #[test]
1688 fn spawn_context_names_the_interpreter_the_entry_and_the_working_directory() {
1689 assert_eq!(
1690 spawn_context("node", "/pkg/dist/mutation/main.js", Path::new("/pkg")),
1691 "spawning `node /pkg/dist/mutation/main.js` in `/pkg` (is `node` on PATH?)"
1692 );
1693 }
1694
1695 #[test]
1696 fn scan_prefix_is_the_scan_path_relative_to_the_package_root() {
1697 assert_eq!(
1698 scan_prefix(Path::new("/repo/pkg/src"), Path::new("/repo/pkg")),
1699 Some("src".to_string())
1700 );
1701 assert_eq!(
1702 scan_prefix(Path::new("/repo/pkg/src/nested"), Path::new("/repo/pkg")),
1703 Some("src/nested".to_string())
1704 );
1705 assert_eq!(
1706 scan_prefix(Path::new("/repo/pkg"), Path::new("/repo/pkg")),
1707 None
1708 );
1709 assert_eq!(
1710 scan_prefix(Path::new("pkg/src"), Path::new("pkg")),
1711 Some("src".to_string())
1712 );
1713 }
1714
1715 #[test]
1716 fn prefix_mutate_specs_rebases_diff_ranges_onto_the_package_root() {
1717 let specs = vec!["index.ts:8-11".to_string(), "a/b.ts:2-2".to_string()];
1718 assert_eq!(
1719 prefix_mutate_specs(specs.clone(), Some("src")),
1720 vec![
1721 "src/index.ts:8-11".to_string(),
1722 "src/a/b.ts:2-2".to_string()
1723 ]
1724 );
1725 assert_eq!(prefix_mutate_specs(specs.clone(), None), specs);
1726 }
1727
1728 #[test]
1729 fn scan_scoped_mutate_globs_mirror_strykers_default_under_the_scan_path() {
1730 assert_eq!(
1731 scan_scoped_mutate_globs("src"),
1732 vec![
1733 "src/**/!(*.+(s|S)pec|*.+(t|T)est).+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
1734 .to_string(),
1735 "!src/**/__tests__/**/*.+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
1736 .to_string(),
1737 ]
1738 );
1739 }
1740
1741 #[test]
1742 fn scan_scoped_test_file_globs_narrow_the_run_without_moving_the_runner_root() {
1743 assert_eq!(
1744 scan_scoped_test_file_globs("src"),
1745 vec!["src/**".to_string()]
1746 );
1747 assert_eq!(
1748 scan_scoped_test_file_globs("packages/core/src"),
1749 vec!["packages/core/src/**".to_string()]
1750 );
1751 }
1752
1753 #[test]
1754 fn to_scan_relative_strips_the_prefix_and_drops_out_of_scope_mutants() {
1755 let mutants = parse_normalized_results(
1756 r#"[
1757 {"file": "src/a.ts", "line": 2, "status": "survived", "mutator": "X"},
1758 {"file": "tests/e2e/t.ts", "line": 9, "status": "survived", "mutator": "X"}
1759 ]"#,
1760 )
1761 .unwrap();
1762 let rebased = to_scan_relative(mutants.clone(), Some("src"));
1763 assert_eq!(rebased.len(), 1, "the out-of-scan-path mutant is dropped");
1764 assert_eq!(rebased[0].file, "a.ts");
1765 let unchanged = to_scan_relative(mutants, None);
1766 assert_eq!(unchanged.len(), 2);
1767 assert_eq!(unchanged[0].file, "src/a.ts");
1768 }
1769
1770 #[test]
1771 fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
1772 assert!(is_mutatable_ts("src/index.ts"));
1773 assert!(is_mutatable_ts("src/util.tsx"));
1774 assert!(is_mutatable_ts("src/util.js"));
1775 assert!(!is_mutatable_ts("src/index.test.ts"));
1776 assert!(!is_mutatable_ts("src/index.spec.ts"));
1777 assert!(!is_mutatable_ts("src/types.d.ts"));
1778 assert!(!is_mutatable_ts("README.md"));
1779 }
1780
1781 #[test]
1782 fn contiguous_runs_collapses_adjacent_lines() {
1783 let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
1784 assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
1785 assert!(contiguous_runs(&BTreeSet::new()).is_empty());
1786 }
1787
1788 #[test]
1789 fn one_line_flattens_and_caps() {
1790 assert_eq!(one_line("a -\n b"), "a - b");
1791 let long = "x".repeat(80);
1792 let capped = one_line(&long);
1793 assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
1794 }
1795
1796 #[test]
1797 fn is_mutatable_py_keeps_sources_and_drops_tests() {
1798 assert!(is_mutatable_py("calc.py"));
1799 assert!(is_mutatable_py("pkg/util.py"));
1800 assert!(!is_mutatable_py("calc_test.py"));
1801 assert!(!is_mutatable_py("test_calc.py"));
1802 assert!(!is_mutatable_py("pkg/conftest.py"));
1803 assert!(!is_mutatable_py("README.md"));
1804 }
1805
1806 #[test]
1807 fn mutated_lines_collects_caught_and_missed() {
1808 let report = parse_mutants_report(SAMPLE).unwrap();
1809 assert_eq!(
1810 mutated_lines(&report),
1811 [
1812 ("src/lib.rs".to_string(), 7),
1813 ("src/other.rs".to_string(), 3)
1814 ]
1815 .into_iter()
1816 .collect()
1817 );
1818 }
1819
1820 #[test]
1821 fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
1822 let report = parse_mutants_report(SAMPLE).unwrap();
1823 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
1824 let kept = evaluate_scoped(
1825 cargo_mutants_survivors(&report),
1826 &mutated_lines(&report),
1827 &[],
1828 &line_scoped,
1829 )
1830 .unwrap();
1831 assert!(
1832 kept.is_empty(),
1833 "the src/lib.rs:7 survivor should be lifted"
1834 );
1835 }
1836
1837 #[test]
1838 fn evaluate_scoped_rejects_exempting_a_caught_line() {
1839 let report = parse_mutants_report(SAMPLE).unwrap();
1840 let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
1841 let err = evaluate_scoped(
1842 cargo_mutants_survivors(&report),
1843 &mutated_lines(&report),
1844 &[],
1845 &line_scoped,
1846 )
1847 .unwrap_err();
1848 assert!(
1849 err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
1850 "got: {err}"
1851 );
1852 }
1853
1854 #[test]
1855 fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
1856 let report = parse_mutants_report(SAMPLE).unwrap();
1857 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
1858 let kept = evaluate_scoped(
1859 cargo_mutants_survivors(&report),
1860 &mutated_lines(&report),
1861 &[],
1862 &line_scoped,
1863 )
1864 .unwrap();
1865 assert_eq!(kept.len(), 1);
1866 assert_eq!(kept[0].line, 7);
1867 }
1868
1869 #[test]
1870 fn evaluate_scoped_still_honors_a_whole_file_exemption() {
1871 let report = parse_mutants_report(SAMPLE).unwrap();
1872 let kept = evaluate_scoped(
1873 cargo_mutants_survivors(&report),
1874 &mutated_lines(&report),
1875 &["src/lib.rs".to_string()],
1876 &BTreeMap::new(),
1877 )
1878 .unwrap();
1879 assert!(kept.is_empty());
1880 }
1881
1882 fn unique_tmp() -> PathBuf {
1883 static COUNTER: AtomicU64 = AtomicU64::new(0);
1884 let dir = std::env::temp_dir().join(format!(
1885 "tc-provision-test-{}-{}",
1886 std::process::id(),
1887 COUNTER.fetch_add(1, Ordering::Relaxed)
1888 ));
1889 std::fs::create_dir_all(&dir).unwrap();
1890 dir
1891 }
1892
1893 enum Install {
1894 MustNotRun,
1895 WritesNothing,
1896 WritesBin,
1897 Fails,
1898 CountsSleepsAndWritesBin(std::sync::Arc<AtomicU64>),
1899 }
1900
1901 fn write_bin(bin: &Path) {
1902 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1903 std::fs::write(bin, b"binary").unwrap();
1904 }
1905
1906 fn drive_provision(bin: &Path, lock: &Path, install: Install) -> Result<PathBuf> {
1907 provision(bin, lock, || match install {
1908 Install::MustNotRun => panic!("must not reinstall"),
1909 Install::WritesNothing => Ok(()),
1910 Install::WritesBin => {
1911 write_bin(bin);
1912 Ok(())
1913 }
1914 Install::Fails => bail!("install blew up"),
1915 Install::CountsSleepsAndWritesBin(count) => {
1916 count.fetch_add(1, Ordering::SeqCst);
1917 std::thread::sleep(std::time::Duration::from_millis(50));
1918 write_bin(bin);
1919 Ok(())
1920 }
1921 })
1922 }
1923
1924 #[test]
1925 fn provision_returns_an_existing_binary_without_installing() {
1926 let tmp = unique_tmp();
1927 let bin = tmp.join("bin").join("cargo-mutants");
1928 let lock = tmp.join(".install.lock");
1929 write_bin(&bin);
1930 let got = drive_provision(&bin, &lock, Install::MustNotRun).unwrap();
1931 assert_eq!(got, bin);
1932 std::fs::remove_dir_all(&tmp).unwrap();
1933 }
1934
1935 #[test]
1936 fn the_must_not_run_sentinel_panics_when_installation_runs() {
1937 let tmp = unique_tmp();
1938 std::fs::create_dir_all(&tmp).unwrap();
1939 let bin = tmp.join("bin").join("cargo-mutants");
1940 let lock = tmp.join(".install.lock");
1941 let panicked =
1942 std::panic::catch_unwind(|| drive_provision(&bin, &lock, Install::MustNotRun)).is_err();
1943 std::fs::remove_dir_all(&tmp).unwrap();
1944 assert!(panicked);
1945 }
1946
1947 #[test]
1948 fn provision_with_a_rootless_lock_path_fails_to_open_the_lock() {
1949 let bin = unique_tmp().join("bin").join("cargo-mutants");
1950 let err = drive_provision(&bin, Path::new("/"), Install::WritesNothing).unwrap_err();
1951 let msg = format!("{err:#}");
1952 assert!(msg.contains("opening the provisioning lock"), "{msg}");
1953 }
1954
1955 #[test]
1956 fn provision_installs_when_the_binary_is_absent() {
1957 let tmp = unique_tmp();
1958 let bin = tmp.join("bin").join("cargo-mutants");
1959 let lock = tmp.join(".install.lock");
1960 let got = drive_provision(&bin, &lock, Install::WritesBin).unwrap();
1961 assert_eq!(got, bin);
1962 assert_eq!(
1963 std::fs::read(&bin).unwrap(),
1964 b"binary",
1965 "an absent binary must be installed"
1966 );
1967 std::fs::remove_dir_all(&tmp).unwrap();
1968 }
1969
1970 #[test]
1971 fn provision_errors_when_install_produces_no_binary() {
1972 let tmp = unique_tmp();
1973 let bin = tmp.join("bin").join("cargo-mutants");
1974 let lock = tmp.join(".install.lock");
1975 let err = drive_provision(&bin, &lock, Install::WritesNothing).unwrap_err();
1976 assert!(
1977 err.to_string().contains("cargo-mutants is not at"),
1978 "got: {err}"
1979 );
1980 std::fs::remove_dir_all(&tmp).unwrap();
1981 }
1982
1983 #[test]
1984 fn provision_propagates_an_install_failure() {
1985 let tmp = unique_tmp();
1986 let bin = tmp.join("bin").join("cargo-mutants");
1987 let lock = tmp.join(".install.lock");
1988 let err = drive_provision(&bin, &lock, Install::Fails).unwrap_err();
1989 assert!(err.to_string().contains("install blew up"), "got: {err}");
1990 std::fs::remove_dir_all(&tmp).unwrap();
1991 }
1992
1993 #[test]
1994 fn provision_does_not_duplicate_the_install_under_concurrent_callers() {
1995 use std::sync::{Arc, Barrier};
1999 use std::thread;
2000
2001 let tmp = unique_tmp();
2002 let bin = tmp.join("bin").join("cargo-mutants");
2003 let lock = tmp.join(".install.lock");
2004 let install_count = Arc::new(AtomicU64::new(0));
2005 let barrier = Arc::new(Barrier::new(2));
2006
2007 let handles: Vec<_> = (0..2)
2008 .map(|_| {
2009 let bin = bin.clone();
2010 let lock = lock.clone();
2011 let install_count = Arc::clone(&install_count);
2012 let barrier = Arc::clone(&barrier);
2013 thread::spawn(move || {
2014 barrier.wait();
2015 drive_provision(
2016 &bin,
2017 &lock,
2018 Install::CountsSleepsAndWritesBin(install_count),
2019 )
2020 })
2021 })
2022 .collect();
2023
2024 for h in handles {
2025 h.join()
2026 .expect("provisioning thread must not panic")
2027 .unwrap();
2028 }
2029
2030 assert_eq!(
2031 install_count.load(Ordering::SeqCst),
2032 1,
2033 "two concurrent callers on a cold cache must share one install, not each run their own"
2034 );
2035 std::fs::remove_dir_all(&tmp).unwrap();
2036 }
2037
2038 #[test]
2039 fn resolve_cache_base_prefers_xdg_then_home_then_temp() {
2040 let xdg = |s: &str| Some(OsString::from(s));
2041 assert_eq!(
2042 resolve_cache_base(xdg("/xdg"), xdg("/home")),
2043 PathBuf::from("/xdg")
2044 );
2045 assert_eq!(
2046 resolve_cache_base(xdg(""), xdg("/home")),
2047 PathBuf::from("/home/.cache")
2048 );
2049 assert_eq!(
2050 resolve_cache_base(None, xdg("/home")),
2051 PathBuf::from("/home/.cache")
2052 );
2053 assert_eq!(resolve_cache_base(None, None), std::env::temp_dir());
2054 assert_eq!(
2055 resolve_cache_base(xdg(""), Some(OsString::new())),
2056 std::env::temp_dir()
2057 );
2058 }
2059
2060 #[test]
2061 fn cache_root_is_absolute_and_version_scoped() {
2062 let root = cargo_mutants_cache_root();
2063 assert!(
2064 root.ends_with(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}")),
2065 "version-scoped; got {root:?}"
2066 );
2067 assert!(
2068 root.to_string_lossy().contains("testing-conventions"),
2069 "tool-namespaced; got {root:?}"
2070 );
2071 assert!(
2072 root.is_absolute(),
2073 "expected an absolute path; got {root:?}"
2074 );
2075 }
2076
2077 #[test]
2078 fn install_argv_pins_the_version_and_isolates_the_root() {
2079 let argv: Vec<String> = install_argv(Path::new("/cache/cargo-mutants-27"))
2080 .iter()
2081 .map(|arg| arg.to_string_lossy().into_owned())
2082 .collect();
2083 assert_eq!(
2084 argv,
2085 vec![
2086 "install",
2087 "cargo-mutants",
2088 "--locked",
2089 "--version",
2090 CARGO_MUTANTS_VERSION,
2091 "--root",
2092 "/cache/cargo-mutants-27",
2093 ]
2094 );
2095 }
2096
2097 #[test]
2098 fn mutants_argv_enables_features_on_the_engine_itself() {
2099 let argv = |diff, features: &[&str]| -> Vec<String> {
2100 mutants_argv(
2101 Path::new("/out"),
2102 diff,
2103 &features.iter().map(|f| f.to_string()).collect::<Vec<_>>(),
2104 )
2105 .iter()
2106 .map(|arg| arg.to_string_lossy().into_owned())
2107 .collect()
2108 };
2109 assert_eq!(
2110 argv(None, &["cli", "boost"]),
2111 vec![
2112 "mutants",
2113 "--output",
2114 "/out",
2115 "--cargo-test-arg",
2116 "--lib",
2117 "--features",
2118 "cli,boost"
2119 ]
2120 );
2121 assert_eq!(
2122 argv(Some(Path::new("/out/base.diff")), &["cli"]),
2123 vec![
2124 "mutants",
2125 "--output",
2126 "/out",
2127 "--cargo-test-arg",
2128 "--lib",
2129 "--in-diff",
2130 "/out/base.diff",
2131 "--features",
2132 "cli",
2133 ]
2134 );
2135 assert_eq!(
2136 argv(None, &[]),
2137 vec!["mutants", "--output", "/out", "--cargo-test-arg", "--lib"]
2138 );
2139 }
2140
2141 #[test]
2142 fn list_argv_mirrors_the_run_feature_selection() {
2143 let argv = |features: &[&str]| -> Vec<String> {
2144 list_argv(&features.iter().map(|f| f.to_string()).collect::<Vec<_>>())
2145 .iter()
2146 .map(|arg| arg.to_string_lossy().into_owned())
2147 .collect()
2148 };
2149 assert_eq!(argv(&[]), vec!["mutants", "--list", "--json"]);
2150 assert_eq!(
2151 argv(&["cli", "boost"]),
2152 vec!["mutants", "--list", "--json", "--features", "cli,boost"]
2153 );
2154 }
2155
2156 #[test]
2157 fn parse_base_diff_maps_inserted_lines_per_hunk() {
2158 let diff = "\
2159diff --git a/src/lib.rs b/src/lib.rs
2160--- a/src/lib.rs
2161+++ b/src/lib.rs
2162@@ -1,4 +1,5 @@
2163 fn a() {}
2164+fn b() {}
2165 fn c() {}
2166-fn d() {}
2167+fn e() {}
2168 fn f() {}
2169@@ -10,2 +11,4 @@
2170 tail
2171+one
2172+two
2173 more
2174";
2175 let parsed = parse_base_diff(diff);
2176 assert_eq!(parsed.files, vec!["src/lib.rs"]);
2177 assert_eq!(
2178 parsed.inserted.get("src/lib.rs"),
2179 Some(&BTreeSet::from([2, 4, 12, 13]))
2180 );
2181 }
2182
2183 #[test]
2184 fn parse_base_diff_leaves_a_deletion_only_file_without_inserted_lines() {
2185 let diff = "\
2186--- a/src/gone.rs
2187+++ b/src/gone.rs
2188@@ -5,2 +4,0 @@
2189-x
2190-y
2191";
2192 let parsed = parse_base_diff(diff);
2193 assert_eq!(parsed.files, vec!["src/gone.rs"]);
2194 assert!(parsed.inserted.is_empty());
2195 }
2196
2197 #[test]
2198 fn parse_base_diff_skips_a_deleted_file() {
2199 let diff = "\
2200--- a/src/dead.rs
2201+++ /dev/null
2202@@ -1,2 +0,0 @@
2203-a
2204-b
2205";
2206 let parsed = parse_base_diff(diff);
2207 assert!(parsed.files.is_empty());
2208 assert!(parsed.inserted.is_empty());
2209 }
2210
2211 #[test]
2212 fn parse_base_diff_consumes_hunk_bodies_by_count_so_content_never_reads_as_a_header() {
2213 let diff = "\
2216+++ b/notes.txt
2217@@ -1,1 +1,2 @@
2218 keep
2219++++ not a header
2220";
2221 let parsed = parse_base_diff(diff);
2222 assert_eq!(parsed.files, vec!["notes.txt"]);
2223 assert_eq!(parsed.inserted.get("notes.txt"), Some(&BTreeSet::from([2])));
2224 }
2225
2226 #[test]
2227 fn parse_base_diff_defaults_an_elided_hunk_count_to_one() {
2228 let diff = "\
2229+++ b/one.txt
2230@@ -1 +1 @@
2231-old
2232+new
2233";
2234 let parsed = parse_base_diff(diff);
2235 assert_eq!(parsed.inserted.get("one.txt"), Some(&BTreeSet::from([1])));
2236 }
2237
2238 #[test]
2239 fn parse_base_diff_skips_no_newline_annotations_mid_hunk() {
2240 let diff = "\
2241+++ b/n.txt
2242@@ -1 +1 @@
2243-old
2244\\ No newline at end of file
2245+new
2246\\ No newline at end of file
2247";
2248 let parsed = parse_base_diff(diff);
2249 assert_eq!(parsed.inserted.get("n.txt"), Some(&BTreeSet::from([1])));
2250 }
2251
2252 #[cfg(unix)]
2253 fn fake_output(code: i32, stderr: &str) -> Output {
2254 use std::os::unix::process::ExitStatusExt;
2255 Output {
2256 status: std::process::ExitStatus::from_raw(code << 8),
2257 stdout: Vec::new(),
2258 stderr: stderr.as_bytes().to_vec(),
2259 }
2260 }
2261
2262 #[cfg(unix)]
2263 enum FakeRun {
2264 AssertsVersionAndSucceeds,
2265 FailsWith(&'static str),
2266 SpawnError,
2267 }
2268
2269 #[cfg(unix)]
2270 fn drive_install(root: &Path, run: FakeRun) -> Result<()> {
2271 run_install(root, |command| match run {
2272 FakeRun::AssertsVersionAndSucceeds => {
2273 let argv: Vec<String> = command
2274 .get_args()
2275 .map(|arg| arg.to_string_lossy().into_owned())
2276 .collect();
2277 assert!(argv.contains(&CARGO_MUTANTS_VERSION.to_string()));
2278 Ok(fake_output(0, ""))
2279 }
2280 FakeRun::FailsWith(stderr) => Ok(fake_output(1, stderr)),
2281 FakeRun::SpawnError => Err(std::io::Error::new(
2282 std::io::ErrorKind::NotFound,
2283 "no cargo",
2284 )),
2285 })
2286 }
2287
2288 #[cfg(unix)]
2289 #[test]
2290 fn run_install_succeeds_on_a_zero_exit() {
2291 drive_install(Path::new("/cache/root"), FakeRun::AssertsVersionAndSucceeds).unwrap();
2292 }
2293
2294 #[cfg(unix)]
2295 #[test]
2296 fn run_install_reports_a_nonzero_exit_with_the_engine_output() {
2297 let err = drive_install(
2298 Path::new("/cache/root"),
2299 FakeRun::FailsWith("error: could not compile cargo-mutants"),
2300 )
2301 .unwrap_err();
2302 assert!(
2303 err.to_string()
2304 .contains("failed to provision cargo-mutants")
2305 && err.to_string().contains("could not compile"),
2306 "got: {err}"
2307 );
2308 }
2309
2310 #[cfg(unix)]
2311 #[test]
2312 fn run_install_propagates_a_spawn_failure() {
2313 let err = drive_install(Path::new("/cache/root"), FakeRun::SpawnError).unwrap_err();
2314 assert!(
2315 err.to_string().contains("is cargo installed?"),
2316 "got: {err}"
2317 );
2318 }
2319
2320 #[cfg(unix)]
2321 #[test]
2322 fn provision_pinned_installs_via_the_injected_runner() {
2323 let root = unique_tmp();
2324 let bin = root.join("bin").join(CARGO_MUTANTS_BIN_NAME);
2325 let expected = bin.clone();
2326 let got = provision_pinned(&root, |_| {
2327 write_bin(&bin);
2328 Ok(fake_output(0, ""))
2329 })
2330 .unwrap();
2331 assert_eq!(got, expected);
2332 std::fs::remove_dir_all(&root).unwrap();
2333 }
2334
2335 #[test]
2336 fn execute_surfaces_a_spawn_failure() {
2337 let err = execute(&mut Command::new("/nonexistent-tc-cargo")).unwrap_err();
2338 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
2339 }
2340
2341 #[cfg(unix)]
2342 fn fake_stdout(code: i32, stdout: &str) -> Output {
2343 use std::os::unix::process::ExitStatusExt;
2344 Output {
2345 status: std::process::ExitStatus::from_raw(code << 8),
2346 stdout: stdout.as_bytes().to_vec(),
2347 stderr: Vec::new(),
2348 }
2349 }
2350
2351 #[cfg(unix)]
2352 enum FakeList {
2353 AssertsArgvAndReturns(&'static str, Vec<&'static str>),
2354 FailsWith(&'static str),
2355 SpawnError,
2356 }
2357
2358 #[cfg(unix)]
2359 fn drive_list(features: &[String], run: FakeList) -> Result<Vec<MutantInfo>> {
2360 list_cargo_mutants(
2361 Path::new("/cache/bin/cargo-mutants"),
2362 Path::new("/crate"),
2363 features,
2364 |command| match run {
2365 FakeList::AssertsArgvAndReturns(json, expected) => {
2366 let argv: Vec<String> = command
2367 .get_args()
2368 .map(|arg| arg.to_string_lossy().into_owned())
2369 .collect();
2370 assert_eq!(argv, expected);
2371 assert_eq!(command.get_current_dir(), Some(Path::new("/crate")));
2372 Ok(fake_stdout(0, json))
2373 }
2374 FakeList::FailsWith(stderr) => Ok(fake_output(1, stderr)),
2375 FakeList::SpawnError => Err(std::io::Error::new(
2376 std::io::ErrorKind::NotFound,
2377 "no engine",
2378 )),
2379 },
2380 )
2381 }
2382
2383 #[cfg(unix)]
2384 #[test]
2385 fn list_cargo_mutants_parses_the_listing_from_a_clean_run() {
2386 let json = r#"[{"file": "src/lib.rs", "name": "replace add -> 0",
2387 "span": {"start": {"line": 3, "column": 1}, "end": {"line": 5, "column": 2}}}]"#;
2388 let listed = drive_list(
2389 &["cli".to_string()],
2390 FakeList::AssertsArgvAndReturns(
2391 json,
2392 vec!["mutants", "--list", "--json", "--features", "cli"],
2393 ),
2394 )
2395 .unwrap();
2396 assert_eq!(listed.len(), 1);
2397 assert_eq!(listed[0].file, "src/lib.rs");
2398 assert_eq!(listed[0].span.start.line, 3);
2399 assert_eq!(listed[0].span.end.line, 5);
2400 assert_eq!(listed[0].name, "replace add -> 0");
2401 }
2402
2403 #[cfg(unix)]
2404 #[test]
2405 fn list_cargo_mutants_reports_a_nonzero_exit_with_the_engine_output() {
2406 let err = drive_list(&[], FakeList::FailsWith("error: no such option")).unwrap_err();
2407 assert!(
2408 err.to_string().contains("cargo-mutants --list failed")
2409 && err.to_string().contains("no such option"),
2410 "got: {err}"
2411 );
2412 }
2413
2414 #[cfg(unix)]
2415 #[test]
2416 fn list_cargo_mutants_propagates_a_spawn_failure() {
2417 let err = drive_list(&[], FakeList::SpawnError).unwrap_err();
2418 assert!(
2419 err.to_string()
2420 .contains("listing the crate's mutants with cargo-mutants"),
2421 "got: {err}"
2422 );
2423 }
2424
2425 #[cfg(unix)]
2426 fn listed_mutant(file: &str, start: u32, end: u32, name: &str) -> MutantInfo {
2427 MutantInfo {
2428 file: file.to_string(),
2429 span: Span {
2430 start: LineCol { line: start },
2431 end: LineCol { line: end },
2432 },
2433 name: name.to_string(),
2434 }
2435 }
2436
2437 #[cfg(unix)]
2438 fn diff_with_inserted(file: &str, lines: &[u32]) -> BaseDiff {
2439 BaseDiff {
2440 files: vec![file.to_string()],
2441 inserted: BTreeMap::from([(file.to_string(), lines.iter().copied().collect())]),
2442 }
2443 }
2444
2445 #[cfg(unix)]
2446 #[test]
2447 fn zero_mutant_verdict_accepts_a_zero_with_no_mutant_on_the_inserted_lines() {
2448 let run = fake_output(0, "");
2449 zero_mutant_verdict(&[], &diff_with_inserted("src/lib.rs", &[5]), &run).unwrap();
2450 let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2451 zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[4, 9]), &run).unwrap();
2452 zero_mutant_verdict(&listed, &diff_with_inserted("src/other.rs", &[6]), &run).unwrap();
2453 }
2454
2455 #[cfg(unix)]
2456 #[test]
2457 fn zero_mutant_verdict_is_fatal_on_a_mutant_at_either_span_boundary() {
2458 let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2459 let run = fake_stdout(0, "0 mutants tested");
2460 for line in [5, 8] {
2461 let err =
2462 zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[line]), &run)
2463 .unwrap_err();
2464 let message = err.to_string();
2465 assert!(
2466 message.contains("1 of the crate's 1 mutant site(s)")
2467 && message.contains("src/lib.rs:5: replace add -> 0")
2468 && message.contains("0 mutants tested"),
2469 "got: {message}"
2470 );
2471 }
2472 }
2473
2474 #[cfg(unix)]
2475 #[test]
2476 fn zero_mutant_verdict_names_each_dropped_site_once() {
2477 let listed = [
2478 listed_mutant(
2479 "src/lib.rs",
2480 7,
2481 7,
2482 "src/lib.rs:7:7: replace > with == in is_positive",
2483 ),
2484 listed_mutant("src/lib.rs", 7, 7, "replace add -> 0"),
2485 ];
2486 let run = fake_stdout(0, "0 mutants tested");
2487 let message = zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[7]), &run)
2488 .unwrap_err()
2489 .to_string();
2490 assert!(
2491 message.contains(" src/lib.rs:7: replace > with == in is_positive"),
2492 "the name's embedded `file:line:col:` prefix is stripped; got: {message}"
2493 );
2494 assert!(
2495 !message.contains(": src/lib.rs:7:7:"),
2496 "a dropped site carries one location; got: {message}"
2497 );
2498 assert!(
2499 message.contains(" src/lib.rs:7: replace add -> 0"),
2500 "a name with no embedded location keeps its rendered location; got: {message}"
2501 );
2502 }
2503
2504 #[cfg(unix)]
2505 #[test]
2506 fn classify_mutants_exit_accepts_the_caught_and_survivor_exits() {
2507 classify_mutants_exit(Path::new("/crate"), &fake_output(0, "")).unwrap();
2508 classify_mutants_exit(Path::new("/crate"), &fake_output(2, "")).unwrap();
2509 }
2510
2511 #[cfg(unix)]
2512 #[test]
2513 fn classify_mutants_exit_accepts_a_timeout_exit_3() {
2514 classify_mutants_exit(Path::new("/crate"), &fake_output(3, ""))
2515 .expect("a timeout (exit 3) is inconclusive, not fatal");
2516 }
2517
2518 #[cfg(unix)]
2519 #[test]
2520 fn classify_mutants_exit_is_fatal_on_a_baseline_failure() {
2521 let err = classify_mutants_exit(Path::new("/crate"), &fake_output(4, "baseline broke"))
2522 .unwrap_err();
2523 assert!(
2524 err.to_string().contains("did not run cleanly")
2525 && err.to_string().contains("baseline broke"),
2526 "got: {err}"
2527 );
2528 }
2529
2530 #[test]
2531 fn cargo_mutants_bin_name_matches_the_platform() {
2532 #[cfg(windows)]
2533 assert_eq!(CARGO_MUTANTS_BIN_NAME, "cargo-mutants.exe");
2534 #[cfg(not(windows))]
2535 assert_eq!(CARGO_MUTANTS_BIN_NAME, "cargo-mutants");
2536 }
2537}