Skip to main content

fallow_api/runtime/
mutation.rs

1//! Orchestration entry points of mutation testing for CLI, MCP and NAPI.
2//!
3//! [`plan_mutation`] runs only the static half and never executes user code.
4//! [`run_mutation`] plans through the engine, then runs the tests of the
5//! project against each planned mutant through the embedded companion.
6
7use 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
50/// The default timeout of `init` and the dry run (`protocol.md` section 8).
51const 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
122/// The engine scope, and the base ref of a changed scope.
123fn 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
180/// The operators of the `mutation.operators` config key. The CLI and MCP
181/// callers pass `None` when the user names no operator, so both apply this
182/// one rule.
183fn 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    /// The test runner, selected from the config value and the first plan.
208    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    // The runner decision reads the test files of the first plan. A second
236    // plan without the component files keeps that decision.
237    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
258/// Builds the static mutation plan. It runs no user code.
259///
260/// # Errors
261///
262/// Returns an error for invalid options, a base ref that does not resolve,
263/// or a failed analysis.
264pub 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
292/// Refuses a cache directory that is the root or a directory above it. The
293/// worktree check leaves the mutation cache out, and the analysis writes a
294/// `.gitignore` that ignores the whole cache directory, so such a cache
295/// directory hides each stray write from the check.
296fn 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
306/// Refuses a cache directory that holds a planned source or test file.
307fn 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    // The caller writes a relative output path against the process
336    // directory, which can be below the root.
337    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    // Only the mutation cache is left out of the status check. The analysis
340    // caches next to it are git-ignored, and an ignored file changes only
341    // in the ignored-file check, so the whole cache is left out there.
342    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
352/// The worktree facts at the end of a run.
353struct WorktreeEnd {
354    /// The paths that changed since the start snapshot.
355    changed_paths: Vec<String>,
356    /// Why the end snapshot failed. Stray writes are then unknown.
357    check_error: Option<String>,
358}
359
360/// Compares the worktree with `start`. A failed end snapshot is a fact of
361/// the report, never a reason to drop the run result or its error.
362fn 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
384/// Adds the worktree facts to the error of a failed run.
385fn 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
434/// The help of a runner version outside `range`. It names the supported
435/// range as an install step. When the minimum is not a `.0.0` release, it
436/// also says that a minor update inside the major version is enough.
437fn 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
454/// Maps a schedule error to its public error. A setup watchdog fault names
455/// `--setup-timeout`, because a slow or busy host is the usual cause. A
456/// runner version error names the supported range of `runner`.
457fn 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
545/// The facts of a started run that the report reads.
546struct Execution {
547    outcome: Option<ScheduleOutcome>,
548    runner: Option<MutationRunnerOutput>,
549    cache: Option<MutationCacheOutput>,
550    setup_ms: u64,
551}
552
553/// The result cache of a run. `None` with `--no-cache`.
554fn 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
571/// Stores the new results of `outcome` in `cache` and writes the cache file.
572/// A failed write adds a `cache-write-failed` issue and keeps the run result.
573fn 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
590/// The `init` options of the runner. The `node:test` runner gets the module
591/// loading flags of the `node --test` command in the project scripts.
592fn 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
603/// The public error of a schedule that stopped the run.
604fn 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    // With no planned mutant the dry run still runs the static test files,
612    // because a hit on a statically unreached mutant is a static map miss.
613    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
709/// The errors of a finished run: the scheduler issues and the worktree facts.
710fn 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
741/// Refuses a run with the survivor type check before the tests start when no
742/// trusted type-aware companion exists.
743fn 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
754/// Plans the mutants, runs the project tests against each planned mutant,
755/// and reports the result.
756///
757/// The run refuses a dirty git worktree unless
758/// [`MutationRunOptions::allow_dirty`] is set. After the run it compares the
759/// worktree with the start state and reports each changed path.
760///
761/// # Errors
762///
763/// Returns an error for invalid options, a dirty worktree, a failed plan, a
764/// missing runtime, a companion that does not start or load the runner, a
765/// companion file that changed after install, or a test suite that fails
766/// with no active mutant outside each test file.
767pub 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}