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