Skip to main content

forge/mutation/
orchestrator.rs

1//! Mutation testing orchestrator.
2//!
3//! This module coordinates the mutation testing workflow, including:
4//! - Filtering source files for mutation
5//! - Managing mutation handlers per file
6//! - Running mutations in parallel with caching
7//! - Aggregating results and reporting
8
9use std::{
10    collections::{BTreeMap, BTreeSet, HashSet},
11    path::{Path, PathBuf},
12    sync::{
13        Arc,
14        atomic::{AtomicBool, Ordering},
15    },
16    time::Instant,
17};
18
19use alloy_primitives::keccak256;
20use eyre::{Result, WrapErr};
21use foundry_cli::utils::FoundryPathExt;
22use foundry_common::{compile::ProjectCompiler, sh_println};
23use foundry_compilers::{
24    Language, ProjectCompileOutput,
25    compilers::multi::{MultiCompiler, MultiCompilerLanguage},
26    utils::source_files_iter,
27};
28use foundry_config::{Config, filter::GlobMatcher};
29use foundry_evm::{
30    backend::Backend, core::evm::FoundryEvmNetwork, executors::ExecutorBuilder, fork::Fork,
31    opts::EvmOpts,
32};
33
34use crate::{
35    cmd::test::{FilterArgs, RerunFailure},
36    mutation::{
37        MutationHandler, MutationProgress, MutationReporter, MutationsSummary,
38        mutant::{Mutant, MutationResult},
39        runner::{MutationEvmConfig, run_mutations_parallel_with_progress},
40        type_analysis::{collect_mutation_exclusions, normalize_path},
41    },
42};
43
44#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Serialize)]
45struct ArtifactCacheFingerprint {
46    source: String,
47    name: String,
48    version: String,
49    build_id: String,
50    profile: String,
51}
52
53#[derive(serde::Serialize)]
54struct ExecutionCacheFingerprint<'a> {
55    schema: &'static str,
56    config: &'a Config,
57    evm_opts: &'a EvmOpts,
58    resolved_fork: Option<alloy_primitives::B256>,
59    filter_args: FilterArgsFingerprint<'a>,
60    rerun_failures: Option<&'a [RerunFailure]>,
61    artifacts: &'a [ArtifactCacheFingerprint],
62}
63
64#[derive(serde::Serialize)]
65struct FilterArgsFingerprint<'a> {
66    test_pattern: Option<&'a str>,
67    test_pattern_inverse: Option<&'a str>,
68    contract_pattern: Option<&'a str>,
69    contract_pattern_inverse: Option<&'a str>,
70    path_pattern: Option<&'a str>,
71    path_pattern_inverse: Option<&'a str>,
72}
73
74/// Configuration for mutation testing run.
75pub struct MutationRunConfig {
76    /// Paths to mutate (if empty, use all source files).
77    pub mutate_paths: Vec<PathBuf>,
78    /// Optional glob pattern to filter paths.
79    pub mutate_path_pattern: Option<GlobMatcher>,
80    /// Optional contract regex pattern to filter contracts.
81    pub mutate_contract_pattern: Option<regex::Regex>,
82    /// Number of parallel workers (0 = auto-detect).
83    pub num_workers: usize,
84    /// Whether to show progress display.
85    pub show_progress: bool,
86    /// Whether to output JSON (suppress all other output).
87    pub json_output: bool,
88    /// Test filter (`--match-test`, `--match-contract`, `--match-path`, ...)
89    /// applied identically to baseline and every mutant run so they exercise
90    /// the same test set.
91    pub filter_args: FilterArgs,
92    /// Exact contract/test pairs selected by `--rerun`, if present. These are
93    /// not representable by `FilterArgs` alone because rerun stores precise
94    /// suite identifiers in addition to a test-name regex.
95    pub rerun_failures: Option<Vec<RerunFailure>>,
96    /// Project-relative source files selected for the baseline compile.
97    /// Re-rooted into each per-mutant workspace so compilation and execution
98    /// honor the same filtered test universe.
99    pub selected_sources_relative: Vec<PathBuf>,
100    /// EVM isolation flag — mirrors the canonical `forge test` runner so
101    /// baseline and mutant runs use the same execution model.
102    pub isolate: bool,
103}
104
105impl MutationRunConfig {
106    /// Determine number of workers, using auto-detection if 0.
107    pub fn effective_workers(&self) -> usize {
108        if self.num_workers == 0 {
109            std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
110        } else {
111            self.num_workers
112        }
113    }
114}
115
116/// Result of a mutation testing run.
117pub struct MutationRunResult {
118    /// Summary of all mutations across all files.
119    pub summary: MutationsSummary,
120    /// Whether the run was cancelled (e.g., Ctrl+C).
121    pub cancelled: bool,
122    /// Duration of the mutation testing run in seconds.
123    pub duration_secs: f64,
124}
125
126/// Run mutation testing on the project.
127///
128/// This function encapsulates the mutation testing logic that was previously
129/// in the test command. It handles:
130/// - Filtering source files based on patterns
131/// - Per-file mutation handling with caching
132/// - Parallel mutation execution
133/// - Result aggregation and reporting
134pub async fn run_mutation_testing<FEN: FoundryEvmNetwork>(
135    config: Arc<Config>,
136    output: &ProjectCompileOutput<MultiCompiler>,
137    evm_opts: EvmOpts,
138    backend: Backend<FEN>,
139    executor_builder: ExecutorBuilder<FEN>,
140    mutation_config: MutationRunConfig,
141) -> Result<MutationRunResult> {
142    let create2_deployer_available =
143        backend.can_use_create2_deployer(evm_opts.create2_deployer).await?;
144    let fork = backend.fork()?;
145    let mutation_evm = MutationEvmConfig {
146        opts: evm_opts.clone(),
147        backend,
148        executor_builder,
149        create2_deployer_available,
150    };
151    let num_workers = mutation_config.effective_workers();
152    let json_output = mutation_config.json_output;
153    let artifact_link_references = output.artifact_ids().filter_map(|(id, artifact)| {
154        let source = project_relative_path(&config.root, &id.source)?;
155        let links = artifact
156            .all_link_references()
157            .into_keys()
158            .filter_map(|file| project_relative_path(&config.root, Path::new(&file)))
159            .collect::<BTreeSet<_>>();
160        Some((source, links))
161    });
162    let selected_sources_relative = mutation_compile_sources(
163        mutation_config.selected_sources_relative.iter().cloned(),
164        artifact_link_references,
165    );
166
167    // Determine which paths to mutate
168    let mutate_paths = resolve_mutate_paths(&config, output, &mutation_config)?;
169    let execution_cache_output = ProjectCompiler::new()
170        .dynamic_test_linking(config.dynamic_test_linking)
171        .quiet(json_output)
172        .files(
173            selected_sources_relative
174                .iter()
175                .map(|path| config.root.join(path))
176                .filter(|path| path.exists())
177                .collect::<Vec<_>>(),
178        )
179        .compile(&config.project()?)?;
180    let execution_cache_key = mutation_execution_cache_key(
181        &config,
182        &execution_cache_output,
183        &evm_opts,
184        fork.as_ref(),
185        &mutation_config.filter_args,
186        mutation_config.rerun_failures.as_deref(),
187    )?;
188    let mut mutation_exclusions = collect_mutation_exclusions(&config, output).unwrap_or_default();
189
190    if !mutation_config.show_progress && !json_output {
191        sh_println!("Running mutation tests with {} parallel workers...", num_workers)?;
192    }
193
194    let mut mutation_summary = MutationsSummary::new();
195    let mut cancelled = false;
196    let start_time = Instant::now();
197    let cancellation_requested = Arc::new(AtomicBool::new(false));
198    let ctrlc_handle = {
199        let cancellation_requested = Arc::clone(&cancellation_requested);
200        tokio::spawn(async move {
201            if tokio::signal::ctrl_c().await.is_ok() {
202                cancellation_requested.store(true, Ordering::SeqCst);
203            }
204        })
205    };
206
207    for path in mutate_paths {
208        if cancellation_requested.load(Ordering::SeqCst) {
209            cancelled = true;
210            break;
211        }
212
213        if !mutation_config.show_progress && !json_output {
214            sh_println!("Running mutation tests for {}", path.display())?;
215        }
216
217        // Create handler for this file, optionally restricting to a subset of
218        // contracts by name when --mutate-contract is provided.
219        let mut handler = MutationHandler::new(path.clone(), config.clone());
220        if let Some(mutations) = mutation_exclusions.remove(&normalize_path(&path)) {
221            handler = handler.with_mutation_exclusions(mutations);
222        }
223        if let Some(filter) = &mutation_config.mutate_contract_pattern {
224            handler = handler.with_contract_filter(filter.clone());
225        }
226        handler.read_source_contract()?;
227
228        // Get build ID for caching
229        let build_id = output
230            .artifact_ids()
231            .find_map(|(id, _)| (id.source == path).then_some(id.build_id))
232            .unwrap_or_default();
233
234        // Load persisted survived spans before generating/loading mutants so
235        // resumed runs can retain adaptively skipped points as Skipped results
236        // while only executing mutants whose spans still need coverage.
237        handler.retrieve_survived_spans(&build_id, &execution_cache_key);
238
239        // Generate or load cached mutants. Adaptive resume happens after the
240        // full mutant set is known so skipped points are still counted and
241        // reported as Skipped instead of disappearing from totals.
242        let mut mutants = if let Some(ms) = handler.retrieve_cached_mutants(&build_id) {
243            ms
244        } else {
245            handler.generate_ast().await?;
246            handler.mutations.clone()
247        };
248
249        if mutants.is_empty() {
250            if !mutation_config.show_progress && !json_output {
251                sh_println!("  No mutants generated for {}", path.display())?;
252            }
253            continue;
254        }
255
256        // Check for cached results only after the current mutant set is known.
257        // The result cache carries a count/hash of that set so stale or partial
258        // caches cannot suppress newly generated mutants.
259        if let Some(prior) =
260            handler.retrieve_cached_mutant_results(&build_id, &execution_cache_key, &mutants)
261        {
262            if !mutation_config.show_progress && !json_output {
263                sh_println!("  Using cached results for {} mutants", prior.len())?;
264            }
265            for (mutant, status) in prior {
266                match status {
267                    MutationResult::Dead => handler.add_dead_mutant(mutant),
268                    MutationResult::Alive => handler.add_survived_mutant(mutant),
269                    MutationResult::Invalid => handler.add_invalid_mutant(mutant),
270                    MutationResult::Skipped => handler.add_skipped_mutant(mutant),
271                    MutationResult::TimedOut => handler.add_timed_out_mutant(mutant),
272                }
273            }
274            mutation_summary.merge(handler.get_report());
275            continue;
276        }
277
278        // Sort mutations by span for optimal adaptive testing
279        mutants.sort_by(|a, b| {
280            a.span.lo().0.cmp(&b.span.lo().0).then_with(|| b.span.hi().0.cmp(&a.span.hi().0))
281        });
282
283        let (mutants_to_test, skipped_results) =
284            partition_adaptively_skipped_mutants(&mut handler, &mutants);
285
286        // Create progress display if enabled (not in JSON mode)
287        let progress = if mutation_config.show_progress && !json_output {
288            let p = MutationProgress::with_timeout(
289                mutants_to_test.len(),
290                num_workers,
291                config.mutation.timeout,
292            );
293            // Show relative path from project root
294            let display_path =
295                path.strip_prefix(&config.root).unwrap_or(&path).display().to_string();
296            p.set_current_file(&display_path);
297            Some(p)
298        } else if !json_output {
299            sh_println!(
300                "  Generated {} mutants; testing {}, adaptively skipped {}",
301                mutants.len(),
302                mutants_to_test.len(),
303                skipped_results.len()
304            )?;
305            None
306        } else {
307            None
308        };
309
310        // Run mutations in parallel using isolated workspaces
311        let batch = run_mutations_parallel_with_progress(
312            mutants_to_test,
313            path.clone(),
314            handler.src.clone(),
315            config.clone(),
316            mutation_evm.clone(),
317            num_workers,
318            progress,
319            json_output,
320            mutation_config.filter_args.clone(),
321            mutation_config.rerun_failures.clone(),
322            Arc::new(selected_sources_relative.clone()),
323            mutation_config.isolate,
324            Arc::clone(&cancellation_requested),
325        )?;
326        let file_cancelled = batch.cancelled;
327
328        // Collect results for caching
329        let mut results_vec = Vec::with_capacity(skipped_results.len() + batch.results.len());
330        results_vec.extend(skipped_results);
331        for result in batch.results {
332            results_vec.push((result.mutant.clone(), result.result.clone()));
333            match result.result {
334                MutationResult::Dead => handler.add_dead_mutant(result.mutant),
335                MutationResult::Alive => {
336                    handler.mark_span_survived(result.mutant.span);
337                    handler.add_survived_mutant(result.mutant);
338                }
339                MutationResult::Invalid => handler.add_invalid_mutant(result.mutant),
340                MutationResult::Skipped => handler.add_skipped_mutant(result.mutant),
341                MutationResult::TimedOut => handler.add_timed_out_mutant(result.mutant),
342            }
343        }
344
345        // Detect cancellation early so we can decide whether the result set is
346        // complete before persisting it. Without this guard a Ctrl+C mid-run
347        // would write a *partial* results vector to the cache and the next run
348        // would treat that subset as the full answer for this file.
349        let complete_run = !file_cancelled && results_vec.len() == mutants.len();
350
351        // Persist results for caching only when the run for this file is
352        // complete. Partial caches are silent correctness bugs:
353        //   - cancelled runs would be reloaded as authoritative
354        //   - non-cancelled-but-short result vectors indicate a bug, not a hit
355        // The mutants list itself is fine to persist (it's deterministic from
356        // the AST + operator set) and so are survived spans (best-effort hint).
357        //
358        // Sort the persisted result vector by mutant span so the on-disk
359        // cache is independent of rayon worker completion order; otherwise
360        // the cache file changes content-hash run-to-run even when the
361        // outcomes are identical, defeating diffing and reproducibility.
362        results_vec.sort_by(|(a, _), (b, _)| {
363            a.span.lo().0.cmp(&b.span.lo().0).then_with(|| a.span.hi().0.cmp(&b.span.hi().0))
364        });
365        if !mutants.is_empty() && !build_id.is_empty() {
366            let _ = handler.persist_cached_mutants(&build_id, &mutants);
367            if complete_run {
368                let _ = handler.persist_cached_results(
369                    &build_id,
370                    &execution_cache_key,
371                    &mutants,
372                    &results_vec,
373                );
374            }
375            let _ = handler.persist_survived_spans(&build_id, &execution_cache_key);
376        }
377
378        mutation_summary.merge(handler.get_report());
379
380        // If cancelled, break out of the loop
381        if file_cancelled {
382            cancelled = true;
383            break;
384        }
385    }
386    cancelled |= cancellation_requested.load(Ordering::SeqCst);
387
388    // Report results
389    let duration = start_time.elapsed();
390    let duration_secs = duration.as_secs_f64();
391
392    // Only show human-readable report if not in JSON mode
393    if !json_output {
394        MutationReporter::new().report(&mutation_summary, duration);
395    }
396
397    ctrlc_handle.abort();
398
399    Ok(MutationRunResult { summary: mutation_summary, cancelled, duration_secs })
400}
401
402/// Build the cache discriminator for mutation *results*.
403///
404/// Mutant generation only depends on the source build + selected mutators, but
405/// result correctness depends on the compiled test universe and execution
406/// settings. Hashing the full serialized config intentionally includes fuzz /
407/// invariant settings, test filters, fs permissions, sender/balance/env values,
408/// and future config fields unless explicitly skipped by `Config` itself. The
409/// artifact fingerprint covers the same filter-selected source and test build
410/// IDs that baseline and mutant runs compile. Worker count is not included: every
411/// mutant is tested independently, so the results do not depend on it.
412fn mutation_execution_cache_key(
413    config: &Config,
414    output: &ProjectCompileOutput<MultiCompiler>,
415    evm_opts: &EvmOpts,
416    resolved_fork: Option<&Fork>,
417    filter_args: &FilterArgs,
418    rerun_failures: Option<&[RerunFailure]>,
419) -> Result<String> {
420    let artifacts = output
421        .artifact_ids()
422        .map(|(id, _)| ArtifactCacheFingerprint {
423            source: id.source.display().to_string(),
424            name: id.name,
425            version: id.version.to_string(),
426            build_id: id.build_id,
427            profile: id.profile,
428        })
429        .collect::<Vec<_>>();
430    mutation_execution_cache_key_from_parts_with_rerun_failures(
431        config,
432        evm_opts,
433        resolved_fork.map(Fork::fingerprint),
434        filter_args,
435        rerun_failures,
436        artifacts,
437    )
438}
439
440#[cfg(test)]
441fn mutation_execution_cache_key_from_parts(
442    config: &Config,
443    evm_opts: &EvmOpts,
444    filter_args: &FilterArgs,
445    artifacts: Vec<ArtifactCacheFingerprint>,
446) -> Result<String> {
447    mutation_execution_cache_key_from_parts_with_rerun_failures(
448        config,
449        evm_opts,
450        None,
451        filter_args,
452        None,
453        artifacts,
454    )
455}
456
457fn mutation_execution_cache_key_from_parts_with_rerun_failures(
458    config: &Config,
459    evm_opts: &EvmOpts,
460    resolved_fork: Option<alloy_primitives::B256>,
461    filter_args: &FilterArgs,
462    rerun_failures: Option<&[RerunFailure]>,
463    mut artifacts: Vec<ArtifactCacheFingerprint>,
464) -> Result<String> {
465    artifacts.sort();
466    let fingerprint = ExecutionCacheFingerprint {
467        schema: "mutation-results-v2",
468        config,
469        evm_opts,
470        resolved_fork,
471        filter_args: filter_args_fingerprint(filter_args),
472        rerun_failures,
473        artifacts: &artifacts,
474    };
475    let encoded = serde_json::to_vec(&fingerprint)
476        .wrap_err("failed to encode mutation execution cache key")?;
477
478    Ok(keccak256(encoded).to_string())
479}
480
481fn filter_args_fingerprint(filter_args: &FilterArgs) -> FilterArgsFingerprint<'_> {
482    FilterArgsFingerprint {
483        test_pattern: filter_args.test_pattern.as_ref().map(|re| re.as_str()),
484        test_pattern_inverse: filter_args.test_pattern_inverse.as_ref().map(|re| re.as_str()),
485        contract_pattern: filter_args.contract_pattern.as_ref().map(|re| re.as_str()),
486        contract_pattern_inverse: filter_args
487            .contract_pattern_inverse
488            .as_ref()
489            .map(|re| re.as_str()),
490        path_pattern: filter_args.path_pattern.as_ref().map(|glob| glob.as_str()),
491        path_pattern_inverse: filter_args.path_pattern_inverse.as_ref().map(|glob| glob.as_str()),
492    }
493}
494
495pub(super) fn project_relative_path(root: &Path, path: &Path) -> Option<PathBuf> {
496    if path.is_relative() {
497        return Some(path.to_path_buf());
498    }
499
500    if let Ok(stripped) = path.strip_prefix(root) {
501        return Some(stripped.to_path_buf());
502    }
503
504    path.canonicalize().ok()?.strip_prefix(root.canonicalize().ok()?).ok().map(PathBuf::from)
505}
506
507fn mutation_compile_sources(
508    selected_sources: impl IntoIterator<Item = PathBuf>,
509    artifact_link_references: impl IntoIterator<Item = (PathBuf, BTreeSet<PathBuf>)>,
510) -> Vec<PathBuf> {
511    let link_edges = artifact_link_references.into_iter().collect::<BTreeMap<_, _>>();
512    let mut selected_sources_relative = selected_sources.into_iter().collect::<BTreeSet<_>>();
513    let mut queue = selected_sources_relative.iter().cloned().collect::<Vec<_>>();
514
515    while let Some(source) = queue.pop() {
516        if let Some(links) = link_edges.get(&source) {
517            for link in links {
518                if selected_sources_relative.insert(link.clone()) {
519                    queue.push(link.clone());
520                }
521            }
522        }
523    }
524
525    selected_sources_relative.into_iter().collect()
526}
527
528fn partition_adaptively_skipped_mutants(
529    handler: &mut MutationHandler,
530    mutants: &[Mutant],
531) -> (Vec<Mutant>, Vec<(Mutant, MutationResult)>) {
532    let mut skipped_results = Vec::new();
533    let mutants_to_test = mutants
534        .iter()
535        .filter_map(|mutant| {
536            if handler.should_skip_span(mutant.span) {
537                handler.add_skipped_mutant(mutant.clone());
538                skipped_results.push((mutant.clone(), MutationResult::Skipped));
539                None
540            } else {
541                Some(mutant.clone())
542            }
543        })
544        .collect();
545
546    (mutants_to_test, skipped_results)
547}
548
549/// Resolve which paths to mutate based on configuration.
550///
551/// Resolution order:
552/// 1. Pick the *base* set of candidate files:
553///    - `--mutate-path <GLOB>` → all source files matching the glob, OR
554///    - explicit `--mutate PATH...` → those validated files, OR
555///    - default → every Solidity file under `config.src`.
556/// 2. If `--mutate-contract <REGEX>` is set, intersect the base set with files that contain at
557///    least one contract whose name matches the regex. The per-file contract filter still
558///    re-applies inside the handler.
559fn resolve_mutate_paths(
560    config: &Config,
561    output: &ProjectCompileOutput<MultiCompiler>,
562    mutation_config: &MutationRunConfig,
563) -> Result<Vec<PathBuf>> {
564    // 1. Base path set.
565    let base: Vec<PathBuf> = if let Some(pattern) = &mutation_config.mutate_path_pattern {
566        let paths: Vec<_> = source_files_iter(&config.src, MultiCompilerLanguage::FILE_EXTENSIONS)
567            .filter(|entry| entry.is_sol() && !entry.is_sol_test() && pattern.is_match(entry))
568            .collect();
569        if paths.is_empty() {
570            eyre::bail!("no source matched --mutate-path pattern `{pattern}`");
571        }
572        paths
573    } else if !mutation_config.mutate_paths.is_empty() {
574        let root_canon =
575            config.root.canonicalize().wrap_err("failed to canonicalize project root")?;
576        let mut validated = Vec::with_capacity(mutation_config.mutate_paths.len());
577        for path in &mutation_config.mutate_paths {
578            let resolved = if path.is_relative() { config.root.join(path) } else { path.clone() };
579            if !resolved.exists() {
580                eyre::bail!("mutate path does not exist: {}", resolved.display());
581            }
582            if !resolved.is_file() {
583                eyre::bail!("mutate path is not a file: {}", resolved.display());
584            }
585            let canon = resolved
586                .canonicalize()
587                .wrap_err_with(|| format!("failed to canonicalize: {}", resolved.display()))?;
588            if !canon.starts_with(&root_canon) {
589                eyre::bail!("mutate path is outside the project root: {}", resolved.display());
590            }
591            if !canon.is_sol() {
592                eyre::bail!("mutate path is not a Solidity file: {}", resolved.display());
593            }
594            if canon.is_sol_test() {
595                eyre::bail!(
596                    "mutate path is a test file, not a source file: {}",
597                    resolved.display()
598                );
599            }
600            validated.push(canon);
601        }
602        validated
603    } else {
604        source_files_iter(&config.src, MultiCompilerLanguage::FILE_EXTENSIONS)
605            .filter(|entry| entry.is_sol() && !entry.is_sol_test())
606            .collect()
607    };
608
609    // 2. Intersect with `--mutate-contract` if set, so explicit `--mutate <paths>` combined with
610    //    `--mutate-contract <regex>` does the principled thing (the listed files, restricted to
611    //    those containing a matching contract) instead of silently expanding to every source file.
612    let paths = if let Some(contract_pattern) = &mutation_config.mutate_contract_pattern {
613        let matching_sources: HashSet<PathBuf> = output
614            .artifact_ids()
615            .filter_map(|(id, _)| contract_pattern.is_match(&id.name).then_some(id.source.clone()))
616            .collect();
617        let paths: Vec<_> =
618            base.into_iter().filter(|entry| matching_sources.contains(entry)).collect();
619        if paths.is_empty() {
620            if mutation_config.mutate_paths.is_empty()
621                && mutation_config.mutate_path_pattern.is_none()
622            {
623                eyre::bail!("no source matched --mutate-contract pattern `{contract_pattern}`");
624            }
625            eyre::bail!("no source matched --mutate-contract within the selected mutation paths");
626        }
627        paths
628    } else {
629        base
630    };
631
632    Ok(paths)
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use std::str::FromStr;
639
640    use crate::mutation::mutant::MutationType;
641    use solar::{ast::Span, interface::BytePos};
642
643    fn artifact(build_id: &str) -> ArtifactCacheFingerprint {
644        ArtifactCacheFingerprint {
645            source: "src/Counter.sol".to_string(),
646            name: "Counter".to_string(),
647            version: "0.8.30".to_string(),
648            build_id: build_id.to_string(),
649            profile: "default".to_string(),
650        }
651    }
652
653    fn filter_args() -> FilterArgs {
654        FilterArgs {
655            test_pattern: None,
656            test_pattern_inverse: None,
657            contract_pattern: None,
658            contract_pattern_inverse: None,
659            path_pattern: None,
660            path_pattern_inverse: None,
661            coverage_pattern_inverse: None,
662        }
663    }
664
665    fn mutant(lo: u32, hi: u32) -> Mutant {
666        Mutant {
667            path: PathBuf::from("src/Counter.sol"),
668            span: Span::new(BytePos(lo), BytePos(hi)),
669            mutation: MutationType::DeleteExpression,
670            original: "number++".to_string(),
671            source_line: "number++;".to_string(),
672            line_number: 1,
673            column_number: 1,
674        }
675    }
676
677    #[test]
678    fn execution_cache_key_changes_when_fuzz_config_changes() {
679        let first = Config::default();
680        let mut second = first.clone();
681        second.fuzz.runs += 1;
682
683        let evm_opts = EvmOpts::default();
684        let filter_args = filter_args();
685        let artifacts = vec![artifact("build-a")];
686
687        let first_key = mutation_execution_cache_key_from_parts(
688            &first,
689            &evm_opts,
690            &filter_args,
691            artifacts.clone(),
692        )
693        .unwrap();
694        let second_key =
695            mutation_execution_cache_key_from_parts(&second, &evm_opts, &filter_args, artifacts)
696                .unwrap();
697
698        assert_ne!(first_key, second_key);
699    }
700
701    #[test]
702    fn execution_cache_key_changes_when_evm_options_change() {
703        let config = Config::default();
704        let first = EvmOpts::default();
705        let mut second = first.clone();
706        second.memory_limit = first.memory_limit + 1;
707
708        let filter_args = filter_args();
709        let artifacts = vec![artifact("build-a")];
710
711        let first_key = mutation_execution_cache_key_from_parts(
712            &config,
713            &first,
714            &filter_args,
715            artifacts.clone(),
716        )
717        .unwrap();
718        let second_key =
719            mutation_execution_cache_key_from_parts(&config, &second, &filter_args, artifacts)
720                .unwrap();
721
722        assert_ne!(first_key, second_key);
723    }
724
725    #[test]
726    fn execution_cache_key_changes_when_resolved_fork_changes() {
727        let config = Config::default();
728        let evm_opts = EvmOpts::default();
729        let filter_args = filter_args();
730        let artifacts = vec![artifact("build-a")];
731
732        let first_key = mutation_execution_cache_key_from_parts_with_rerun_failures(
733            &config,
734            &evm_opts,
735            Some(alloy_primitives::B256::with_last_byte(1)),
736            &filter_args,
737            None,
738            artifacts.clone(),
739        )
740        .unwrap();
741        let second_key = mutation_execution_cache_key_from_parts_with_rerun_failures(
742            &config,
743            &evm_opts,
744            Some(alloy_primitives::B256::with_last_byte(2)),
745            &filter_args,
746            None,
747            artifacts,
748        )
749        .unwrap();
750
751        assert_ne!(first_key, second_key);
752    }
753
754    #[test]
755    fn execution_cache_key_changes_when_compiled_artifacts_change() {
756        let config = Config::default();
757        let evm_opts = EvmOpts::default();
758        let filter_args = filter_args();
759
760        let first_key = mutation_execution_cache_key_from_parts(
761            &config,
762            &evm_opts,
763            &filter_args,
764            vec![artifact("build-a")],
765        )
766        .unwrap();
767        let second_key = mutation_execution_cache_key_from_parts(
768            &config,
769            &evm_opts,
770            &filter_args,
771            vec![artifact("build-b")],
772        )
773        .unwrap();
774
775        assert_ne!(first_key, second_key);
776    }
777
778    #[test]
779    fn execution_cache_key_sorts_artifacts_before_hashing() {
780        let config = Config::default();
781        let evm_opts = EvmOpts::default();
782        let filter_args = filter_args();
783
784        let first = vec![artifact("build-a"), artifact("build-b")];
785        let second = vec![artifact("build-b"), artifact("build-a")];
786
787        let first_key =
788            mutation_execution_cache_key_from_parts(&config, &evm_opts, &filter_args, first)
789                .unwrap();
790        let second_key =
791            mutation_execution_cache_key_from_parts(&config, &evm_opts, &filter_args, second)
792                .unwrap();
793
794        assert_eq!(first_key, second_key);
795    }
796
797    #[test]
798    fn execution_cache_key_changes_when_match_test_filter_changes() {
799        let config = Config::default();
800        let evm_opts = EvmOpts::default();
801        let mut first_filter = filter_args();
802        let mut second_filter = filter_args();
803        first_filter.test_pattern = Some(regex::Regex::new("testA|testAlpha").unwrap());
804        second_filter.test_pattern = Some(regex::Regex::new("testB|testBeta").unwrap());
805        let artifacts = vec![artifact("build-a")];
806
807        let first_key = mutation_execution_cache_key_from_parts(
808            &config,
809            &evm_opts,
810            &first_filter,
811            artifacts.clone(),
812        )
813        .unwrap();
814        let second_key =
815            mutation_execution_cache_key_from_parts(&config, &evm_opts, &second_filter, artifacts)
816                .unwrap();
817
818        assert_ne!(first_key, second_key);
819    }
820
821    #[test]
822    fn execution_cache_key_changes_when_match_path_filter_changes() {
823        let config = Config::default();
824        let evm_opts = EvmOpts::default();
825        let mut first_filter = filter_args();
826        let mut second_filter = filter_args();
827        first_filter.path_pattern = Some(GlobMatcher::from_str("test/A.t.sol").unwrap());
828        second_filter.path_pattern = Some(GlobMatcher::from_str("test/B.t.sol").unwrap());
829        let artifacts = vec![artifact("build-a")];
830
831        let first_key = mutation_execution_cache_key_from_parts(
832            &config,
833            &evm_opts,
834            &first_filter,
835            artifacts.clone(),
836        )
837        .unwrap();
838        let second_key =
839            mutation_execution_cache_key_from_parts(&config, &evm_opts, &second_filter, artifacts)
840                .unwrap();
841
842        assert_ne!(first_key, second_key);
843    }
844
845    #[test]
846    fn execution_cache_key_changes_when_rerun_failures_change() {
847        let config = Config::default();
848        let evm_opts = EvmOpts::default();
849        let filter_args = filter_args();
850        let first_failures = vec![RerunFailure {
851            contract: "test/Counter.t.sol:WeakTest".to_string(),
852            test: "test_increment()".to_string(),
853        }];
854        let second_failures = vec![RerunFailure {
855            contract: "test/Counter.t.sol:StrongTest".to_string(),
856            test: "test_increment()".to_string(),
857        }];
858        let artifacts = vec![artifact("build-a")];
859
860        let first_key = mutation_execution_cache_key_from_parts_with_rerun_failures(
861            &config,
862            &evm_opts,
863            None,
864            &filter_args,
865            Some(&first_failures),
866            artifacts.clone(),
867        )
868        .unwrap();
869        let second_key = mutation_execution_cache_key_from_parts_with_rerun_failures(
870            &config,
871            &evm_opts,
872            None,
873            &filter_args,
874            Some(&second_failures),
875            artifacts,
876        )
877        .unwrap();
878
879        assert_ne!(first_key, second_key);
880    }
881
882    #[test]
883    fn mutation_compile_sources_only_include_selected_link_reference_closure() {
884        let sources = mutation_compile_sources(
885            [PathBuf::from("test/Selected.t.sol")],
886            [
887                (
888                    PathBuf::from("test/Selected.t.sol"),
889                    BTreeSet::from([PathBuf::from("test/SelectedLinkedHelper.sol")]),
890                ),
891                (
892                    PathBuf::from("test/SelectedLinkedHelper.sol"),
893                    BTreeSet::from([PathBuf::from("test/TransitiveLinkedHelper.sol")]),
894                ),
895                (
896                    PathBuf::from("test/Unrelated.t.sol"),
897                    BTreeSet::from([PathBuf::from("test/UnusedLinkedHelper.sol")]),
898                ),
899            ],
900        );
901
902        assert_eq!(
903            sources,
904            vec![
905                PathBuf::from("test/Selected.t.sol"),
906                PathBuf::from("test/SelectedLinkedHelper.sol"),
907                PathBuf::from("test/TransitiveLinkedHelper.sol"),
908            ]
909        );
910    }
911
912    #[test]
913    fn resumed_adaptive_skips_are_reported_as_skipped_results() {
914        let mut handler =
915            MutationHandler::new(PathBuf::from("src/Counter.sol"), Arc::new(Config::default()));
916        handler.mark_span_survived(Span::new(BytePos(10), BytePos(20)));
917
918        let exact_survivor = mutant(10, 20);
919        let skipped_child = mutant(12, 18);
920        let unrelated = mutant(30, 40);
921        let (mutants_to_test, skipped_results) = partition_adaptively_skipped_mutants(
922            &mut handler,
923            &[exact_survivor.clone(), skipped_child.clone(), unrelated.clone()],
924        );
925
926        assert_eq!(mutants_to_test.len(), 2);
927        assert_eq!(mutants_to_test[0].span, exact_survivor.span);
928        assert_eq!(mutants_to_test[1].span, unrelated.span);
929        assert_eq!(skipped_results.len(), 1);
930        assert!(matches!(skipped_results[0].1, MutationResult::Skipped));
931        assert_eq!(skipped_results[0].0.span, skipped_child.span);
932        assert_eq!(handler.get_report().total_skipped(), 1);
933        assert_eq!(handler.get_report().total_mutants(), 1);
934    }
935}