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
9const CACHE_DIR: &str = ".testx";
11const CACHE_FILE: &str = "cache.json";
12const MAX_CACHE_ENTRIES: usize = 100;
13
14#[derive(Debug, Clone)]
16pub struct CacheConfig {
17 pub enabled: bool,
19 pub max_age_secs: u64,
21 pub max_entries: usize,
23}
24
25impl Default for CacheConfig {
26 fn default() -> Self {
27 Self {
28 enabled: true,
29 max_age_secs: 86400, max_entries: MAX_CACHE_ENTRIES,
31 }
32 }
33}
34
35#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37pub struct CacheEntry {
38 pub hash: String,
40 pub adapter: String,
42 pub timestamp: u64,
44 pub passed: bool,
46 pub total_passed: usize,
48 pub total_failed: usize,
49 pub total_skipped: usize,
50 pub total_tests: usize,
51 pub duration_ms: u64,
53 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#[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 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 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 pub fn lookup(&self, hash: &str, config: &CacheConfig) -> Option<&CacheEntry> {
126 self.entries
127 .iter()
128 .rev() .find(|e| e.hash == hash && !e.is_expired(config.max_age_secs))
130 }
131
132 pub fn insert(&mut self, entry: CacheEntry, config: &CacheConfig) {
134 self.entries.retain(|e| e.hash != entry.hash);
136 self.entries.push(entry);
137 self.prune(config);
138 }
139
140 pub fn prune(&mut self, config: &CacheConfig) {
142 self.entries.retain(|e| !e.is_expired(config.max_age_secs));
144
145 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 pub fn clear(&mut self) {
154 self.entries.clear();
155 }
156
157 pub fn len(&self) -> usize {
159 self.entries.len()
160 }
161
162 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
174pub fn compute_project_hash(project_dir: &Path, adapter_name: &str) -> Result<String> {
179 let mut hasher = DefaultHasher::new();
180
181 adapter_name.hash(&mut hasher);
183
184 let mut file_entries: Vec<(String, u64, u64)> = Vec::new();
186 collect_source_files(project_dir, project_dir, &mut file_entries)?;
187
188 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
201fn 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 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
271fn 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
318pub 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
349pub 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
355pub 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, 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 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 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 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 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 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 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, 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}