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