Skip to main content

forge/mutation/
mod.rs

1use std::{
2    collections::{BTreeMap, HashSet, hash_map::DefaultHasher},
3    hash::{Hash, Hasher},
4    path::{Path, PathBuf},
5    sync::Arc,
6};
7
8use crate::mutation::{
9    mutant::{Mutant, MutationResult},
10    visitor::MutantVisitor,
11};
12pub use crate::mutation::{
13    orchestrator::{MutationRunConfig, MutationRunResult, run_mutation_testing},
14    progress::MutationProgress,
15    reporter::MutationReporter,
16    runner::run_mutations_parallel_with_progress,
17};
18use eyre::eyre;
19use foundry_common::sh_warn;
20use serde::{Deserialize, Serialize};
21use solar::{
22    ast::{
23        Span,
24        interface::{Session, source_map::FileName},
25        visit::Visit,
26    },
27    parse::Parser,
28};
29
30fn failed_to_parse(path: &Path) -> eyre::Report {
31    eyre!("failed to parse {}", path.display())
32}
33
34#[derive(Clone, Copy)]
35enum CacheKind<'a> {
36    Mutants,
37    Results { execution_key: &'a str },
38    Survived { execution_key: &'a str },
39}
40
41#[derive(Serialize, Deserialize)]
42struct CachedMutationResults {
43    mutant_count: usize,
44    mutant_hash: u64,
45    results: Vec<(Mutant, MutationResult)>,
46}
47
48fn mutant_set_hash(mutants: &[Mutant]) -> u64 {
49    let mut entries: Vec<_> = mutants
50        .iter()
51        .map(|mutant| {
52            (
53                mutant.span.lo().0,
54                mutant.span.hi().0,
55                mutant.mutation.to_string(),
56                mutant.original.clone(),
57            )
58        })
59        .collect();
60    entries.sort();
61
62    let mut hasher = DefaultHasher::new();
63    for entry in entries {
64        entry.hash(&mut hasher);
65    }
66    hasher.finish()
67}
68
69pub mod mutant;
70mod mutators;
71pub mod orchestrator;
72pub mod progress;
73mod reporter;
74pub mod runner;
75mod type_analysis;
76mod visitor;
77
78pub struct MutationsSummary {
79    dead: Vec<Mutant>,
80    survived: Vec<Mutant>,
81    invalid: Vec<Mutant>,
82    skipped: Vec<Mutant>,
83    /// Mutants whose compile-and-test work exceeded the configured timeout.
84    /// Tracked separately so they are not counted toward survived/killed.
85    timed_out: Vec<Mutant>,
86}
87
88impl Default for MutationsSummary {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl MutationsSummary {
95    pub const fn new() -> Self {
96        Self {
97            dead: Vec::new(),
98            survived: Vec::new(),
99            invalid: Vec::new(),
100            skipped: Vec::new(),
101            timed_out: Vec::new(),
102        }
103    }
104
105    pub fn update_invalid_mutant(&mut self, mutant: Mutant) {
106        self.invalid.push(mutant);
107    }
108
109    pub fn add_dead_mutant(&mut self, mutant: Mutant) {
110        self.dead.push(mutant);
111    }
112
113    pub fn add_survived_mutant(&mut self, mutant: Mutant) {
114        self.survived.push(mutant);
115    }
116
117    pub fn add_skipped_mutant(&mut self, mutant: Mutant) {
118        self.skipped.push(mutant);
119    }
120
121    pub fn add_timed_out_mutant(&mut self, mutant: Mutant) {
122        self.timed_out.push(mutant);
123    }
124
125    pub const fn total_mutants(&self) -> usize {
126        self.dead.len()
127            + self.survived.len()
128            + self.invalid.len()
129            + self.skipped.len()
130            + self.timed_out.len()
131    }
132
133    pub const fn total_dead(&self) -> usize {
134        self.dead.len()
135    }
136
137    pub const fn total_survived(&self) -> usize {
138        self.survived.len()
139    }
140
141    pub const fn total_invalid(&self) -> usize {
142        self.invalid.len()
143    }
144
145    pub const fn total_skipped(&self) -> usize {
146        self.skipped.len()
147    }
148
149    pub const fn total_timed_out(&self) -> usize {
150        self.timed_out.len()
151    }
152
153    pub const fn get_dead(&self) -> &Vec<Mutant> {
154        &self.dead
155    }
156
157    pub const fn get_survived(&self) -> &Vec<Mutant> {
158        &self.survived
159    }
160
161    pub const fn get_invalid(&self) -> &Vec<Mutant> {
162        &self.invalid
163    }
164
165    pub const fn get_timed_out(&self) -> &Vec<Mutant> {
166        &self.timed_out
167    }
168
169    /// Merge another MutationsSummary into this one
170    pub fn merge(&mut self, other: &Self) {
171        self.dead.extend(other.dead.clone());
172        self.survived.extend(other.survived.clone());
173        self.invalid.extend(other.invalid.clone());
174        self.skipped.extend(other.skipped.clone());
175        self.timed_out.extend(other.timed_out.clone());
176    }
177
178    /// Calculate mutation score (percentage of dead mutants out of valid mutants)
179    /// Higher scores indicate better test coverage
180    pub fn mutation_score(&self) -> f64 {
181        let valid_mutants = self.dead.len() + self.survived.len();
182        if valid_mutants == 0 { 0.0 } else { self.dead.len() as f64 / valid_mutants as f64 * 100.0 }
183    }
184
185    /// Mutants that reached a test verdict and can contribute to the score.
186    pub const fn total_evaluated(&self) -> usize {
187        self.dead.len() + self.survived.len()
188    }
189
190    /// Whether the score is useful enough to present as a coverage signal.
191    pub const fn has_reliable_score(&self) -> bool {
192        self.total_evaluated() > 0 && self.timed_out.len() < self.total_evaluated()
193    }
194
195    /// Convert to JSON output format.
196    ///
197    /// Output is sorted deterministically: files in lexicographic order
198    /// (`BTreeMap` keys), and survived mutants within each file sorted by
199    /// `(line, column, original, mutant)`. Without this, parallel worker
200    /// completion order leaks into the JSON and breaks downstream diffing,
201    /// snapshot tests, and reproducibility.
202    pub fn to_json_output(&self, duration_secs: f64) -> MutationJsonOutput {
203        let mut survived_mutants: BTreeMap<String, Vec<SurvivedMutantJson>> = BTreeMap::new();
204
205        for mutant in &self.survived {
206            let file_path = mutant.relative_path();
207            let entry = survived_mutants.entry(file_path).or_default();
208            entry.push(SurvivedMutantJson::from_mutant(mutant));
209        }
210
211        for entries in survived_mutants.values_mut() {
212            entries.sort_by(|a, b| {
213                (a.line, a.column, &a.original, &a.mutant).cmp(&(
214                    b.line,
215                    b.column,
216                    &b.original,
217                    &b.mutant,
218                ))
219            });
220        }
221
222        MutationJsonOutput {
223            summary: MutationSummaryJson {
224                total: self.total_mutants(),
225                killed: self.total_dead(),
226                survived: self.total_survived(),
227                invalid: self.total_invalid(),
228                skipped: self.total_skipped(),
229                timed_out: self.total_timed_out(),
230                mutation_score: self.mutation_score(),
231                duration_secs,
232            },
233            survived_mutants,
234        }
235    }
236}
237
238/// JSON output for mutation testing results.
239///
240/// Uses [`BTreeMap`] for `survived_mutants` so file ordering in the emitted
241/// JSON is deterministic.
242#[derive(Debug, Clone, Serialize)]
243pub struct MutationJsonOutput {
244    pub summary: MutationSummaryJson,
245    pub survived_mutants: BTreeMap<String, Vec<SurvivedMutantJson>>,
246}
247
248/// Summary section of JSON output
249#[derive(Debug, Clone, Serialize)]
250pub struct MutationSummaryJson {
251    pub total: usize,
252    pub killed: usize,
253    pub survived: usize,
254    pub invalid: usize,
255    pub skipped: usize,
256    pub timed_out: usize,
257    pub mutation_score: f64,
258    pub duration_secs: f64,
259}
260
261/// Individual survived mutant in JSON output
262#[derive(Debug, Clone, Serialize)]
263pub struct SurvivedMutantJson {
264    pub line: usize,
265    pub column: usize,
266    pub original: String,
267    pub mutant: String,
268}
269
270impl SurvivedMutantJson {
271    /// Create from a Mutant, using the full original expression
272    pub fn from_mutant(mutant: &Mutant) -> Self {
273        Self {
274            line: mutant.line_number,
275            column: mutant.column_number,
276            original: mutant.original.clone(),
277            mutant: mutant.mutation.to_string(),
278        }
279    }
280}
281
282/// Tracks spans where mutations have survived (weren't killed by tests).
283/// Used for adaptive mutation testing to skip redundant mutations.
284#[derive(Debug, Clone, Default)]
285pub struct SurvivedSpans {
286    spans: HashSet<(u32, u32)>, // (lo, hi) byte positions
287}
288
289impl SurvivedSpans {
290    pub fn new() -> Self {
291        Self { spans: HashSet::new() }
292    }
293
294    /// Mark a span as having a surviving mutation
295    pub fn mark_survived(&mut self, span: Span) {
296        self.spans.insert((span.lo().0, span.hi().0));
297    }
298
299    /// Check if any survived parent span contains this span.
300    ///
301    /// Exact span matches are not skipped: a persisted survived-span cache only
302    /// records byte ranges, not which mutant at that range survived. Re-testing
303    /// exact spans after an interrupted run keeps known survivors from being
304    /// converted into `Skipped` results in the next complete cache.
305    pub fn should_skip(&self, span: Span) -> bool {
306        let (lo, hi) = (span.lo().0, span.hi().0);
307
308        self.spans.iter().any(|&(parent_lo, parent_hi)| {
309            parent_lo <= lo && hi <= parent_hi && (parent_lo != lo || parent_hi != hi)
310        })
311    }
312
313    /// Check if any survived span contains this span, including exact matches.
314    ///
315    /// Live workers know exact same-span mutants are siblings in the current
316    /// run, so once one survives the remaining siblings can be skipped.
317    pub fn should_skip_in_live_run(&self, span: Span) -> bool {
318        let (lo, hi) = (span.lo().0, span.hi().0);
319
320        self.spans.iter().any(|&(parent_lo, parent_hi)| parent_lo <= lo && hi <= parent_hi)
321    }
322
323    /// Serialize to a list of (lo, hi) pairs for caching
324    fn to_vec(&self) -> Vec<(u32, u32)> {
325        self.spans.iter().copied().collect()
326    }
327
328    /// Deserialize from a list of (lo, hi) pairs
329    fn from_vec(pairs: Vec<(u32, u32)>) -> Self {
330        Self { spans: pairs.into_iter().collect() }
331    }
332}
333
334pub struct MutationHandler {
335    contract_to_mutate: PathBuf,
336    pub src: Arc<String>,
337    pub mutations: Vec<Mutant>,
338    config: Arc<foundry_config::Config>,
339    report: MutationsSummary,
340    survived_spans: SurvivedSpans,
341    /// Optional regex used to restrict mutation to specific contracts within
342    /// the file (matches against contract name).
343    contract_filter: Option<regex::Regex>,
344    mutation_exclusions: type_analysis::MutationExclusionSet,
345}
346
347impl MutationHandler {
348    pub fn new(contract_to_mutate: PathBuf, config: Arc<foundry_config::Config>) -> Self {
349        Self {
350            contract_to_mutate,
351            src: Arc::default(),
352            mutations: vec![],
353            config,
354            report: MutationsSummary::new(),
355            survived_spans: SurvivedSpans::new(),
356            contract_filter: None,
357            mutation_exclusions: type_analysis::MutationExclusionSet::new(),
358        }
359    }
360
361    /// Restrict mutation to contracts whose name matches `filter`.
362    pub fn with_contract_filter(mut self, filter: regex::Regex) -> Self {
363        self.contract_filter = Some(filter);
364        self
365    }
366
367    /// Exclude operator replacements rejected by type analysis.
368    pub(crate) fn with_mutation_exclusions(
369        mut self,
370        mutations: type_analysis::MutationExclusionSet,
371    ) -> Self {
372        self.mutation_exclusions = mutations;
373        self
374    }
375
376    pub fn read_source_contract(&mut self) -> Result<(), std::io::Error> {
377        let content = std::fs::read_to_string(&self.contract_to_mutate)?;
378        self.src = Arc::new(content);
379        Ok(())
380    }
381
382    /// Add a dead mutant to the report
383    pub fn add_dead_mutant(&mut self, mutant: Mutant) {
384        self.report.add_dead_mutant(mutant);
385    }
386
387    /// Add a survived mutant to the report
388    pub fn add_survived_mutant(&mut self, mutant: Mutant) {
389        self.report.add_survived_mutant(mutant);
390    }
391
392    /// Add an invalid mutant to the report
393    pub fn add_invalid_mutant(&mut self, mutant: Mutant) {
394        self.report.update_invalid_mutant(mutant);
395    }
396
397    pub fn add_skipped_mutant(&mut self, mutant: Mutant) {
398        self.report.add_skipped_mutant(mutant);
399    }
400
401    pub fn add_timed_out_mutant(&mut self, mutant: Mutant) {
402        self.report.add_timed_out_mutant(mutant);
403    }
404
405    /// Get a reference to the current report
406    pub const fn get_report(&self) -> &MutationsSummary {
407        &self.report
408    }
409
410    // Note: we now get the build hash directly from the recent compile output (see test flow)
411
412    /// Returns the cache file path for the given build hash and cache kind.
413    /// The filename encodes a hash of the full contract path to prevent collisions
414    /// between files with the same stem in different directories, and a hash of
415    /// the active mutation config so changes to enabled operators invalidate
416    /// previously cached mutants. Result-like caches also include an execution
417    /// key so stale outcomes are not reused after test/config/EVM changes.
418    fn cache_file_path(&self, hash: &str, kind: CacheKind<'_>) -> PathBuf {
419        let mut hasher = DefaultHasher::new();
420        self.contract_to_mutate.hash(&mut hasher);
421        let path_hash = hasher.finish();
422
423        // Hash the effective set of enabled mutation operators so mutant cache
424        // entries are invalidated when the user changes `include_operators` /
425        // `exclude_operators` in their config.
426        //
427        // Also fold in the active `--mutate-contract` regex pattern, because
428        // running with vs. without that filter produces a different mutant set
429        // for the same file.
430        let mut mutant_cfg_hasher = DefaultHasher::new();
431        // Version salt for this mutant-set cache schema. Bump this if the
432        // inputs that define generated mutants change.
433        "mutant-set-v7".hash(&mut mutant_cfg_hasher);
434        for op in self.config.mutation.enabled_operators() {
435            op.to_string().hash(&mut mutant_cfg_hasher);
436        }
437        match self.contract_filter.as_ref() {
438            Some(re) => {
439                "filter:".hash(&mut mutant_cfg_hasher);
440                re.as_str().hash(&mut mutant_cfg_hasher);
441            }
442            None => "nofilter".hash(&mut mutant_cfg_hasher),
443        }
444        let mut exclusion_hashes = self
445            .mutation_exclusions
446            .iter()
447            .map(|exclusion| {
448                let mut hasher = DefaultHasher::new();
449                exclusion.hash(&mut hasher);
450                hasher.finish()
451            })
452            .collect::<Vec<_>>();
453        exclusion_hashes.sort_unstable();
454        "exclusions:".hash(&mut mutant_cfg_hasher);
455        exclusion_hashes.len().hash(&mut mutant_cfg_hasher);
456        for exclusion_hash in exclusion_hashes {
457            exclusion_hash.hash(&mut mutant_cfg_hasher);
458        }
459        let mutant_cfg_hash = mutant_cfg_hasher.finish();
460
461        let (ext, execution_suffix) = match kind {
462            CacheKind::Mutants => ("mutants", String::new()),
463            CacheKind::Results { execution_key } => ("results", format!("_{execution_key}")),
464            CacheKind::Survived { execution_key } => ("survived", format!("_{execution_key}")),
465        };
466
467        let stem =
468            self.contract_to_mutate.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown");
469        self.config.root.join(&self.config.mutation_dir).join(format!(
470            "{hash}_{stem}_{path_hash:x}_{mutant_cfg_hash:x}{execution_suffix}.{ext}"
471        ))
472    }
473
474    /// Persists cached mutants using build hash for cache invalidation.
475    pub fn persist_cached_mutants(&self, hash: &str, mutants: &[Mutant]) -> std::io::Result<()> {
476        let cache_file = self.cache_file_path(hash, CacheKind::Mutants);
477        if let Some(dir) = cache_file.parent() {
478            std::fs::create_dir_all(dir)?;
479        }
480        let json = serde_json::to_string_pretty(mutants).map_err(std::io::Error::other)?;
481        std::fs::write(cache_file, json)
482    }
483
484    /// Persists results for mutants using build hash for cache invalidation.
485    pub fn persist_cached_results(
486        &self,
487        hash: &str,
488        execution_key: &str,
489        mutants: &[Mutant],
490        results: &[(Mutant, crate::mutation::mutant::MutationResult)],
491    ) -> std::io::Result<()> {
492        let cache_file = self.cache_file_path(hash, CacheKind::Results { execution_key });
493        if let Some(dir) = cache_file.parent() {
494            std::fs::create_dir_all(dir)?;
495        }
496        let cached = CachedMutationResults {
497            mutant_count: mutants.len(),
498            mutant_hash: mutant_set_hash(mutants),
499            results: results.to_vec(),
500        };
501        let json = serde_json::to_string_pretty(&cached).map_err(std::io::Error::other)?;
502        std::fs::write(cache_file, json)
503    }
504
505    /// Read a source string, and for each contract found, gets its ast and visit it to list
506    /// all mutations to conduct.
507    pub async fn generate_ast(&mut self) -> eyre::Result<()> {
508        let path = &self.contract_to_mutate;
509        let target_content = Arc::clone(&self.src);
510        let sess = Session::builder().with_silent_emitter(None).build();
511
512        let contract_filter = self.contract_filter.clone();
513
514        let result = sess.enter(|| -> eyre::Result<Vec<Mutant>> {
515            let arena = solar::ast::Arena::new();
516            let mut parser =
517                Parser::from_lazy_source_code(&sess, &arena, FileName::from(path.clone()), || {
518                    Ok((*target_content).clone())
519                })
520                .map_err(|_e| failed_to_parse(path))?;
521
522            let ast = parser.parse_file().map_err(|e| {
523                e.emit();
524                failed_to_parse(path)
525            })?;
526            drop(parser);
527
528            let operators = self.config.mutation.enabled_operators();
529            let mut mutant_visitor = MutantVisitor::with_operators(path.clone(), &operators)
530                .with_source(&target_content)
531                .with_mutation_exclusions(self.mutation_exclusions.clone());
532
533            if let Some(filter) = contract_filter {
534                mutant_visitor =
535                    mutant_visitor.with_contract_filter(move |name| filter.is_match(name));
536            }
537            let _ = mutant_visitor.visit_source_unit(&ast);
538
539            for err in mutant_visitor.take_errors() {
540                let _ = sh_warn!("{err:?}");
541            }
542
543            Ok(mutant_visitor.mutation_to_conduct)
544        });
545
546        match result {
547            Ok(mutations) => {
548                self.mutations.extend(mutations);
549                Ok(())
550            }
551            Err(err) => Err(err),
552        }
553    }
554
555    /// Retrieves cached mutants using build hash.
556    pub fn retrieve_cached_mutants(&self, hash: &str) -> Option<Vec<Mutant>> {
557        let cache_file = self.cache_file_path(hash, CacheKind::Mutants);
558        let data = std::fs::read_to_string(cache_file).ok()?;
559        serde_json::from_str(&data).ok()
560    }
561
562    /// Retrieves cached results using build hash.
563    pub fn retrieve_cached_mutant_results(
564        &self,
565        hash: &str,
566        execution_key: &str,
567        mutants: &[Mutant],
568    ) -> Option<Vec<(Mutant, MutationResult)>> {
569        let cache_file = self.cache_file_path(hash, CacheKind::Results { execution_key });
570        let data = std::fs::read_to_string(cache_file).ok()?;
571        let cached: CachedMutationResults = serde_json::from_str(&data).ok()?;
572        (cached.mutant_count == mutants.len() && cached.mutant_hash == mutant_set_hash(mutants))
573            .then_some(cached.results)
574    }
575
576    /// Mark a span as having a surviving mutation
577    pub fn mark_span_survived(&mut self, span: Span) {
578        self.survived_spans.mark_survived(span);
579    }
580
581    /// Check if a span should be skipped (has survived mutation or is child of survived span)
582    pub fn should_skip_span(&self, span: Span) -> bool {
583        self.survived_spans.should_skip(span)
584    }
585
586    /// Persist survived spans to cache for adaptive mutation testing.
587    pub fn persist_survived_spans(&self, hash: &str, execution_key: &str) -> std::io::Result<()> {
588        let cache_file = self.cache_file_path(hash, CacheKind::Survived { execution_key });
589        if let Some(dir) = cache_file.parent() {
590            std::fs::create_dir_all(dir)?;
591        }
592        let spans = self.survived_spans.to_vec();
593        let json = serde_json::to_string_pretty(&spans).map_err(std::io::Error::other)?;
594        std::fs::write(cache_file, json)
595    }
596
597    /// Retrieve survived spans from cache.
598    pub fn retrieve_survived_spans(&mut self, hash: &str, execution_key: &str) -> bool {
599        let cache_file = self.cache_file_path(hash, CacheKind::Survived { execution_key });
600
601        if let Ok(data) = std::fs::read_to_string(cache_file)
602            && let Ok(pairs) = serde_json::from_str::<Vec<(u32, u32)>>(&data)
603        {
604            self.survived_spans = SurvivedSpans::from_vec(pairs);
605            return true;
606        }
607
608        false
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use crate::mutation::type_analysis::{AssignmentReplacement, MutationExclusion};
616    use foundry_config::Config;
617    use solar::ast::interface::BytePos;
618    use tempfile::TempDir;
619
620    fn test_handler(config: Config) -> MutationHandler {
621        let source = config.root.join("src").join("Counter.sol");
622        MutationHandler::new(source, Arc::new(config))
623    }
624
625    fn test_config() -> (TempDir, Config) {
626        let temp = TempDir::new().unwrap();
627        let config = Config {
628            root: temp.path().to_path_buf(),
629            mutation_dir: "cache/mutation".into(),
630            ..Default::default()
631        };
632        (temp, config)
633    }
634
635    fn mutant(lo: u32, hi: u32, original: &str) -> Mutant {
636        Mutant {
637            path: PathBuf::from("src/Counter.sol"),
638            span: Span::new(BytePos(lo), BytePos(hi)),
639            mutation: mutant::MutationType::DeleteExpression,
640            original: original.to_string(),
641            source_line: "number++;".to_string(),
642            line_number: 1,
643            column_number: 1,
644        }
645    }
646
647    #[test]
648    fn result_cache_path_includes_execution_key() {
649        let (_temp, config) = test_config();
650        let handler = test_handler(config);
651
652        let first =
653            handler.cache_file_path("build", CacheKind::Results { execution_key: "exec-a" });
654        let second =
655            handler.cache_file_path("build", CacheKind::Results { execution_key: "exec-b" });
656        let mutants = handler.cache_file_path("build", CacheKind::Mutants);
657
658        assert_ne!(first, second);
659        assert_ne!(first, mutants);
660        assert_ne!(second, mutants);
661    }
662
663    #[test]
664    fn survived_span_cache_path_includes_execution_key() {
665        let (_temp, config) = test_config();
666        let handler = test_handler(config);
667
668        let first =
669            handler.cache_file_path("build", CacheKind::Survived { execution_key: "exec-a" });
670        let second =
671            handler.cache_file_path("build", CacheKind::Survived { execution_key: "exec-b" });
672
673        assert_ne!(first, second);
674    }
675
676    #[test]
677    fn mutant_cache_path_ignores_execution_only_timeout() {
678        let (_temp, mut first_config) = test_config();
679        let mut second_config = first_config.clone();
680
681        first_config.mutation.timeout = Some(1);
682        second_config.mutation.timeout = Some(99);
683
684        let first = test_handler(first_config).cache_file_path("build", CacheKind::Mutants);
685        let second = test_handler(second_config).cache_file_path("build", CacheKind::Mutants);
686
687        assert_eq!(first, second);
688    }
689
690    #[test]
691    fn mutation_cache_paths_include_type_analysis_exclusions() {
692        let (_temp, config) = test_config();
693        let without_exclusions = test_handler(config.clone());
694        let exclusion = MutationExclusion::assignment(
695            Span::new(BytePos(10), BytePos(20)),
696            AssignmentReplacement::Zero,
697        );
698        let with_exclusions = test_handler(config).with_mutation_exclusions([exclusion].into());
699        without_exclusions.persist_cached_mutants("build", &[mutant(10, 20, "account")]).unwrap();
700
701        assert!(with_exclusions.retrieve_cached_mutants("build").is_none());
702
703        for kind in [
704            CacheKind::Mutants,
705            CacheKind::Results { execution_key: "exec" },
706            CacheKind::Survived { execution_key: "exec" },
707        ] {
708            assert_ne!(
709                without_exclusions.cache_file_path("build", kind),
710                with_exclusions.cache_file_path("build", kind),
711            );
712        }
713    }
714
715    #[test]
716    fn result_cache_validates_current_mutant_set() {
717        let (_temp, config) = test_config();
718        let handler = test_handler(config);
719        let mutants = vec![mutant(10, 20, "number++")];
720        let results = vec![(mutants[0].clone(), MutationResult::Dead)];
721
722        handler.persist_cached_results("build", "exec", &mutants, &results).unwrap();
723
724        assert!(handler.retrieve_cached_mutant_results("build", "exec", &mutants).is_some());
725
726        let changed_mutants = vec![mutant(10, 20, "number--")];
727        assert!(
728            handler.retrieve_cached_mutant_results("build", "exec", &changed_mutants).is_none()
729        );
730    }
731
732    #[test]
733    fn mutation_score_is_unreliable_when_evaluated_mutants_equal_timeouts() {
734        let mut summary = MutationsSummary::new();
735        summary.add_dead_mutant(mutant(10, 20, "number++"));
736        summary.add_timed_out_mutant(mutant(30, 40, "number--"));
737
738        assert_eq!(summary.total_evaluated(), 1);
739        assert!(!summary.has_reliable_score());
740    }
741
742    #[test]
743    fn mutation_score_is_unreliable_when_timeouts_dominate() {
744        let mut summary = MutationsSummary::new();
745        summary.add_dead_mutant(mutant(10, 20, "number++"));
746        summary.add_timed_out_mutant(mutant(30, 40, "number--"));
747        summary.add_timed_out_mutant(mutant(50, 60, "number += 1"));
748
749        assert_eq!(summary.total_evaluated(), 1);
750        assert!(!summary.has_reliable_score());
751    }
752
753    #[test]
754    fn mutation_score_is_unreliable_with_no_evaluated_mutants() {
755        let mut summary = MutationsSummary::new();
756        summary.add_timed_out_mutant(mutant(10, 20, "number++"));
757
758        assert_eq!(summary.total_evaluated(), 0);
759        assert!(!summary.has_reliable_score());
760        assert_eq!(summary.mutation_score(), 0.0);
761    }
762}