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 = rust_base_scope(root, &workspace_root, prefix.as_deref(), base, &out)?;
418 let (diff, base_diff) = match &scope {
419 RustScope::NothingToJudge => return Ok(Measurement::EngineNotRun),
420 RustScope::WholeCrate => (None, None),
421 RustScope::Diff(path, parsed) => (Some(path.as_path()), Some(parsed)),
422 };
423
424 let engine = ensure_cargo_mutants()?;
425 let run = run_cargo_mutants(&engine, root, &out.0, diff, features)?;
426 let outcomes = out.0.join("mutants.out").join("outcomes.json");
427 match std::fs::read_to_string(&outcomes) {
430 Ok(json) => rust_measurement(&json, root, prefix.as_deref(), exempt, exempt_lines),
431 Err(_) => {
432 zero_mutant_measurement(&engine, root, &workspace_root, features, base_diff, &run)
433 }
434 }
435}
436
437fn base_diff_command<'a>(
442 root: &'a Path,
443 workspace_root: &'a Path,
444 prefix: Option<&'a str>,
445 range: &'a str,
446) -> (&'a Path, Vec<&'a str>) {
447 match prefix {
448 None => (root, vec!["diff", "--relative", range]),
449 Some(prefix) => (
450 workspace_root,
451 vec!["diff", "--relative", range, "--", prefix],
452 ),
453 }
454}
455
456fn base_diff_exit(output: &std::process::Output, range: &str) -> Result<()> {
459 if !output.status.success() {
460 bail!(
461 "git diff {range} failed: {}",
462 String::from_utf8_lossy(&output.stderr)
463 );
464 }
465 Ok(())
466}
467
468enum RustScope {
470 NothingToJudge,
473 WholeCrate,
475 Diff(PathBuf, BaseDiff),
477}
478
479fn rust_base_scope(
482 root: &Path,
483 workspace_root: &Path,
484 prefix: Option<&str>,
485 base: Option<&str>,
486 out: &MutantsOut,
487) -> Result<RustScope> {
488 let Some(base) = base else {
489 return Ok(RustScope::WholeCrate);
490 };
491 Ok(
492 match rust_diff_scope(root, workspace_root, prefix, base, out)? {
493 None => RustScope::NothingToJudge,
494 Some((path, parsed)) => RustScope::Diff(path, parsed),
495 },
496 )
497}
498
499fn rust_diff_scope(
503 root: &Path,
504 workspace_root: &Path,
505 prefix: Option<&str>,
506 base: &str,
507 out: &MutantsOut,
508) -> Result<Option<(PathBuf, BaseDiff)>> {
509 let written = write_base_diff(root, workspace_root, prefix, base, out)?;
510 let read = written.as_deref().map(read_base_diff).transpose()?;
511 Ok(rust_scope(written, read))
512}
513
514fn rust_scope(path: Option<PathBuf>, diff: Option<String>) -> Option<(PathBuf, BaseDiff)> {
520 let (path, diff) = (path?, diff?);
521 let parsed = parse_base_diff(&diff);
522 if !parsed.files.iter().any(|file| file.ends_with(".rs")) {
523 return None;
524 }
525 Some((path, parsed))
526}
527
528fn rust_measurement(
531 json: &str,
532 root: &Path,
533 prefix: Option<&str>,
534 exempt: &[String],
535 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
536) -> Result<Measurement> {
537 let mut report = rebase_report_paths(parse_mutants_report(json)?, prefix);
538 report.outcomes.retain(|outcome| match &outcome.scenario {
539 Scenario::Baseline => true,
540 Scenario::Mutant(mutant) => is_judged(root, mutant),
541 });
542 let survivors = evaluate_scoped(
543 cargo_mutants_survivors(&report),
544 &mutated_lines(&report),
545 exempt,
546 exempt_lines,
547 )?;
548 Ok(Measurement::Tested {
549 count: conclusive_count(&report),
550 survivors,
551 })
552}
553
554fn zero_mutant_measurement(
558 engine: &Path,
559 root: &Path,
560 workspace_root: &Path,
561 features: &[String],
562 base_diff: Option<&BaseDiff>,
563 run: &Output,
564) -> Result<Measurement> {
565 if let Some(diff) = base_diff {
566 let listed: Vec<MutantInfo> =
567 list_cargo_mutants(engine, root, features, |command| command.output())?
568 .into_iter()
569 .filter(|mutant| is_judged(workspace_root, mutant))
570 .collect();
571 zero_mutant_verdict(&listed, diff, run)?;
572 }
573 Ok(Measurement::Tested {
574 count: 0,
575 survivors: Vec::new(),
576 })
577}
578
579fn one_line(replacement: &str) -> String {
582 let flat = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
583 const MAX: usize = 60;
584 if flat.chars().count() > MAX {
585 format!("{}…", flat.chars().take(MAX).collect::<String>())
586 } else {
587 flat
588 }
589}
590
591pub fn measure_typescript(
595 root: &Path,
596 exempt: &[String],
597 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
598 base: Option<&str>,
599 adapter: &Path,
600) -> Result<Measurement> {
601 let package_root =
602 crate::tiers::package_root(root, "package.json").unwrap_or_else(|| root.to_path_buf());
603 let prefix = scan_prefix(root, &package_root);
604 let mutate = match ts_base_scope(root, base, prefix.as_deref())? {
605 TsScope::NothingToJudge => return Ok(Measurement::EngineNotRun),
606 TsScope::Mutate(specs) => specs,
607 };
608 let test_files = prefix.as_deref().map(scan_scoped_test_file_globs);
609 let json = run_ts_adapter(
610 &package_root,
611 adapter,
612 mutate.as_deref(),
613 test_files.as_deref(),
614 )?;
615 let mut mutants = to_scan_relative(parse_normalized_results(&json)?, prefix.as_deref());
616 mutants.retain(|mutant| !is_declaration_only(root, &mutant.file, Language::TypeScript));
617 let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
618 Ok(Measurement::Tested {
619 count: normalized_conclusive_count(&mutants),
620 survivors,
621 })
622}
623
624enum TsScope {
626 NothingToJudge,
628 Mutate(Option<Vec<String>>),
631}
632
633fn ts_base_scope(root: &Path, base: Option<&str>, prefix: Option<&str>) -> Result<TsScope> {
637 let Some(base) = base else {
638 return Ok(TsScope::Mutate(prefix.map(scan_scoped_mutate_globs)));
639 };
640 let ranges = mutate_ranges(root, base)?;
641 if ranges.is_empty() {
642 return Ok(TsScope::NothingToJudge);
643 }
644 Ok(TsScope::Mutate(Some(prefix_mutate_specs(ranges, prefix))))
645}
646
647fn scan_prefix(root: &Path, package_root: &Path) -> Option<String> {
650 let rel = root.strip_prefix(package_root).ok()?;
651 let parts: Vec<String> = rel
652 .components()
653 .map(|part| part.as_os_str().to_string_lossy().into_owned())
654 .collect();
655 if parts.is_empty() {
656 None
657 } else {
658 Some(parts.join("/"))
659 }
660}
661
662fn prefix_mutate_specs(specs: Vec<String>, prefix: Option<&str>) -> Vec<String> {
665 match prefix {
666 None => specs,
667 Some(prefix) => specs
668 .into_iter()
669 .map(|spec| format!("{prefix}/{spec}"))
670 .collect(),
671 }
672}
673
674fn scan_scoped_mutate_globs(prefix: &str) -> Vec<String> {
678 const EXTENSIONS: &str = "+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)";
679 vec![
680 format!("{prefix}/**/!(*.+(s|S)pec|*.+(t|T)est).{EXTENSIONS}"),
681 format!("!{prefix}/**/__tests__/**/*.{EXTENSIONS}"),
682 ]
683}
684
685fn scan_scoped_test_file_globs(prefix: &str) -> Vec<String> {
689 vec![format!("{prefix}/**")]
690}
691
692fn to_scan_relative(mutants: Vec<NormalizedMutant>, prefix: Option<&str>) -> Vec<NormalizedMutant> {
696 let Some(prefix) = prefix else {
697 return mutants;
698 };
699 let prefix = format!("{prefix}/");
700 mutants
701 .into_iter()
702 .filter_map(|mut mutant| {
703 mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
704 Some(mutant)
705 })
706 .collect()
707}
708
709fn adapter_cwd<'a>(root: &'a Path, engine: &str) -> Result<&'a Path> {
713 let cwd = if root.as_os_str().is_empty() {
714 Path::new(".")
715 } else {
716 root
717 };
718 if !cwd.is_dir() {
719 bail!(
720 "the {engine} mutation adapter's working directory `{}` is not a directory",
721 cwd.display()
722 );
723 }
724 Ok(cwd)
725}
726
727fn spawn_context(interpreter: &str, entry: &str, cwd: &Path) -> String {
731 format!(
732 "spawning `{interpreter} {entry}` in `{}` (is `{interpreter}` on PATH?)",
733 cwd.display()
734 )
735}
736
737fn ts_adapter_args(mutate: Option<&[String]>, test_files: Option<&[String]>) -> Vec<String> {
741 let mut args = Vec::new();
742 if let Some(specs) = mutate {
743 args.push("--mutate".to_string());
744 args.push(specs.join(","));
745 }
746 if let Some(globs) = test_files {
747 args.push("--test-files".to_string());
748 args.push(globs.join(","));
749 }
750 args
751}
752
753fn run_ts_adapter(
757 package_root: &Path,
758 adapter: &Path,
759 mutate: Option<&[String]>,
760 test_files: Option<&[String]>,
761) -> Result<String> {
762 let (_out, results) = adapter_results_path()?;
763 let cwd = adapter_cwd(package_root, "TypeScript")?;
764
765 let mut command = Command::new("node");
766 command
767 .current_dir(cwd)
768 .arg(adapter)
769 .arg("--out")
770 .arg(&results)
771 .args(ts_adapter_args(mutate, test_files));
772 let output =
773 command
774 .output()
775 .context(spawn_context("node", &adapter.display().to_string(), cwd))?;
776 adapter_exit(&output, "TypeScript", cwd)?;
777 read_adapter_results(&results, "TypeScript")
778}
779
780fn read_adapter_results(results: &Path, engine: &str) -> Result<String> {
782 std::fs::read_to_string(results).with_context(|| {
783 format!(
784 "reading the {engine} mutation adapter's results from `{}`",
785 results.display()
786 )
787 })
788}
789
790fn adapter_results_path() -> Result<(AdapterOut, PathBuf)> {
796 let out = AdapterOut::new();
797 std::fs::create_dir_all(&out.0).context("creating the mutation adapter output dir")?;
798 let results = out.0.join("results.json");
799 Ok((out, results))
800}
801
802fn adapter_exit(output: &std::process::Output, language: &str, cwd: &Path) -> Result<()> {
806 if !output.status.success() {
807 bail!(
808 "the {language} mutation adapter failed in `{}`:\n{}{}",
809 cwd.display(),
810 String::from_utf8_lossy(&output.stdout),
811 String::from_utf8_lossy(&output.stderr),
812 );
813 }
814 Ok(())
815}
816
817struct AdapterOut(PathBuf);
818
819impl AdapterOut {
820 fn new() -> Self {
821 static COUNTER: AtomicU64 = AtomicU64::new(0);
822 let name = format!(
823 "testing-conventions-ts-adapter-{}-{}",
824 std::process::id(),
825 COUNTER.fetch_add(1, Ordering::Relaxed),
826 );
827 AdapterOut(std::env::temp_dir().join(name))
828 }
829}
830
831impl Drop for AdapterOut {
832 fn drop(&mut self) {
833 let _ = std::fs::remove_dir_all(&self.0);
834 }
835}
836
837fn mutate_ranges(root: &Path, base: &str) -> Result<Vec<String>> {
841 let changed = crate::patch_coverage::changed_lines(root, base)?;
842 let mut specs = Vec::new();
843 for (file, lines) in changed {
844 if !is_mutatable_ts(&file) || is_declaration_only(root, &file, Language::TypeScript) {
845 continue;
846 }
847 for (start, end) in contiguous_runs(&lines) {
848 specs.push(format!("{file}:{start}-{end}"));
849 }
850 }
851 Ok(specs)
852}
853
854fn is_mutatable_ts(file: &str) -> bool {
858 let is_source = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
859 .iter()
860 .any(|ext| file.ends_with(ext));
861 let is_decl = file.ends_with(".d.ts");
862 let is_test = file.contains(".test.") || file.contains(".spec.");
863 is_source && !is_decl && !is_test
864}
865
866fn contiguous_runs(lines: &BTreeSet<u64>) -> Vec<(u64, u64)> {
868 let mut runs: Vec<(u64, u64)> = Vec::new();
869 for &line in lines {
870 match runs.last_mut() {
871 Some(run) if run.1 + 1 == line => run.1 = line,
872 _ => runs.push((line, line)),
873 }
874 }
875 runs
876}
877
878pub fn measure_python(
882 root: &Path,
883 exempt: &[String],
884 exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
885 base: Option<&str>,
886) -> Result<Measurement> {
887 let changed = match base {
888 Some(base) => Some(crate::patch_coverage::changed_lines(root, base)?),
889 None => None,
890 };
891 let modules = match &changed {
892 None => Vec::new(),
893 Some(changed) => {
894 let modules = mutatable_py_modules(changed);
895 if modules.is_empty() {
896 return Ok(Measurement::EngineNotRun);
897 }
898 modules
899 }
900 };
901 let json = run_py_adapter(root, &modules)?;
902 let mutants = judged_py_mutants(parse_normalized_results(&json)?, root, changed.as_ref());
903 let survivors = evaluate_normalized(&mutants, exempt, exempt_lines)?;
904 Ok(Measurement::Tested {
905 count: normalized_conclusive_count(&mutants),
906 survivors,
907 })
908}
909
910fn mutatable_py_modules(changed: &BTreeMap<String, BTreeSet<u64>>) -> Vec<String> {
913 changed
914 .keys()
915 .filter(|file| is_mutatable_py(file))
916 .cloned()
917 .collect()
918}
919
920fn judged_py_mutants(
923 mutants: Vec<NormalizedMutant>,
924 root: &Path,
925 changed: Option<&BTreeMap<String, BTreeSet<u64>>>,
926) -> Vec<NormalizedMutant> {
927 let mut mutants = mutants;
928 if let Some(changed) = changed {
929 mutants.retain(|mutant| {
930 changed
931 .get(&mutant.file)
932 .is_some_and(|lines| lines.contains(&u64::from(mutant.line)))
933 });
934 }
935 mutants.retain(|mutant| !is_declaration_only(root, &mutant.file, Language::Python));
936 mutants
937}
938
939fn run_py_adapter(root: &Path, modules: &[String]) -> Result<String> {
943 let (_out, results) = adapter_results_path()?;
944 let cwd = adapter_cwd(root, "Python")?;
945
946 const ENTRY: &str = "-m testing_conventions.mutation.main";
947 let mut command = Command::new("python3");
948 command
949 .current_dir(cwd)
950 .args(["-m", "testing_conventions.mutation.main", "--out"])
951 .arg(&results)
952 .env("PYTHONDONTWRITEBYTECODE", "1");
953 for module in modules {
954 command.arg("--module").arg(module);
955 }
956 let output = command
957 .output()
958 .context(spawn_context("python3", ENTRY, cwd))?;
959 adapter_exit(&output, "Python", cwd)?;
960 read_adapter_results(&results, "Python")
961}
962
963fn is_mutatable_py(file: &str) -> bool {
966 if !file.ends_with(".py") {
967 return false;
968 }
969 let base = file.rsplit('/').next().unwrap_or(file);
970 !(base.ends_with("_test.py") || base.starts_with("test_") || base == "conftest.py")
971}
972
973struct MutantsOut(PathBuf);
976
977impl MutantsOut {
978 fn new() -> Self {
979 static COUNTER: AtomicU64 = AtomicU64::new(0);
980 let name = format!(
981 "testing-conventions-mutants-{}-{}",
982 std::process::id(),
983 COUNTER.fetch_add(1, Ordering::Relaxed),
984 );
985 MutantsOut(std::env::temp_dir().join(name))
986 }
987}
988
989impl Drop for MutantsOut {
990 fn drop(&mut self) {
991 let _ = std::fs::remove_dir_all(&self.0);
992 }
993}
994
995fn cargo_workspace_root(root: &Path) -> Result<PathBuf> {
999 let output = Command::new("cargo")
1000 .current_dir(root)
1001 .args(["locate-project", "--workspace", "--message-format", "plain"])
1002 .output()
1003 .context("running `cargo locate-project` (is cargo installed?)")?;
1004 if !output.status.success() {
1005 bail!(
1006 "cargo locate-project failed in `{}`: {}",
1007 root.display(),
1008 String::from_utf8_lossy(&output.stderr)
1009 );
1010 }
1011 let manifest = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
1012 manifest_dir(&manifest)
1013}
1014
1015fn manifest_dir(manifest: &Path) -> Result<PathBuf> {
1017 manifest.parent().map(Path::to_path_buf).with_context(|| {
1018 format!(
1019 "no parent dir for the workspace manifest `{}`",
1020 manifest.display()
1021 )
1022 })
1023}
1024
1025fn canonical_scan_prefix(root: &Path, workspace_root: &Path) -> Option<String> {
1029 let root = root.canonicalize().ok()?;
1030 let workspace_root = workspace_root.canonicalize().ok()?;
1031 scan_prefix(&root, &workspace_root)
1032}
1033
1034fn write_base_diff(
1038 root: &Path,
1039 workspace_root: &Path,
1040 prefix: Option<&str>,
1041 base: &str,
1042 out: &MutantsOut,
1043) -> Result<Option<PathBuf>> {
1044 let range = format!("{base}...HEAD");
1045 let (dir, args) = base_diff_command(root, workspace_root, prefix, &range);
1046 let output = Command::new("git")
1047 .current_dir(dir)
1048 .args(&args)
1049 .output()
1050 .context("running `git diff` for `--base` (is git installed?)")?;
1051 base_diff_exit(&output, &range)?;
1052 if output.stdout.is_empty() {
1053 return Ok(None);
1054 }
1055 std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
1056 let path = out.0.join("base.diff");
1057 std::fs::write(&path, &output.stdout).context("writing the base diff")?;
1058 Ok(Some(path))
1059}
1060
1061fn read_base_diff(path: &Path) -> Result<String> {
1063 std::fs::read_to_string(path)
1064 .with_context(|| format!("reading the written base diff `{}`", path.display()))
1065}
1066
1067struct BaseDiff {
1071 files: Vec<String>,
1072 inserted: BTreeMap<String, BTreeSet<u32>>,
1073}
1074
1075fn parse_base_diff(diff: &str) -> BaseDiff {
1078 let mut files = Vec::new();
1079 let mut inserted: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
1080 let mut current: Option<String> = None;
1081 let mut lines = diff.lines();
1082 while let Some(line) = lines.next() {
1083 if let Some(path) = line.strip_prefix("+++ ") {
1084 current = (path != "/dev/null").then(|| {
1085 let path = path.strip_prefix("b/").unwrap_or(path).to_string();
1086 files.push(path.clone());
1087 path
1088 });
1089 } else if let Some(header) = line.strip_prefix("@@ ") {
1090 if let Some(counts) = parse_hunk_header(header) {
1091 consume_hunk(&mut lines, counts, current.as_ref(), &mut inserted);
1092 }
1093 }
1094 }
1095 BaseDiff { files, inserted }
1096}
1097
1098fn consume_hunk(
1102 lines: &mut std::str::Lines<'_>,
1103 (new_start, old_count, new_count): (u32, u32, u32),
1104 file: Option<&String>,
1105 inserted: &mut BTreeMap<String, BTreeSet<u32>>,
1106) {
1107 let mut new_line = new_start;
1108 let (mut old_left, mut new_left) = (old_count, new_count);
1109 while old_left > 0 || new_left > 0 {
1110 let Some(line) = lines.next() else { break };
1111 if line.starts_with('\\') {
1112 } else if line.starts_with('+') {
1115 if let Some(file) = file {
1116 inserted.entry(file.clone()).or_default().insert(new_line);
1117 }
1118 new_line += 1;
1119 new_left = new_left.saturating_sub(1);
1120 } else if line.starts_with('-') {
1121 old_left = old_left.saturating_sub(1);
1122 } else {
1123 new_line += 1;
1124 old_left = old_left.saturating_sub(1);
1125 new_left = new_left.saturating_sub(1);
1126 }
1127 }
1128}
1129
1130fn parse_hunk_header(header: &str) -> Option<(u32, u32, u32)> {
1132 let mut parts = header.split(' ');
1133 let (_, old_count) = parse_range(parts.next()?.strip_prefix('-')?)?;
1134 let (new_start, new_count) = parse_range(parts.next()?.strip_prefix('+')?)?;
1135 Some((new_start, old_count, new_count))
1136}
1137
1138fn parse_range(range: &str) -> Option<(u32, u32)> {
1140 match range.split_once(',') {
1141 Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
1142 None => Some((range.parse().ok()?, 1)),
1143 }
1144}
1145
1146fn rebase_report_paths(report: MutantsReport, prefix: Option<&str>) -> MutantsReport {
1150 let Some(prefix) = prefix else {
1151 return report;
1152 };
1153 let prefix = format!("{prefix}/");
1154 MutantsReport {
1155 outcomes: report
1156 .outcomes
1157 .into_iter()
1158 .filter_map(|mut outcome| {
1159 if let Scenario::Mutant(mutant) = &mut outcome.scenario {
1160 mutant.file = mutant.file.strip_prefix(&prefix)?.to_string();
1161 }
1162 Some(outcome)
1163 })
1164 .collect(),
1165 }
1166}
1167
1168const CARGO_MUTANTS_VERSION: &str = "27.1.0";
1171
1172fn ensure_cargo_mutants() -> Result<PathBuf> {
1176 provision_pinned(&cargo_mutants_cache_root(), execute)
1177}
1178
1179fn provision_pinned(
1181 root: &Path,
1182 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1183) -> Result<PathBuf> {
1184 let bin = root.join("bin").join(CARGO_MUTANTS_BIN_NAME);
1185 let lock_path = root.join(".install.lock");
1186 provision(&bin, &lock_path, || run_install(root, run))
1187}
1188
1189fn execute(command: &mut Command) -> std::io::Result<Output> {
1191 command.output()
1192}
1193
1194const CARGO_MUTANTS_BIN_NAME: &str = if cfg!(windows) {
1197 "cargo-mutants.exe"
1198} else {
1199 "cargo-mutants"
1200};
1201
1202fn cargo_mutants_cache_root() -> PathBuf {
1206 cache_base()
1207 .join("testing-conventions")
1208 .join(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}"))
1209}
1210
1211fn cache_base() -> PathBuf {
1214 resolve_cache_base(std::env::var_os("XDG_CACHE_HOME"), std::env::var_os("HOME"))
1215}
1216
1217fn resolve_cache_base(xdg: Option<OsString>, home: Option<OsString>) -> PathBuf {
1220 if let Some(dir) = xdg.filter(|value| !value.is_empty()) {
1221 return PathBuf::from(dir);
1222 }
1223 if let Some(dir) = home.filter(|value| !value.is_empty()) {
1224 return PathBuf::from(dir).join(".cache");
1225 }
1226 std::env::temp_dir()
1227}
1228
1229fn hold_lock(lock_path: &Path) -> Result<std::fs::File> {
1233 if let Some(parent) = lock_path.parent() {
1234 std::fs::create_dir_all(parent).context("creating the provisioning lock's parent dir")?;
1235 }
1236 let lock_file = std::fs::OpenOptions::new()
1237 .create(true)
1238 .truncate(false)
1239 .write(true)
1240 .open(lock_path)
1241 .context("opening the provisioning lock file")?;
1242 lock_file
1243 .lock()
1244 .context("acquiring the provisioning lock")?;
1245 Ok(lock_file)
1246}
1247
1248fn installed_binary(bin: &Path) -> Result<PathBuf> {
1251 if !bin.exists() {
1252 bail!(
1253 "provisioning reported success but cargo-mutants is not at `{}`",
1254 bin.display()
1255 );
1256 }
1257 Ok(bin.to_path_buf())
1258}
1259
1260fn provision(
1264 bin: &Path,
1265 lock_path: &Path,
1266 install: impl FnOnce() -> Result<()>,
1267) -> Result<PathBuf> {
1268 if bin.exists() {
1269 return Ok(bin.to_path_buf());
1270 }
1271 let _lock = hold_lock(lock_path)?;
1272 if bin.exists() {
1274 return Ok(bin.to_path_buf());
1275 }
1276 install()?;
1277 installed_binary(bin)
1278}
1279
1280fn install_argv(root: &Path) -> Vec<OsString> {
1284 vec![
1285 OsString::from("install"),
1286 OsString::from("cargo-mutants"),
1287 OsString::from("--locked"),
1288 OsString::from("--version"),
1289 OsString::from(CARGO_MUTANTS_VERSION),
1290 OsString::from("--root"),
1291 root.as_os_str().to_os_string(),
1292 ]
1293}
1294
1295fn run_install(
1299 root: &Path,
1300 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1301) -> Result<()> {
1302 let mut command = Command::new("cargo");
1303 command.args(install_argv(root));
1304 strip_llvm_cov_env(&mut command);
1305 let output = run(&mut command)
1306 .context("provisioning cargo-mutants via `cargo install` (is cargo installed?)")?;
1307 if !output.status.success() {
1308 bail!(
1309 "failed to provision cargo-mutants {CARGO_MUTANTS_VERSION}:\n{}{}",
1310 String::from_utf8_lossy(&output.stdout),
1311 String::from_utf8_lossy(&output.stderr),
1312 );
1313 }
1314 Ok(())
1315}
1316
1317fn strip_llvm_cov_env(command: &mut Command) {
1321 for var in [
1322 "RUSTFLAGS",
1323 "CARGO_ENCODED_RUSTFLAGS",
1324 "RUSTDOCFLAGS",
1325 "CARGO_ENCODED_RUSTDOCFLAGS",
1326 "LLVM_PROFILE_FILE",
1327 "CARGO_LLVM_COV",
1328 "CARGO_LLVM_COV_SHOW_ENV",
1329 "CARGO_LLVM_COV_TARGET_DIR",
1330 "CARGO_LLVM_COV_BUILD_DIR",
1331 "RUSTC_WRAPPER",
1332 "RUSTC_WORKSPACE_WRAPPER",
1333 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
1334 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
1335 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
1336 ] {
1337 command.env_remove(var);
1338 }
1339}
1340
1341fn run_cargo_mutants(
1345 engine: &Path,
1346 root: &Path,
1347 out: &Path,
1348 in_diff: Option<&Path>,
1349 features: &[String],
1350) -> Result<Output> {
1351 let mut command = Command::new(engine);
1352 command
1353 .current_dir(root)
1354 .args(mutants_argv(out, in_diff, features));
1355 strip_llvm_cov_env(&mut command);
1356 let output = command.output().context("running cargo-mutants")?;
1357 classify_mutants_exit(root, &output)?;
1358 Ok(output)
1359}
1360
1361fn zero_mutant_verdict(listed: &[MutantInfo], diff: &BaseDiff, run: &Output) -> Result<()> {
1365 let dropped = dropped_mutants(listed, diff);
1366 if dropped.is_empty() {
1367 return Ok(());
1368 }
1369 let sites: Vec<String> = dropped.iter().map(|mutant| mutant_site(mutant)).collect();
1370 bail!(
1371 "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{}{}",
1372 dropped.len(),
1373 listed.len(),
1374 sites.join("\n"),
1375 String::from_utf8_lossy(&run.stdout),
1376 String::from_utf8_lossy(&run.stderr),
1377 )
1378}
1379
1380fn dropped_mutants<'a>(listed: &'a [MutantInfo], diff: &BaseDiff) -> Vec<&'a MutantInfo> {
1383 listed
1384 .iter()
1385 .filter(|mutant| {
1386 diff.inserted.get(&mutant.file).is_some_and(|lines| {
1387 lines
1388 .range(mutant.span.start.line..=mutant.span.end.line)
1389 .next()
1390 .is_some()
1391 })
1392 })
1393 .collect()
1394}
1395
1396fn mutant_site(mutant: &MutantInfo) -> String {
1398 format!(
1399 " {}:{}: {}",
1400 mutant.file,
1401 mutant.span.start.line,
1402 strip_embedded_location(&mutant.name)
1403 )
1404}
1405
1406fn list_argv(features: &[String]) -> Vec<OsString> {
1410 let mut argv = vec![
1411 OsString::from("mutants"),
1412 OsString::from("--list"),
1413 OsString::from("--json"),
1414 ];
1415 if !features.is_empty() {
1416 argv.push(OsString::from("--features"));
1417 argv.push(OsString::from(features.join(",")));
1418 }
1419 argv
1420}
1421
1422fn list_cargo_mutants(
1426 engine: &Path,
1427 root: &Path,
1428 features: &[String],
1429 run: impl FnOnce(&mut Command) -> std::io::Result<Output>,
1430) -> Result<Vec<MutantInfo>> {
1431 let mut command = Command::new(engine);
1432 command.current_dir(root).args(list_argv(features));
1433 strip_llvm_cov_env(&mut command);
1434 let output = run(&mut command).context("listing the crate's mutants with cargo-mutants")?;
1435 if !output.status.success() {
1436 bail!(
1437 "cargo-mutants --list failed in `{}`:\n{}{}",
1438 root.display(),
1439 String::from_utf8_lossy(&output.stdout),
1440 String::from_utf8_lossy(&output.stderr),
1441 );
1442 }
1443 parse_mutants_list(&String::from_utf8_lossy(&output.stdout))
1444}
1445
1446fn mutants_argv(out: &Path, in_diff: Option<&Path>, features: &[String]) -> Vec<OsString> {
1448 let mut argv = vec![
1449 OsString::from("mutants"),
1450 OsString::from("--output"),
1451 out.as_os_str().to_os_string(),
1452 OsString::from("--cargo-test-arg"),
1453 OsString::from("--lib"),
1454 ];
1455 if let Some(diff) = in_diff {
1456 argv.push(OsString::from("--in-diff"));
1457 argv.push(diff.as_os_str().to_os_string());
1458 }
1459 if !features.is_empty() {
1460 argv.push(OsString::from("--features"));
1461 argv.push(OsString::from(features.join(",")));
1462 }
1463 argv
1464}
1465
1466fn classify_mutants_exit(root: &Path, output: &Output) -> Result<()> {
1470 match output.status.code() {
1471 Some(0) | Some(2) | Some(3) => Ok(()),
1472 _ => bail!(
1473 "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
1474 root.display(),
1475 String::from_utf8_lossy(&output.stdout),
1476 String::from_utf8_lossy(&output.stderr),
1477 ),
1478 }
1479}
1480
1481#[cfg(test)]
1482mod tests {
1483 use super::*;
1484
1485 #[test]
1486 fn neither_scope_adds_an_argument() {
1487 assert!(ts_adapter_args(None, None).is_empty());
1488 }
1489
1490 #[test]
1491 fn each_scope_contributes_its_own_flag_and_a_comma_joined_list() {
1492 let mutate = vec!["src/a.ts".to_string(), "src/b.ts".to_string()];
1493 let tests = vec!["src/a.test.ts".to_string()];
1494 assert_eq!(
1495 ts_adapter_args(Some(&mutate), None),
1496 vec!["--mutate", "src/a.ts,src/b.ts"]
1497 );
1498 assert_eq!(
1499 ts_adapter_args(None, Some(&tests)),
1500 vec!["--test-files", "src/a.test.ts"]
1501 );
1502 assert_eq!(
1503 ts_adapter_args(Some(&mutate), Some(&tests)),
1504 vec![
1505 "--mutate",
1506 "src/a.ts,src/b.ts",
1507 "--test-files",
1508 "src/a.test.ts"
1509 ]
1510 );
1511 }
1512
1513 #[test]
1514 fn an_empty_list_still_passes_its_flag() {
1515 assert_eq!(ts_adapter_args(Some(&[]), None), vec!["--mutate", ""]);
1517 }
1518
1519 #[test]
1520 fn with_no_prefix_the_base_diff_runs_unscoped_at_the_scan_path() {
1521 let (dir, args) = base_diff_command(
1522 Path::new("/crate"),
1523 Path::new("/workspace"),
1524 None,
1525 "main...HEAD",
1526 );
1527 assert_eq!(dir, Path::new("/crate"));
1528 assert_eq!(args, vec!["diff", "--relative", "main...HEAD"]);
1529 }
1530
1531 #[test]
1532 fn with_a_prefix_the_base_diff_runs_at_the_workspace_root_scoped_by_it() {
1533 let (dir, args) = base_diff_command(
1536 Path::new("/workspace/crate"),
1537 Path::new("/workspace"),
1538 Some("crate/src"),
1539 "main...HEAD",
1540 );
1541 assert_eq!(dir, Path::new("/workspace"));
1542 assert_eq!(
1543 args,
1544 vec!["diff", "--relative", "main...HEAD", "--", "crate/src"]
1545 );
1546 }
1547
1548 #[test]
1549 fn a_failed_base_diff_names_the_range() {
1550 let output = std::process::Output {
1551 status: std::process::Command::new("false").status().unwrap(),
1552 stdout: Vec::new(),
1553 stderr: b"fatal: bad revision".to_vec(),
1554 };
1555 let err = base_diff_exit(&output, "nope...HEAD").unwrap_err();
1556 assert!(err.to_string().contains("nope...HEAD"), "got: {err}");
1557 }
1558
1559 #[test]
1560 fn an_adapter_failure_surfaces_both_captured_streams() {
1561 let output = std::process::Output {
1562 status: std::process::Command::new("false").status().unwrap(),
1563 stdout: b"stryker said this".to_vec(),
1564 stderr: b"and then this".to_vec(),
1565 };
1566 let err = adapter_exit(&output, "TypeScript", Path::new("/pkg")).unwrap_err();
1567 let text = err.to_string();
1568 assert!(
1569 text.contains("TypeScript mutation adapter failed"),
1570 "got: {text}"
1571 );
1572 assert!(text.contains("/pkg"), "got: {text}");
1573 assert!(text.contains("stryker said this"), "got: {text}");
1574 assert!(text.contains("and then this"), "got: {text}");
1575 }
1576
1577 #[test]
1578 fn an_adapter_that_succeeded_is_not_an_error() {
1579 let output = std::process::Output {
1580 status: std::process::Command::new("true").status().unwrap(),
1581 stdout: Vec::new(),
1582 stderr: Vec::new(),
1583 };
1584 assert!(adapter_exit(&output, "Python", Path::new("/pkg")).is_ok());
1585 }
1586
1587 #[test]
1588 fn an_install_that_produced_no_binary_is_an_error() {
1589 let missing = scratch_dir("installed-binary-absent").join("cargo-mutants");
1590 let err = installed_binary(&missing).unwrap_err();
1591 assert!(
1592 err.to_string().contains("provisioning reported success"),
1593 "got: {err}"
1594 );
1595 }
1596
1597 #[test]
1598 fn an_install_that_produced_the_binary_returns_its_path() {
1599 let dir = scratch_dir("installed-binary-present");
1600 std::fs::create_dir_all(&dir).unwrap();
1601 let bin = dir.join("cargo-mutants");
1602 std::fs::write(&bin, "").unwrap();
1603 assert_eq!(installed_binary(&bin).unwrap(), bin);
1604 }
1605
1606 fn scratch_dir(name: &str) -> PathBuf {
1608 std::env::temp_dir().join(format!("testing-conventions-{name}-{}", std::process::id()))
1609 }
1610
1611 const NORMALIZED: &str = r#"[
1612 {"file": "src/a.ts", "line": 2, "status": "survived",
1613 "mutator": "ConditionalExpression", "replacement": "true", "id": "ignored"},
1614 {"file": "src/a.ts", "line": 5, "status": "no_coverage", "mutator": "ArithmeticOperator"},
1615 {"file": "src/a.ts", "line": 9, "status": "killed",
1616 "mutator": "BooleanLiteral", "replacement": "false"},
1617 {"file": "src/a.ts", "line": 12, "status": "timeout", "mutator": "BlockStatement"},
1618 {"file": "src/a.ts", "line": 15, "status": "compile_error", "mutator": "OptionalChaining"},
1619 {"file": "src/a.ts", "line": 18, "status": "runtime_error", "mutator": "StringLiteral"}
1620 ]"#;
1621
1622 #[test]
1623 fn parses_the_normalized_schema() {
1624 let mutants = parse_normalized_results(NORMALIZED).expect("valid normalized results");
1625 assert_eq!(mutants.len(), 6);
1626 assert_eq!(mutants[0].status, MutantStatus::Survived);
1627 assert_eq!(mutants[1].status, MutantStatus::NoCoverage);
1628 assert_eq!(mutants[0].replacement.as_deref(), Some("true"));
1629 assert_eq!(mutants[1].replacement, None);
1630 }
1631
1632 #[test]
1633 fn normalized_survivors_are_survived_and_nocoverage_only() {
1634 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1635 let survivors = normalized_survivors(&mutants);
1636 assert_eq!(survivors.len(), 2);
1637 assert_eq!((survivors[0].line, survivors[1].line), (2, 5));
1638 assert!(survivors[0].description.contains("ConditionalExpression"));
1639 assert!(survivors[0].description.contains("-> true"));
1640 assert_eq!(survivors[1].description, "ArithmeticOperator");
1641 }
1642
1643 #[test]
1644 fn normalized_mutated_lines_collects_only_viable_mutants() {
1645 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1646 assert_eq!(
1647 normalized_mutated_lines(&mutants),
1648 [2u32, 5, 9, 12]
1649 .into_iter()
1650 .map(|line| ("src/a.ts".to_string(), line))
1651 .collect()
1652 );
1653 }
1654
1655 #[test]
1656 fn normalized_conclusive_count_is_survived_killed_and_nocoverage() {
1657 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1658 assert_eq!(normalized_conclusive_count(&mutants), 3);
1659 assert_eq!(normalized_conclusive_count(&[]), 0);
1660 }
1661
1662 #[test]
1663 fn evaluate_normalized_reports_unexempted_survivors() {
1664 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1665 let kept = evaluate_normalized(&mutants, &[], &BTreeMap::new()).unwrap();
1666 assert_eq!(kept.len(), 2, "both survivors stand with no exemptions");
1667 }
1668
1669 #[test]
1670 fn evaluate_normalized_drops_a_whole_file_exemption() {
1671 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1672 let kept =
1673 evaluate_normalized(&mutants, &["src/a.ts".to_string()], &BTreeMap::new()).unwrap();
1674 assert!(
1675 kept.is_empty(),
1676 "the whole-file exemption lifts both survivors"
1677 );
1678 }
1679
1680 #[test]
1681 fn evaluate_normalized_drops_a_line_scoped_exemption() {
1682 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1683 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([2u32]))]);
1684 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1685 assert_eq!(kept.len(), 1);
1686 assert_eq!(kept[0].line, 5);
1687 }
1688
1689 #[test]
1690 fn evaluate_normalized_rejects_exempting_a_caught_line() {
1691 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1692 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([9u32]))]);
1693 let err = evaluate_normalized(&mutants, &[], &line_scoped).unwrap_err();
1694 assert!(
1695 err.to_string().contains("all caught") && err.to_string().contains("src/a.ts:9"),
1696 "got: {err}"
1697 );
1698 }
1699
1700 #[test]
1701 fn evaluate_normalized_leaves_an_unviable_listed_line_alone() {
1702 let mutants = parse_normalized_results(NORMALIZED).unwrap();
1703 let line_scoped = BTreeMap::from([("src/a.ts".to_string(), BTreeSet::from([15u32]))]);
1704 let kept = evaluate_normalized(&mutants, &[], &line_scoped).unwrap();
1705 assert_eq!(kept.len(), 2);
1706 }
1707
1708 const SAMPLE: &str = r#"{
1709 "outcomes": [
1710 {"scenario": "Baseline", "summary": "Success",
1711 "phase_results": []},
1712 {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
1713 "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
1714 "function": {"function_name": "is_positive"},
1715 "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
1716 "summary": "MissedMutant"},
1717 {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
1718 "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
1719 "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
1720 "summary": "CaughtMutant"}
1721 ],
1722 "total_mutants": 2
1723 }"#;
1724
1725 #[test]
1726 fn parses_the_outcomes_export() {
1727 let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
1728 assert_eq!(report.outcomes.len(), 3);
1729 assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
1730 }
1731
1732 #[test]
1733 fn collects_only_missed_mutants_as_survivors() {
1734 let report = parse_mutants_report(SAMPLE).unwrap();
1735 let survivors = unexplained_survivors(&report, &[]);
1736 assert_eq!(survivors.len(), 1);
1737 assert_eq!(survivors[0].file, "src/lib.rs");
1738 assert_eq!(survivors[0].line, 7);
1739 assert!(survivors[0].description.contains("replace > with =="));
1740 }
1741
1742 #[test]
1743 fn a_survivor_description_carries_no_location_prefix() {
1744 let report = parse_mutants_report(SAMPLE).unwrap();
1745 let survivors = unexplained_survivors(&report, &[]);
1746 assert_eq!(
1747 survivors[0].description, "replace > with == in is_positive",
1748 "the name's embedded `file:line:col:` prefix is stripped"
1749 );
1750 }
1751
1752 #[test]
1753 fn strip_embedded_location_removes_a_file_line_col_prefix() {
1754 assert_eq!(
1755 strip_embedded_location("src/lib.rs:7:5: replace > with == in is_positive"),
1756 "replace > with == in is_positive"
1757 );
1758 }
1759
1760 #[test]
1761 fn strip_embedded_location_keeps_a_name_without_one() {
1762 for name in [
1763 "replace add -> 0",
1764 "note: no location segment",
1765 "7:5: no file segment",
1766 "src/lib.rs:7:x: non-numeric column",
1767 "src/lib.rs:x:5: non-numeric line",
1768 ] {
1769 assert_eq!(strip_embedded_location(name), name);
1770 }
1771 }
1772
1773 #[test]
1774 fn conclusive_count_is_caught_plus_missed() {
1775 let report = parse_mutants_report(SAMPLE).unwrap();
1776 assert_eq!(conclusive_count(&report), 2);
1777 assert_eq!(conclusive_count(&MutantsReport { outcomes: vec![] }), 0);
1778 }
1779
1780 #[test]
1781 fn an_exemption_drops_a_survivor_in_that_file() {
1782 let report = parse_mutants_report(SAMPLE).unwrap();
1783 let exempt = vec!["src/lib.rs".to_string()];
1784 assert!(unexplained_survivors(&report, &exempt).is_empty());
1785 }
1786
1787 #[test]
1788 fn an_exemption_on_another_file_leaves_the_survivor() {
1789 let report = parse_mutants_report(SAMPLE).unwrap();
1790 let exempt = vec!["src/elsewhere.rs".to_string()];
1791 assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
1792 }
1793
1794 const BASELINE_ONLY: &str = r#"{
1795 "outcomes": [
1796 {"scenario": "Baseline", "summary": "MissedMutant", "phase_results": []},
1797 {"scenario": "Baseline", "summary": "CaughtMutant", "phase_results": []}
1798 ],
1799 "total_mutants": 0
1800 }"#;
1801
1802 #[test]
1803 fn a_baseline_outcome_is_never_a_survivor() {
1804 let report = parse_mutants_report(BASELINE_ONLY).unwrap();
1805 assert!(unexplained_survivors(&report, &[]).is_empty());
1806 }
1807
1808 #[test]
1809 fn a_baseline_outcome_is_never_a_mutated_line() {
1810 let report = parse_mutants_report(BASELINE_ONLY).unwrap();
1811 assert!(mutated_lines(&report).is_empty());
1812 }
1813
1814 #[test]
1815 fn parse_base_diff_skips_a_malformed_hunk_header() {
1816 let diff = "\
1817diff --git a/src/lib.rs b/src/lib.rs
1818--- a/src/lib.rs
1819+++ b/src/lib.rs
1820@@ junk @@
1821";
1822 let parsed = parse_base_diff(diff);
1823 assert_eq!(parsed.files, vec!["src/lib.rs"]);
1824 assert!(parsed.inserted.is_empty());
1825 }
1826
1827 #[test]
1828 fn a_missing_base_diff_read_reports_its_path() {
1829 let missing = unique_tmp().join("base.diff");
1830 let err = read_base_diff(&missing).unwrap_err();
1831 let msg = format!("{err:#}");
1832 assert!(msg.contains("reading the written base diff"), "{msg}");
1833 }
1834
1835 #[test]
1836 fn a_missing_adapter_results_read_names_the_engine_and_path() {
1837 let missing = unique_tmp().join("results.json");
1838 let err = read_adapter_results(&missing, "TypeScript").unwrap_err();
1839 let msg = format!("{err:#}");
1840 assert!(
1841 msg.contains("TypeScript mutation adapter's results"),
1842 "{msg}"
1843 );
1844 }
1845
1846 #[test]
1847 fn manifest_dir_is_the_manifest_parent() {
1848 let dir = manifest_dir(Path::new("/w/Cargo.toml")).unwrap();
1849 assert_eq!(dir, Path::new("/w"));
1850 }
1851
1852 #[test]
1853 fn a_rootless_manifest_path_is_an_error() {
1854 let err = manifest_dir(Path::new("/")).unwrap_err();
1855 let msg = format!("{err:#}");
1856 assert!(msg.contains("no parent dir"), "{msg}");
1857 }
1858
1859 #[test]
1860 fn a_directory_outside_any_workspace_fails_locate_project() {
1861 let dir = unique_tmp();
1862 let err = cargo_workspace_root(&dir).unwrap_err();
1863 let msg = format!("{err:#}");
1864 assert!(msg.contains("cargo locate-project failed"), "{msg}");
1865 std::fs::remove_dir_all(&dir).ok();
1866 }
1867
1868 #[test]
1869 fn a_bad_base_ref_fails_the_base_diff() {
1870 let dir = unique_tmp();
1871 let init = Command::new("git")
1872 .current_dir(&dir)
1873 .args(["init", "-q"])
1874 .output()
1875 .unwrap();
1876 assert!(init.status.success());
1877 let out = MutantsOut::new();
1878 let err = write_base_diff(&dir, &dir, None, "tc-no-such-ref", &out).unwrap_err();
1879 let msg = format!("{err:#}");
1880 assert!(msg.contains("git diff"), "{msg}");
1881 std::fs::remove_dir_all(&dir).ok();
1882 }
1883
1884 #[test]
1885 fn a_python_adapter_failure_reports_the_adapter_output() {
1886 let dir = unique_tmp();
1887 let err = run_py_adapter(&dir, &[]).unwrap_err();
1888 let msg = format!("{err:#}");
1889 assert!(msg.contains("the Python mutation adapter failed"), "{msg}");
1890 std::fs::remove_dir_all(&dir).ok();
1891 }
1892
1893 #[test]
1894 fn is_declaration_only_is_true_for_a_const_only_rust_file() {
1895 let dir = unique_tmp();
1896 std::fs::write(
1897 dir.join("settings.rs"),
1898 "pub const TIMEOUT: u64 = 30 * 60;\n",
1899 )
1900 .unwrap();
1901 assert!(is_declaration_only(&dir, "settings.rs", Language::Rust));
1902 std::fs::remove_dir_all(&dir).ok();
1903 }
1904
1905 #[test]
1906 fn is_declaration_only_is_false_for_a_rust_file_with_a_function() {
1907 let dir = unique_tmp();
1908 std::fs::write(dir.join("lib.rs"), "pub fn run() {}\n").unwrap();
1909 assert!(!is_declaration_only(&dir, "lib.rs", Language::Rust));
1910 std::fs::remove_dir_all(&dir).ok();
1911 }
1912
1913 #[test]
1914 fn is_declaration_only_covers_python_and_typescript_too() {
1915 let dir = unique_tmp();
1916 std::fs::write(dir.join("settings.py"), "TIMEOUT = 30 * 60\n").unwrap();
1917 std::fs::write(dir.join("settings.ts"), "export const TIMEOUT = 30 * 60;\n").unwrap();
1918 assert!(is_declaration_only(&dir, "settings.py", Language::Python));
1919 assert!(is_declaration_only(
1920 &dir,
1921 "settings.ts",
1922 Language::TypeScript
1923 ));
1924 std::fs::remove_dir_all(&dir).ok();
1925 }
1926
1927 #[test]
1928 fn is_declaration_only_is_false_for_an_unreadable_file() {
1929 let dir = unique_tmp();
1930 assert!(!is_declaration_only(&dir, "missing.rs", Language::Rust));
1931 std::fs::remove_dir_all(&dir).ok();
1932 }
1933
1934 fn mutant_at(file: &str, line: u32, genre: &str) -> MutantInfo {
1935 MutantInfo {
1936 file: file.to_string(),
1937 span: Span {
1938 start: LineCol { line },
1939 end: LineCol { line },
1940 },
1941 name: format!("a mutant at {file}:{line}"),
1942 genre: genre.to_string(),
1943 }
1944 }
1945
1946 #[test]
1947 fn is_judged_is_true_for_an_ordinary_mutant() {
1948 let dir = unique_tmp();
1949 std::fs::write(
1950 dir.join("add.rs"),
1951 "pub fn add(a: u64) -> u64 {\n a + 1\n}\n",
1952 )
1953 .unwrap();
1954 assert!(is_judged(&dir, &mutant_at("add.rs", 2, "FnValue")));
1955 std::fs::remove_dir_all(&dir).ok();
1956 }
1957
1958 #[test]
1959 fn is_judged_is_false_in_a_declaration_only_module() {
1960 let dir = unique_tmp();
1961 std::fs::write(
1962 dir.join("settings.rs"),
1963 "pub const TIMEOUT: u64 = 30 * 60;\n",
1964 )
1965 .unwrap();
1966 assert!(!is_judged(&dir, &mutant_at("settings.rs", 1, "FnValue")));
1967 std::fs::remove_dir_all(&dir).ok();
1968 }
1969
1970 #[test]
1971 fn is_judged_is_false_inside_an_item_the_test_build_skips() {
1972 let dir = unique_tmp();
1973 std::fs::write(
1974 dir.join("entrypoint.rs"),
1975 "#[cfg(not(test))]\npub fn main() -> u8 {\n 1\n}\n",
1976 )
1977 .unwrap();
1978 assert!(!is_judged(&dir, &mutant_at("entrypoint.rs", 3, "FnValue")));
1979 std::fs::remove_dir_all(&dir).ok();
1980 }
1981
1982 #[test]
1983 fn is_judged_is_false_for_a_whole_body_replacement_in_a_seam() {
1984 let dir = unique_tmp();
1985 std::fs::write(
1986 dir.join("probe.rs"),
1987 "pub fn probe() -> bool {\n Command::new(\"true\").output().is_ok()\n}\n",
1988 )
1989 .unwrap();
1990 assert!(!is_judged(&dir, &mutant_at("probe.rs", 2, "FnValue")));
1991 std::fs::remove_dir_all(&dir).ok();
1992 }
1993
1994 #[test]
1995 fn is_judged_still_judges_an_operator_swap_in_a_seam() {
1996 let dir = unique_tmp();
1997 std::fs::write(
1998 dir.join("probe.rs"),
1999 "pub fn probe() -> bool {\n Command::new(\"true\").output().is_ok()\n}\n",
2000 )
2001 .unwrap();
2002 assert!(is_judged(&dir, &mutant_at("probe.rs", 2, "BinaryOperator")));
2003 std::fs::remove_dir_all(&dir).ok();
2004 }
2005
2006 #[test]
2007 fn rebase_report_paths_strips_the_workspace_prefix() {
2008 let report = parse_mutants_report(SAMPLE).unwrap();
2009 let prefixed = MutantsReport {
2010 outcomes: report
2011 .outcomes
2012 .iter()
2013 .cloned()
2014 .map(|mut outcome| {
2015 if let Scenario::Mutant(mutant) = &mut outcome.scenario {
2016 mutant.file = format!("member/{}", mutant.file);
2017 }
2018 outcome
2019 })
2020 .collect(),
2021 };
2022 let rebased = rebase_report_paths(prefixed, Some("member"));
2023 let survivors = unexplained_survivors(&rebased, &[]);
2024 assert_eq!(survivors.len(), 1);
2025 assert_eq!(survivors[0].file, "src/lib.rs");
2026 assert_eq!(rebased.outcomes.len(), 3);
2027 }
2028
2029 #[test]
2030 fn rebase_report_paths_drops_an_out_of_scope_mutant_and_keeps_none_identity() {
2031 let report = parse_mutants_report(SAMPLE).unwrap();
2032 let rebased = rebase_report_paths(report.clone(), Some("member"));
2033 assert_eq!(
2034 rebased.outcomes.len(),
2035 1,
2036 "only the pathless baseline outcome remains"
2037 );
2038 let unchanged = rebase_report_paths(report, None);
2039 assert_eq!(unchanged.outcomes.len(), 3);
2040 assert_eq!(unexplained_survivors(&unchanged, &[])[0].file, "src/lib.rs");
2041 }
2042
2043 #[test]
2044 fn adapter_cwd_normalises_the_empty_package_root_to_the_current_dir() {
2045 assert_eq!(
2049 adapter_cwd(Path::new(""), "TypeScript").unwrap(),
2050 Path::new(".")
2051 );
2052 assert_eq!(
2053 adapter_cwd(Path::new("src"), "TypeScript").unwrap(),
2054 Path::new("src")
2055 );
2056 }
2057
2058 #[test]
2059 fn adapter_cwd_rejects_a_directory_that_is_not_there() {
2060 let err = adapter_cwd(Path::new("no/such/dir"), "Python")
2063 .expect_err("a directory that is not there is an error");
2064 assert_eq!(
2065 err.to_string(),
2066 "the Python mutation adapter's working directory `no/such/dir` is not a directory"
2067 );
2068 }
2069
2070 #[test]
2071 fn spawn_context_names_the_interpreter_the_entry_and_the_working_directory() {
2072 assert_eq!(
2073 spawn_context("node", "/pkg/dist/mutation/main.js", Path::new("/pkg")),
2074 "spawning `node /pkg/dist/mutation/main.js` in `/pkg` (is `node` on PATH?)"
2075 );
2076 }
2077
2078 #[test]
2079 fn scan_prefix_is_the_scan_path_relative_to_the_package_root() {
2080 assert_eq!(
2081 scan_prefix(Path::new("/repo/pkg/src"), Path::new("/repo/pkg")),
2082 Some("src".to_string())
2083 );
2084 assert_eq!(
2085 scan_prefix(Path::new("/repo/pkg/src/nested"), Path::new("/repo/pkg")),
2086 Some("src/nested".to_string())
2087 );
2088 assert_eq!(
2089 scan_prefix(Path::new("/repo/pkg"), Path::new("/repo/pkg")),
2090 None
2091 );
2092 assert_eq!(
2093 scan_prefix(Path::new("pkg/src"), Path::new("pkg")),
2094 Some("src".to_string())
2095 );
2096 }
2097
2098 #[test]
2099 fn prefix_mutate_specs_rebases_diff_ranges_onto_the_package_root() {
2100 let specs = vec!["index.ts:8-11".to_string(), "a/b.ts:2-2".to_string()];
2101 assert_eq!(
2102 prefix_mutate_specs(specs.clone(), Some("src")),
2103 vec![
2104 "src/index.ts:8-11".to_string(),
2105 "src/a/b.ts:2-2".to_string()
2106 ]
2107 );
2108 assert_eq!(prefix_mutate_specs(specs.clone(), None), specs);
2109 }
2110
2111 #[test]
2112 fn scan_scoped_mutate_globs_mirror_strykers_default_under_the_scan_path() {
2113 assert_eq!(
2114 scan_scoped_mutate_globs("src"),
2115 vec![
2116 "src/**/!(*.+(s|S)pec|*.+(t|T)est).+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
2117 .to_string(),
2118 "!src/**/__tests__/**/*.+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)"
2119 .to_string(),
2120 ]
2121 );
2122 }
2123
2124 #[test]
2125 fn scan_scoped_test_file_globs_narrow_the_run_without_moving_the_runner_root() {
2126 assert_eq!(
2127 scan_scoped_test_file_globs("src"),
2128 vec!["src/**".to_string()]
2129 );
2130 assert_eq!(
2131 scan_scoped_test_file_globs("packages/core/src"),
2132 vec!["packages/core/src/**".to_string()]
2133 );
2134 }
2135
2136 #[test]
2137 fn to_scan_relative_strips_the_prefix_and_drops_out_of_scope_mutants() {
2138 let mutants = parse_normalized_results(
2139 r#"[
2140 {"file": "src/a.ts", "line": 2, "status": "survived", "mutator": "X"},
2141 {"file": "tests/e2e/t.ts", "line": 9, "status": "survived", "mutator": "X"}
2142 ]"#,
2143 )
2144 .unwrap();
2145 let rebased = to_scan_relative(mutants.clone(), Some("src"));
2146 assert_eq!(rebased.len(), 1, "the out-of-scan-path mutant is dropped");
2147 assert_eq!(rebased[0].file, "a.ts");
2148 let unchanged = to_scan_relative(mutants, None);
2149 assert_eq!(unchanged.len(), 2);
2150 assert_eq!(unchanged[0].file, "src/a.ts");
2151 }
2152
2153 #[test]
2154 fn is_mutatable_ts_keeps_sources_and_drops_tests_and_decls() {
2155 assert!(is_mutatable_ts("src/index.ts"));
2156 assert!(is_mutatable_ts("src/util.tsx"));
2157 assert!(is_mutatable_ts("src/util.js"));
2158 assert!(!is_mutatable_ts("src/index.test.ts"));
2159 assert!(!is_mutatable_ts("src/index.spec.ts"));
2160 assert!(!is_mutatable_ts("src/types.d.ts"));
2161 assert!(!is_mutatable_ts("README.md"));
2162 }
2163
2164 #[test]
2165 fn contiguous_runs_collapses_adjacent_lines() {
2166 let lines: BTreeSet<u64> = [2u64, 3, 4, 7, 9, 10].into_iter().collect();
2167 assert_eq!(contiguous_runs(&lines), vec![(2, 4), (7, 7), (9, 10)]);
2168 assert!(contiguous_runs(&BTreeSet::new()).is_empty());
2169 }
2170
2171 #[test]
2172 fn one_line_flattens_and_caps() {
2173 assert_eq!(one_line("a -\n b"), "a - b");
2174 let long = "x".repeat(80);
2175 let capped = one_line(&long);
2176 assert!(capped.chars().count() <= 61 && capped.ends_with('…'));
2177 }
2178
2179 #[test]
2180 fn mutatable_py_modules_keeps_only_the_changed_python_sources() {
2181 let changed = BTreeMap::from([
2182 ("pkg/calc.py".to_string(), BTreeSet::from([3u64])),
2183 ("pkg/calc_test.py".to_string(), BTreeSet::from([4u64])),
2184 ("README.md".to_string(), BTreeSet::from([1u64])),
2185 ("pkg/util.py".to_string(), BTreeSet::from([9u64])),
2186 ]);
2187
2188 assert_eq!(
2189 mutatable_py_modules(&changed),
2190 ["pkg/calc.py", "pkg/util.py"]
2191 );
2192 }
2193
2194 #[test]
2195 fn mutatable_py_modules_is_empty_when_the_diff_touches_no_python_source() {
2196 let changed = BTreeMap::from([("docs/guide.md".to_string(), BTreeSet::from([1u64]))]);
2197
2198 assert!(mutatable_py_modules(&changed).is_empty());
2199 }
2200
2201 #[test]
2202 fn a_diff_scoped_python_run_judges_only_mutants_on_a_changed_line() {
2203 let mutants = vec![py_mutant("pkg/calc.py", 3), py_mutant("pkg/calc.py", 99)];
2204 let changed = BTreeMap::from([("pkg/calc.py".to_string(), BTreeSet::from([3u64]))]);
2205
2206 let judged = judged_py_mutants(mutants, Path::new("/nonexistent-tc-py"), Some(&changed));
2207
2208 assert_eq!(
2209 judged.iter().map(|m| m.line).collect::<Vec<_>>(),
2210 [3],
2211 "a mutant off the diff is not this run's to judge"
2212 );
2213 }
2214
2215 #[test]
2216 fn an_unscoped_python_run_judges_every_mutant() {
2217 let mutants = vec![py_mutant("pkg/calc.py", 3), py_mutant("pkg/calc.py", 99)];
2218
2219 let judged = judged_py_mutants(mutants, Path::new("/nonexistent-tc-py"), None);
2220
2221 assert_eq!(judged.iter().map(|m| m.line).collect::<Vec<_>>(), [3, 99]);
2222 }
2223
2224 fn py_mutant(file: &str, line: u32) -> NormalizedMutant {
2225 NormalizedMutant {
2226 file: file.to_string(),
2227 line,
2228 status: MutantStatus::Survived,
2229 mutator: "ConditionalExpression".to_string(),
2230 replacement: None,
2231 }
2232 }
2233
2234 #[test]
2235 fn is_mutatable_py_keeps_sources_and_drops_tests() {
2236 assert!(is_mutatable_py("calc.py"));
2237 assert!(is_mutatable_py("pkg/util.py"));
2238 assert!(!is_mutatable_py("calc_test.py"));
2239 assert!(!is_mutatable_py("test_calc.py"));
2240 assert!(!is_mutatable_py("pkg/conftest.py"));
2241 assert!(!is_mutatable_py("README.md"));
2242 }
2243
2244 #[test]
2245 fn mutated_lines_collects_caught_and_missed() {
2246 let report = parse_mutants_report(SAMPLE).unwrap();
2247 assert_eq!(
2248 mutated_lines(&report),
2249 [
2250 ("src/lib.rs".to_string(), 7),
2251 ("src/other.rs".to_string(), 3)
2252 ]
2253 .into_iter()
2254 .collect()
2255 );
2256 }
2257
2258 #[test]
2259 fn evaluate_scoped_drops_a_survivor_on_an_exempt_line() {
2260 let report = parse_mutants_report(SAMPLE).unwrap();
2261 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([7u32]))]);
2262 let kept = evaluate_scoped(
2263 cargo_mutants_survivors(&report),
2264 &mutated_lines(&report),
2265 &[],
2266 &line_scoped,
2267 )
2268 .unwrap();
2269 assert!(
2270 kept.is_empty(),
2271 "the src/lib.rs:7 survivor should be lifted"
2272 );
2273 }
2274
2275 #[test]
2276 fn evaluate_scoped_rejects_exempting_a_caught_line() {
2277 let report = parse_mutants_report(SAMPLE).unwrap();
2278 let line_scoped = BTreeMap::from([("src/other.rs".to_string(), BTreeSet::from([3u32]))]);
2279 let err = evaluate_scoped(
2280 cargo_mutants_survivors(&report),
2281 &mutated_lines(&report),
2282 &[],
2283 &line_scoped,
2284 )
2285 .unwrap_err();
2286 assert!(
2287 err.to_string().contains("all caught") && err.to_string().contains("src/other.rs:3"),
2288 "got: {err}"
2289 );
2290 }
2291
2292 #[test]
2293 fn evaluate_scoped_leaves_an_unmutated_listed_line_alone() {
2294 let report = parse_mutants_report(SAMPLE).unwrap();
2295 let line_scoped = BTreeMap::from([("src/lib.rs".to_string(), BTreeSet::from([99u32]))]);
2296 let kept = evaluate_scoped(
2297 cargo_mutants_survivors(&report),
2298 &mutated_lines(&report),
2299 &[],
2300 &line_scoped,
2301 )
2302 .unwrap();
2303 assert_eq!(kept.len(), 1);
2304 assert_eq!(kept[0].line, 7);
2305 }
2306
2307 #[test]
2308 fn evaluate_scoped_still_honors_a_whole_file_exemption() {
2309 let report = parse_mutants_report(SAMPLE).unwrap();
2310 let kept = evaluate_scoped(
2311 cargo_mutants_survivors(&report),
2312 &mutated_lines(&report),
2313 &["src/lib.rs".to_string()],
2314 &BTreeMap::new(),
2315 )
2316 .unwrap();
2317 assert!(kept.is_empty());
2318 }
2319
2320 fn unique_tmp() -> PathBuf {
2321 static COUNTER: AtomicU64 = AtomicU64::new(0);
2322 let dir = std::env::temp_dir().join(format!(
2323 "tc-provision-test-{}-{}",
2324 std::process::id(),
2325 COUNTER.fetch_add(1, Ordering::Relaxed)
2326 ));
2327 std::fs::create_dir_all(&dir).unwrap();
2328 dir
2329 }
2330
2331 enum Install {
2332 MustNotRun,
2333 WritesNothing,
2334 WritesBin,
2335 Fails,
2336 CountsSleepsAndWritesBin(std::sync::Arc<AtomicU64>),
2337 }
2338
2339 fn write_bin(bin: &Path) {
2340 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
2341 std::fs::write(bin, b"binary").unwrap();
2342 }
2343
2344 fn drive_provision(bin: &Path, lock: &Path, install: Install) -> Result<PathBuf> {
2345 provision(bin, lock, || match install {
2346 Install::MustNotRun => panic!("must not reinstall"),
2347 Install::WritesNothing => Ok(()),
2348 Install::WritesBin => {
2349 write_bin(bin);
2350 Ok(())
2351 }
2352 Install::Fails => bail!("install blew up"),
2353 Install::CountsSleepsAndWritesBin(count) => {
2354 count.fetch_add(1, Ordering::SeqCst);
2355 std::thread::sleep(std::time::Duration::from_millis(50));
2356 write_bin(bin);
2357 Ok(())
2358 }
2359 })
2360 }
2361
2362 #[test]
2363 fn provision_returns_an_existing_binary_without_installing() {
2364 let tmp = unique_tmp();
2365 let bin = tmp.join("bin").join("cargo-mutants");
2366 let lock = tmp.join(".install.lock");
2367 write_bin(&bin);
2368 let got = drive_provision(&bin, &lock, Install::MustNotRun).unwrap();
2369 assert_eq!(got, bin);
2370 std::fs::remove_dir_all(&tmp).unwrap();
2371 }
2372
2373 #[test]
2374 fn the_must_not_run_sentinel_panics_when_installation_runs() {
2375 let tmp = unique_tmp();
2376 std::fs::create_dir_all(&tmp).unwrap();
2377 let bin = tmp.join("bin").join("cargo-mutants");
2378 let lock = tmp.join(".install.lock");
2379 let panicked =
2380 std::panic::catch_unwind(|| drive_provision(&bin, &lock, Install::MustNotRun)).is_err();
2381 std::fs::remove_dir_all(&tmp).unwrap();
2382 assert!(panicked);
2383 }
2384
2385 #[test]
2386 fn provision_with_a_rootless_lock_path_fails_to_open_the_lock() {
2387 let bin = unique_tmp().join("bin").join("cargo-mutants");
2388 let err = drive_provision(&bin, Path::new("/"), Install::WritesNothing).unwrap_err();
2389 let msg = format!("{err:#}");
2390 assert!(msg.contains("opening the provisioning lock"), "{msg}");
2391 }
2392
2393 #[test]
2394 fn provision_installs_when_the_binary_is_absent() {
2395 let tmp = unique_tmp();
2396 let bin = tmp.join("bin").join("cargo-mutants");
2397 let lock = tmp.join(".install.lock");
2398 let got = drive_provision(&bin, &lock, Install::WritesBin).unwrap();
2399 assert_eq!(got, bin);
2400 assert_eq!(
2401 std::fs::read(&bin).unwrap(),
2402 b"binary",
2403 "an absent binary must be installed"
2404 );
2405 std::fs::remove_dir_all(&tmp).unwrap();
2406 }
2407
2408 #[test]
2409 fn provision_errors_when_install_produces_no_binary() {
2410 let tmp = unique_tmp();
2411 let bin = tmp.join("bin").join("cargo-mutants");
2412 let lock = tmp.join(".install.lock");
2413 let err = drive_provision(&bin, &lock, Install::WritesNothing).unwrap_err();
2414 assert!(
2415 err.to_string().contains("cargo-mutants is not at"),
2416 "got: {err}"
2417 );
2418 std::fs::remove_dir_all(&tmp).unwrap();
2419 }
2420
2421 #[test]
2422 fn provision_propagates_an_install_failure() {
2423 let tmp = unique_tmp();
2424 let bin = tmp.join("bin").join("cargo-mutants");
2425 let lock = tmp.join(".install.lock");
2426 let err = drive_provision(&bin, &lock, Install::Fails).unwrap_err();
2427 assert!(err.to_string().contains("install blew up"), "got: {err}");
2428 std::fs::remove_dir_all(&tmp).unwrap();
2429 }
2430
2431 #[test]
2432 fn provision_does_not_duplicate_the_install_under_concurrent_callers() {
2433 use std::sync::{Arc, Barrier};
2437 use std::thread;
2438
2439 let tmp = unique_tmp();
2440 let bin = tmp.join("bin").join("cargo-mutants");
2441 let lock = tmp.join(".install.lock");
2442 let install_count = Arc::new(AtomicU64::new(0));
2443 let barrier = Arc::new(Barrier::new(2));
2444
2445 let handles: Vec<_> = (0..2)
2446 .map(|_| {
2447 let bin = bin.clone();
2448 let lock = lock.clone();
2449 let install_count = Arc::clone(&install_count);
2450 let barrier = Arc::clone(&barrier);
2451 thread::spawn(move || {
2452 barrier.wait();
2453 drive_provision(
2454 &bin,
2455 &lock,
2456 Install::CountsSleepsAndWritesBin(install_count),
2457 )
2458 })
2459 })
2460 .collect();
2461
2462 for h in handles {
2463 h.join()
2464 .expect("provisioning thread must not panic")
2465 .unwrap();
2466 }
2467
2468 assert_eq!(
2469 install_count.load(Ordering::SeqCst),
2470 1,
2471 "two concurrent callers on a cold cache must share one install, not each run their own"
2472 );
2473 std::fs::remove_dir_all(&tmp).unwrap();
2474 }
2475
2476 #[test]
2477 fn resolve_cache_base_prefers_xdg_then_home_then_temp() {
2478 let xdg = |s: &str| Some(OsString::from(s));
2479 assert_eq!(
2480 resolve_cache_base(xdg("/xdg"), xdg("/home")),
2481 PathBuf::from("/xdg")
2482 );
2483 assert_eq!(
2484 resolve_cache_base(xdg(""), xdg("/home")),
2485 PathBuf::from("/home/.cache")
2486 );
2487 assert_eq!(
2488 resolve_cache_base(None, xdg("/home")),
2489 PathBuf::from("/home/.cache")
2490 );
2491 assert_eq!(resolve_cache_base(None, None), std::env::temp_dir());
2492 assert_eq!(
2493 resolve_cache_base(xdg(""), Some(OsString::new())),
2494 std::env::temp_dir()
2495 );
2496 }
2497
2498 #[test]
2499 fn cache_root_is_absolute_and_version_scoped() {
2500 let root = cargo_mutants_cache_root();
2501 assert!(
2502 root.ends_with(format!("cargo-mutants-{CARGO_MUTANTS_VERSION}")),
2503 "version-scoped; got {root:?}"
2504 );
2505 assert!(
2506 root.to_string_lossy().contains("testing-conventions"),
2507 "tool-namespaced; got {root:?}"
2508 );
2509 assert!(
2510 root.is_absolute(),
2511 "expected an absolute path; got {root:?}"
2512 );
2513 }
2514
2515 #[test]
2516 fn install_argv_pins_the_version_and_isolates_the_root() {
2517 let argv: Vec<String> = install_argv(Path::new("/cache/cargo-mutants-27"))
2518 .iter()
2519 .map(|arg| arg.to_string_lossy().into_owned())
2520 .collect();
2521 assert_eq!(
2522 argv,
2523 vec![
2524 "install",
2525 "cargo-mutants",
2526 "--locked",
2527 "--version",
2528 CARGO_MUTANTS_VERSION,
2529 "--root",
2530 "/cache/cargo-mutants-27",
2531 ]
2532 );
2533 }
2534
2535 #[test]
2536 fn mutants_argv_enables_features_on_the_engine_itself() {
2537 let argv = |diff, features: &[&str]| -> Vec<String> {
2538 mutants_argv(
2539 Path::new("/out"),
2540 diff,
2541 &features.iter().map(|f| f.to_string()).collect::<Vec<_>>(),
2542 )
2543 .iter()
2544 .map(|arg| arg.to_string_lossy().into_owned())
2545 .collect()
2546 };
2547 assert_eq!(
2548 argv(None, &["cli", "boost"]),
2549 vec![
2550 "mutants",
2551 "--output",
2552 "/out",
2553 "--cargo-test-arg",
2554 "--lib",
2555 "--features",
2556 "cli,boost"
2557 ]
2558 );
2559 assert_eq!(
2560 argv(Some(Path::new("/out/base.diff")), &["cli"]),
2561 vec![
2562 "mutants",
2563 "--output",
2564 "/out",
2565 "--cargo-test-arg",
2566 "--lib",
2567 "--in-diff",
2568 "/out/base.diff",
2569 "--features",
2570 "cli",
2571 ]
2572 );
2573 assert_eq!(
2574 argv(None, &[]),
2575 vec!["mutants", "--output", "/out", "--cargo-test-arg", "--lib"]
2576 );
2577 }
2578
2579 #[test]
2580 fn list_argv_mirrors_the_run_feature_selection() {
2581 let argv = |features: &[&str]| -> Vec<String> {
2582 list_argv(&features.iter().map(|f| f.to_string()).collect::<Vec<_>>())
2583 .iter()
2584 .map(|arg| arg.to_string_lossy().into_owned())
2585 .collect()
2586 };
2587 assert_eq!(argv(&[]), vec!["mutants", "--list", "--json"]);
2588 assert_eq!(
2589 argv(&["cli", "boost"]),
2590 vec!["mutants", "--list", "--json", "--features", "cli,boost"]
2591 );
2592 }
2593
2594 #[test]
2595 fn parse_base_diff_maps_inserted_lines_per_hunk() {
2596 let diff = "\
2597diff --git a/src/lib.rs b/src/lib.rs
2598--- a/src/lib.rs
2599+++ b/src/lib.rs
2600@@ -1,4 +1,5 @@
2601 fn a() {}
2602+fn b() {}
2603 fn c() {}
2604-fn d() {}
2605+fn e() {}
2606 fn f() {}
2607@@ -10,2 +11,4 @@
2608 tail
2609+one
2610+two
2611 more
2612";
2613 let parsed = parse_base_diff(diff);
2614 assert_eq!(parsed.files, vec!["src/lib.rs"]);
2615 assert_eq!(
2616 parsed.inserted.get("src/lib.rs"),
2617 Some(&BTreeSet::from([2, 4, 12, 13]))
2618 );
2619 }
2620
2621 #[test]
2622 fn parse_base_diff_leaves_a_deletion_only_file_without_inserted_lines() {
2623 let diff = "\
2624--- a/src/gone.rs
2625+++ b/src/gone.rs
2626@@ -5,2 +4,0 @@
2627-x
2628-y
2629";
2630 let parsed = parse_base_diff(diff);
2631 assert_eq!(parsed.files, vec!["src/gone.rs"]);
2632 assert!(parsed.inserted.is_empty());
2633 }
2634
2635 #[test]
2636 fn parse_base_diff_skips_a_deleted_file() {
2637 let diff = "\
2638--- a/src/dead.rs
2639+++ /dev/null
2640@@ -1,2 +0,0 @@
2641-a
2642-b
2643";
2644 let parsed = parse_base_diff(diff);
2645 assert!(parsed.files.is_empty());
2646 assert!(parsed.inserted.is_empty());
2647 }
2648
2649 #[test]
2650 fn parse_base_diff_consumes_hunk_bodies_by_count_so_content_never_reads_as_a_header() {
2651 let diff = "\
2654+++ b/notes.txt
2655@@ -1,1 +1,2 @@
2656 keep
2657++++ not a header
2658";
2659 let parsed = parse_base_diff(diff);
2660 assert_eq!(parsed.files, vec!["notes.txt"]);
2661 assert_eq!(parsed.inserted.get("notes.txt"), Some(&BTreeSet::from([2])));
2662 }
2663
2664 #[test]
2665 fn parse_base_diff_defaults_an_elided_hunk_count_to_one() {
2666 let diff = "\
2667+++ b/one.txt
2668@@ -1 +1 @@
2669-old
2670+new
2671";
2672 let parsed = parse_base_diff(diff);
2673 assert_eq!(parsed.inserted.get("one.txt"), Some(&BTreeSet::from([1])));
2674 }
2675
2676 #[test]
2677 fn parse_base_diff_skips_no_newline_annotations_mid_hunk() {
2678 let diff = "\
2679+++ b/n.txt
2680@@ -1 +1 @@
2681-old
2682\\ No newline at end of file
2683+new
2684\\ No newline at end of file
2685";
2686 let parsed = parse_base_diff(diff);
2687 assert_eq!(parsed.inserted.get("n.txt"), Some(&BTreeSet::from([1])));
2688 }
2689
2690 #[cfg(unix)]
2691 fn fake_output(code: i32, stderr: &str) -> Output {
2692 use std::os::unix::process::ExitStatusExt;
2693 Output {
2694 status: std::process::ExitStatus::from_raw(code << 8),
2695 stdout: Vec::new(),
2696 stderr: stderr.as_bytes().to_vec(),
2697 }
2698 }
2699
2700 #[cfg(unix)]
2701 enum FakeRun {
2702 AssertsVersionAndSucceeds,
2703 FailsWith(&'static str),
2704 SpawnError,
2705 }
2706
2707 #[cfg(unix)]
2708 fn drive_install(root: &Path, run: FakeRun) -> Result<()> {
2709 run_install(root, |command| match run {
2710 FakeRun::AssertsVersionAndSucceeds => {
2711 let argv: Vec<String> = command
2712 .get_args()
2713 .map(|arg| arg.to_string_lossy().into_owned())
2714 .collect();
2715 assert!(argv.contains(&CARGO_MUTANTS_VERSION.to_string()));
2716 Ok(fake_output(0, ""))
2717 }
2718 FakeRun::FailsWith(stderr) => Ok(fake_output(1, stderr)),
2719 FakeRun::SpawnError => Err(std::io::Error::new(
2720 std::io::ErrorKind::NotFound,
2721 "no cargo",
2722 )),
2723 })
2724 }
2725
2726 #[cfg(unix)]
2727 #[test]
2728 fn run_install_succeeds_on_a_zero_exit() {
2729 drive_install(Path::new("/cache/root"), FakeRun::AssertsVersionAndSucceeds).unwrap();
2730 }
2731
2732 #[cfg(unix)]
2733 #[test]
2734 fn run_install_reports_a_nonzero_exit_with_the_engine_output() {
2735 let err = drive_install(
2736 Path::new("/cache/root"),
2737 FakeRun::FailsWith("error: could not compile cargo-mutants"),
2738 )
2739 .unwrap_err();
2740 assert!(
2741 err.to_string()
2742 .contains("failed to provision cargo-mutants")
2743 && err.to_string().contains("could not compile"),
2744 "got: {err}"
2745 );
2746 }
2747
2748 #[cfg(unix)]
2749 #[test]
2750 fn run_install_propagates_a_spawn_failure() {
2751 let err = drive_install(Path::new("/cache/root"), FakeRun::SpawnError).unwrap_err();
2752 assert!(
2753 err.to_string().contains("is cargo installed?"),
2754 "got: {err}"
2755 );
2756 }
2757
2758 #[cfg(unix)]
2759 #[test]
2760 fn provision_pinned_installs_via_the_injected_runner() {
2761 let root = unique_tmp();
2762 let bin = root.join("bin").join(CARGO_MUTANTS_BIN_NAME);
2763 let expected = bin.clone();
2764 let got = provision_pinned(&root, |_| {
2765 write_bin(&bin);
2766 Ok(fake_output(0, ""))
2767 })
2768 .unwrap();
2769 assert_eq!(got, expected);
2770 std::fs::remove_dir_all(&root).unwrap();
2771 }
2772
2773 #[test]
2774 fn execute_surfaces_a_spawn_failure() {
2775 let err = execute(&mut Command::new("/nonexistent-tc-cargo")).unwrap_err();
2776 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
2777 }
2778
2779 #[cfg(unix)]
2780 fn fake_stdout(code: i32, stdout: &str) -> Output {
2781 use std::os::unix::process::ExitStatusExt;
2782 Output {
2783 status: std::process::ExitStatus::from_raw(code << 8),
2784 stdout: stdout.as_bytes().to_vec(),
2785 stderr: Vec::new(),
2786 }
2787 }
2788
2789 #[cfg(unix)]
2790 enum FakeList {
2791 AssertsArgvAndReturns(&'static str, Vec<&'static str>),
2792 FailsWith(&'static str),
2793 SpawnError,
2794 }
2795
2796 #[cfg(unix)]
2797 fn drive_list(features: &[String], run: FakeList) -> Result<Vec<MutantInfo>> {
2798 list_cargo_mutants(
2799 Path::new("/cache/bin/cargo-mutants"),
2800 Path::new("/crate"),
2801 features,
2802 |command| match run {
2803 FakeList::AssertsArgvAndReturns(json, expected) => {
2804 let argv: Vec<String> = command
2805 .get_args()
2806 .map(|arg| arg.to_string_lossy().into_owned())
2807 .collect();
2808 assert_eq!(argv, expected);
2809 assert_eq!(command.get_current_dir(), Some(Path::new("/crate")));
2810 Ok(fake_stdout(0, json))
2811 }
2812 FakeList::FailsWith(stderr) => Ok(fake_output(1, stderr)),
2813 FakeList::SpawnError => Err(std::io::Error::new(
2814 std::io::ErrorKind::NotFound,
2815 "no engine",
2816 )),
2817 },
2818 )
2819 }
2820
2821 #[cfg(unix)]
2822 #[test]
2823 fn list_cargo_mutants_parses_the_listing_from_a_clean_run() {
2824 let json = r#"[{"file": "src/lib.rs", "name": "replace add -> 0", "genre": "FnValue",
2825 "span": {"start": {"line": 3, "column": 1}, "end": {"line": 5, "column": 2}}}]"#;
2826 let listed = drive_list(
2827 &["cli".to_string()],
2828 FakeList::AssertsArgvAndReturns(
2829 json,
2830 vec!["mutants", "--list", "--json", "--features", "cli"],
2831 ),
2832 )
2833 .unwrap();
2834 assert_eq!(listed.len(), 1);
2835 assert_eq!(listed[0].file, "src/lib.rs");
2836 assert_eq!(listed[0].span.start.line, 3);
2837 assert_eq!(listed[0].span.end.line, 5);
2838 assert_eq!(listed[0].name, "replace add -> 0");
2839 }
2840
2841 #[cfg(unix)]
2842 #[test]
2843 fn list_cargo_mutants_reports_a_nonzero_exit_with_the_engine_output() {
2844 let err = drive_list(&[], FakeList::FailsWith("error: no such option")).unwrap_err();
2845 assert!(
2846 err.to_string().contains("cargo-mutants --list failed")
2847 && err.to_string().contains("no such option"),
2848 "got: {err}"
2849 );
2850 }
2851
2852 #[cfg(unix)]
2853 #[test]
2854 fn list_cargo_mutants_propagates_a_spawn_failure() {
2855 let err = drive_list(&[], FakeList::SpawnError).unwrap_err();
2856 assert!(
2857 err.to_string()
2858 .contains("listing the crate's mutants with cargo-mutants"),
2859 "got: {err}"
2860 );
2861 }
2862
2863 #[cfg(unix)]
2864 #[test]
2865 fn a_dropped_site_renders_indented_with_its_location_stripped_from_the_name() {
2866 let mutant = listed_mutant("src/a.rs", 12, 14, "src/a.rs:12:5: replace foo with bar");
2867
2868 assert_eq!(mutant_site(&mutant), " src/a.rs:12: replace foo with bar");
2869 }
2870
2871 #[cfg(unix)]
2872 fn listed_mutant(file: &str, start: u32, end: u32, name: &str) -> MutantInfo {
2873 MutantInfo {
2874 file: file.to_string(),
2875 span: Span {
2876 start: LineCol { line: start },
2877 end: LineCol { line: end },
2878 },
2879 name: name.to_string(),
2880 genre: "FnValue".to_string(),
2881 }
2882 }
2883
2884 #[cfg(unix)]
2885 fn diff_with_inserted(file: &str, lines: &[u32]) -> BaseDiff {
2886 BaseDiff {
2887 files: vec![file.to_string()],
2888 inserted: BTreeMap::from([(file.to_string(), lines.iter().copied().collect())]),
2889 }
2890 }
2891
2892 #[cfg(unix)]
2893 #[test]
2894 fn zero_mutant_verdict_accepts_a_zero_with_no_mutant_on_the_inserted_lines() {
2895 let run = fake_output(0, "");
2896 zero_mutant_verdict(&[], &diff_with_inserted("src/lib.rs", &[5]), &run).unwrap();
2897 let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2898 zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[4, 9]), &run).unwrap();
2899 zero_mutant_verdict(&listed, &diff_with_inserted("src/other.rs", &[6]), &run).unwrap();
2900 }
2901
2902 #[cfg(unix)]
2903 #[test]
2904 fn zero_mutant_verdict_is_fatal_on_a_mutant_at_either_span_boundary() {
2905 let listed = [listed_mutant("src/lib.rs", 5, 8, "replace add -> 0")];
2906 let run = fake_stdout(0, "0 mutants tested");
2907 for line in [5, 8] {
2908 let err =
2909 zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[line]), &run)
2910 .unwrap_err();
2911 let message = err.to_string();
2912 assert!(
2913 message.contains("1 of the crate's 1 mutant site(s)")
2914 && message.contains("src/lib.rs:5: replace add -> 0")
2915 && message.contains("0 mutants tested"),
2916 "got: {message}"
2917 );
2918 }
2919 }
2920
2921 #[cfg(unix)]
2922 #[test]
2923 fn zero_mutant_verdict_names_each_dropped_site_once() {
2924 let listed = [
2925 listed_mutant(
2926 "src/lib.rs",
2927 7,
2928 7,
2929 "src/lib.rs:7:7: replace > with == in is_positive",
2930 ),
2931 listed_mutant("src/lib.rs", 7, 7, "replace add -> 0"),
2932 ];
2933 let run = fake_stdout(0, "0 mutants tested");
2934 let message = zero_mutant_verdict(&listed, &diff_with_inserted("src/lib.rs", &[7]), &run)
2935 .unwrap_err()
2936 .to_string();
2937 assert!(
2938 message.contains(" src/lib.rs:7: replace > with == in is_positive"),
2939 "the name's embedded `file:line:col:` prefix is stripped; got: {message}"
2940 );
2941 assert!(
2942 !message.contains(": src/lib.rs:7:7:"),
2943 "a dropped site carries one location; got: {message}"
2944 );
2945 assert!(
2946 message.contains(" src/lib.rs:7: replace add -> 0"),
2947 "a name with no embedded location keeps its rendered location; got: {message}"
2948 );
2949 }
2950
2951 #[cfg(unix)]
2952 #[test]
2953 fn classify_mutants_exit_accepts_the_caught_and_survivor_exits() {
2954 classify_mutants_exit(Path::new("/crate"), &fake_output(0, "")).unwrap();
2955 classify_mutants_exit(Path::new("/crate"), &fake_output(2, "")).unwrap();
2956 }
2957
2958 #[cfg(unix)]
2959 #[test]
2960 fn classify_mutants_exit_accepts_a_timeout_exit_3() {
2961 classify_mutants_exit(Path::new("/crate"), &fake_output(3, ""))
2962 .expect("a timeout (exit 3) is inconclusive, not fatal");
2963 }
2964
2965 #[cfg(unix)]
2966 #[test]
2967 fn classify_mutants_exit_is_fatal_on_a_baseline_failure() {
2968 let err = classify_mutants_exit(Path::new("/crate"), &fake_output(4, "baseline broke"))
2969 .unwrap_err();
2970 assert!(
2971 err.to_string().contains("did not run cleanly")
2972 && err.to_string().contains("baseline broke"),
2973 "got: {err}"
2974 );
2975 }
2976
2977 #[test]
2978 fn cargo_mutants_bin_name_matches_the_platform() {
2979 #[cfg(windows)]
2980 assert_eq!(CARGO_MUTANTS_BIN_NAME, "cargo-mutants.exe");
2981 #[cfg(not(windows))]
2982 assert_eq!(CARGO_MUTANTS_BIN_NAME, "cargo-mutants");
2983 }
2984
2985 fn one_file_diff(file: &str) -> String {
2987 format!("diff --git a/{file} b/{file}\n--- a/{file}\n+++ b/{file}\n@@ -1,0 +1,1 @@\n+x\n")
2988 }
2989
2990 #[test]
2991 fn a_diff_touching_rust_source_is_in_scope_with_its_parsed_reading() {
2992 let (path, parsed) = rust_scope(
2993 Some(PathBuf::from("base.diff")),
2994 Some(one_file_diff("src/lib.rs")),
2995 )
2996 .expect("a diff touching a .rs file is in scope");
2997
2998 assert_eq!(path, PathBuf::from("base.diff"));
2999 assert_eq!(parsed.files, vec!["src/lib.rs".to_string()]);
3000 }
3001
3002 #[test]
3003 fn a_diff_touching_no_rust_source_is_out_of_scope() {
3004 assert!(rust_scope(
3005 Some(PathBuf::from("base.diff")),
3006 Some(one_file_diff("docs/readme.md")),
3007 )
3008 .is_none());
3009 }
3010
3011 #[test]
3012 fn a_run_whose_git_diff_wrote_nothing_is_out_of_scope() {
3013 assert!(rust_scope(None, None).is_none());
3014 assert!(rust_scope(Some(PathBuf::from("base.diff")), None).is_none());
3015 }
3016
3017 #[test]
3018 fn a_deletion_only_hunk_still_consumes_the_lines_it_declared() {
3019 let text = "-gone\n--- a/not-a-file.rs\n";
3023 let mut lines = text.lines();
3024 let mut inserted = BTreeMap::new();
3025
3026 consume_hunk(
3027 &mut lines,
3028 (1, 1, 0),
3029 Some(&"src/lib.rs".to_string()),
3030 &mut inserted,
3031 );
3032
3033 assert!(inserted.is_empty(), "a deletion inserts nothing");
3034 assert_eq!(lines.next(), Some("--- a/not-a-file.rs"));
3035 }
3036}