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