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