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