Skip to main content

testx/
cache.rs

1use std::hash::{Hash, Hasher};
2use std::path::Path;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use crate::adapters::TestRunResult;
6use crate::error::{Result, TestxError};
7use crate::hash::StableHasher;
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
105        // Guard against symlink-based cache poisoning: if .testx is a symlink,
106        // refuse to write through it.
107        if cache_dir.exists() && cache_dir.read_link().is_ok() {
108            return Err(TestxError::IoError {
109                context: format!(
110                    "Cache directory is a symlink (possible symlink attack): {}",
111                    cache_dir.display()
112                ),
113                source: std::io::Error::new(
114                    std::io::ErrorKind::PermissionDenied,
115                    "symlink in cache path",
116                ),
117            });
118        }
119
120        if !cache_dir.exists() {
121            std::fs::create_dir_all(&cache_dir).map_err(|e| TestxError::IoError {
122                context: format!("Failed to create cache directory: {}", cache_dir.display()),
123                source: e,
124            })?;
125        }
126
127        let cache_path = cache_dir.join(CACHE_FILE);
128
129        // Also check if the cache file itself is a symlink
130        if cache_path.exists() && cache_path.read_link().is_ok() {
131            return Err(TestxError::IoError {
132                context: format!(
133                    "Cache file is a symlink (possible symlink attack): {}",
134                    cache_path.display()
135                ),
136                source: std::io::Error::new(
137                    std::io::ErrorKind::PermissionDenied,
138                    "symlink in cache path",
139                ),
140            });
141        }
142        let content = serde_json::to_string_pretty(self).map_err(|e| TestxError::ConfigError {
143            message: format!("Failed to serialize cache: {}", e),
144        })?;
145
146        std::fs::write(&cache_path, content).map_err(|e| TestxError::IoError {
147            context: format!("Failed to write cache file: {}", cache_path.display()),
148            source: e,
149        })?;
150
151        Ok(())
152    }
153
154    /// Look up a cache entry by hash.
155    pub fn lookup(&self, hash: &str, config: &CacheConfig) -> Option<&CacheEntry> {
156        self.entries
157            .iter()
158            .rev() // Most recent first
159            .find(|e| e.hash == hash && !e.is_expired(config.max_age_secs))
160    }
161
162    /// Insert a new cache entry.
163    pub fn insert(&mut self, entry: CacheEntry, config: &CacheConfig) {
164        // Remove old entries with the same hash
165        self.entries.retain(|e| e.hash != entry.hash);
166        self.entries.push(entry);
167        self.prune(config);
168    }
169
170    /// Remove expired and excess entries.
171    pub fn prune(&mut self, config: &CacheConfig) {
172        // Remove expired entries
173        self.entries.retain(|e| !e.is_expired(config.max_age_secs));
174
175        // Keep only the most recent entries
176        if self.entries.len() > config.max_entries {
177            let excess = self.entries.len() - config.max_entries;
178            self.entries.drain(..excess);
179        }
180    }
181
182    /// Clear all cache entries.
183    pub fn clear(&mut self) {
184        self.entries.clear();
185    }
186
187    /// Number of entries.
188    pub fn len(&self) -> usize {
189        self.entries.len()
190    }
191
192    /// Whether the cache is empty.
193    pub fn is_empty(&self) -> bool {
194        self.entries.is_empty()
195    }
196}
197
198impl Default for CacheStore {
199    fn default() -> Self {
200        Self::new()
201    }
202}
203
204/// Compute a content hash of the project's source files.
205///
206/// This walks the project directory, collecting file modification times and sizes
207/// for relevant source files, then produces a combined hash.
208pub fn compute_project_hash(project_dir: &Path, adapter_name: &str) -> Result<String> {
209    let mut hasher = StableHasher::new();
210
211    // Hash the adapter name so different adapters have different cache keys
212    adapter_name.hash(&mut hasher);
213
214    // Collect relevant files and their metadata
215    let mut file_entries: Vec<(String, u64, u64)> = Vec::new();
216    let mut visited = std::collections::HashSet::new();
217    collect_source_files(project_dir, project_dir, &mut file_entries, 0, &mut visited)?;
218
219    // Sort for determinism
220    file_entries.sort_by(|a, b| a.0.cmp(&b.0));
221
222    for (path, mtime, size) in &file_entries {
223        path.hash(&mut hasher);
224        mtime.hash(&mut hasher);
225        size.hash(&mut hasher);
226    }
227
228    let hash = hasher.finish();
229    Ok(format!("{:016x}", hash))
230}
231
232/// Maximum recursion depth for source file collection.
233const MAX_SOURCE_DEPTH: usize = 20;
234
235/// Recursively collect source files with their modification time and size.
236fn collect_source_files(
237    root: &Path,
238    dir: &Path,
239    entries: &mut Vec<(String, u64, u64)>,
240    depth: usize,
241    visited: &mut std::collections::HashSet<std::path::PathBuf>,
242) -> Result<()> {
243    if depth > MAX_SOURCE_DEPTH {
244        return Ok(());
245    }
246
247    // Canonicalize to avoid symlink loops
248    if let Ok(canonical) = dir.canonicalize()
249        && !visited.insert(canonical)
250    {
251        return Ok(());
252    }
253
254    let read_dir = std::fs::read_dir(dir).map_err(|e| TestxError::IoError {
255        context: format!("Failed to read directory: {}", dir.display()),
256        source: e,
257    })?;
258
259    for entry in read_dir {
260        let entry = entry.map_err(|e| TestxError::IoError {
261            context: "Failed to read directory entry".into(),
262            source: e,
263        })?;
264
265        let path = entry.path();
266        let file_name = entry.file_name();
267        let name = file_name.to_string_lossy();
268
269        // Skip hidden directories, build artifacts, and caches
270        if name.starts_with('.')
271            || name == "target"
272            || name == "node_modules"
273            || name == "__pycache__"
274            || name == "build"
275            || name == "dist"
276            || name == "vendor"
277            || name == ".testx"
278        {
279            continue;
280        }
281
282        let file_type = entry.file_type().map_err(|e| TestxError::IoError {
283            context: format!("Failed to get file type: {}", path.display()),
284            source: e,
285        })?;
286
287        if file_type.is_dir() {
288            collect_source_files(root, &path, entries, depth + 1, visited)?;
289        } else if file_type.is_file()
290            && let Some(ext) = path.extension().and_then(|e| e.to_str())
291            && is_source_extension(ext)
292        {
293            let metadata = std::fs::metadata(&path).map_err(|e| TestxError::IoError {
294                context: format!("Failed to read metadata: {}", path.display()),
295                source: e,
296            })?;
297
298            let mtime = metadata
299                .modified()
300                .ok()
301                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
302                .map(|d| d.as_secs())
303                .unwrap_or(0);
304
305            let rel_path = path
306                .strip_prefix(root)
307                .unwrap_or(&path)
308                .to_string_lossy()
309                .to_string();
310
311            entries.push((rel_path, mtime, metadata.len()));
312        }
313    }
314
315    Ok(())
316}
317
318/// Check if a file extension indicates a source file.
319fn is_source_extension(ext: &str) -> bool {
320    matches!(
321        ext,
322        "rs" | "go"
323            | "py"
324            | "pyi"
325            | "js"
326            | "jsx"
327            | "ts"
328            | "tsx"
329            | "mjs"
330            | "cjs"
331            | "java"
332            | "kt"
333            | "kts"
334            | "cpp"
335            | "cc"
336            | "cxx"
337            | "c"
338            | "h"
339            | "hpp"
340            | "hxx"
341            | "rb"
342            | "ex"
343            | "exs"
344            | "php"
345            | "cs"
346            | "fs"
347            | "vb"
348            | "zig"
349            | "toml"
350            | "json"
351            | "xml"
352            | "yaml"
353            | "yml"
354            | "cfg"
355            | "ini"
356            | "gradle"
357            | "properties"
358            | "cmake"
359            | "lock"
360            | "mod"
361            | "sum"
362    )
363}
364
365/// Cache a test run result.
366pub fn cache_result(
367    project_dir: &Path,
368    hash: &str,
369    adapter: &str,
370    result: &TestRunResult,
371    extra_args: &[String],
372    config: &CacheConfig,
373) -> Result<()> {
374    let mut store = CacheStore::load(project_dir);
375
376    let entry = CacheEntry {
377        hash: hash.to_string(),
378        adapter: adapter.to_string(),
379        timestamp: SystemTime::now()
380            .duration_since(UNIX_EPOCH)
381            .unwrap_or_default()
382            .as_secs(),
383        passed: result.is_success(),
384        total_passed: result.total_passed(),
385        total_failed: result.total_failed(),
386        total_skipped: result.total_skipped(),
387        total_tests: result.total_tests(),
388        duration_ms: result.duration.as_millis() as u64,
389        extra_args: extra_args.to_vec(),
390    };
391
392    store.insert(entry, config);
393    store.save(project_dir)
394}
395
396/// Check if we have a cached result.
397pub fn check_cache(project_dir: &Path, hash: &str, config: &CacheConfig) -> Option<CacheEntry> {
398    let store = CacheStore::load(project_dir);
399    store.lookup(hash, config).cloned()
400}
401
402/// Format cache hit info for display.
403pub fn format_cache_hit(entry: &CacheEntry) -> String {
404    let age = entry.age_secs();
405    let age_str = if age < 60 {
406        format!("{}s ago", age)
407    } else if age < 3600 {
408        format!("{}m ago", age / 60)
409    } else {
410        format!("{}h ago", age / 3600)
411    };
412
413    format!(
414        "Cache hit ({}) — {} tests: {} passed, {} failed, {} skipped ({:.1}ms, cached {})",
415        entry.adapter,
416        entry.total_tests,
417        entry.total_passed,
418        entry.total_failed,
419        entry.total_skipped,
420        entry.duration_ms as f64,
421        age_str,
422    )
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use crate::adapters::{TestCase, TestStatus, TestSuite};
429    use std::time::Duration;
430
431    fn make_result() -> TestRunResult {
432        TestRunResult {
433            suites: vec![TestSuite {
434                name: "suite".to_string(),
435                tests: vec![
436                    TestCase {
437                        name: "test_1".to_string(),
438                        status: TestStatus::Passed,
439                        duration: Duration::from_millis(10),
440                        error: None,
441                    },
442                    TestCase {
443                        name: "test_2".to_string(),
444                        status: TestStatus::Passed,
445                        duration: Duration::from_millis(20),
446                        error: None,
447                    },
448                ],
449            }],
450            duration: Duration::from_millis(30),
451            raw_exit_code: 0,
452        }
453    }
454
455    #[test]
456    fn cache_store_new_empty() {
457        let store = CacheStore::new();
458        assert!(store.is_empty());
459        assert_eq!(store.len(), 0);
460    }
461
462    #[test]
463    fn cache_store_insert_and_lookup() {
464        let config = CacheConfig::default();
465        let mut store = CacheStore::new();
466
467        let entry = CacheEntry {
468            hash: "abc123".to_string(),
469            adapter: "Rust".to_string(),
470            timestamp: SystemTime::now()
471                .duration_since(UNIX_EPOCH)
472                .unwrap()
473                .as_secs(),
474            passed: true,
475            total_passed: 5,
476            total_failed: 0,
477            total_skipped: 1,
478            total_tests: 6,
479            duration_ms: 123,
480            extra_args: vec![],
481        };
482
483        store.insert(entry.clone(), &config);
484
485        assert_eq!(store.len(), 1);
486        let found = store.lookup("abc123", &config);
487        assert!(found.is_some());
488        assert_eq!(found.unwrap().adapter, "Rust");
489    }
490
491    #[test]
492    fn cache_store_lookup_miss() {
493        let config = CacheConfig::default();
494        let store = CacheStore::new();
495        assert!(store.lookup("nonexistent", &config).is_none());
496    }
497
498    #[test]
499    fn cache_store_replaces_same_hash() {
500        let config = CacheConfig::default();
501        let mut store = CacheStore::new();
502        let now = SystemTime::now()
503            .duration_since(UNIX_EPOCH)
504            .unwrap()
505            .as_secs();
506
507        let entry1 = CacheEntry {
508            hash: "abc".to_string(),
509            adapter: "Rust".to_string(),
510            timestamp: now,
511            passed: true,
512            total_passed: 5,
513            total_failed: 0,
514            total_skipped: 0,
515            total_tests: 5,
516            duration_ms: 100,
517            extra_args: vec![],
518        };
519
520        let entry2 = CacheEntry {
521            hash: "abc".to_string(),
522            adapter: "Rust".to_string(),
523            timestamp: now + 1,
524            passed: false,
525            total_passed: 3,
526            total_failed: 2,
527            total_skipped: 0,
528            total_tests: 5,
529            duration_ms: 200,
530            extra_args: vec![],
531        };
532
533        store.insert(entry1, &config);
534        store.insert(entry2, &config);
535
536        assert_eq!(store.len(), 1);
537        let found = store.lookup("abc", &config).unwrap();
538        assert!(!found.passed);
539        assert_eq!(found.total_failed, 2);
540    }
541
542    #[test]
543    fn cache_entry_expiry() {
544        let entry = CacheEntry {
545            hash: "abc".to_string(),
546            adapter: "Rust".to_string(),
547            timestamp: 0, // Very old
548            passed: true,
549            total_passed: 5,
550            total_failed: 0,
551            total_skipped: 0,
552            total_tests: 5,
553            duration_ms: 100,
554            extra_args: vec![],
555        };
556
557        assert!(entry.is_expired(86400));
558    }
559
560    #[test]
561    fn cache_entry_not_expired() {
562        let now = SystemTime::now()
563            .duration_since(UNIX_EPOCH)
564            .unwrap()
565            .as_secs();
566
567        let entry = CacheEntry {
568            hash: "abc".to_string(),
569            adapter: "Rust".to_string(),
570            timestamp: now,
571            passed: true,
572            total_passed: 5,
573            total_failed: 0,
574            total_skipped: 0,
575            total_tests: 5,
576            duration_ms: 100,
577            extra_args: vec![],
578        };
579
580        assert!(!entry.is_expired(86400));
581    }
582
583    #[test]
584    fn cache_store_prune_expired() {
585        let config = CacheConfig {
586            max_age_secs: 10,
587            ..Default::default()
588        };
589
590        let mut store = CacheStore::new();
591
592        // Old entry
593        store.entries.push(CacheEntry {
594            hash: "old".to_string(),
595            adapter: "Rust".to_string(),
596            timestamp: 0,
597            passed: true,
598            total_passed: 1,
599            total_failed: 0,
600            total_skipped: 0,
601            total_tests: 1,
602            duration_ms: 10,
603            extra_args: vec![],
604        });
605
606        // Fresh entry
607        let now = SystemTime::now()
608            .duration_since(UNIX_EPOCH)
609            .unwrap()
610            .as_secs();
611        store.entries.push(CacheEntry {
612            hash: "new".to_string(),
613            adapter: "Rust".to_string(),
614            timestamp: now,
615            passed: true,
616            total_passed: 1,
617            total_failed: 0,
618            total_skipped: 0,
619            total_tests: 1,
620            duration_ms: 10,
621            extra_args: vec![],
622        });
623
624        store.prune(&config);
625        assert_eq!(store.len(), 1);
626        assert_eq!(store.entries[0].hash, "new");
627    }
628
629    #[test]
630    fn cache_store_prune_excess() {
631        let config = CacheConfig {
632            max_entries: 2,
633            ..Default::default()
634        };
635
636        let now = SystemTime::now()
637            .duration_since(UNIX_EPOCH)
638            .unwrap()
639            .as_secs();
640
641        let mut store = CacheStore::new();
642        for i in 0..5 {
643            store.entries.push(CacheEntry {
644                hash: format!("hash_{}", i),
645                adapter: "Rust".to_string(),
646                timestamp: now,
647                passed: true,
648                total_passed: 1,
649                total_failed: 0,
650                total_skipped: 0,
651                total_tests: 1,
652                duration_ms: 10,
653                extra_args: vec![],
654            });
655        }
656
657        store.prune(&config);
658        assert_eq!(store.len(), 2);
659        // Should keep the most recent (last two)
660        assert_eq!(store.entries[0].hash, "hash_3");
661        assert_eq!(store.entries[1].hash, "hash_4");
662    }
663
664    #[test]
665    fn cache_store_clear() {
666        let mut store = CacheStore::new();
667
668        let now = SystemTime::now()
669            .duration_since(UNIX_EPOCH)
670            .unwrap()
671            .as_secs();
672        store.entries.push(CacheEntry {
673            hash: "abc".to_string(),
674            adapter: "Rust".to_string(),
675            timestamp: now,
676            passed: true,
677            total_passed: 1,
678            total_failed: 0,
679            total_skipped: 0,
680            total_tests: 1,
681            duration_ms: 10,
682            extra_args: vec![],
683        });
684
685        assert!(!store.is_empty());
686        store.clear();
687        assert!(store.is_empty());
688    }
689
690    #[test]
691    fn cache_store_save_and_load() {
692        let dir = tempfile::tempdir().unwrap();
693        let now = SystemTime::now()
694            .duration_since(UNIX_EPOCH)
695            .unwrap()
696            .as_secs();
697
698        let mut store = CacheStore::new();
699        store.entries.push(CacheEntry {
700            hash: "disk_test".to_string(),
701            adapter: "Go".to_string(),
702            timestamp: now,
703            passed: true,
704            total_passed: 3,
705            total_failed: 0,
706            total_skipped: 1,
707            total_tests: 4,
708            duration_ms: 500,
709            extra_args: vec!["-v".to_string()],
710        });
711
712        store.save(dir.path()).unwrap();
713
714        let loaded = CacheStore::load(dir.path());
715        assert_eq!(loaded.len(), 1);
716        assert_eq!(loaded.entries[0].hash, "disk_test");
717        assert_eq!(loaded.entries[0].adapter, "Go");
718        assert_eq!(loaded.entries[0].total_passed, 3);
719        assert_eq!(loaded.entries[0].extra_args, vec!["-v"]);
720    }
721
722    #[test]
723    fn cache_store_load_missing_file() {
724        let dir = tempfile::tempdir().unwrap();
725        let store = CacheStore::load(dir.path());
726        assert!(store.is_empty());
727    }
728
729    #[test]
730    fn cache_store_load_corrupt_file() {
731        let dir = tempfile::tempdir().unwrap();
732        let cache_dir = dir.path().join(CACHE_DIR);
733        std::fs::create_dir_all(&cache_dir).unwrap();
734        std::fs::write(cache_dir.join(CACHE_FILE), "not valid json").unwrap();
735
736        let store = CacheStore::load(dir.path());
737        assert!(store.is_empty());
738    }
739
740    #[test]
741    fn compute_hash_deterministic() {
742        let dir = tempfile::tempdir().unwrap();
743        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
744
745        let hash1 = compute_project_hash(dir.path(), "Rust").unwrap();
746        let hash2 = compute_project_hash(dir.path(), "Rust").unwrap();
747        assert_eq!(hash1, hash2);
748    }
749
750    #[test]
751    fn compute_hash_different_adapters() {
752        let dir = tempfile::tempdir().unwrap();
753        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
754
755        let hash_rust = compute_project_hash(dir.path(), "Rust").unwrap();
756        let hash_go = compute_project_hash(dir.path(), "Go").unwrap();
757        assert_ne!(hash_rust, hash_go);
758    }
759
760    #[test]
761    fn compute_hash_changes_with_content() {
762        let dir = tempfile::tempdir().unwrap();
763        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
764
765        let hash1 = compute_project_hash(dir.path(), "Rust").unwrap();
766
767        // Modify file (changes mtime and/or size)
768        std::thread::sleep(std::time::Duration::from_millis(50));
769        std::fs::write(
770            dir.path().join("main.rs"),
771            "fn main() { println!(\"hello\"); }",
772        )
773        .unwrap();
774
775        let hash2 = compute_project_hash(dir.path(), "Rust").unwrap();
776        // Hash should change because mtime/size changed
777        // Note: on some filesystems mtime resolution may be coarse, so we also check size
778        assert_ne!(hash1, hash2);
779    }
780
781    #[test]
782    fn compute_hash_empty_dir() {
783        let dir = tempfile::tempdir().unwrap();
784        let hash = compute_project_hash(dir.path(), "Rust").unwrap();
785        assert!(!hash.is_empty());
786    }
787
788    #[test]
789    fn compute_hash_skips_hidden_dirs() {
790        let dir = tempfile::tempdir().unwrap();
791        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
792        std::fs::create_dir_all(dir.path().join(".git")).unwrap();
793        std::fs::write(dir.path().join(".git").join("config"), "git stuff").unwrap();
794
795        // Adding files to .git shouldn't change the hash
796        let hash1 = compute_project_hash(dir.path(), "Rust").unwrap();
797        std::fs::write(dir.path().join(".git").join("newfile"), "more stuff").unwrap();
798        let hash2 = compute_project_hash(dir.path(), "Rust").unwrap();
799
800        assert_eq!(hash1, hash2);
801    }
802
803    #[test]
804    fn is_source_ext_coverage() {
805        assert!(is_source_extension("rs"));
806        assert!(is_source_extension("py"));
807        assert!(is_source_extension("js"));
808        assert!(is_source_extension("go"));
809        assert!(is_source_extension("java"));
810        assert!(is_source_extension("cpp"));
811
812        assert!(!is_source_extension("md"));
813        assert!(!is_source_extension("png"));
814        assert!(!is_source_extension("txt"));
815        assert!(!is_source_extension(""));
816    }
817
818    #[test]
819    fn format_cache_hit_display() {
820        let now = SystemTime::now()
821            .duration_since(UNIX_EPOCH)
822            .unwrap()
823            .as_secs();
824
825        let entry = CacheEntry {
826            hash: "abc".to_string(),
827            adapter: "Rust".to_string(),
828            timestamp: now - 120, // 2 minutes ago
829            passed: true,
830            total_passed: 10,
831            total_failed: 0,
832            total_skipped: 2,
833            total_tests: 12,
834            duration_ms: 1500,
835            extra_args: vec![],
836        };
837
838        let output = format_cache_hit(&entry);
839        assert!(output.contains("Rust"));
840        assert!(output.contains("12 tests"));
841        assert!(output.contains("10 passed"));
842        assert!(output.contains("2m ago"));
843    }
844
845    #[test]
846    fn cache_result_and_check() {
847        let dir = tempfile::tempdir().unwrap();
848        let config = CacheConfig::default();
849        let result = make_result();
850
851        cache_result(dir.path(), "test_hash", "Rust", &result, &[], &config).unwrap();
852
853        let cached = check_cache(dir.path(), "test_hash", &config);
854        assert!(cached.is_some());
855        let entry = cached.unwrap();
856        assert!(entry.passed);
857        assert_eq!(entry.total_tests, 2);
858        assert_eq!(entry.total_passed, 2);
859    }
860
861    #[test]
862    fn cache_miss_different_hash() {
863        let dir = tempfile::tempdir().unwrap();
864        let config = CacheConfig::default();
865        let result = make_result();
866
867        cache_result(dir.path(), "hash_a", "Rust", &result, &[], &config).unwrap();
868
869        let cached = check_cache(dir.path(), "hash_b", &config);
870        assert!(cached.is_none());
871    }
872
873    #[test]
874    fn cache_config_defaults() {
875        let config = CacheConfig::default();
876        assert!(config.enabled);
877        assert_eq!(config.max_age_secs, 86400);
878        assert_eq!(config.max_entries, 100);
879    }
880
881    // ─── Recursion depth safety ───
882
883    #[test]
884    fn collect_source_files_deep_nesting_no_crash() {
885        let dir = tempfile::tempdir().unwrap();
886
887        // Create a 50-level deep directory tree with source files
888        let mut current = dir.path().to_path_buf();
889        for i in 0..50 {
890            current = current.join(format!("level_{}", i));
891        }
892        std::fs::create_dir_all(&current).unwrap();
893        std::fs::write(current.join("deep.rs"), "fn deep() {}").unwrap();
894
895        // Should not crash or stack overflow
896        let hash = compute_project_hash(dir.path(), "Rust").unwrap();
897        assert!(!hash.is_empty());
898    }
899
900    #[test]
901    fn collect_source_files_respects_max_depth() {
902        let dir = tempfile::tempdir().unwrap();
903
904        // Create a tree deeper than MAX_SOURCE_DEPTH (20)
905        let mut current = dir.path().to_path_buf();
906        for i in 0..25 {
907            current = current.join(format!("d{}", i));
908        }
909        std::fs::create_dir_all(&current).unwrap();
910        std::fs::write(current.join("too_deep.rs"), "fn too_deep() {}").unwrap();
911
912        // The file at depth 25 should be unreachable
913        let mut entries = Vec::new();
914        let mut visited = std::collections::HashSet::new();
915        collect_source_files(dir.path(), dir.path(), &mut entries, 0, &mut visited).unwrap();
916
917        // No file entry should contain "too_deep" since it's past MAX_SOURCE_DEPTH
918        assert!(
919            !entries.iter().any(|(path, _, _)| path.contains("too_deep")),
920            "files beyond MAX_SOURCE_DEPTH should not be collected"
921        );
922    }
923
924    #[cfg(unix)]
925    #[test]
926    fn collect_source_files_symlink_loop_safe() {
927        let dir = tempfile::tempdir().unwrap();
928        let sub = dir.path().join("src");
929        std::fs::create_dir_all(&sub).unwrap();
930        std::fs::write(sub.join("lib.rs"), "fn lib() {}").unwrap();
931
932        // Create symlink loop: src/loop -> parent
933        std::os::unix::fs::symlink(dir.path(), sub.join("loop")).unwrap();
934
935        // Should not hang
936        let hash = compute_project_hash(dir.path(), "Rust").unwrap();
937        assert!(!hash.is_empty());
938    }
939
940    #[test]
941    fn collect_source_files_many_files_no_crash() {
942        let dir = tempfile::tempdir().unwrap();
943
944        // Create 200 source files in one directory
945        for i in 0..200 {
946            std::fs::write(
947                dir.path().join(format!("file_{}.rs", i)),
948                format!("fn f{}() {{}}", i),
949            )
950            .unwrap();
951        }
952
953        let hash = compute_project_hash(dir.path(), "Rust").unwrap();
954        assert!(!hash.is_empty());
955
956        // Verify all files were collected
957        let mut entries = Vec::new();
958        let mut visited = std::collections::HashSet::new();
959        collect_source_files(dir.path(), dir.path(), &mut entries, 0, &mut visited).unwrap();
960        assert_eq!(entries.len(), 200, "should collect all 200 source files");
961    }
962}