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 MutationRunnerOutput, MutationRuntimeOutput, MutationSchemaVersion, MutationScopeKind,
19 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::discover_runtime;
28use crate::mutation::last_report::ignore_cache_dir;
29use crate::mutation::manifest::{RunnerRange, manifest};
30use crate::mutation::node_args::node_test_args;
31use crate::mutation::protocol::{InitOptions, InitRequest};
32use crate::mutation::report::{
33 CompletionFacts, baseline_failures, completion, function_results, issues, mutant_results,
34 plan_counts, plan_files, plan_mutants, skipped_files, summary, tool_version,
35};
36use crate::mutation::result_cache::{KeyFiles, NOT_KEYED, ResultCache};
37use crate::mutation::run_dir::{RunFile, write_run_dir};
38use crate::mutation::runner_select::{Runner, select_runner};
39use crate::mutation::scheduler::{
40 ResultLookup, ScheduleConfig, ScheduleError, ScheduleIssue, ScheduleOutcome, SchedulePlan,
41 ScheduledMutant, run_schedule_with_progress,
42};
43use crate::mutation::transport::ProcessSpawner;
44use crate::mutation::typecheck::typecheck_survivors;
45use crate::mutation::worktree::{Exclusions, WorktreeSnapshot, canonical_path, snapshot};
46use crate::mutation::{
47 MutationProgress, MutationProgressSink, MutationRunOptions, MutationRunScope,
48};
49
50type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
51
52const DEFAULT_RUNNER_TIMEOUT: Duration = Duration::from_secs(120);
54
55fn mutation_error(code: &str, message: impl Into<String>) -> ProgrammaticError {
56 ProgrammaticError::new(message, 2).with_code(code)
57}
58
59#[expect(
60 clippy::cast_possible_truncation,
61 reason = "a run lasts far less than 2^64 milliseconds"
62)]
63const fn millis(duration: Duration) -> u64 {
64 duration.as_millis() as u64
65}
66
67fn validate(options: &MutationRunOptions) -> ProgrammaticResult<()> {
68 if options.concurrency == Some(0) {
69 return Err(mutation_error(
70 "FALLOW_INVALID_MUTATION_OPTIONS",
71 "the mutation concurrency must be at least 1",
72 )
73 .with_context("mutation.concurrency"));
74 }
75 if options.max_mutants == Some(0) {
76 return Err(mutation_error(
77 "FALLOW_INVALID_MUTATION_OPTIONS",
78 "the mutant limit must be at least 1",
79 )
80 .with_context("mutation.maxMutants"));
81 }
82 if options.time_budget.is_some_and(|budget| budget.is_zero())
83 || options
84 .runner_timeout
85 .is_some_and(|timeout| timeout.is_zero())
86 {
87 return Err(mutation_error(
88 "FALLOW_INVALID_MUTATION_OPTIONS",
89 "a mutation time limit must be more than zero",
90 ));
91 }
92 Ok(())
93}
94
95fn load_session(resolved: &ProgrammaticAnalysisContext) -> ProgrammaticResult<AnalysisSession> {
96 let mut project = fallow_engine::project_config::config_for_project_with_load_options(
97 resolved.root(),
98 resolved.config_path().as_deref(),
99 fallow_config::ConfigLoadOptions {
100 allow_remote_extends: resolved.allow_remote_extends,
101 },
102 )
103 .map_err(|error| {
104 ProgrammaticError::new(format!("failed to load config: {error}"), 2)
105 .with_code("FALLOW_CONFIG_LOAD_FAILED")
106 .with_context("analysis.configPath")
107 })?;
108 project.config.no_cache = resolved.no_cache;
109 project.config.threads = resolved.threads;
110 Ok(AnalysisSession::from_config(project))
111}
112
113fn scope_output(options: &MutationRunOptions, base_ref: Option<String>) -> MutationScopeOutput {
114 MutationScopeOutput {
115 kind: match options.scope {
116 MutationRunScope::Changed { .. } => MutationScopeKind::Changed,
117 MutationRunScope::All => MutationScopeKind::All,
118 MutationRunScope::Paths(_) => MutationScopeKind::Paths,
119 },
120 base_ref,
121 }
122}
123
124fn engine_scope(
126 options: &MutationRunOptions,
127 resolved: &ProgrammaticAnalysisContext,
128) -> ProgrammaticResult<(MutationScope, Option<String>)> {
129 let root = resolved.root();
130 match &options.scope {
131 MutationRunScope::All => Ok((MutationScope::All, None)),
132 MutationRunScope::Paths(paths) => Ok((MutationScope::Paths(paths.clone()), None)),
133 MutationRunScope::Changed { base } => {
134 if let Some(diff) = &resolved.diff {
135 return Ok((MutationScope::ChangedSinceBase(diff.clone()), None));
136 }
137 let resolved_base = crate::audit_run::resolve_audit_base(root, base.as_deref())
138 .map_err(|error| {
139 let message = match error {
140 crate::audit_run::AuditBaseError::InvalidRef { value, reason, .. } => {
141 format!("invalid base ref `{value}`: {reason}")
142 }
143 crate::audit_run::AuditBaseError::NotDetected => {
144 "no base branch to compare against; pass a base ref or mutate all files"
145 .to_owned()
146 }
147 };
148 mutation_error("FALLOW_MUTATION_BASE_NOT_FOUND", message)
149 .with_context("mutation.base")
150 })?;
151 let text =
152 fallow_engine::changed_files::try_get_changed_diff(root, &resolved_base.git_ref)
153 .map_err(|error| {
154 mutation_error(
155 "FALLOW_CHANGED_FILES_FAILED",
156 format!(
157 "cannot diff against `{}`: {}",
158 resolved_base.git_ref,
159 error.describe()
160 ),
161 )
162 })?;
163 let label = format!("git diff {}", resolved_base.git_ref);
164 let index = fallow_engine::diff_source::place_diff(
165 DiffIndex::from_unified_diff(&text),
166 root,
167 &fallow_engine::diff_source::diff_base_candidates(root),
168 &label,
169 )
170 .map_err(|stand_down| {
171 let (_, message) = stand_down.into_parts();
172 mutation_error("FALLOW_CHANGED_FILES_FAILED", message)
173 })?;
174 Ok((
175 MutationScope::ChangedSinceBase(index),
176 Some(resolved_base.git_ref),
177 ))
178 }
179 }
180}
181
182fn config_operators(
186 session: &AnalysisSession,
187) -> ProgrammaticResult<Option<Vec<fallow_engine::mutation::Operator>>> {
188 let Some(names) = &session.config().mutation.operators else {
189 return Ok(None);
190 };
191 names
192 .iter()
193 .map(|name| {
194 fallow_engine::mutation::Operator::from_name(name.as_str()).ok_or_else(|| {
195 mutation_error(
196 "FALLOW_INVALID_MUTATION_OPTIONS",
197 format!("unknown mutation operator `{}`", name.as_str()),
198 )
199 .with_context("mutation.operators")
200 })
201 })
202 .collect::<ProgrammaticResult<Vec<_>>>()
203 .map(Some)
204}
205
206struct Planned {
207 resolved: ProgrammaticAnalysisContext,
208 cache_dir: PathBuf,
209 runner: Runner,
211 plan: MutationPlan,
212 base_ref: Option<String>,
213 runner_config: Option<String>,
215}
216
217fn runner_config(
226 options: &MutationRunOptions,
227 session: &AnalysisSession,
228 root: &Path,
229 runner: Runner,
230) -> ProgrammaticResult<Option<String>> {
231 let Some(raw) = options.runner_config.clone().or_else(|| {
232 session
233 .config()
234 .mutation
235 .runner_config
236 .as_ref()
237 .map(PathBuf::from)
238 }) else {
239 return Ok(None);
240 };
241 let invalid = |message: String| {
242 mutation_error("FALLOW_INVALID_MUTATION_OPTIONS", message)
243 .with_context("mutation.runnerConfig")
244 };
245 let shown = raw.display();
246 if runner != Runner::Vitest {
247 return Err(invalid(format!(
248 "the runner config `{shown}` needs the Vitest runner, but the run uses `{}`; only Vitest reads a config file",
249 runner.name()
250 )));
251 }
252 let candidate = if raw.is_absolute() {
253 raw.clone()
254 } else {
255 root.join(&raw)
256 };
257 if !candidate.is_file() {
258 return Err(invalid(format!(
259 "the runner config `{shown}` is not a file; a relative path is relative to the project root"
260 )));
261 }
262 let root = canonical_path(root);
263 let file = canonical_path(&candidate);
264 let Ok(relative) = file.strip_prefix(&root) else {
265 return Err(invalid(format!(
266 "the runner config `{shown}` is outside the project root `{}`",
267 root.display()
268 )));
269 };
270 Ok(Some(
271 relative
272 .components()
273 .map(|part| part.as_os_str().to_string_lossy())
274 .collect::<Vec<_>>()
275 .join("/"),
276 ))
277}
278
279fn plan_for(
280 options: &MutationRunOptions,
281 before_plan: impl FnOnce(&Path, &Path) -> ProgrammaticResult<()>,
282) -> ProgrammaticResult<Planned> {
283 validate(options)?;
284 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
285 let session = load_session(&resolved)?;
286 let cache_dir = session.config().cache_dir.clone();
287 let configured = session.config().mutation.runner;
288 before_plan(resolved.root(), &cache_dir)?;
289 let (scope, base_ref) = engine_scope(options, &resolved)?;
290 let operators = match &options.operators {
291 Some(operators) => Some(operators.clone()),
292 None => config_operators(&session)?,
293 };
294 let request = MutationPlanRequest {
295 scope,
296 operators,
297 allow_side_effects: options.allow_side_effects,
298 max_mutants: options.max_mutants,
299 skip_component_files: false,
300 };
301 let mut selected = None;
304 let plan = resolved
305 .install(|| {
306 plan_mutations_for_session_with(&session, &request, |plan| {
307 let runner = select_runner(configured, resolved.root(), &plan.test_files);
308 selected = Some(runner);
309 !runner.loads_component_files()
310 })
311 })
312 .map_err(|error| mutation_error("FALLOW_MUTATION_PLAN_FAILED", error.to_string()))?;
313 let runner =
314 selected.unwrap_or_else(|| select_runner(configured, resolved.root(), &plan.test_files));
315 let runner_config = runner_config(options, &session, resolved.root(), runner)?;
316 Ok(Planned {
317 resolved,
318 cache_dir,
319 runner,
320 plan,
321 base_ref,
322 runner_config,
323 })
324}
325
326pub fn plan_mutation(options: &MutationRunOptions) -> ProgrammaticResult<MutationPlanOutput> {
333 let started = Instant::now();
334 let planned = plan_for(options, |_, _| Ok(()))?;
335 let plan = &planned.plan;
336 Ok(MutationPlanOutput {
337 schema_version: MutationPlanSchemaVersion::V1,
338 version: tool_version(),
339 elapsed_ms: ElapsedMs(millis(started.elapsed())),
340 scope: scope_output(options, planned.base_ref.clone()),
341 counts: plan_counts(plan),
342 mutants: plan_mutants(plan),
343 files: plan_files(plan),
344 test_files: plan.test_files.clone(),
345 no_test_files: plan.no_test_files,
346 framework_config_checked: plan.framework_config_checked,
347 skipped_files: skipped_files(plan),
348 unmatched_paths: plan.unmatched_paths.clone(),
349 })
350}
351
352fn unsafe_cache_dir(message: String) -> ProgrammaticError {
353 mutation_error("FALLOW_MUTATION_UNSAFE_CACHE_DIR", message)
354 .with_help(
355 "set cache.dir or FALLOW_CACHE_DIR to a directory that holds no project file, such as .fallow",
356 )
357 .with_context("cache.dir")
358}
359
360fn guard_cache_dir(root: &Path, cache_dir: &Path) -> ProgrammaticResult<()> {
365 if !canonical_path(root).starts_with(canonical_path(cache_dir)) {
366 return Ok(());
367 }
368 Err(unsafe_cache_dir(format!(
369 "the cache directory `{}` is the project root or a directory above it, so the worktree check cannot see stray writes",
370 cache_dir.display()
371 )))
372}
373
374fn guard_cache_holds_no_planned_file(
376 root: &Path,
377 cache_dir: &Path,
378 plan: &MutationPlan,
379) -> ProgrammaticResult<()> {
380 let root = canonical_path(root);
381 let cache = canonical_path(cache_dir);
382 let inside = plan
383 .files
384 .iter()
385 .map(|file| file.path.as_str())
386 .chain(plan.test_files.iter().map(String::as_str))
387 .find(|path| root.join(path).starts_with(&cache));
388 let Some(path) = inside else {
389 return Ok(());
390 };
391 Err(unsafe_cache_dir(format!(
392 "the cache directory `{}` holds the project file `{path}`, so the worktree check cannot see stray writes",
393 cache_dir.display()
394 )))
395}
396
397fn take_worktree_snapshot(
398 root: &Path,
399 cache_dir: &Path,
400 output_paths: &[PathBuf],
401 start: Option<&WorktreeSnapshot>,
402) -> ProgrammaticResult<Option<WorktreeSnapshot>> {
403 let cwd = std::env::current_dir().unwrap_or_else(|_| root.to_path_buf());
406 let outputs = output_paths.iter().map(|path| cwd.join(path));
407 let exclusions = Exclusions {
411 paths: std::iter::once(cache_dir.join("mutation"))
412 .chain(outputs)
413 .collect(),
414 ignored: vec![cache_dir.to_path_buf()],
415 };
416 snapshot(root, &exclusions, start)
417 .map_err(|message| mutation_error("FALLOW_MUTATION_WORKTREE_CHECK_FAILED", message))
418}
419
420struct WorktreeEnd {
422 changed_paths: Vec<String>,
424 check_error: Option<String>,
426}
427
428fn settle_worktree(
431 start: Option<&WorktreeSnapshot>,
432 take_end: impl FnOnce(&WorktreeSnapshot) -> ProgrammaticResult<Option<WorktreeSnapshot>>,
433) -> WorktreeEnd {
434 let Some(start) = start else {
435 return WorktreeEnd {
436 changed_paths: Vec::new(),
437 check_error: None,
438 };
439 };
440 match take_end(start) {
441 Ok(end) => WorktreeEnd {
442 changed_paths: end.map(|end| start.changed_paths(&end)).unwrap_or_default(),
443 check_error: None,
444 },
445 Err(error) => WorktreeEnd {
446 changed_paths: Vec::new(),
447 check_error: Some(error.message),
448 },
449 }
450}
451
452fn with_worktree_facts(mut error: ProgrammaticError, end: &WorktreeEnd) -> ProgrammaticError {
454 if !end.changed_paths.is_empty() {
455 error.message = format!(
456 "{}; the run changed these worktree paths: {}",
457 error.message,
458 end.changed_paths.join(", ")
459 );
460 }
461 if let Some(check_error) = &end.check_error {
462 error.message = format!(
463 "{}; the end-of-run worktree check failed: {check_error}",
464 error.message
465 );
466 }
467 error
468}
469
470const fn path_noun(count: usize) -> &'static str {
471 if count == 1 { "path" } else { "paths" }
472}
473
474fn guard_worktree(allow_dirty: bool, start: Option<&WorktreeSnapshot>) -> ProgrammaticResult<()> {
475 if allow_dirty {
476 return Ok(());
477 }
478 let Some(start) = start else {
479 return Err(mutation_error(
480 "FALLOW_MUTATION_NOT_A_REPOSITORY",
481 "mutation testing runs the tests against changed code and needs a git worktree to detect stray writes",
482 )
483 .with_help("run inside a git worktree, or pass --allow-dirty"));
484 };
485 let dirty = start.dirty_paths();
486 if dirty.is_empty() {
487 return Ok(());
488 }
489 let shown: Vec<&str> = dirty.iter().take(10).map(String::as_str).collect();
490 Err(mutation_error(
491 "FALLOW_MUTATION_DIRTY_WORKTREE",
492 format!(
493 "the git worktree has {} uncommitted {}: {}",
494 dirty.len(),
495 path_noun(dirty.len()),
496 shown.join(", ")
497 ),
498 )
499 .with_help("commit or stash the changes, or pass --allow-dirty"))
500}
501
502fn runner_version_help(range: &RunnerRange) -> String {
506 let RunnerRange {
507 package,
508 min_version,
509 max_version_exclusive,
510 ..
511 } = range;
512 let install =
513 format!("install {package} {min_version} or a later version below {max_version_exclusive}");
514 match min_version.split_once('.') {
515 Some((major, rest)) if rest != "0.0" => {
516 format!("{install}; a {package} {major}.x release from {min_version} is in the range")
517 }
518 _ => install,
519 }
520}
521
522fn schedule_error(
526 error: &ScheduleError,
527 runner: &RunnerRange,
528 setup_timeout: Duration,
529) -> ProgrammaticError {
530 let (code, stderr_tail) = match error {
531 ScheduleError::Integrity(_) => ("FALLOW_MUTATION_COMPANION_INTEGRITY", None),
532 ScheduleError::RunnerVersion(_) => ("FALLOW_MUTATION_RUNNER_UNSUPPORTED", None),
533 ScheduleError::Setup { stderr_tail, .. } => {
534 ("FALLOW_MUTATION_RUNNER_FAILED", stderr_tail.clone())
535 }
536 ScheduleError::RunPath { .. } => ("FALLOW_MUTATION_RUNNER_FAILED", None),
537 ScheduleError::UnknownHit(_) => ("FALLOW_MUTATION_PLAN_MISMATCH", None),
538 ScheduleError::Baseline(_) => ("FALLOW_MUTATION_BASELINE_FAILED", None),
539 };
540 let mut help = Vec::new();
541 if matches!(error, ScheduleError::RunnerVersion(_)) {
542 help.push(runner_version_help(runner));
543 }
544 if let ScheduleError::RunPath { help: run_path, .. } = error {
545 help.push(run_path.clone());
546 }
547 if matches!(
548 error,
549 ScheduleError::Setup {
550 timed_out: true,
551 ..
552 }
553 ) {
554 help.push(format!(
555 "the runner setup took longer than {}s; on a slow or busy host, pass --setup-timeout with a longer duration, for example 5m",
556 setup_timeout.as_secs()
557 ));
558 }
559 if let Some(tail) = stderr_tail.filter(|tail| !tail.trim().is_empty()) {
560 help.push(format!("companion stderr:\n{}", tail.trim_end()));
561 }
562 let public = mutation_error(code, error.to_string());
563 if help.is_empty() {
564 public
565 } else {
566 public.with_help(help.join("\n"))
567 }
568}
569
570fn default_concurrency() -> usize {
571 std::thread::available_parallelism()
572 .map_or(1, std::num::NonZeroUsize::get)
573 .div_ceil(2)
574 .max(1)
575}
576
577fn runtime_error(runner: Runner, message: String) -> ProgrammaticError {
578 mutation_error("FALLOW_MUTATION_RUNTIME_NOT_FOUND", message)
579 .with_help(runner.runtime_help())
580 .with_context("mutation.runtime")
581}
582
583fn scheduled(plan: &MutationPlan, disposition: MutantDisposition) -> Vec<ScheduledMutant> {
584 plan.mutants
585 .iter()
586 .filter(|mutant| mutant.disposition == disposition)
587 .map(|mutant| ScheduledMutant {
588 id: mutant.mutant.id.as_str().to_owned(),
589 file: mutant.file.clone(),
590 })
591 .collect()
592}
593
594fn schedule_plan(plan: &MutationPlan) -> SchedulePlan {
595 SchedulePlan {
596 mutants: scheduled(plan, MutantDisposition::Planned),
597 unreached: scheduled(plan, MutantDisposition::NoCoverage),
598 pending: scheduled(plan, MutantDisposition::Pending),
599 known_ids: plan
600 .mutants
601 .iter()
602 .map(|mutant| mutant.mutant.id.as_str().to_owned())
603 .collect::<FxHashSet<_>>(),
604 test_files: plan.test_files.clone(),
605 file_test_files: plan
606 .files
607 .iter()
608 .map(|file| (file.path.clone(), file.static_test_files.clone()))
609 .collect(),
610 }
611}
612
613struct Execution {
615 outcome: Option<ScheduleOutcome>,
616 runner: Option<MutationRunnerOutput>,
617 cache: Option<MutationCacheOutput>,
618 setup_ms: u64,
619}
620
621fn open_result_cache(
623 no_cache: bool,
624 mutation_cache: &Path,
625 root: &Path,
626 runner: &str,
627 files: KeyFiles,
628) -> Option<Arc<ResultCache>> {
629 (!no_cache).then(|| Arc::new(ResultCache::open(mutation_cache, root, runner, files)))
630}
631
632fn key_files(plan: &MutationPlan, runner_config: Option<&str>) -> KeyFiles {
636 let mut files = KeyFiles::from_plan(plan);
637 if let Some(config) = runner_config
638 && !files.support_files.iter().any(|file| file == config)
639 {
640 files.support_files.push(config.to_owned());
641 }
642 files
643}
644
645fn store_results(cache: &ResultCache, outcome: &mut ScheduleOutcome) -> MutationCacheOutput {
648 let counts = cache.record(outcome);
649 if let Err(message) = cache.save() {
650 outcome.issues.push(ScheduleIssue {
651 code: "cache-write-failed",
652 message: format!("the mutation result cache was not saved: {message}"),
653 mutant_id: None,
654 stderr_tail: None,
655 });
656 }
657 MutationCacheOutput {
658 hits: counts.hits,
659 stored: counts.stored,
660 not_keyed: NOT_KEYED.iter().map(|&item| item.to_owned()).collect(),
661 }
662}
663
664fn init_options(runner: Runner, root: &Path, static_test_files: &[String]) -> InitOptions {
670 let node_args = match runner {
671 Runner::NodeTest => node_test_args(root),
672 Runner::Vitest | Runner::BunTest => Vec::new(),
673 };
674 let extra_test_files = match runner {
675 Runner::NodeTest | Runner::BunTest => static_test_files.to_vec(),
676 Runner::Vitest => Vec::new(),
677 };
678 InitOptions {
679 extra_test_files,
680 node_args,
681 ..InitOptions::default()
682 }
683}
684
685fn execute(
687 options: &MutationRunOptions,
688 planned: &Planned,
689 deadline: Option<Instant>,
690 progress: MutationProgressSink<'_>,
691) -> ProgrammaticResult<Execution> {
692 let plan = &planned.plan;
693 let schedule = schedule_plan(plan);
694 if schedule.mutants.is_empty()
697 && (schedule.unreached.is_empty() || schedule.test_files.is_empty())
698 {
699 return Ok(Execution {
700 outcome: None,
701 runner: None,
702 cache: None,
703 setup_ms: 0,
704 });
705 }
706 progress(MutationProgress::Setup {
707 mutants: schedule.mutants.len(),
708 });
709 let setup_started = Instant::now();
710 let manifest = manifest()
711 .map_err(|message| mutation_error("FALLOW_MUTATION_PROTOCOL_INVALID", message))?;
712 let runner = planned.runner;
713 let name = runner.name();
714 let range = manifest.runner(name).ok_or_else(|| {
715 mutation_error(
716 "FALLOW_MUTATION_PROTOCOL_INVALID",
717 format!("the protocol manifest has no `{name}` runner"),
718 )
719 })?;
720 let runtime = discover_runtime(options.runtime.as_deref(), runner.runtimes())
721 .map_err(|message| runtime_error(runner, message))?;
722 let cache_error =
723 |message: String| mutation_error("FALLOW_MUTATION_CACHE_WRITE_FAILED", message);
724 ignore_cache_dir(&planned.cache_dir).map_err(|error| cache_error(error.to_string()))?;
725 let mutation_cache = planned.cache_dir.join("mutation");
726 let companion = install(&mutation_cache, COMPANION_FILES)
727 .map_err(|message| mutation_error("FALLOW_MUTATION_COMPANION_INTEGRITY", message))?;
728 let files: Vec<RunFile<'_>> = plan
729 .files
730 .iter()
731 .map(|file| RunFile {
732 path: &file.path,
733 code: &file.instrumented_code,
734 })
735 .collect();
736 let run_dir = write_run_dir(&mutation_cache, &files, Some(&companion)).map_err(cache_error)?;
737 let resolved = &planned.resolved;
738 let root = resolved.root().to_path_buf();
739 let files = key_files(plan, planned.runner_config.as_deref());
740 let result_cache = open_result_cache(resolved.no_cache, &mutation_cache, &root, name, files);
741 let config = ScheduleConfig {
742 init: InitRequest {
743 root: root.to_string_lossy().into_owned(),
744 runner: name.to_owned(),
745 config_path: planned.runner_config.clone(),
746 run_dir: run_dir.dir.to_string_lossy().into_owned(),
747 path_map: run_dir.path_map.clone(),
748 options: init_options(runner, &root, &plan.test_files),
749 },
750 runner: range.clone(),
751 concurrency: options.concurrency.unwrap_or_else(default_concurrency),
752 runner_timeout: options.runner_timeout.unwrap_or(DEFAULT_RUNNER_TIMEOUT),
753 deadline,
754 cache: result_cache
755 .clone()
756 .map(|cache| cache as Arc<dyn ResultLookup>),
757 };
758 let spawner = ProcessSpawner {
759 runtime: runtime.path.clone(),
760 companion,
761 root,
762 wire_version: manifest.wire_protocol_version,
763 session_token_env: manifest.session_token_env.clone(),
764 };
765 let setup_ms = millis(setup_started.elapsed());
766 let mut outcome = run_schedule_with_progress(&spawner, &schedule, &config, progress)
767 .map_err(|error| schedule_error(&error, &config.runner, config.runner_timeout))?;
768 let runner = outcome.ready.as_ref().map(|ready| MutationRunnerOutput {
769 name: name.to_owned(),
770 version: ready.runner_version.clone(),
771 protocol_version: manifest.wire_protocol_version,
772 runtime: MutationRuntimeOutput {
773 name: runtime.kind.name().to_owned(),
774 version: runtime.version.clone(),
775 },
776 concurrency: outcome.workers,
777 notes: ready
778 .notes
779 .iter()
780 .filter(|note| manifest.ready_notes.contains(note))
781 .cloned()
782 .collect(),
783 });
784 let cache = match (&result_cache, outcome.ready.is_some()) {
785 (Some(result_cache), true) => Some(store_results(result_cache, &mut outcome)),
786 _ => None,
787 };
788 Ok(Execution {
789 outcome: Some(outcome),
790 runner,
791 cache,
792 setup_ms,
793 })
794}
795
796fn run_issues(
798 outcome: Option<&ScheduleOutcome>,
799 changed_paths: &[String],
800 check_error: Option<String>,
801) -> Vec<fallow_output::MutationRunIssue> {
802 let mut errors = issues(outcome);
803 if !changed_paths.is_empty() {
804 errors.push(fallow_output::MutationRunIssue {
805 code: "worktree-changed".to_owned(),
806 message: format!(
807 "the run changed {} worktree {}; a mutant or a test wrote to the project",
808 changed_paths.len(),
809 path_noun(changed_paths.len())
810 ),
811 mutant_id: None,
812 stderr_tail: None,
813 });
814 }
815 if let Some(message) = check_error {
816 errors.push(fallow_output::MutationRunIssue {
817 code: "worktree-check-failed".to_owned(),
818 message: format!(
819 "the end-of-run worktree check failed, so stray writes are unknown: {message}"
820 ),
821 mutant_id: None,
822 stderr_tail: None,
823 });
824 }
825 errors
826}
827
828fn guard_typecheck_companion(enabled: bool, root: &Path) -> ProgrammaticResult<()> {
831 if !enabled {
832 return Ok(());
833 }
834 crate::type_aware::check_typecheck_companion(root).map_err(|message| {
835 mutation_error("FALLOW_MUTATION_TYPECHECK_UNAVAILABLE", message)
836 .with_help("install the fallow-type-aware companion, or run without --typecheck")
837 .with_context("mutation.typecheck")
838 })
839}
840
841pub fn run_mutation(options: &MutationRunOptions) -> ProgrammaticResult<MutationReport> {
855 run_mutation_with_progress(options, &|_| {})
856}
857
858pub fn run_mutation_with_progress(
868 options: &MutationRunOptions,
869 progress: MutationProgressSink<'_>,
870) -> ProgrammaticResult<MutationReport> {
871 let started = Instant::now();
872 let deadline = options.time_budget.map(|budget| started + budget);
873 let mut start_snapshot = None;
874 let planned = plan_for(options, |root, cache_dir| {
875 guard_cache_dir(root, cache_dir)?;
876 start_snapshot = take_worktree_snapshot(root, cache_dir, &options.output_paths, None)?;
877 guard_worktree(options.allow_dirty, start_snapshot.as_ref())
878 })?;
879 guard_cache_holds_no_planned_file(planned.resolved.root(), &planned.cache_dir, &planned.plan)?;
880 guard_typecheck_companion(options.typecheck, planned.resolved.root())?;
881 let plan_ms = millis(started.elapsed());
882 let execution = execute(options, &planned, deadline, progress);
883 progress(MutationProgress::Finalize);
884 let worktree_end = settle_worktree(start_snapshot.as_ref(), |start| {
885 take_worktree_snapshot(
886 planned.resolved.root(),
887 &planned.cache_dir,
888 &options.output_paths,
889 Some(start),
890 )
891 });
892 let execution = execution.map_err(|error| with_worktree_facts(error, &worktree_end))?;
893 let WorktreeEnd {
894 changed_paths,
895 check_error,
896 } = worktree_end;
897 let plan = &planned.plan;
898 let outcome = execution.outcome.as_ref();
899 let mut results = mutant_results(plan, outcome);
900 let typecheck = typecheck_survivors(
901 options.typecheck,
902 planned.resolved.root(),
903 plan,
904 &mut results,
905 );
906 let typecheck_ms = millis(typecheck.elapsed);
907 let baseline = baseline_failures(outcome);
908 let completion = completion(&CompletionFacts {
909 plan,
910 results: &results,
911 baseline_failures: &baseline,
912 worktree_changed: !changed_paths.is_empty(),
913 worktree_unchecked: check_error.is_some(),
914 unretried: outcome.is_some_and(|outcome| !outcome.unretried.is_empty()),
915 unloaded_paths: outcome.map_or(&[], |outcome| outcome.unloaded_paths.as_slice()),
916 undiscovered_test_files: outcome
917 .map_or(&[], |outcome| outcome.undiscovered_test_files.as_slice()),
918 typecheck_failed: typecheck.issue.is_some(),
919 promoted: outcome.map_or(&[], |outcome| outcome.promoted.as_slice()),
920 });
921 let mut errors = run_issues(outcome, &changed_paths, check_error);
922 errors.extend(typecheck.issue);
923 let timings = outcome.map_or_else(
924 || MutationTimings {
925 plan_ms,
926 typecheck_ms,
927 ..MutationTimings::default()
928 },
929 |outcome| MutationTimings {
930 plan_ms,
931 setup_ms: execution.setup_ms + millis(outcome.timings.setup),
932 dry_run_ms: millis(outcome.timings.dry_run),
933 mutants_ms: millis(outcome.timings.mutants),
934 retry_ms: millis(outcome.timings.retry),
935 typecheck_ms,
936 },
937 );
938 Ok(MutationReport {
939 schema_version: MutationSchemaVersion::V1,
940 version: tool_version(),
941 elapsed_ms: ElapsedMs(millis(started.elapsed())),
942 completion,
943 scope: scope_output(options, planned.base_ref.clone()),
944 runner: execution.runner,
945 cache: execution.cache,
946 summary: summary(&results),
947 functions: function_results(plan, &results),
948 mutants: results,
949 no_test_files: plan.no_test_files,
950 baseline_failures: baseline,
951 skipped_files: skipped_files(plan),
952 unmatched_paths: plan.unmatched_paths.clone(),
953 unloaded_paths: outcome
954 .map(|outcome| outcome.unloaded_paths.clone())
955 .unwrap_or_default(),
956 worktree: MutationWorktreeCheck {
957 checked: start_snapshot.is_some(),
958 allow_dirty: options.allow_dirty,
959 dirty_at_start: start_snapshot
960 .as_ref()
961 .map(WorktreeSnapshot::dirty_paths)
962 .unwrap_or_default(),
963 changed_paths,
964 },
965 errors,
966 timings,
967 files: plan_files(plan),
968 verdicts: None,
969 typecheck: typecheck.output,
970 })
971}
972
973#[cfg(test)]
974mod tests {
975 use super::*;
976
977 fn vitest_range() -> &'static RunnerRange {
978 manifest()
979 .expect("manifest")
980 .runner("vitest")
981 .expect("vitest range")
982 }
983
984 #[test]
985 fn a_runner_version_error_names_the_supported_range_as_the_install_step() {
986 let error = schedule_error(
987 &ScheduleError::RunnerVersion(
988 "vitest 4.0.18 is not supported; the supported range is >=4.1.0 <6.0.0".to_owned(),
989 ),
990 vitest_range(),
991 Duration::from_secs(120),
992 );
993 assert_eq!(
994 error.code.as_deref(),
995 Some("FALLOW_MUTATION_RUNNER_UNSUPPORTED")
996 );
997 assert_eq!(
998 error.help.as_deref(),
999 Some(
1000 "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"
1001 )
1002 );
1003
1004 let help: Vec<String> = manifest()
1005 .expect("manifest")
1006 .runners
1007 .iter()
1008 .map(runner_version_help)
1009 .collect();
1010 assert_eq!(
1011 help,
1012 [
1013 "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",
1014 "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",
1015 "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",
1016 ]
1017 );
1018
1019 let major_start = RunnerRange {
1020 name: "vitest".to_owned(),
1021 package: "vitest".to_owned(),
1022 min_version: "5.0.0".to_owned(),
1023 max_version_exclusive: "6.0.0".to_owned(),
1024 };
1025 assert_eq!(
1026 runner_version_help(&major_start),
1027 "install vitest 5.0.0 or a later version below 6.0.0"
1028 );
1029 }
1030
1031 #[test]
1032 fn a_setup_watchdog_error_names_the_setup_timeout_flag() {
1033 let error = schedule_error(
1034 &ScheduleError::Setup {
1035 message: "the mutation companion dry_run failed".to_owned(),
1036 stderr_tail: Some("vitest started\n".to_owned()),
1037 timed_out: true,
1038 },
1039 vitest_range(),
1040 Duration::from_secs(120),
1041 );
1042 let help = error.help.as_deref().unwrap_or_default();
1043 assert!(help.contains("longer than 120s"), "{help}");
1044 assert!(help.contains("--setup-timeout"), "{help}");
1045 assert!(help.contains("companion stderr:\nvitest started"), "{help}");
1046
1047 let other = schedule_error(
1048 &ScheduleError::Setup {
1049 message: "the mutation companion init failed".to_owned(),
1050 stderr_tail: None,
1051 timed_out: false,
1052 },
1053 vitest_range(),
1054 Duration::from_secs(120),
1055 );
1056 assert_eq!(other.help, None);
1057 }
1058
1059 #[test]
1060 fn a_failed_self_check_is_a_runner_failure_with_its_own_help() {
1061 let error = schedule_error(
1062 &ScheduleError::RunPath {
1063 message: "vitest 5.0.4 executes no tests in a mutant run".to_owned(),
1064 help: "report this with the runner name and version (vitest 5.0.4)".to_owned(),
1065 },
1066 vitest_range(),
1067 Duration::from_secs(120),
1068 );
1069 assert_eq!(error.code.as_deref(), Some("FALLOW_MUTATION_RUNNER_FAILED"));
1070 assert_eq!(error.exit_code, 2);
1071 assert_eq!(
1072 error.message,
1073 "vitest 5.0.4 executes no tests in a mutant run"
1074 );
1075 assert_eq!(
1076 error.help.as_deref(),
1077 Some("report this with the runner name and version (vitest 5.0.4)")
1078 );
1079 }
1080
1081 #[test]
1082 fn a_failed_end_check_keeps_the_execute_error_and_reports_the_check_error() {
1083 let start = WorktreeSnapshot::default();
1084 let end = settle_worktree(Some(&start), |_| {
1085 Err(mutation_error(
1086 "FALLOW_MUTATION_WORKTREE_CHECK_FAILED",
1087 "git status failed",
1088 ))
1089 });
1090 assert!(end.changed_paths.is_empty());
1091 assert_eq!(end.check_error.as_deref(), Some("git status failed"));
1092
1093 let error = with_worktree_facts(
1094 mutation_error(
1095 "FALLOW_MUTATION_SETUP_FAILED",
1096 "the companion did not start",
1097 ),
1098 &end,
1099 );
1100 assert_eq!(error.code.as_deref(), Some("FALLOW_MUTATION_SETUP_FAILED"));
1101 assert!(
1102 error.message.starts_with("the companion did not start"),
1103 "{}",
1104 error.message
1105 );
1106 assert!(
1107 error.message.contains("git status failed"),
1108 "{}",
1109 error.message
1110 );
1111 }
1112
1113 #[test]
1114 fn every_config_operator_name_maps_to_an_engine_operator() {
1115 for operator in fallow_engine::mutation::Operator::ALL {
1116 let name: fallow_config::MutationOperatorName =
1117 serde_json::from_value(serde_json::json!(operator.as_str()))
1118 .expect("a config operator name");
1119 assert_eq!(
1120 fallow_engine::mutation::Operator::from_name(name.as_str()),
1121 Some(operator)
1122 );
1123 }
1124 }
1125
1126 #[test]
1127 fn no_cache_opens_no_result_cache() {
1128 let dir = tempfile::tempdir().expect("dir");
1129 let plan = MutationPlan {
1130 files: Vec::new(),
1131 mutants: Vec::new(),
1132 test_files: Vec::new(),
1133 reach_files: Vec::new(),
1134 test_file_reach: std::collections::BTreeMap::new(),
1135 support_files: Vec::new(),
1136 counts: fallow_engine::mutation::MutationPlanCounts::default(),
1137 no_test_files: false,
1138 framework_config_checked: false,
1139 skipped_files: Vec::new(),
1140 unmatched_paths: Vec::new(),
1141 };
1142 assert!(
1143 open_result_cache(
1144 true,
1145 dir.path(),
1146 dir.path(),
1147 "vitest",
1148 KeyFiles::from_plan(&plan)
1149 )
1150 .is_none()
1151 );
1152 assert!(
1153 open_result_cache(
1154 false,
1155 dir.path(),
1156 dir.path(),
1157 "vitest",
1158 KeyFiles::from_plan(&plan)
1159 )
1160 .is_some()
1161 );
1162 }
1163
1164 fn empty_plan() -> MutationPlan {
1165 MutationPlan {
1166 files: Vec::new(),
1167 mutants: Vec::new(),
1168 test_files: Vec::new(),
1169 reach_files: Vec::new(),
1170 test_file_reach: std::collections::BTreeMap::new(),
1171 support_files: Vec::new(),
1172 counts: fallow_engine::mutation::MutationPlanCounts::default(),
1173 no_test_files: false,
1174 framework_config_checked: false,
1175 skipped_files: Vec::new(),
1176 unmatched_paths: Vec::new(),
1177 }
1178 }
1179
1180 #[test]
1181 fn a_cache_dir_at_or_above_the_root_is_refused() {
1182 let dir = tempfile::tempdir().expect("dir");
1183 let root = dir.path().join("project");
1184 std::fs::create_dir_all(root.join("src")).expect("dir");
1185 for cache in [root.clone(), root.join("."), dir.path().to_path_buf()] {
1186 let error = guard_cache_dir(&root, &cache).expect_err("refused");
1187 assert_eq!(
1188 error.code.as_deref(),
1189 Some("FALLOW_MUTATION_UNSAFE_CACHE_DIR")
1190 );
1191 assert_eq!(error.exit_code, 2);
1192 assert_eq!(error.context.as_deref(), Some("cache.dir"));
1193 }
1194 assert!(guard_cache_dir(&root, &root.join(".fallow")).is_ok());
1195 assert!(guard_cache_dir(&root, &root.join("src")).is_ok());
1196 }
1197
1198 #[test]
1199 fn a_cache_dir_that_holds_a_planned_file_is_refused() {
1200 let dir = tempfile::tempdir().expect("dir");
1201 let root = dir.path();
1202 let mut plan = empty_plan();
1203 plan.files.push(fallow_engine::mutation::PlannedFile {
1204 path: "src/a.ts".to_owned(),
1205 source_sha256: String::new(),
1206 instrumented_code: String::new(),
1207 static_test_files: Vec::new(),
1208 });
1209 plan.test_files.push("test/a.test.ts".to_owned());
1210
1211 let error = guard_cache_holds_no_planned_file(root, &root.join("src"), &plan)
1212 .expect_err("the cache holds a planned source file");
1213 assert_eq!(
1214 error.code.as_deref(),
1215 Some("FALLOW_MUTATION_UNSAFE_CACHE_DIR")
1216 );
1217 assert!(error.message.contains("src/a.ts"), "{}", error.message);
1218 let error = guard_cache_holds_no_planned_file(root, &root.join("test"), &plan)
1219 .expect_err("the cache holds a planned test file");
1220 assert!(
1221 error.message.contains("test/a.test.ts"),
1222 "{}",
1223 error.message
1224 );
1225 assert!(guard_cache_holds_no_planned_file(root, &root.join(".fallow"), &plan).is_ok());
1226 }
1227
1228 #[test]
1229 fn no_start_snapshot_needs_no_end_check() {
1230 let end = settle_worktree(None, |_| panic!("no end check without a start snapshot"));
1231 assert!(end.changed_paths.is_empty());
1232 assert!(end.check_error.is_none());
1233 }
1234
1235 #[test]
1236 fn the_glob_runners_get_the_static_test_files_and_vitest_does_not() {
1237 let dir = tempfile::tempdir().expect("dir");
1238 let files = vec![".github/tests/math.test.mjs".to_owned()];
1239 for runner in [Runner::NodeTest, Runner::BunTest] {
1240 let options = init_options(runner, dir.path(), &files);
1241 assert_eq!(options.extra_test_files, files, "{runner:?}");
1242 assert!(options.test_files.is_empty(), "{runner:?}");
1243 }
1244 let vitest = init_options(Runner::Vitest, dir.path(), &files);
1245 assert!(vitest.extra_test_files.is_empty());
1246 }
1247}