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