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