1use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use fallow_engine::mutation::{
12 DiffIndex, MutantDisposition, MutationPlan, MutationPlanRequest, MutationScope,
13 plan_mutations_for_session_with,
14};
15use fallow_engine::session::AnalysisSession;
16use fallow_output::{
17 MutationCacheOutput, MutationPlanOutput, MutationPlanSchemaVersion, MutationReport,
18 MutationResumeOutput, MutationRunnerOutput, MutationRuntimeOutput, MutationSchemaVersion,
19 MutationScopeKind, MutationScopeOutput, MutationTimings, MutationWorktreeCheck,
20};
21use fallow_types::envelope::ElapsedMs;
22use rustc_hash::FxHashSet;
23
24use crate::ProgrammaticError;
25use crate::analysis_context::{ProgrammaticAnalysisContext, resolve_programmatic_analysis_context};
26use crate::mutation::companion::{COMPANION_FILES, install};
27use crate::mutation::discovery::{JsRuntime, discover_runtime};
28use crate::mutation::journal::{JOURNAL_FORMAT, Journal, JournalHeader, JournalMismatch};
29use crate::mutation::last_report::ignore_cache_dir;
30use crate::mutation::manifest::{Manifest, RunnerRange, manifest};
31use crate::mutation::node_args::node_test_args;
32use crate::mutation::protocol::{InitOptions, InitRequest};
33use crate::mutation::report::{
34 CompletionFacts, baseline_failures, completion, function_results, issues, mutant_results,
35 plan_counts, plan_files, plan_mutants, skipped_files, summary, tool_version,
36};
37use crate::mutation::result_cache::{KeyFiles, NOT_KEYED, ResultCache};
38use crate::mutation::run_dir::{RunFile, write_run_dir};
39use crate::mutation::runner_select::{Runner, select_runner};
40use crate::mutation::scheduler::{
41 OutcomeJournal, ResultLookup, ScheduleConfig, ScheduleError, ScheduleIssue, ScheduleOutcome,
42 SchedulePlan, ScheduledMutant, run_schedule_with_progress,
43};
44use crate::mutation::transport::ProcessSpawner;
45use crate::mutation::typecheck::typecheck_survivors;
46use crate::mutation::worktree::{Exclusions, WorktreeSnapshot, canonical_path, snapshot};
47use crate::mutation::{
48 MutationProgress, MutationProgressSink, MutationRunOptions, MutationRunScope,
49};
50
51type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
52
53const DEFAULT_RUNNER_TIMEOUT: Duration = Duration::from_secs(120);
55
56fn mutation_error(code: &str, message: impl Into<String>) -> ProgrammaticError {
57 ProgrammaticError::new(message, 2).with_code(code)
58}
59
60#[expect(
61 clippy::cast_possible_truncation,
62 reason = "a run lasts far less than 2^64 milliseconds"
63)]
64const fn millis(duration: Duration) -> u64 {
65 duration.as_millis() as u64
66}
67
68fn validate(options: &MutationRunOptions) -> ProgrammaticResult<()> {
69 if options.concurrency == Some(0) {
70 return Err(mutation_error(
71 "FALLOW_INVALID_MUTATION_OPTIONS",
72 "the mutation concurrency must be at least 1",
73 )
74 .with_context("mutation.concurrency"));
75 }
76 if options.max_mutants == Some(0) {
77 return Err(mutation_error(
78 "FALLOW_INVALID_MUTATION_OPTIONS",
79 "the mutant limit must be at least 1",
80 )
81 .with_context("mutation.maxMutants"));
82 }
83 if options.time_budget.is_some_and(|budget| budget.is_zero())
84 || options
85 .runner_timeout
86 .is_some_and(|timeout| timeout.is_zero())
87 {
88 return Err(mutation_error(
89 "FALLOW_INVALID_MUTATION_OPTIONS",
90 "a mutation time limit must be more than zero",
91 ));
92 }
93 Ok(())
94}
95
96fn load_session(resolved: &ProgrammaticAnalysisContext) -> ProgrammaticResult<AnalysisSession> {
97 let mut project = fallow_engine::project_config::config_for_project_with_load_options(
98 resolved.root(),
99 resolved.config_path().as_deref(),
100 fallow_config::ConfigLoadOptions {
101 allow_remote_extends: resolved.allow_remote_extends,
102 },
103 )
104 .map_err(|error| {
105 ProgrammaticError::new(format!("failed to load config: {error}"), 2)
106 .with_code("FALLOW_CONFIG_LOAD_FAILED")
107 .with_context("analysis.configPath")
108 })?;
109 project.config.no_cache = resolved.no_cache;
110 project.config.threads = resolved.threads;
111 Ok(AnalysisSession::from_config(project))
112}
113
114fn scope_output(options: &MutationRunOptions, base_ref: Option<String>) -> MutationScopeOutput {
115 MutationScopeOutput {
116 kind: match options.scope {
117 MutationRunScope::Changed { .. } => MutationScopeKind::Changed,
118 MutationRunScope::All => MutationScopeKind::All,
119 MutationRunScope::Paths(_) => MutationScopeKind::Paths,
120 },
121 base_ref,
122 }
123}
124
125fn engine_scope(
127 options: &MutationRunOptions,
128 resolved: &ProgrammaticAnalysisContext,
129) -> ProgrammaticResult<(MutationScope, Option<String>)> {
130 let root = resolved.root();
131 match &options.scope {
132 MutationRunScope::All => Ok((MutationScope::All, None)),
133 MutationRunScope::Paths(paths) => Ok((MutationScope::Paths(paths.clone()), None)),
134 MutationRunScope::Changed { base } => {
135 if let Some(diff) = &resolved.diff {
136 return Ok((MutationScope::ChangedSinceBase(diff.clone()), None));
137 }
138 let resolved_base = crate::audit_run::resolve_audit_base(root, base.as_deref())
139 .map_err(|error| {
140 let message = match error {
141 crate::audit_run::AuditBaseError::InvalidRef { value, reason, .. } => {
142 format!("invalid base ref `{value}`: {reason}")
143 }
144 crate::audit_run::AuditBaseError::NotDetected => {
145 "no base branch to compare against; pass a base ref or mutate all files"
146 .to_owned()
147 }
148 };
149 mutation_error("FALLOW_MUTATION_BASE_NOT_FOUND", message)
150 .with_context("mutation.base")
151 })?;
152 let text =
153 fallow_engine::changed_files::try_get_changed_diff(root, &resolved_base.git_ref)
154 .map_err(|error| {
155 mutation_error(
156 "FALLOW_CHANGED_FILES_FAILED",
157 format!(
158 "cannot diff against `{}`: {}",
159 resolved_base.git_ref,
160 error.describe()
161 ),
162 )
163 })?;
164 let label = format!("git diff {}", resolved_base.git_ref);
165 let index = fallow_engine::diff_source::place_diff(
166 DiffIndex::from_unified_diff(&text),
167 root,
168 &fallow_engine::diff_source::diff_base_candidates(root),
169 &label,
170 )
171 .map_err(|stand_down| {
172 let (_, message) = stand_down.into_parts();
173 mutation_error("FALLOW_CHANGED_FILES_FAILED", message)
174 })?;
175 Ok((
176 MutationScope::ChangedSinceBase(index),
177 Some(resolved_base.git_ref),
178 ))
179 }
180 }
181}
182
183fn config_operators(
187 session: &AnalysisSession,
188) -> ProgrammaticResult<Option<Vec<fallow_engine::mutation::Operator>>> {
189 let Some(names) = &session.config().mutation.operators else {
190 return Ok(None);
191 };
192 names
193 .iter()
194 .map(|name| {
195 fallow_engine::mutation::Operator::from_name(name.as_str()).ok_or_else(|| {
196 mutation_error(
197 "FALLOW_INVALID_MUTATION_OPTIONS",
198 format!("unknown mutation operator `{}`", name.as_str()),
199 )
200 .with_context("mutation.operators")
201 })
202 })
203 .collect::<ProgrammaticResult<Vec<_>>>()
204 .map(Some)
205}
206
207struct Planned {
208 resolved: ProgrammaticAnalysisContext,
209 cache_dir: PathBuf,
210 runner: Runner,
212 plan: MutationPlan,
213 base_ref: Option<String>,
214 runner_config: Option<String>,
216 operators: Option<Vec<String>>,
218}
219
220fn runner_config(
229 options: &MutationRunOptions,
230 session: &AnalysisSession,
231 root: &Path,
232 runner: Runner,
233) -> ProgrammaticResult<Option<String>> {
234 let Some(raw) = options.runner_config.clone().or_else(|| {
235 session
236 .config()
237 .mutation
238 .runner_config
239 .as_ref()
240 .map(PathBuf::from)
241 }) else {
242 return Ok(None);
243 };
244 let invalid = |message: String| {
245 mutation_error("FALLOW_INVALID_MUTATION_OPTIONS", message)
246 .with_context("mutation.runnerConfig")
247 };
248 let shown = raw.display();
249 if runner != Runner::Vitest {
250 return Err(invalid(format!(
251 "the runner config `{shown}` needs the Vitest runner, but the run uses `{}`; only Vitest reads a config file",
252 runner.name()
253 )));
254 }
255 let candidate = if raw.is_absolute() {
256 raw.clone()
257 } else {
258 root.join(&raw)
259 };
260 if !candidate.is_file() {
261 return Err(invalid(format!(
262 "the runner config `{shown}` is not a file; a relative path is relative to the project root"
263 )));
264 }
265 let root = canonical_path(root);
266 let file = canonical_path(&candidate);
267 let Ok(relative) = file.strip_prefix(&root) else {
268 return Err(invalid(format!(
269 "the runner config `{shown}` is outside the project root `{}`",
270 root.display()
271 )));
272 };
273 Ok(Some(
274 relative
275 .components()
276 .map(|part| part.as_os_str().to_string_lossy())
277 .collect::<Vec<_>>()
278 .join("/"),
279 ))
280}
281
282fn plan_for(
283 options: &MutationRunOptions,
284 before_plan: impl FnOnce(&Path, &Path) -> ProgrammaticResult<()>,
285) -> ProgrammaticResult<Planned> {
286 validate(options)?;
287 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
288 let session = load_session(&resolved)?;
289 let cache_dir = session.config().cache_dir.clone();
290 let configured = session.config().mutation.runner;
291 before_plan(resolved.root(), &cache_dir)?;
292 let (scope, base_ref) = engine_scope(options, &resolved)?;
293 let operators = match &options.operators {
294 Some(operators) => Some(operators.clone()),
295 None => config_operators(&session)?,
296 };
297 let operator_names = operators.as_deref().map(operator_names);
298 let request = MutationPlanRequest {
299 scope,
300 operators,
301 allow_side_effects: options.allow_side_effects,
302 max_mutants: options.max_mutants,
303 skip_component_files: false,
304 };
305 let mut selected = None;
308 let plan = resolved
309 .install(|| {
310 plan_mutations_for_session_with(&session, &request, |plan| {
311 let runner = select_runner(configured, resolved.root(), &plan.test_files);
312 selected = Some(runner);
313 !runner.loads_component_files()
314 })
315 })
316 .map_err(|error| mutation_error("FALLOW_MUTATION_PLAN_FAILED", error.to_string()))?;
317 let runner =
318 selected.unwrap_or_else(|| select_runner(configured, resolved.root(), &plan.test_files));
319 let runner_config = runner_config(options, &session, resolved.root(), runner)?;
320 Ok(Planned {
321 resolved,
322 cache_dir,
323 runner,
324 plan,
325 base_ref,
326 runner_config,
327 operators: operator_names,
328 })
329}
330
331pub fn plan_mutation(options: &MutationRunOptions) -> ProgrammaticResult<MutationPlanOutput> {
338 let started = Instant::now();
339 let planned = plan_for(options, |_, _| Ok(()))?;
340 let plan = &planned.plan;
341 Ok(MutationPlanOutput {
342 schema_version: MutationPlanSchemaVersion::V1,
343 version: tool_version(),
344 elapsed_ms: ElapsedMs(millis(started.elapsed())),
345 scope: scope_output(options, planned.base_ref.clone()),
346 counts: plan_counts(plan),
347 mutants: plan_mutants(plan),
348 files: plan_files(plan),
349 test_files: plan.test_files.clone(),
350 no_test_files: plan.no_test_files,
351 framework_config_checked: plan.framework_config_checked,
352 skipped_files: skipped_files(plan),
353 unmatched_paths: plan.unmatched_paths.clone(),
354 })
355}
356
357fn unsafe_cache_dir(message: String) -> ProgrammaticError {
358 mutation_error("FALLOW_MUTATION_UNSAFE_CACHE_DIR", message)
359 .with_help(
360 "set cache.dir or FALLOW_CACHE_DIR to a directory that holds no project file, such as .fallow",
361 )
362 .with_context("cache.dir")
363}
364
365fn guard_cache_dir(root: &Path, cache_dir: &Path) -> ProgrammaticResult<()> {
370 if !canonical_path(root).starts_with(canonical_path(cache_dir)) {
371 return Ok(());
372 }
373 Err(unsafe_cache_dir(format!(
374 "the cache directory `{}` is the project root or a directory above it, so the worktree check cannot see stray writes",
375 cache_dir.display()
376 )))
377}
378
379fn guard_cache_holds_no_planned_file(
381 root: &Path,
382 cache_dir: &Path,
383 plan: &MutationPlan,
384) -> ProgrammaticResult<()> {
385 let root = canonical_path(root);
386 let cache = canonical_path(cache_dir);
387 let inside = plan
388 .files
389 .iter()
390 .map(|file| file.path.as_str())
391 .chain(plan.test_files.iter().map(String::as_str))
392 .find(|path| root.join(path).starts_with(&cache));
393 let Some(path) = inside else {
394 return Ok(());
395 };
396 Err(unsafe_cache_dir(format!(
397 "the cache directory `{}` holds the project file `{path}`, so the worktree check cannot see stray writes",
398 cache_dir.display()
399 )))
400}
401
402fn take_worktree_snapshot(
403 root: &Path,
404 cache_dir: &Path,
405 output_paths: &[PathBuf],
406 start: Option<&WorktreeSnapshot>,
407) -> ProgrammaticResult<Option<WorktreeSnapshot>> {
408 let cwd = std::env::current_dir().unwrap_or_else(|_| root.to_path_buf());
411 let outputs = output_paths.iter().map(|path| cwd.join(path));
412 let exclusions = Exclusions {
416 paths: std::iter::once(cache_dir.join("mutation"))
417 .chain(outputs)
418 .collect(),
419 ignored: vec![cache_dir.to_path_buf()],
420 };
421 snapshot(root, &exclusions, start)
422 .map_err(|message| mutation_error("FALLOW_MUTATION_WORKTREE_CHECK_FAILED", message))
423}
424
425struct WorktreeEnd {
427 changed_paths: Vec<String>,
429 check_error: Option<String>,
431}
432
433fn settle_worktree(
436 start: Option<&WorktreeSnapshot>,
437 take_end: impl FnOnce(&WorktreeSnapshot) -> ProgrammaticResult<Option<WorktreeSnapshot>>,
438) -> WorktreeEnd {
439 let Some(start) = start else {
440 return WorktreeEnd {
441 changed_paths: Vec::new(),
442 check_error: None,
443 };
444 };
445 match take_end(start) {
446 Ok(end) => WorktreeEnd {
447 changed_paths: end.map(|end| start.changed_paths(&end)).unwrap_or_default(),
448 check_error: None,
449 },
450 Err(error) => WorktreeEnd {
451 changed_paths: Vec::new(),
452 check_error: Some(error.message),
453 },
454 }
455}
456
457fn with_worktree_facts(mut error: ProgrammaticError, end: &WorktreeEnd) -> ProgrammaticError {
459 if !end.changed_paths.is_empty() {
460 error.message = format!(
461 "{}; the run changed these worktree paths: {}",
462 error.message,
463 end.changed_paths.join(", ")
464 );
465 }
466 if let Some(check_error) = &end.check_error {
467 error.message = format!(
468 "{}; the end-of-run worktree check failed: {check_error}",
469 error.message
470 );
471 }
472 error
473}
474
475const fn path_noun(count: usize) -> &'static str {
476 if count == 1 { "path" } else { "paths" }
477}
478
479fn guard_worktree(allow_dirty: bool, start: Option<&WorktreeSnapshot>) -> ProgrammaticResult<()> {
480 if allow_dirty {
481 return Ok(());
482 }
483 let Some(start) = start else {
484 return Err(mutation_error(
485 "FALLOW_MUTATION_NOT_A_REPOSITORY",
486 "mutation testing runs the tests against changed code and needs a git worktree to detect stray writes",
487 )
488 .with_help("run inside a git worktree, or pass --allow-dirty"));
489 };
490 let dirty = start.dirty_paths();
491 if dirty.is_empty() {
492 return Ok(());
493 }
494 let shown: Vec<&str> = dirty.iter().take(10).map(String::as_str).collect();
495 Err(mutation_error(
496 "FALLOW_MUTATION_DIRTY_WORKTREE",
497 format!(
498 "the git worktree has {} uncommitted {}: {}",
499 dirty.len(),
500 path_noun(dirty.len()),
501 shown.join(", ")
502 ),
503 )
504 .with_help("commit or stash the changes, or pass --allow-dirty"))
505}
506
507fn runner_version_help(range: &RunnerRange) -> String {
511 let RunnerRange {
512 package,
513 min_version,
514 max_version_exclusive,
515 ..
516 } = range;
517 let install =
518 format!("install {package} {min_version} or a later version below {max_version_exclusive}");
519 match min_version.split_once('.') {
520 Some((major, rest)) if rest != "0.0" => {
521 format!("{install}; a {package} {major}.x release from {min_version} is in the range")
522 }
523 _ => install,
524 }
525}
526
527fn schedule_error(
531 error: &ScheduleError,
532 runner: &RunnerRange,
533 setup_timeout: Duration,
534) -> ProgrammaticError {
535 let (code, stderr_tail) = match error {
536 ScheduleError::Integrity(_) => ("FALLOW_MUTATION_COMPANION_INTEGRITY", None),
537 ScheduleError::RunnerVersion(_) => ("FALLOW_MUTATION_RUNNER_UNSUPPORTED", None),
538 ScheduleError::Setup { stderr_tail, .. } => {
539 ("FALLOW_MUTATION_RUNNER_FAILED", stderr_tail.clone())
540 }
541 ScheduleError::RunPath { .. } => ("FALLOW_MUTATION_RUNNER_FAILED", None),
542 ScheduleError::UnknownHit(_) => ("FALLOW_MUTATION_PLAN_MISMATCH", None),
543 ScheduleError::Baseline(_) => ("FALLOW_MUTATION_BASELINE_FAILED", None),
544 };
545 let mut help = Vec::new();
546 if matches!(error, ScheduleError::RunnerVersion(_)) {
547 help.push(runner_version_help(runner));
548 }
549 if let ScheduleError::RunPath { help: run_path, .. } = error {
550 help.push(run_path.clone());
551 }
552 if matches!(
553 error,
554 ScheduleError::Setup {
555 timed_out: true,
556 ..
557 }
558 ) {
559 help.push(format!(
560 "the runner setup took longer than {}s; on a slow or busy host, pass --setup-timeout with a longer duration, for example 5m",
561 setup_timeout.as_secs()
562 ));
563 }
564 if let Some(tail) = stderr_tail.filter(|tail| !tail.trim().is_empty()) {
565 help.push(format!("companion stderr:\n{}", tail.trim_end()));
566 }
567 let public = mutation_error(code, error.to_string());
568 if help.is_empty() {
569 public
570 } else {
571 public.with_help(help.join("\n"))
572 }
573}
574
575fn default_concurrency() -> usize {
576 std::thread::available_parallelism()
577 .map_or(1, std::num::NonZeroUsize::get)
578 .div_ceil(2)
579 .max(1)
580}
581
582fn runtime_error(runner: Runner, message: String) -> ProgrammaticError {
583 mutation_error("FALLOW_MUTATION_RUNTIME_NOT_FOUND", message)
584 .with_help(runner.runtime_help())
585 .with_context("mutation.runtime")
586}
587
588fn scheduled(plan: &MutationPlan, disposition: MutantDisposition) -> Vec<ScheduledMutant> {
589 plan.mutants
590 .iter()
591 .filter(|mutant| mutant.disposition == disposition)
592 .map(|mutant| ScheduledMutant {
593 id: mutant.mutant.id.as_str().to_owned(),
594 file: mutant.file.clone(),
595 })
596 .collect()
597}
598
599fn schedule_plan(plan: &MutationPlan) -> SchedulePlan {
600 SchedulePlan {
601 mutants: scheduled(plan, MutantDisposition::Planned),
602 unreached: scheduled(plan, MutantDisposition::NoCoverage),
603 pending: scheduled(plan, MutantDisposition::Pending),
604 known_ids: plan
605 .mutants
606 .iter()
607 .map(|mutant| mutant.mutant.id.as_str().to_owned())
608 .collect::<FxHashSet<_>>(),
609 test_files: plan.test_files.clone(),
610 file_test_files: plan
611 .files
612 .iter()
613 .map(|file| (file.path.clone(), file.static_test_files.clone()))
614 .collect(),
615 }
616}
617
618struct Execution {
620 outcome: Option<ScheduleOutcome>,
621 runner: Option<MutationRunnerOutput>,
622 cache: Option<MutationCacheOutput>,
623 resume: Option<MutationResumeOutput>,
624 setup_ms: u64,
625}
626
627fn shown_path(root: &Path, path: &Path) -> String {
630 let relative = path.strip_prefix(root).map(Path::to_path_buf).or_else(|_| {
631 canonical_path(path)
632 .strip_prefix(canonical_path(root))
633 .map(Path::to_path_buf)
634 });
635 relative.map_or_else(
636 |_| path.display().to_string(),
637 |relative| {
638 relative
639 .components()
640 .map(|part| part.as_os_str().to_string_lossy())
641 .collect::<Vec<_>>()
642 .join("/")
643 },
644 )
645}
646
647fn operator_names(operators: &[fallow_engine::mutation::Operator]) -> Vec<String> {
650 let mut names: Vec<String> = operators
651 .iter()
652 .map(|operator| operator.as_str().to_owned())
653 .collect();
654 names.sort_unstable();
655 names.dedup();
656 names
657}
658
659fn scope_path(root: &Path, path: &Path) -> String {
662 let relative = path.strip_prefix(root).unwrap_or(path);
663 relative
664 .components()
665 .filter(|part| !matches!(part, std::path::Component::CurDir))
666 .map(|part| part.as_os_str().to_string_lossy())
667 .collect::<Vec<_>>()
668 .join("/")
669}
670
671fn scope_label(options: &MutationRunOptions, base_ref: Option<&str>, root: &Path) -> String {
675 match &options.scope {
676 MutationRunScope::All => "all".to_owned(),
677 MutationRunScope::Paths(paths) => {
678 let mut paths: Vec<String> = paths.iter().map(|path| scope_path(root, path)).collect();
679 paths.sort_unstable();
680 paths.dedup();
681 format!("paths {}", paths.join(","))
682 }
683 MutationRunScope::Changed { .. } => base_ref.map_or_else(
684 || "changed lines of a diff".to_owned(),
685 |base| format!("changed against {base}"),
686 ),
687 }
688}
689
690fn resume_mismatch(mismatch: &JournalMismatch, journal: &str) -> ProgrammaticError {
691 mutation_error(
692 "FALLOW_MUTATION_RESUME_MISMATCH",
693 format!(
694 "the journal {journal} belongs to a run with other inputs: {}",
695 mismatch.differences.join("; ")
696 ),
697 )
698 .with_help("run without --resume to start a new run; that run replaces the journal")
699 .with_context("mutation.resume")
700}
701
702fn unread_journal(options: &MutationRunOptions, planned: &Planned) -> Option<MutationResumeOutput> {
705 options.resume.then(|| MutationResumeOutput {
706 journal: shown_path(
707 planned.resolved.root(),
708 &Journal::path(&planned.cache_dir.join("mutation")),
709 ),
710 reused: 0,
711 rejected: 0,
712 note: Some("the run read no journal, because no mutant needs a test run".to_owned()),
713 })
714}
715
716fn open_stores(
719 options: &MutationRunOptions,
720 planned: &Planned,
721 mutation_cache: &Path,
722 runtime: &str,
723 run_dir: &Path,
724) -> ProgrammaticResult<(Option<Arc<ResultCache>>, RunJournal)> {
725 let root = planned.resolved.root();
726 let name = planned.runner.name();
727 let files = key_files(&planned.plan, planned.runner_config.as_deref());
728 let result_cache = open_result_cache(
729 planned.resolved.no_cache,
730 mutation_cache,
731 root,
732 name,
733 files.clone(),
734 );
735 let keys = result_cache
736 .clone()
737 .unwrap_or_else(|| Arc::new(ResultCache::keys_only(mutation_cache, root, name, files)));
738 let header = JournalHeader {
739 journal_format: JOURNAL_FORMAT,
740 fallow_version: env!("CARGO_PKG_VERSION").to_owned(),
741 runner: name.to_owned(),
742 runtime: runtime.to_owned(),
743 run_dir: run_dir
744 .file_name()
745 .map(|hash| hash.to_string_lossy().into_owned())
746 .unwrap_or_default(),
747 runner_config: planned.runner_config.clone(),
748 scope: scope_label(options, planned.base_ref.as_deref(), root),
749 operators: planned.operators.clone(),
750 allow_side_effects: options.allow_side_effects,
751 };
752 let run_journal = start_journal(options, planned, mutation_cache, &header, keys)?;
753 Ok((result_cache, run_journal))
754}
755
756struct RunJournal {
758 journal: Arc<Journal>,
759 shown: String,
761 note: Option<String>,
763}
764
765fn start_journal(
768 options: &MutationRunOptions,
769 planned: &Planned,
770 mutation_cache: &Path,
771 header: &JournalHeader,
772 keys: Arc<ResultCache>,
773) -> ProgrammaticResult<RunJournal> {
774 let shown = shown_path(planned.resolved.root(), &Journal::path(mutation_cache));
775 let opened = Journal::open(mutation_cache, header, keys, options.resume)
776 .map_err(|mismatch| resume_mismatch(&mismatch, &shown))?;
777 Ok(RunJournal {
778 journal: Arc::new(opened.journal),
779 shown,
780 note: opened.note,
781 })
782}
783
784fn settle_journal(
787 options: &MutationRunOptions,
788 run_journal: RunJournal,
789 outcome: &mut ScheduleOutcome,
790) -> Option<MutationResumeOutput> {
791 if let Some(message) = run_journal.journal.write_error() {
792 outcome.issues.push(ScheduleIssue {
793 code: "journal-write-failed",
794 message: format!(
795 "the outcome journal was not written, so an interrupted run cannot resume each result: {message}"
796 ),
797 mutant_id: None,
798 stderr_tail: None,
799 });
800 }
801 options.resume.then(|| MutationResumeOutput {
802 journal: run_journal.shown,
803 reused: outcome
804 .results
805 .values()
806 .filter(|result| result.resumed)
807 .count(),
808 rejected: run_journal.journal.rejected(),
809 note: run_journal.note,
810 })
811}
812
813fn open_result_cache(
815 no_cache: bool,
816 mutation_cache: &Path,
817 root: &Path,
818 runner: &str,
819 files: KeyFiles,
820) -> Option<Arc<ResultCache>> {
821 (!no_cache).then(|| Arc::new(ResultCache::open(mutation_cache, root, runner, files)))
822}
823
824fn key_files(plan: &MutationPlan, runner_config: Option<&str>) -> KeyFiles {
828 let mut files = KeyFiles::from_plan(plan);
829 if let Some(config) = runner_config
830 && !files.support_files.iter().any(|file| file == config)
831 {
832 files.support_files.push(config.to_owned());
833 }
834 files
835}
836
837fn store_results(cache: &ResultCache, outcome: &mut ScheduleOutcome) -> MutationCacheOutput {
840 let counts = cache.record(outcome);
841 if let Err(message) = cache.save() {
842 outcome.issues.push(ScheduleIssue {
843 code: "cache-write-failed",
844 message: format!("the mutation result cache was not saved: {message}"),
845 mutant_id: None,
846 stderr_tail: None,
847 });
848 }
849 MutationCacheOutput {
850 hits: counts.hits,
851 stored: counts.stored,
852 not_keyed: NOT_KEYED.iter().map(|&item| item.to_owned()).collect(),
853 }
854}
855
856fn init_options(runner: Runner, root: &Path, static_test_files: &[String]) -> InitOptions {
862 let node_args = match runner {
863 Runner::NodeTest => node_test_args(root),
864 Runner::Vitest | Runner::BunTest => Vec::new(),
865 };
866 let extra_test_files = match runner {
867 Runner::NodeTest | Runner::BunTest => static_test_files.to_vec(),
868 Runner::Vitest => Vec::new(),
869 };
870 InitOptions {
871 extra_test_files,
872 node_args,
873 ..InitOptions::default()
874 }
875}
876
877fn runner_output(
879 outcome: &ScheduleOutcome,
880 name: &str,
881 manifest: &Manifest,
882 runtime: &JsRuntime,
883) -> Option<MutationRunnerOutput> {
884 outcome.ready.as_ref().map(|ready| MutationRunnerOutput {
885 name: name.to_owned(),
886 version: ready.runner_version.clone(),
887 protocol_version: manifest.wire_protocol_version,
888 runtime: MutationRuntimeOutput {
889 name: runtime.kind.name().to_owned(),
890 version: runtime.version.clone(),
891 },
892 concurrency: outcome.workers,
893 notes: ready
894 .notes
895 .iter()
896 .filter(|note| manifest.ready_notes.contains(note))
897 .cloned()
898 .collect(),
899 })
900}
901
902fn execute(
904 options: &MutationRunOptions,
905 planned: &Planned,
906 deadline: Option<Instant>,
907 progress: MutationProgressSink<'_>,
908) -> ProgrammaticResult<Execution> {
909 let plan = &planned.plan;
910 let schedule = schedule_plan(plan);
911 if schedule.mutants.is_empty()
914 && (schedule.unreached.is_empty() || schedule.test_files.is_empty())
915 {
916 return Ok(Execution {
917 outcome: None,
918 runner: None,
919 cache: None,
920 resume: unread_journal(options, planned),
921 setup_ms: 0,
922 });
923 }
924 progress(MutationProgress::Setup {
925 mutants: schedule.mutants.len(),
926 });
927 let setup_started = Instant::now();
928 let manifest = manifest()
929 .map_err(|message| mutation_error("FALLOW_MUTATION_PROTOCOL_INVALID", message))?;
930 let runner = planned.runner;
931 let name = runner.name();
932 let range = manifest.runner(name).ok_or_else(|| {
933 mutation_error(
934 "FALLOW_MUTATION_PROTOCOL_INVALID",
935 format!("the protocol manifest has no `{name}` runner"),
936 )
937 })?;
938 let runtime = discover_runtime(options.runtime.as_deref(), runner.runtimes())
939 .map_err(|message| runtime_error(runner, message))?;
940 let cache_error =
941 |message: String| mutation_error("FALLOW_MUTATION_CACHE_WRITE_FAILED", message);
942 ignore_cache_dir(&planned.cache_dir).map_err(|error| cache_error(error.to_string()))?;
943 let mutation_cache = planned.cache_dir.join("mutation");
944 let companion = install(&mutation_cache, COMPANION_FILES)
945 .map_err(|message| mutation_error("FALLOW_MUTATION_COMPANION_INTEGRITY", message))?;
946 let files: Vec<RunFile<'_>> = plan
947 .files
948 .iter()
949 .map(|file| RunFile {
950 path: &file.path,
951 code: &file.instrumented_code,
952 })
953 .collect();
954 let run_dir = write_run_dir(&mutation_cache, &files, Some(&companion)).map_err(cache_error)?;
955 let root = planned.resolved.root().to_path_buf();
956 let runtime_label = format!("{} {}", runtime.kind.name(), runtime.version);
957 let (result_cache, run_journal) = open_stores(
958 options,
959 planned,
960 &mutation_cache,
961 &runtime_label,
962 &run_dir.dir,
963 )?;
964 let config = ScheduleConfig {
965 init: InitRequest {
966 root: root.to_string_lossy().into_owned(),
967 runner: name.to_owned(),
968 config_path: planned.runner_config.clone(),
969 run_dir: run_dir.dir.to_string_lossy().into_owned(),
970 path_map: run_dir.path_map.clone(),
971 options: init_options(runner, &root, &plan.test_files),
972 },
973 runner: range.clone(),
974 concurrency: options.concurrency.unwrap_or_else(default_concurrency),
975 runner_timeout: options.runner_timeout.unwrap_or(DEFAULT_RUNNER_TIMEOUT),
976 deadline,
977 cache: result_cache
978 .clone()
979 .map(|cache| cache as Arc<dyn ResultLookup>),
980 journal: Some(Arc::clone(&run_journal.journal) as Arc<dyn OutcomeJournal>),
981 };
982 let spawner = ProcessSpawner {
983 runtime: runtime.path.clone(),
984 companion,
985 root,
986 wire_version: manifest.wire_protocol_version,
987 session_token_env: manifest.session_token_env.clone(),
988 };
989 let setup_ms = millis(setup_started.elapsed());
990 let mut outcome = run_schedule_with_progress(&spawner, &schedule, &config, progress)
991 .map_err(|error| schedule_error(&error, &config.runner, config.runner_timeout))?;
992 let runner = runner_output(&outcome, name, manifest, &runtime);
993 let cache = match (&result_cache, outcome.ready.is_some()) {
994 (Some(result_cache), true) => Some(store_results(result_cache, &mut outcome)),
995 _ => None,
996 };
997 let resume = settle_journal(options, run_journal, &mut outcome);
998 Ok(Execution {
999 outcome: Some(outcome),
1000 runner,
1001 cache,
1002 resume,
1003 setup_ms,
1004 })
1005}
1006
1007fn run_issues(
1009 outcome: Option<&ScheduleOutcome>,
1010 changed_paths: &[String],
1011 check_error: Option<String>,
1012) -> Vec<fallow_output::MutationRunIssue> {
1013 let mut errors = issues(outcome);
1014 if !changed_paths.is_empty() {
1015 errors.push(fallow_output::MutationRunIssue {
1016 code: "worktree-changed".to_owned(),
1017 message: format!(
1018 "the run changed {} worktree {}; a mutant or a test wrote to the project",
1019 changed_paths.len(),
1020 path_noun(changed_paths.len())
1021 ),
1022 mutant_id: None,
1023 stderr_tail: None,
1024 });
1025 }
1026 if let Some(message) = check_error {
1027 errors.push(fallow_output::MutationRunIssue {
1028 code: "worktree-check-failed".to_owned(),
1029 message: format!(
1030 "the end-of-run worktree check failed, so stray writes are unknown: {message}"
1031 ),
1032 mutant_id: None,
1033 stderr_tail: None,
1034 });
1035 }
1036 errors
1037}
1038
1039fn run_timings(
1042 outcome: Option<&ScheduleOutcome>,
1043 setup_ms: u64,
1044 plan_ms: u64,
1045 typecheck_ms: u64,
1046) -> MutationTimings {
1047 outcome.map_or_else(
1048 || MutationTimings {
1049 plan_ms,
1050 typecheck_ms,
1051 ..MutationTimings::default()
1052 },
1053 |outcome| MutationTimings {
1054 plan_ms,
1055 setup_ms: setup_ms + millis(outcome.timings.setup),
1056 dry_run_ms: millis(outcome.timings.dry_run),
1057 mutants_ms: millis(outcome.timings.mutants),
1058 retry_ms: millis(outcome.timings.retry),
1059 typecheck_ms,
1060 },
1061 )
1062}
1063
1064fn guard_typecheck_companion(enabled: bool, root: &Path) -> ProgrammaticResult<()> {
1067 if !enabled {
1068 return Ok(());
1069 }
1070 crate::type_aware::check_typecheck_companion(root).map_err(|message| {
1071 mutation_error("FALLOW_MUTATION_TYPECHECK_UNAVAILABLE", message)
1072 .with_help("install the fallow-type-aware companion, or run without --typecheck")
1073 .with_context("mutation.typecheck")
1074 })
1075}
1076
1077pub fn run_mutation(options: &MutationRunOptions) -> ProgrammaticResult<MutationReport> {
1091 run_mutation_with_progress(options, &|_| {})
1092}
1093
1094pub fn run_mutation_with_progress(
1104 options: &MutationRunOptions,
1105 progress: MutationProgressSink<'_>,
1106) -> ProgrammaticResult<MutationReport> {
1107 let started = Instant::now();
1108 let deadline = options.time_budget.map(|budget| started + budget);
1109 let mut start_snapshot = None;
1110 let planned = plan_for(options, |root, cache_dir| {
1111 guard_cache_dir(root, cache_dir)?;
1112 start_snapshot = take_worktree_snapshot(root, cache_dir, &options.output_paths, None)?;
1113 guard_worktree(options.allow_dirty, start_snapshot.as_ref())
1114 })?;
1115 guard_cache_holds_no_planned_file(planned.resolved.root(), &planned.cache_dir, &planned.plan)?;
1116 guard_typecheck_companion(options.typecheck, planned.resolved.root())?;
1117 let plan_ms = millis(started.elapsed());
1118 let execution = execute(options, &planned, deadline, progress);
1119 progress(MutationProgress::Finalize);
1120 let worktree_end = settle_worktree(start_snapshot.as_ref(), |start| {
1121 take_worktree_snapshot(
1122 planned.resolved.root(),
1123 &planned.cache_dir,
1124 &options.output_paths,
1125 Some(start),
1126 )
1127 });
1128 let execution = execution.map_err(|error| with_worktree_facts(error, &worktree_end))?;
1129 let WorktreeEnd {
1130 changed_paths,
1131 check_error,
1132 } = worktree_end;
1133 let plan = &planned.plan;
1134 let outcome = execution.outcome.as_ref();
1135 let mut results = mutant_results(plan, outcome);
1136 let typecheck = typecheck_survivors(
1137 options.typecheck,
1138 planned.resolved.root(),
1139 plan,
1140 &mut results,
1141 );
1142 let typecheck_ms = millis(typecheck.elapsed);
1143 let baseline = baseline_failures(outcome);
1144 let completion = completion(&CompletionFacts {
1145 plan,
1146 results: &results,
1147 baseline_failures: &baseline,
1148 worktree_changed: !changed_paths.is_empty(),
1149 worktree_unchecked: check_error.is_some(),
1150 unretried: outcome.is_some_and(|outcome| !outcome.unretried.is_empty()),
1151 unloaded_paths: outcome.map_or(&[], |outcome| outcome.unloaded_paths.as_slice()),
1152 undiscovered_test_files: outcome
1153 .map_or(&[], |outcome| outcome.undiscovered_test_files.as_slice()),
1154 typecheck_failed: typecheck.issue.is_some(),
1155 promoted: outcome.map_or(&[], |outcome| outcome.promoted.as_slice()),
1156 });
1157 let mut errors = run_issues(outcome, &changed_paths, check_error);
1158 errors.extend(typecheck.issue);
1159 let timings = run_timings(outcome, execution.setup_ms, plan_ms, typecheck_ms);
1160 Ok(MutationReport {
1161 schema_version: MutationSchemaVersion::V1,
1162 version: tool_version(),
1163 elapsed_ms: ElapsedMs(millis(started.elapsed())),
1164 completion,
1165 scope: scope_output(options, planned.base_ref.clone()),
1166 runner: execution.runner,
1167 cache: execution.cache,
1168 resume: execution.resume,
1169 summary: summary(&results),
1170 functions: function_results(plan, &results),
1171 mutants: results,
1172 no_test_files: plan.no_test_files,
1173 baseline_failures: baseline,
1174 skipped_files: skipped_files(plan),
1175 unmatched_paths: plan.unmatched_paths.clone(),
1176 unloaded_paths: outcome
1177 .map(|outcome| outcome.unloaded_paths.clone())
1178 .unwrap_or_default(),
1179 worktree: MutationWorktreeCheck {
1180 checked: start_snapshot.is_some(),
1181 allow_dirty: options.allow_dirty,
1182 dirty_at_start: start_snapshot
1183 .as_ref()
1184 .map(WorktreeSnapshot::dirty_paths)
1185 .unwrap_or_default(),
1186 changed_paths,
1187 },
1188 errors,
1189 timings,
1190 files: plan_files(plan),
1191 verdicts: None,
1192 typecheck: typecheck.output,
1193 })
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198 use super::*;
1199
1200 fn vitest_range() -> &'static RunnerRange {
1201 manifest()
1202 .expect("manifest")
1203 .runner("vitest")
1204 .expect("vitest range")
1205 }
1206
1207 #[test]
1208 fn a_runner_version_error_names_the_supported_range_as_the_install_step() {
1209 let error = schedule_error(
1210 &ScheduleError::RunnerVersion(
1211 "vitest 4.0.18 is not supported; the supported range is >=4.1.0 <6.0.0".to_owned(),
1212 ),
1213 vitest_range(),
1214 Duration::from_secs(120),
1215 );
1216 assert_eq!(
1217 error.code.as_deref(),
1218 Some("FALLOW_MUTATION_RUNNER_UNSUPPORTED")
1219 );
1220 assert_eq!(
1221 error.help.as_deref(),
1222 Some(
1223 "install vitest 4.1.0 or a later version below 6.0.0; a vitest 4.x release from 4.1.0 is in the range"
1224 )
1225 );
1226
1227 let help: Vec<String> = manifest()
1228 .expect("manifest")
1229 .runners
1230 .iter()
1231 .map(runner_version_help)
1232 .collect();
1233 assert_eq!(
1234 help,
1235 [
1236 "install vitest 4.1.0 or a later version below 6.0.0; a vitest 4.x release from 4.1.0 is in the range",
1237 "install node 22.18.0 or a later version below 27.0.0; a node 22.x release from 22.18.0 is in the range",
1238 "install bun 1.4.0 or a later version below 2.0.0; a bun 1.x release from 1.4.0 is in the range",
1239 ]
1240 );
1241
1242 let major_start = RunnerRange {
1243 name: "vitest".to_owned(),
1244 package: "vitest".to_owned(),
1245 min_version: "5.0.0".to_owned(),
1246 max_version_exclusive: "6.0.0".to_owned(),
1247 };
1248 assert_eq!(
1249 runner_version_help(&major_start),
1250 "install vitest 5.0.0 or a later version below 6.0.0"
1251 );
1252 }
1253
1254 #[test]
1255 fn a_setup_watchdog_error_names_the_setup_timeout_flag() {
1256 let error = schedule_error(
1257 &ScheduleError::Setup {
1258 message: "the mutation companion dry_run failed".to_owned(),
1259 stderr_tail: Some("vitest started\n".to_owned()),
1260 timed_out: true,
1261 },
1262 vitest_range(),
1263 Duration::from_secs(120),
1264 );
1265 let help = error.help.as_deref().unwrap_or_default();
1266 assert!(help.contains("longer than 120s"), "{help}");
1267 assert!(help.contains("--setup-timeout"), "{help}");
1268 assert!(help.contains("companion stderr:\nvitest started"), "{help}");
1269
1270 let other = schedule_error(
1271 &ScheduleError::Setup {
1272 message: "the mutation companion init failed".to_owned(),
1273 stderr_tail: None,
1274 timed_out: false,
1275 },
1276 vitest_range(),
1277 Duration::from_secs(120),
1278 );
1279 assert_eq!(other.help, None);
1280 }
1281
1282 #[test]
1283 fn a_failed_self_check_is_a_runner_failure_with_its_own_help() {
1284 let error = schedule_error(
1285 &ScheduleError::RunPath {
1286 message: "vitest 5.0.4 executes no tests in a mutant run".to_owned(),
1287 help: "report this with the runner name and version (vitest 5.0.4)".to_owned(),
1288 },
1289 vitest_range(),
1290 Duration::from_secs(120),
1291 );
1292 assert_eq!(error.code.as_deref(), Some("FALLOW_MUTATION_RUNNER_FAILED"));
1293 assert_eq!(error.exit_code, 2);
1294 assert_eq!(
1295 error.message,
1296 "vitest 5.0.4 executes no tests in a mutant run"
1297 );
1298 assert_eq!(
1299 error.help.as_deref(),
1300 Some("report this with the runner name and version (vitest 5.0.4)")
1301 );
1302 }
1303
1304 #[test]
1305 fn a_failed_end_check_keeps_the_execute_error_and_reports_the_check_error() {
1306 let start = WorktreeSnapshot::default();
1307 let end = settle_worktree(Some(&start), |_| {
1308 Err(mutation_error(
1309 "FALLOW_MUTATION_WORKTREE_CHECK_FAILED",
1310 "git status failed",
1311 ))
1312 });
1313 assert!(end.changed_paths.is_empty());
1314 assert_eq!(end.check_error.as_deref(), Some("git status failed"));
1315
1316 let error = with_worktree_facts(
1317 mutation_error(
1318 "FALLOW_MUTATION_SETUP_FAILED",
1319 "the companion did not start",
1320 ),
1321 &end,
1322 );
1323 assert_eq!(error.code.as_deref(), Some("FALLOW_MUTATION_SETUP_FAILED"));
1324 assert!(
1325 error.message.starts_with("the companion did not start"),
1326 "{}",
1327 error.message
1328 );
1329 assert!(
1330 error.message.contains("git status failed"),
1331 "{}",
1332 error.message
1333 );
1334 }
1335
1336 #[test]
1337 fn every_config_operator_name_maps_to_an_engine_operator() {
1338 for operator in fallow_engine::mutation::Operator::ALL {
1339 let name: fallow_config::MutationOperatorName =
1340 serde_json::from_value(serde_json::json!(operator.as_str()))
1341 .expect("a config operator name");
1342 assert_eq!(
1343 fallow_engine::mutation::Operator::from_name(name.as_str()),
1344 Some(operator)
1345 );
1346 }
1347 }
1348
1349 #[test]
1350 fn no_cache_opens_no_result_cache() {
1351 let dir = tempfile::tempdir().expect("dir");
1352 let plan = MutationPlan {
1353 files: Vec::new(),
1354 mutants: Vec::new(),
1355 test_files: Vec::new(),
1356 reach_files: Vec::new(),
1357 test_file_reach: std::collections::BTreeMap::new(),
1358 support_files: Vec::new(),
1359 counts: fallow_engine::mutation::MutationPlanCounts::default(),
1360 no_test_files: false,
1361 framework_config_checked: false,
1362 skipped_files: Vec::new(),
1363 unmatched_paths: Vec::new(),
1364 };
1365 assert!(
1366 open_result_cache(
1367 true,
1368 dir.path(),
1369 dir.path(),
1370 "vitest",
1371 KeyFiles::from_plan(&plan)
1372 )
1373 .is_none()
1374 );
1375 assert!(
1376 open_result_cache(
1377 false,
1378 dir.path(),
1379 dir.path(),
1380 "vitest",
1381 KeyFiles::from_plan(&plan)
1382 )
1383 .is_some()
1384 );
1385 }
1386
1387 fn empty_plan() -> MutationPlan {
1388 MutationPlan {
1389 files: Vec::new(),
1390 mutants: Vec::new(),
1391 test_files: Vec::new(),
1392 reach_files: Vec::new(),
1393 test_file_reach: std::collections::BTreeMap::new(),
1394 support_files: Vec::new(),
1395 counts: fallow_engine::mutation::MutationPlanCounts::default(),
1396 no_test_files: false,
1397 framework_config_checked: false,
1398 skipped_files: Vec::new(),
1399 unmatched_paths: Vec::new(),
1400 }
1401 }
1402
1403 #[test]
1404 fn a_cache_dir_at_or_above_the_root_is_refused() {
1405 let dir = tempfile::tempdir().expect("dir");
1406 let root = dir.path().join("project");
1407 std::fs::create_dir_all(root.join("src")).expect("dir");
1408 for cache in [root.clone(), root.join("."), dir.path().to_path_buf()] {
1409 let error = guard_cache_dir(&root, &cache).expect_err("refused");
1410 assert_eq!(
1411 error.code.as_deref(),
1412 Some("FALLOW_MUTATION_UNSAFE_CACHE_DIR")
1413 );
1414 assert_eq!(error.exit_code, 2);
1415 assert_eq!(error.context.as_deref(), Some("cache.dir"));
1416 }
1417 assert!(guard_cache_dir(&root, &root.join(".fallow")).is_ok());
1418 assert!(guard_cache_dir(&root, &root.join("src")).is_ok());
1419 }
1420
1421 #[test]
1422 fn a_cache_dir_that_holds_a_planned_file_is_refused() {
1423 let dir = tempfile::tempdir().expect("dir");
1424 let root = dir.path();
1425 let mut plan = empty_plan();
1426 plan.files.push(fallow_engine::mutation::PlannedFile {
1427 path: "src/a.ts".to_owned(),
1428 source_sha256: String::new(),
1429 instrumented_code: String::new(),
1430 static_test_files: Vec::new(),
1431 });
1432 plan.test_files.push("test/a.test.ts".to_owned());
1433
1434 let error = guard_cache_holds_no_planned_file(root, &root.join("src"), &plan)
1435 .expect_err("the cache holds a planned source file");
1436 assert_eq!(
1437 error.code.as_deref(),
1438 Some("FALLOW_MUTATION_UNSAFE_CACHE_DIR")
1439 );
1440 assert!(error.message.contains("src/a.ts"), "{}", error.message);
1441 let error = guard_cache_holds_no_planned_file(root, &root.join("test"), &plan)
1442 .expect_err("the cache holds a planned test file");
1443 assert!(
1444 error.message.contains("test/a.test.ts"),
1445 "{}",
1446 error.message
1447 );
1448 assert!(guard_cache_holds_no_planned_file(root, &root.join(".fallow"), &plan).is_ok());
1449 }
1450
1451 #[test]
1452 fn no_start_snapshot_needs_no_end_check() {
1453 let end = settle_worktree(None, |_| panic!("no end check without a start snapshot"));
1454 assert!(end.changed_paths.is_empty());
1455 assert!(end.check_error.is_none());
1456 }
1457
1458 #[test]
1459 fn the_glob_runners_get_the_static_test_files_and_vitest_does_not() {
1460 let dir = tempfile::tempdir().expect("dir");
1461 let files = vec![".github/tests/math.test.mjs".to_owned()];
1462 for runner in [Runner::NodeTest, Runner::BunTest] {
1463 let options = init_options(runner, dir.path(), &files);
1464 assert_eq!(options.extra_test_files, files, "{runner:?}");
1465 assert!(options.test_files.is_empty(), "{runner:?}");
1466 }
1467 let vitest = init_options(Runner::Vitest, dir.path(), &files);
1468 assert!(vitest.extra_test_files.is_empty());
1469 }
1470
1471 #[test]
1472 fn equal_scopes_and_operators_give_one_fingerprint() {
1473 let root = Path::new("/project");
1474 let paths = |paths: &[&str]| MutationRunOptions {
1475 scope: MutationRunScope::Paths(paths.iter().map(PathBuf::from).collect()),
1476 ..MutationRunOptions::default()
1477 };
1478 let label = scope_label(&paths(&["src/a", "lib"]), None, root);
1479 for same in [
1480 paths(&["./src/a", "lib"]),
1481 paths(&["lib/", "src/a"]),
1482 paths(&["/project/src/a", "lib", "./lib"]),
1483 ] {
1484 assert_eq!(scope_label(&same, None, root), label, "{:?}", same.scope);
1485 }
1486 assert_ne!(scope_label(&paths(&["src/b"]), None, root), label);
1487
1488 use fallow_engine::mutation::Operator;
1489 let names = operator_names(&[
1490 Operator::RelationalOperator,
1491 Operator::ArithmeticOperator,
1492 Operator::RelationalOperator,
1493 ]);
1494 assert_eq!(
1495 names,
1496 operator_names(&[Operator::ArithmeticOperator, Operator::RelationalOperator,])
1497 );
1498 }
1499}