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_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
52/// The default timeout of `init` and the dry run (`protocol.md` section 8).
53const 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
124/// The engine scope, and the base ref of a changed scope.
125fn 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
182/// The operators of the `mutation.operators` config key. The CLI and MCP
183/// callers pass `None` when the user names no operator, so both apply this
184/// one rule.
185fn 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    /// The test runner, selected from the config value and the first plan.
210    runner: Runner,
211    plan: MutationPlan,
212    base_ref: Option<String>,
213    /// The selected runner config file, relative to the project root.
214    runner_config: Option<String>,
215}
216
217/// The selected runner config: the caller option, else the
218/// `mutation.runnerConfig` config key. The result is relative to the project
219/// root, with `/` separators, as the companion resolves it against the root.
220///
221/// # Errors
222///
223/// Returns an exit-2 error when the file does not exist, is not a file, is
224/// outside the project root, or when the runner reads no config file.
225fn 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    // The runner decision reads the test files of the first plan. A second
302    // plan without the component files keeps that decision.
303    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
326/// Builds the static mutation plan. It runs no user code.
327///
328/// # Errors
329///
330/// Returns an error for invalid options, a base ref that does not resolve,
331/// or a failed analysis.
332pub 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
360/// Refuses a cache directory that is the root or a directory above it. The
361/// worktree check leaves the mutation cache out, and the analysis writes a
362/// `.gitignore` that ignores the whole cache directory, so such a cache
363/// directory hides each stray write from the check.
364fn 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
374/// Refuses a cache directory that holds a planned source or test file.
375fn 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    // The caller writes a relative output path against the process
404    // directory, which can be below the root.
405    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    // Only the mutation cache is left out of the status check. The analysis
408    // caches next to it are git-ignored, and an ignored file changes only
409    // in the ignored-file check, so the whole cache is left out there.
410    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
420/// The worktree facts at the end of a run.
421struct WorktreeEnd {
422    /// The paths that changed since the start snapshot.
423    changed_paths: Vec<String>,
424    /// Why the end snapshot failed. Stray writes are then unknown.
425    check_error: Option<String>,
426}
427
428/// Compares the worktree with `start`. A failed end snapshot is a fact of
429/// the report, never a reason to drop the run result or its error.
430fn 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
452/// Adds the worktree facts to the error of a failed run.
453fn 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
502/// The help of a runner version outside `range`. It names the supported
503/// range as an install step. When the minimum is not a `.0.0` release, it
504/// also says that a minor update inside the major version is enough.
505fn 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
522/// Maps a schedule error to its public error. A setup watchdog fault names
523/// `--setup-timeout`, because a slow or busy host is the usual cause. A
524/// runner version error names the supported range of `runner`.
525fn 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
613/// The facts of a started run that the report reads.
614struct Execution {
615    outcome: Option<ScheduleOutcome>,
616    runner: Option<MutationRunnerOutput>,
617    cache: Option<MutationCacheOutput>,
618    setup_ms: u64,
619}
620
621/// The result cache of a run. `None` with `--no-cache`.
622fn 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
632/// The key files of the result cache. A selected runner config file is a
633/// support file of the key, so a result of one config content is never the
634/// result of another.
635fn 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
645/// Stores the new results of `outcome` in `cache` and writes the cache file.
646/// A failed write adds a `cache-write-failed` issue and keeps the run result.
647fn 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
664/// The `init` options of the runner. The `node:test` runner gets the module
665/// loading flags of the `node --test` command in the project scripts. The
666/// `node:test` and `bun:test` runners get the static test files, because
667/// their glob discovery does not go into dot directories. Vitest has its own
668/// include configuration.
669fn 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
685/// The public error of a schedule that stopped the run.
686fn 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    // With no planned mutant the dry run still runs the static test files,
695    // because a hit on a statically unreached mutant is a static map miss.
696    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
796/// The errors of a finished run: the scheduler issues and the worktree facts.
797fn 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
828/// Refuses a run with the survivor type check before the tests start when no
829/// trusted type-aware companion exists.
830fn 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
841/// Plans the mutants, runs the project tests against each planned mutant,
842/// and reports the result.
843///
844/// The run refuses a dirty git worktree unless
845/// [`MutationRunOptions::allow_dirty`] is set. After the run it compares the
846/// worktree with the start state and reports each changed path.
847///
848/// # Errors
849///
850/// Returns an error for invalid options, a dirty worktree, a failed plan, a
851/// missing runtime, a companion that does not start or load the runner, a
852/// companion file that changed after install, or a test suite that fails
853/// with no active mutant outside each test file.
854pub fn run_mutation(options: &MutationRunOptions) -> ProgrammaticResult<MutationReport> {
855    run_mutation_with_progress(options, &|_| {})
856}
857
858/// [`run_mutation`] that sends a [`MutationProgress`] event at the start of
859/// each run phase and after each finished mutant.
860///
861/// The events arrive on the calling thread and on the scheduler worker
862/// threads. A sink must return quickly, because a worker waits for it.
863///
864/// # Errors
865///
866/// Returns the same errors as [`run_mutation`].
867pub 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}