Skip to main content

testx/
cache.rs

1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::path::Path;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use crate::adapters::TestRunResult;
7use crate::error::{Result, TestxError};
8
9/// Directory name for the cache store.
10const CACHE_DIR: &str = ".testx";
11const CACHE_FILE: &str = "cache.json";
12const MAX_CACHE_ENTRIES: usize = 100;
13
14/// Configuration for smart caching.
15#[derive(Debug, Clone)]
16pub struct CacheConfig {
17    /// Whether caching is enabled.
18    pub enabled: bool,
19    /// Maximum age of cache entries in seconds.
20    pub max_age_secs: u64,
21    /// Maximum number of cache entries.
22    pub max_entries: usize,
23}
24
25impl Default for CacheConfig {
26    fn default() -> Self {
27        Self {
28            enabled: true,
29            max_age_secs: 86400, // 24 hours
30            max_entries: MAX_CACHE_ENTRIES,
31        }
32    }
33}
34
35/// A cached test result.
36#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37pub struct CacheEntry {
38    /// Content hash of the project files.
39    pub hash: String,
40    /// Adapter name used.
41    pub adapter: String,
42    /// Timestamp when cached (epoch seconds).
43    pub timestamp: u64,
44    /// Whether the cached run passed.
45    pub passed: bool,
46    /// Number of tests that passed / failed / skipped.
47    pub total_passed: usize,
48    pub total_failed: usize,
49    pub total_skipped: usize,
50    pub total_tests: usize,
51    /// Duration of the original run in milliseconds.
52    pub duration_ms: u64,
53    /// Extra args used.
54    pub extra_args: Vec<String>,
55}
56
57impl CacheEntry {
58    pub fn is_expired(&self, max_age_secs: u64) -> bool {
59        let now = SystemTime::now()
60            .duration_since(UNIX_EPOCH)
61            .unwrap_or_default()
62            .as_secs();
63        now.saturating_sub(self.timestamp) > max_age_secs
64    }
65
66    pub fn age_secs(&self) -> u64 {
67        let now = SystemTime::now()
68            .duration_since(UNIX_EPOCH)
69            .unwrap_or_default()
70            .as_secs();
71        now.saturating_sub(self.timestamp)
72    }
73}
74
75/// The persistent cache store.
76#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
77pub struct CacheStore {
78    pub entries: Vec<CacheEntry>,
79}
80
81impl CacheStore {
82    pub fn new() -> Self {
83        Self {
84            entries: Vec::new(),
85        }
86    }
87
88    /// Load cache from disk.
89    pub fn load(project_dir: &Path) -> Self {
90        let cache_path = project_dir.join(CACHE_DIR).join(CACHE_FILE);
91        if !cache_path.exists() {
92            return Self::new();
93        }
94
95        match std::fs::read_to_string(&cache_path) {
96            Ok(content) => serde_json::from_str(&content).unwrap_or_else(|_| Self::new()),
97            Err(_) => Self::new(),
98        }
99    }
100
101    /// Save cache to disk.
102    pub fn save(&self, project_dir: &Path) -> Result<()> {
103        let cache_dir = project_dir.join(CACHE_DIR);
104        if !cache_dir.exists() {
105            std::fs::create_dir_all(&cache_dir).map_err(|e| TestxError::IoError {
106                context: format!("Failed to create cache directory: {}", cache_dir.display()),
107                source: e,
108            })?;
109        }
110
111        let cache_path = cache_dir.join(CACHE_FILE);
112        let content = serde_json::to_string_pretty(self).map_err(|e| TestxError::ConfigError {
113            message: format!("Failed to serialize cache: {}", e),
114        })?;
115
116        std::fs::write(&cache_path, content).map_err(|e| TestxError::IoError {
117            context: format!("Failed to write cache file: {}", cache_path.display()),
118            source: e,
119        })?;
120
121        Ok(())
122    }
123
124    /// Look up a cache entry by hash.
125    pub fn lookup(&self, hash: &str, config: &CacheConfig) -> Option<&CacheEntry> {
126        self.entries
127            .iter()
128            .rev() // Most recent first
129            .find(|e| e.hash == hash && !e.is_expired(config.max_age_secs))
130    }
131
132    /// Insert a new cache entry.
133    pub fn insert(&mut self, entry: CacheEntry, config: &CacheConfig) {
134        // Remove old entries with the same hash
135        self.entries.retain(|e| e.hash != entry.hash);
136        self.entries.push(entry);
137        self.prune(config);
138    }
139
140    /// Remove expired and excess entries.
141    pub fn prune(&mut self, config: &CacheConfig) {
142        // Remove expired entries
143        self.entries.retain(|e| !e.is_expired(config.max_age_secs));
144
145        // Keep only the most recent entries
146        if self.entries.len() > config.max_entries {
147            let excess = self.entries.len() - config.max_entries;
148            self.entries.drain(..excess);
149        }
150    }
151
152    /// Clear all cache entries.
153    pub fn clear(&mut self) {
154        self.entries.clear();
155    }
156
157    /// Number of entries.
158    pub fn len(&self) -> usize {
159        self.entries.len()
160    }
161
162    /// Whether the cache is empty.
163    pub fn is_empty(&self) -> bool {
164        self.entries.is_empty()
165    }
166}
167
168impl Default for CacheStore {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174/// Compute a content hash of the project's source files.
175///
176/// This walks the project directory, collecting file modification times and sizes
177/// for relevant source files, then produces a combined hash.
178pub fn compute_project_hash(project_dir: &Path, adapter_name: &str) -> Result<String> {
179    let mut hasher = DefaultHasher::new();
180
181    // Hash the adapter name so different adapters have different cache keys
182    adapter_name.hash(&mut hasher);
183
184    // Collect relevant files and their metadata
185    let mut file_entries: Vec<(String, u64, u64)> = Vec::new();
186    collect_source_files(project_dir, project_dir, &mut file_entries)?;
187
188    // Sort for determinism
189    file_entries.sort_by(|a, b| a.0.cmp(&b.0));
190
191    for (path, mtime, size) in &file_entries {
192        path.hash(&mut hasher);
193        mtime.hash(&mut hasher);
194        size.hash(&mut hasher);
195    }
196
197    let hash = hasher.finish();
198    Ok(format!("{:016x}", hash))
199}
200
201/// Recursively collect source files with their modification time and size.
202fn collect_source_files(
203    root: &Path,
204    dir: &Path,
205    entries: &mut Vec<(String, u64, u64)>,
206) -> Result<()> {
207    let read_dir = std::fs::read_dir(dir).map_err(|e| TestxError::IoError {
208        context: format!("Failed to read directory: {}", dir.display()),
209        source: e,
210    })?;
211
212    for entry in read_dir {
213        let entry = entry.map_err(|e| TestxError::IoError {
214            context: "Failed to read directory entry".into(),
215            source: e,
216        })?;
217
218        let path = entry.path();
219        let file_name = entry.file_name();
220        let name = file_name.to_string_lossy();
221
222        // Skip hidden directories, build artifacts, and caches
223        if name.starts_with('.')
224            || name == "target"
225            || name == "node_modules"
226            || name == "__pycache__"
227            || name == "build"
228            || name == "dist"
229            || name == "vendor"
230            || name == ".testx"
231        {
232            continue;
233        }
234
235        let file_type = entry.file_type().map_err(|e| TestxError::IoError {
236            context: format!("Failed to get file type: {}", path.display()),
237            source: e,
238        })?;
239
240        if file_type.is_dir() {
241            collect_source_files(root, &path, entries)?;
242        } else if file_type.is_file()
243            && let Some(ext) = path.extension().and_then(|e| e.to_str())
244            && is_source_extension(ext)
245        {
246            let metadata = std::fs::metadata(&path).map_err(|e| TestxError::IoError {
247                context: format!("Failed to read metadata: {}", path.display()),
248                source: e,
249            })?;
250
251            let mtime = metadata
252                .modified()
253                .ok()
254                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
255                .map(|d| d.as_secs())
256                .unwrap_or(0);
257
258            let rel_path = path
259                .strip_prefix(root)
260                .unwrap_or(&path)
261                .to_string_lossy()
262                .to_string();
263
264            entries.push((rel_path, mtime, metadata.len()));
265        }
266    }
267
268    Ok(())
269}
270
271/// Check if a file extension indicates a source file.
272fn is_source_extension(ext: &str) -> bool {
273    matches!(
274        ext,
275        "rs" | "go"
276            | "py"
277            | "pyi"
278            | "js"
279            | "jsx"
280            | "ts"
281            | "tsx"
282            | "mjs"
283            | "cjs"
284            | "java"
285            | "kt"
286            | "kts"
287            | "cpp"
288            | "cc"
289            | "cxx"
290            | "c"
291            | "h"
292            | "hpp"
293            | "hxx"
294            | "rb"
295            | "ex"
296            | "exs"
297            | "php"
298            | "cs"
299            | "fs"
300            | "vb"
301            | "zig"
302            | "toml"
303            | "json"
304            | "xml"
305            | "yaml"
306            | "yml"
307            | "cfg"
308            | "ini"
309            | "gradle"
310            | "properties"
311            | "cmake"
312            | "lock"
313            | "mod"
314            | "sum"
315    )
316}
317
318/// Cache a test run result.
319pub fn cache_result(
320    project_dir: &Path,
321    hash: &str,
322    adapter: &str,
323    result: &TestRunResult,
324    extra_args: &[String],
325    config: &CacheConfig,
326) -> Result<()> {
327    let mut store = CacheStore::load(project_dir);
328
329    let entry = CacheEntry {
330        hash: hash.to_string(),
331        adapter: adapter.to_string(),
332        timestamp: SystemTime::now()
333            .duration_since(UNIX_EPOCH)
334            .unwrap_or_default()
335            .as_secs(),
336        passed: result.is_success(),
337        total_passed: result.total_passed(),
338        total_failed: result.total_failed(),
339        total_skipped: result.total_skipped(),
340        total_tests: result.total_tests(),
341        duration_ms: result.duration.as_millis() as u64,
342        extra_args: extra_args.to_vec(),
343    };
344
345    store.insert(entry, config);
346    store.save(project_dir)
347}
348
349/// Check if we have a cached result.
350pub fn check_cache(project_dir: &Path, hash: &str, config: &CacheConfig) -> Option<CacheEntry> {
351    let store = CacheStore::load(project_dir);
352    store.lookup(hash, config).cloned()
353}
354
355/// Format cache hit info for display.
356pub fn format_cache_hit(entry: &CacheEntry) -> String {
357    let age = entry.age_secs();
358    let age_str = if age < 60 {
359        format!("{}s ago", age)
360    } else if age < 3600 {
361        format!("{}m ago", age / 60)
362    } else {
363        format!("{}h ago", age / 3600)
364    };
365
366    format!(
367        "Cache hit ({}) — {} tests: {} passed, {} failed, {} skipped ({:.1}ms, cached {})",
368        entry.adapter,
369        entry.total_tests,
370        entry.total_passed,
371        entry.total_failed,
372        entry.total_skipped,
373        entry.duration_ms as f64,
374        age_str,
375    )
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::adapters::{TestCase, TestStatus, TestSuite};
382    use std::time::Duration;
383
384    fn make_result() -> TestRunResult {
385        TestRunResult {
386            suites: vec![TestSuite {
387                name: "suite".to_string(),
388                tests: vec![
389                    TestCase {
390                        name: "test_1".to_string(),
391                        status: TestStatus::Passed,
392                        duration: Duration::from_millis(10),
393                        error: None,
394                    },
395                    TestCase {
396                        name: "test_2".to_string(),
397                        status: TestStatus::Passed,
398                        duration: Duration::from_millis(20),
399                        error: None,
400                    },
401                ],
402            }],
403            duration: Duration::from_millis(30),
404            raw_exit_code: 0,
405        }
406    }
407
408    #[test]
409    fn cache_store_new_empty() {
410        let store = CacheStore::new();
411        assert!(store.is_empty());
412        assert_eq!(store.len(), 0);
413    }
414
415    #[test]
416    fn cache_store_insert_and_lookup() {
417        let config = CacheConfig::default();
418        let mut store = CacheStore::new();
419
420        let entry = CacheEntry {
421            hash: "abc123".to_string(),
422            adapter: "Rust".to_string(),
423            timestamp: SystemTime::now()
424                .duration_since(UNIX_EPOCH)
425                .unwrap()
426                .as_secs(),
427            passed: true,
428            total_passed: 5,
429            total_failed: 0,
430            total_skipped: 1,
431            total_tests: 6,
432            duration_ms: 123,
433            extra_args: vec![],
434        };
435
436        store.insert(entry.clone(), &config);
437
438        assert_eq!(store.len(), 1);
439        let found = store.lookup("abc123", &config);
440        assert!(found.is_some());
441        assert_eq!(found.unwrap().adapter, "Rust");
442    }
443
444    #[test]
445    fn cache_store_lookup_miss() {
446        let config = CacheConfig::default();
447        let store = CacheStore::new();
448        assert!(store.lookup("nonexistent", &config).is_none());
449    }
450
451    #[test]
452    fn cache_store_replaces_same_hash() {
453        let config = CacheConfig::default();
454        let mut store = CacheStore::new();
455        let now = SystemTime::now()
456            .duration_since(UNIX_EPOCH)
457            .unwrap()
458            .as_secs();
459
460        let entry1 = CacheEntry {
461            hash: "abc".to_string(),
462            adapter: "Rust".to_string(),
463            timestamp: now,
464            passed: true,
465            total_passed: 5,
466            total_failed: 0,
467            total_skipped: 0,
468            total_tests: 5,
469            duration_ms: 100,
470            extra_args: vec![],
471        };
472
473        let entry2 = CacheEntry {
474            hash: "abc".to_string(),
475            adapter: "Rust".to_string(),
476            timestamp: now + 1,
477            passed: false,
478            total_passed: 3,
479            total_failed: 2,
480            total_skipped: 0,
481            total_tests: 5,
482            duration_ms: 200,
483            extra_args: vec![],
484        };
485
486        store.insert(entry1, &config);
487        store.insert(entry2, &config);
488
489        assert_eq!(store.len(), 1);
490        let found = store.lookup("abc", &config).unwrap();
491        assert!(!found.passed);
492        assert_eq!(found.total_failed, 2);
493    }
494
495    #[test]
496    fn cache_entry_expiry() {
497        let entry = CacheEntry {
498            hash: "abc".to_string(),
499            adapter: "Rust".to_string(),
500            timestamp: 0, // Very old
501            passed: true,
502            total_passed: 5,
503            total_failed: 0,
504            total_skipped: 0,
505            total_tests: 5,
506            duration_ms: 100,
507            extra_args: vec![],
508        };
509
510        assert!(entry.is_expired(86400));
511    }
512
513    #[test]
514    fn cache_entry_not_expired() {
515        let now = SystemTime::now()
516            .duration_since(UNIX_EPOCH)
517            .unwrap()
518            .as_secs();
519
520        let entry = CacheEntry {
521            hash: "abc".to_string(),
522            adapter: "Rust".to_string(),
523            timestamp: now,
524            passed: true,
525            total_passed: 5,
526            total_failed: 0,
527            total_skipped: 0,
528            total_tests: 5,
529            duration_ms: 100,
530            extra_args: vec![],
531        };
532
533        assert!(!entry.is_expired(86400));
534    }
535
536    #[test]
537    fn cache_store_prune_expired() {
538        let config = CacheConfig {
539            max_age_secs: 10,
540            ..Default::default()
541        };
542
543        let mut store = CacheStore::new();
544
545        // Old entry
546        store.entries.push(CacheEntry {
547            hash: "old".to_string(),
548            adapter: "Rust".to_string(),
549            timestamp: 0,
550            passed: true,
551            total_passed: 1,
552            total_failed: 0,
553            total_skipped: 0,
554            total_tests: 1,
555            duration_ms: 10,
556            extra_args: vec![],
557        });
558
559        // Fresh entry
560        let now = SystemTime::now()
561            .duration_since(UNIX_EPOCH)
562            .unwrap()
563            .as_secs();
564        store.entries.push(CacheEntry {
565            hash: "new".to_string(),
566            adapter: "Rust".to_string(),
567            timestamp: now,
568            passed: true,
569            total_passed: 1,
570            total_failed: 0,
571            total_skipped: 0,
572            total_tests: 1,
573            duration_ms: 10,
574            extra_args: vec![],
575        });
576
577        store.prune(&config);
578        assert_eq!(store.len(), 1);
579        assert_eq!(store.entries[0].hash, "new");
580    }
581
582    #[test]
583    fn cache_store_prune_excess() {
584        let config = CacheConfig {
585            max_entries: 2,
586            ..Default::default()
587        };
588
589        let now = SystemTime::now()
590            .duration_since(UNIX_EPOCH)
591            .unwrap()
592            .as_secs();
593
594        let mut store = CacheStore::new();
595        for i in 0..5 {
596            store.entries.push(CacheEntry {
597                hash: format!("hash_{}", i),
598                adapter: "Rust".to_string(),
599                timestamp: now,
600                passed: true,
601                total_passed: 1,
602                total_failed: 0,
603                total_skipped: 0,
604                total_tests: 1,
605                duration_ms: 10,
606                extra_args: vec![],
607            });
608        }
609
610        store.prune(&config);
611        assert_eq!(store.len(), 2);
612        // Should keep the most recent (last two)
613        assert_eq!(store.entries[0].hash, "hash_3");
614        assert_eq!(store.entries[1].hash, "hash_4");
615    }
616
617    #[test]
618    fn cache_store_clear() {
619        let mut store = CacheStore::new();
620
621        let now = SystemTime::now()
622            .duration_since(UNIX_EPOCH)
623            .unwrap()
624            .as_secs();
625        store.entries.push(CacheEntry {
626            hash: "abc".to_string(),
627            adapter: "Rust".to_string(),
628            timestamp: now,
629            passed: true,
630            total_passed: 1,
631            total_failed: 0,
632            total_skipped: 0,
633            total_tests: 1,
634            duration_ms: 10,
635            extra_args: vec![],
636        });
637
638        assert!(!store.is_empty());
639        store.clear();
640        assert!(store.is_empty());
641    }
642
643    #[test]
644    fn cache_store_save_and_load() {
645        let dir = tempfile::tempdir().unwrap();
646        let now = SystemTime::now()
647            .duration_since(UNIX_EPOCH)
648            .unwrap()
649            .as_secs();
650
651        let mut store = CacheStore::new();
652        store.entries.push(CacheEntry {
653            hash: "disk_test".to_string(),
654            adapter: "Go".to_string(),
655            timestamp: now,
656            passed: true,
657            total_passed: 3,
658            total_failed: 0,
659            total_skipped: 1,
660            total_tests: 4,
661            duration_ms: 500,
662            extra_args: vec!["-v".to_string()],
663        });
664
665        store.save(dir.path()).unwrap();
666
667        let loaded = CacheStore::load(dir.path());
668        assert_eq!(loaded.len(), 1);
669        assert_eq!(loaded.entries[0].hash, "disk_test");
670        assert_eq!(loaded.entries[0].adapter, "Go");
671        assert_eq!(loaded.entries[0].total_passed, 3);
672        assert_eq!(loaded.entries[0].extra_args, vec!["-v"]);
673    }
674
675    #[test]
676    fn cache_store_load_missing_file() {
677        let dir = tempfile::tempdir().unwrap();
678        let store = CacheStore::load(dir.path());
679        assert!(store.is_empty());
680    }
681
682    #[test]
683    fn cache_store_load_corrupt_file() {
684        let dir = tempfile::tempdir().unwrap();
685        let cache_dir = dir.path().join(CACHE_DIR);
686        std::fs::create_dir_all(&cache_dir).unwrap();
687        std::fs::write(cache_dir.join(CACHE_FILE), "not valid json").unwrap();
688
689        let store = CacheStore::load(dir.path());
690        assert!(store.is_empty());
691    }
692
693    #[test]
694    fn compute_hash_deterministic() {
695        let dir = tempfile::tempdir().unwrap();
696        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
697
698        let hash1 = compute_project_hash(dir.path(), "Rust").unwrap();
699        let hash2 = compute_project_hash(dir.path(), "Rust").unwrap();
700        assert_eq!(hash1, hash2);
701    }
702
703    #[test]
704    fn compute_hash_different_adapters() {
705        let dir = tempfile::tempdir().unwrap();
706        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
707
708        let hash_rust = compute_project_hash(dir.path(), "Rust").unwrap();
709        let hash_go = compute_project_hash(dir.path(), "Go").unwrap();
710        assert_ne!(hash_rust, hash_go);
711    }
712
713    #[test]
714    fn compute_hash_changes_with_content() {
715        let dir = tempfile::tempdir().unwrap();
716        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
717
718        let hash1 = compute_project_hash(dir.path(), "Rust").unwrap();
719
720        // Modify file (changes mtime and/or size)
721        std::thread::sleep(std::time::Duration::from_millis(50));
722        std::fs::write(
723            dir.path().join("main.rs"),
724            "fn main() { println!(\"hello\"); }",
725        )
726        .unwrap();
727
728        let hash2 = compute_project_hash(dir.path(), "Rust").unwrap();
729        // Hash should change because mtime/size changed
730        // Note: on some filesystems mtime resolution may be coarse, so we also check size
731        assert_ne!(hash1, hash2);
732    }
733
734    #[test]
735    fn compute_hash_empty_dir() {
736        let dir = tempfile::tempdir().unwrap();
737        let hash = compute_project_hash(dir.path(), "Rust").unwrap();
738        assert!(!hash.is_empty());
739    }
740
741    #[test]
742    fn compute_hash_skips_hidden_dirs() {
743        let dir = tempfile::tempdir().unwrap();
744        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
745        std::fs::create_dir_all(dir.path().join(".git")).unwrap();
746        std::fs::write(dir.path().join(".git").join("config"), "git stuff").unwrap();
747
748        // Adding files to .git shouldn't change the hash
749        let hash1 = compute_project_hash(dir.path(), "Rust").unwrap();
750        std::fs::write(dir.path().join(".git").join("newfile"), "more stuff").unwrap();
751        let hash2 = compute_project_hash(dir.path(), "Rust").unwrap();
752
753        assert_eq!(hash1, hash2);
754    }
755
756    #[test]
757    fn is_source_ext_coverage() {
758        assert!(is_source_extension("rs"));
759        assert!(is_source_extension("py"));
760        assert!(is_source_extension("js"));
761        assert!(is_source_extension("go"));
762        assert!(is_source_extension("java"));
763        assert!(is_source_extension("cpp"));
764
765        assert!(!is_source_extension("md"));
766        assert!(!is_source_extension("png"));
767        assert!(!is_source_extension("txt"));
768        assert!(!is_source_extension(""));
769    }
770
771    #[test]
772    fn format_cache_hit_display() {
773        let now = SystemTime::now()
774            .duration_since(UNIX_EPOCH)
775            .unwrap()
776            .as_secs();
777
778        let entry = CacheEntry {
779            hash: "abc".to_string(),
780            adapter: "Rust".to_string(),
781            timestamp: now - 120, // 2 minutes ago
782            passed: true,
783            total_passed: 10,
784            total_failed: 0,
785            total_skipped: 2,
786            total_tests: 12,
787            duration_ms: 1500,
788            extra_args: vec![],
789        };
790
791        let output = format_cache_hit(&entry);
792        assert!(output.contains("Rust"));
793        assert!(output.contains("12 tests"));
794        assert!(output.contains("10 passed"));
795        assert!(output.contains("2m ago"));
796    }
797
798    #[test]
799    fn cache_result_and_check() {
800        let dir = tempfile::tempdir().unwrap();
801        let config = CacheConfig::default();
802        let result = make_result();
803
804        cache_result(dir.path(), "test_hash", "Rust", &result, &[], &config).unwrap();
805
806        let cached = check_cache(dir.path(), "test_hash", &config);
807        assert!(cached.is_some());
808        let entry = cached.unwrap();
809        assert!(entry.passed);
810        assert_eq!(entry.total_tests, 2);
811        assert_eq!(entry.total_passed, 2);
812    }
813
814    #[test]
815    fn cache_miss_different_hash() {
816        let dir = tempfile::tempdir().unwrap();
817        let config = CacheConfig::default();
818        let result = make_result();
819
820        cache_result(dir.path(), "hash_a", "Rust", &result, &[], &config).unwrap();
821
822        let cached = check_cache(dir.path(), "hash_b", &config);
823        assert!(cached.is_none());
824    }
825
826    #[test]
827    fn cache_config_defaults() {
828        let config = CacheConfig::default();
829        assert!(config.enabled);
830        assert_eq!(config.max_age_secs, 86400);
831        assert_eq!(config.max_entries, 100);
832    }
833}