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