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
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
105 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 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 pub fn lookup(&self, hash: &str, config: &CacheConfig) -> Option<&CacheEntry> {
156 self.entries
157 .iter()
158 .rev() .find(|e| e.hash == hash && !e.is_expired(config.max_age_secs))
160 }
161
162 pub fn insert(&mut self, entry: CacheEntry, config: &CacheConfig) {
164 self.entries.retain(|e| e.hash != entry.hash);
166 self.entries.push(entry);
167 self.prune(config);
168 }
169
170 pub fn prune(&mut self, config: &CacheConfig) {
172 self.entries.retain(|e| !e.is_expired(config.max_age_secs));
174
175 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 pub fn clear(&mut self) {
184 self.entries.clear();
185 }
186
187 pub fn len(&self) -> usize {
189 self.entries.len()
190 }
191
192 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
204pub fn compute_project_hash(project_dir: &Path, adapter_name: &str) -> Result<String> {
209 let mut hasher = StableHasher::new();
210
211 adapter_name.hash(&mut hasher);
213
214 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 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
232const MAX_SOURCE_DEPTH: usize = 20;
234
235fn 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 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 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
318fn 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
365pub 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
396pub 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
402pub 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, 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 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 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 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 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 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 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, 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 #[test]
884 fn collect_source_files_deep_nesting_no_crash() {
885 let dir = tempfile::tempdir().unwrap();
886
887 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(¤t).unwrap();
893 std::fs::write(current.join("deep.rs"), "fn deep() {}").unwrap();
894
895 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 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(¤t).unwrap();
910 std::fs::write(current.join("too_deep.rs"), "fn too_deep() {}").unwrap();
911
912 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 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 std::os::unix::fs::symlink(dir.path(), sub.join("loop")).unwrap();
934
935 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 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 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}