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