1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4mod constants;
5
6#[cfg(feature = "process-scoped-cache")]
7use std::cell::Cell;
8use std::env;
9use std::fs;
10use std::fs::OpenOptions;
11use std::io;
12use std::path::{Path, PathBuf};
13#[cfg(feature = "process-scoped-cache")]
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16
17use constants::{CACHE_DIR_NAME, CARGO_TOML_FILE_NAME};
18#[cfg(feature = "process-scoped-cache")]
19use tempfile::{Builder, TempDir};
20
21#[derive(Clone, Debug, PartialEq, Eq, Default)]
32pub struct EvictPolicy {
33 pub max_files: Option<usize>,
38 pub max_bytes: Option<u64>,
48 pub max_age: Option<Duration>,
52}
53
54#[derive(Clone, Debug, PartialEq, Eq, Default)]
56pub struct EvictionReport {
57 pub marked_for_eviction: Vec<PathBuf>,
59}
60
61#[derive(Clone, Debug)]
62struct FileEntry {
63 path: PathBuf,
64 modified: SystemTime,
65 len: u64,
66}
67
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct CacheRoot {
75 root: PathBuf,
76}
77
78impl CacheRoot {
79 pub fn from_discovery() -> io::Result<Self> {
91 let cwd = env::current_dir()?;
92 let anchor = find_crate_root(&cwd).unwrap_or(cwd);
93 let anchor = anchor.canonicalize().unwrap_or(anchor);
97 let root = anchor.join(CACHE_DIR_NAME);
98 Ok(Self { root })
99 }
100
101 pub fn from_root<P: Into<PathBuf>>(root: P) -> Self {
103 Self { root: root.into() }
104 }
105
106 pub fn path(&self) -> &Path {
108 &self.root
109 }
110
111 pub fn group<P: AsRef<Path>>(&self, relative_group: P) -> CacheGroup {
113 let path = self.root.join(relative_group.as_ref());
114 CacheGroup { path }
115 }
116
117 pub fn group_path<P: AsRef<Path>>(&self, relative_group: P) -> PathBuf {
119 self.root.join(relative_group.as_ref())
120 }
121
122 pub fn ensure_group<P: AsRef<Path>>(&self, relative_group: P) -> io::Result<PathBuf> {
124 self.ensure_group_with_policy(relative_group, None)
125 }
126
127 pub fn ensure_group_with_policy<P: AsRef<Path>>(
132 &self,
133 relative_group: P,
134 policy: Option<&EvictPolicy>,
135 ) -> io::Result<PathBuf> {
136 let group = self.group(relative_group);
137 group.ensure_dir_with_policy(policy)?;
138 Ok(group.path().to_path_buf())
139 }
140
141 pub fn cache_path<P: AsRef<Path>, Q: AsRef<Path>>(
145 &self,
146 cache_dir: P,
147 relative_path: Q,
148 ) -> PathBuf {
149 let rel = relative_path.as_ref();
150 if rel.is_absolute() {
151 return rel.to_path_buf();
152 }
153 self.group(cache_dir).entry_path(rel)
154 }
155}
156
157#[derive(Clone, Debug, PartialEq, Eq)]
158pub struct CacheGroup {
163 path: PathBuf,
164}
165
166impl CacheGroup {
167 pub fn path(&self) -> &Path {
169 &self.path
170 }
171
172 pub fn ensure_dir(&self) -> io::Result<&Path> {
174 self.ensure_dir_with_policy(None)
175 }
176
177 pub fn ensure_dir_with_policy(&self, policy: Option<&EvictPolicy>) -> io::Result<&Path> {
183 fs::create_dir_all(&self.path)?;
184 if let Some(policy) = policy {
185 apply_evict_policy(&self.path, policy)?;
186 }
187 Ok(&self.path)
188 }
189
190 pub fn eviction_report(&self, policy: &EvictPolicy) -> io::Result<EvictionReport> {
199 build_eviction_report(&self.path, policy)
200 }
201
202 pub fn subgroup<P: AsRef<Path>>(&self, relative_group: P) -> Self {
204 Self {
205 path: self.path.join(relative_group.as_ref()),
206 }
207 }
208
209 pub fn entry_path<P: AsRef<Path>>(&self, relative_file: P) -> PathBuf {
211 self.path.join(relative_file.as_ref())
212 }
213
214 pub fn touch<P: AsRef<Path>>(&self, relative_file: P) -> io::Result<PathBuf> {
217 let entry = self.entry_path(relative_file);
218 if let Some(parent) = entry.parent() {
219 fs::create_dir_all(parent)?;
220 }
221 OpenOptions::new().create(true).append(true).open(&entry)?;
222 Ok(entry)
223 }
224}
225
226#[cfg(feature = "process-scoped-cache")]
239#[derive(Debug)]
240pub struct ProcessScopedCacheGroup {
241 process_group: CacheGroup,
242 _temp_dir: TempDir,
243}
244
245#[cfg(feature = "process-scoped-cache")]
246impl ProcessScopedCacheGroup {
247 pub fn new<P: AsRef<Path>>(root: &CacheRoot, relative_group: P) -> io::Result<Self> {
249 Self::from_group(root.group(relative_group))
250 }
251
252 pub fn from_group(base_group: CacheGroup) -> io::Result<Self> {
254 base_group.ensure_dir()?;
255 let pid = std::process::id();
256 let temp_dir = Builder::new()
257 .prefix(&format!("pid-{pid}-"))
258 .tempdir_in(base_group.path())?;
259 let process_group = CacheGroup {
260 path: temp_dir.path().to_path_buf(),
261 };
262
263 Ok(Self {
264 process_group,
265 _temp_dir: temp_dir,
266 })
267 }
268
269 pub fn path(&self) -> &Path {
271 self.process_group.path()
272 }
273
274 pub fn process_group(&self) -> CacheGroup {
276 self.process_group.clone()
277 }
278
279 pub fn thread_group(&self) -> CacheGroup {
284 self.process_group
285 .subgroup(format!("thread-{}", current_thread_cache_group_id()))
286 }
287
288 pub fn ensure_thread_group(&self) -> io::Result<CacheGroup> {
290 let group = self.thread_group();
291 group.ensure_dir()?;
292 Ok(group)
293 }
294
295 pub fn thread_entry_path<P: AsRef<Path>>(&self, relative_file: P) -> PathBuf {
297 self.thread_group().entry_path(relative_file)
298 }
299
300 pub fn touch_thread_entry<P: AsRef<Path>>(&self, relative_file: P) -> io::Result<PathBuf> {
302 self.ensure_thread_group()?.touch(relative_file)
303 }
304}
305
306#[cfg(feature = "process-scoped-cache")]
307fn current_thread_cache_group_id() -> u64 {
308 thread_local! {
309 static THREAD_GROUP_ID: Cell<Option<u64>> = const { Cell::new(None) };
310 }
311
312 static NEXT_THREAD_GROUP_ID: AtomicU64 = AtomicU64::new(1);
313
314 THREAD_GROUP_ID.with(|slot| {
315 if let Some(id) = slot.get() {
316 id
317 } else {
318 let id = NEXT_THREAD_GROUP_ID.fetch_add(1, Ordering::Relaxed);
319 slot.set(Some(id));
320 id
321 }
322 })
323}
324
325fn find_crate_root(start: &Path) -> Option<PathBuf> {
326 let mut current = start.to_path_buf();
327 loop {
328 if current.join(CARGO_TOML_FILE_NAME).is_file() {
329 return Some(current);
330 }
331 if !current.pop() {
332 return None;
333 }
334 }
335}
336
337fn apply_evict_policy(root: &Path, policy: &EvictPolicy) -> io::Result<()> {
338 let report = build_eviction_report(root, policy)?;
339
340 for path in report.marked_for_eviction {
341 let _ = fs::remove_file(path);
342 }
343
344 Ok(())
345}
346
347fn sort_entries_oldest_first(entries: &mut [FileEntry]) {
348 entries.sort_by(|a, b| {
349 let ta = a
350 .modified
351 .duration_since(UNIX_EPOCH)
352 .unwrap_or(Duration::ZERO);
353 let tb = b
354 .modified
355 .duration_since(UNIX_EPOCH)
356 .unwrap_or(Duration::ZERO);
357 ta.cmp(&tb).then_with(|| a.path.cmp(&b.path))
358 });
359}
360
361fn build_eviction_report(root: &Path, policy: &EvictPolicy) -> io::Result<EvictionReport> {
362 let mut entries = collect_files(root)?;
363 let mut marked_for_eviction = Vec::new();
364
365 if let Some(max_age) = policy.max_age {
366 let now = SystemTime::now();
367 let mut survivors = Vec::with_capacity(entries.len());
368 for entry in entries {
369 let age = now.duration_since(entry.modified).unwrap_or(Duration::ZERO);
370 if age >= max_age {
371 marked_for_eviction.push(entry.path);
372 } else {
373 survivors.push(entry);
374 }
375 }
376 entries = survivors;
377 }
378
379 sort_entries_oldest_first(&mut entries);
380
381 if let Some(max_files) = policy.max_files
382 && entries.len() > max_files
383 {
384 let to_remove = entries.len() - max_files;
385 for entry in entries.iter().take(to_remove) {
386 marked_for_eviction.push(entry.path.clone());
387 }
388 entries = entries.into_iter().skip(to_remove).collect();
389 sort_entries_oldest_first(&mut entries);
390 }
391
392 if let Some(max_bytes) = policy.max_bytes {
393 let mut total: u64 = entries.iter().map(|e| e.len).sum();
394 if total > max_bytes {
395 for entry in &entries {
396 if total <= max_bytes {
397 break;
398 }
399 marked_for_eviction.push(entry.path.clone());
400 total = total.saturating_sub(entry.len);
401 }
402 }
403 }
404
405 Ok(EvictionReport {
406 marked_for_eviction,
407 })
408}
409
410fn collect_files(root: &Path) -> io::Result<Vec<FileEntry>> {
411 let mut out = Vec::new();
412 collect_files_recursive(root, &mut out)?;
413 Ok(out)
414}
415
416fn collect_files_recursive(dir: &Path, out: &mut Vec<FileEntry>) -> io::Result<()> {
417 for entry in fs::read_dir(dir)? {
418 let entry = entry?;
419 let path = entry.path();
420 let meta = entry.metadata()?;
421 if meta.is_dir() {
422 collect_files_recursive(&path, out)?;
423 } else if meta.is_file() {
424 out.push(FileEntry {
425 path,
426 modified: meta.modified().unwrap_or(UNIX_EPOCH),
427 len: meta.len(),
428 });
429 }
430 }
431 Ok(())
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use std::collections::BTreeSet;
438 use std::sync::{Mutex, MutexGuard, OnceLock};
446 use tempfile::TempDir;
447
448 fn cwd_lock() -> &'static Mutex<()> {
452 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
453 LOCK.get_or_init(|| Mutex::new(()))
454 }
455
456 struct CwdGuard {
461 previous: PathBuf,
462 _cwd_lock: MutexGuard<'static, ()>,
465 }
466
467 impl CwdGuard {
468 fn swap_to(path: &Path) -> io::Result<Self> {
469 let cwd_lock_guard = cwd_lock().lock().expect("acquire cwd test lock");
472 let previous = env::current_dir()?;
473 env::set_current_dir(path)?;
474 Ok(Self {
475 previous,
476 _cwd_lock: cwd_lock_guard,
477 })
478 }
479 }
480
481 impl Drop for CwdGuard {
482 fn drop(&mut self) {
483 let _ = env::set_current_dir(&self.previous);
484 }
485 }
486
487 #[test]
488 fn from_discovery_uses_cwd_dot_cache_when_no_cargo_toml() {
489 let tmp = TempDir::new().expect("tempdir");
490 let _guard = CwdGuard::swap_to(tmp.path()).expect("set cwd");
491
492 let cache = CacheRoot::from_discovery().expect("discover");
493 let got = cache.path().to_path_buf();
494 let expected = tmp
495 .path()
496 .canonicalize()
497 .expect("canonicalize temp path")
498 .join(CACHE_DIR_NAME);
499 assert_eq!(got, expected);
500 }
501
502 #[test]
503 fn from_discovery_prefers_nearest_crate_root() {
504 let tmp = TempDir::new().expect("tempdir");
505 let crate_root = tmp.path().join("workspace");
506 let nested = crate_root.join("src").join("nested");
507 fs::create_dir_all(&nested).expect("create nested");
508 fs::write(
509 crate_root.join(CARGO_TOML_FILE_NAME),
510 "[package]\nname='x'\nversion='0.1.0'\nedition='2024'\n",
511 )
512 .expect("write cargo");
513
514 let _guard = CwdGuard::swap_to(&nested).expect("set cwd");
515 let cache = CacheRoot::from_discovery().expect("discover");
516 let got = cache.path().to_path_buf();
517 let expected = crate_root
518 .canonicalize()
519 .expect("canonicalize crate root")
520 .join(CACHE_DIR_NAME);
521 assert_eq!(got, expected);
522 }
523
524 #[test]
525 fn from_root_supports_arbitrary_path_and_grouping() {
526 let tmp = TempDir::new().expect("tempdir");
527 let root = CacheRoot::from_root(tmp.path().join("custom-cache-root"));
528 let group = root.group("taxonomy/v1");
529
530 assert_eq!(group.path(), root.path().join("taxonomy/v1").as_path());
531 }
532
533 #[test]
534 fn group_path_building_and_dir_creation() {
535 let tmp = TempDir::new().expect("tempdir");
536 let cache = CacheRoot::from_root(tmp.path());
537 let group = cache.group("artifacts/json");
538
539 let nested_group = group.subgroup("v1");
540 let ensured = nested_group.ensure_dir().expect("ensure nested dir");
541 let expected_group_suffix = Path::new("artifacts").join("json").join("v1");
542 assert!(ensured.ends_with(&expected_group_suffix));
543 assert!(ensured.exists());
544
545 let entry = nested_group.entry_path("a/b/cache.json");
546 let expected_entry_suffix = Path::new("artifacts")
547 .join("json")
548 .join("v1")
549 .join("a")
550 .join("b")
551 .join("cache.json");
552 assert!(entry.ends_with(&expected_entry_suffix));
553 }
554
555 #[test]
556 fn touch_creates_blank_file_and_is_idempotent() {
557 let tmp = TempDir::new().expect("tempdir");
558 let cache = CacheRoot::from_root(tmp.path());
559 let group = cache.group("artifacts/json");
560
561 let touched = group.touch("a/b/cache.json").expect("touch file");
562 assert!(touched.exists());
563 let meta = fs::metadata(&touched).expect("metadata");
564 assert_eq!(meta.len(), 0);
565
566 let touched_again = group.touch("a/b/cache.json").expect("touch file again");
567 assert_eq!(touched_again, touched);
568 let meta_again = fs::metadata(&touched_again).expect("metadata again");
569 assert_eq!(meta_again.len(), 0);
570 }
571
572 #[test]
573 fn touch_with_root_group_and_empty_relative_path_errors() {
574 let root = CacheRoot::from_root("/");
575 let group = root.group("");
576
577 let result = group.touch("");
578 assert!(result.is_err());
579 }
580
581 #[test]
582 fn from_discovery_cache_path_uses_root_and_group() {
583 let tmp = TempDir::new().expect("tempdir");
584 let crate_root = tmp.path().join("workspace");
585 let nested = crate_root.join("src").join("nested");
586 fs::create_dir_all(&nested).expect("create nested");
587 fs::write(
588 crate_root.join(CARGO_TOML_FILE_NAME),
589 "[package]\nname='x'\nversion='0.1.0'\nedition='2024'\n",
590 )
591 .expect("write cargo");
592
593 let _guard = CwdGuard::swap_to(&nested).expect("set cwd");
594 let p = CacheRoot::from_discovery()
595 .expect("discover")
596 .cache_path("taxonomy", "taxonomy_cache.json");
597 let parent = p.parent().expect("cache path parent");
598 fs::create_dir_all(parent).expect("create cache parent");
599 let expected_dir = crate_root.join(CACHE_DIR_NAME).join("taxonomy");
603 fs::create_dir_all(&expected_dir).expect("create expected cache parent");
604 let got_parent = p
605 .parent()
606 .expect("cache path parent")
607 .canonicalize()
608 .expect("canonicalize cache parent");
609 let expected_parent = crate_root
610 .join(CACHE_DIR_NAME)
611 .join("taxonomy")
612 .canonicalize()
613 .expect("canonicalize expected parent");
614 assert_eq!(got_parent, expected_parent);
615 assert_eq!(
616 p.file_name().and_then(|s| s.to_str()),
617 Some("taxonomy_cache.json")
618 );
619 }
620
621 #[test]
622 fn from_discovery_ignores_other_custom_cache_dir_names() {
623 let tmp = TempDir::new().expect("tempdir");
624 let crate_root = tmp.path().join("workspace");
625 let nested = crate_root.join("src").join("nested");
626 fs::create_dir_all(&nested).expect("create nested");
627 fs::write(
628 crate_root.join(CARGO_TOML_FILE_NAME),
629 "[package]\nname='x'\nversion='0.1.0'\nedition='2024'\n",
630 )
631 .expect("write cargo");
632
633 fs::create_dir_all(crate_root.join(".cache-v2")).expect("create custom cache dir");
635
636 let _guard = CwdGuard::swap_to(&nested).expect("set cwd");
637 let cache = CacheRoot::from_discovery().expect("discover");
638
639 let expected = crate_root
641 .canonicalize()
642 .expect("canonicalize crate root")
643 .join(CACHE_DIR_NAME);
644 assert_eq!(cache.path(), expected);
645 }
646
647 #[test]
648 fn cache_path_preserves_absolute_paths() {
649 let root = CacheRoot::from_root("/tmp/project");
650 let absolute = PathBuf::from("/tmp/custom/cache.json");
651 let resolved = root.cache_path(CACHE_DIR_NAME, &absolute);
652 assert_eq!(resolved, absolute);
653 }
654
655 #[test]
656 fn ensure_dir_with_policy_max_files() {
657 let tmp = TempDir::new().expect("tempdir");
658 let cache = CacheRoot::from_root(tmp.path());
659 let group = cache.group("artifacts");
660 group.ensure_dir().expect("ensure dir");
661
662 fs::write(group.entry_path("a.txt"), b"1").expect("write a");
663 fs::write(group.entry_path("b.txt"), b"1").expect("write b");
664 fs::write(group.entry_path("c.txt"), b"1").expect("write c");
665
666 let policy = EvictPolicy {
667 max_files: Some(2),
668 ..EvictPolicy::default()
669 };
670 group
671 .ensure_dir_with_policy(Some(&policy))
672 .expect("ensure with policy");
673
674 let files = collect_files(group.path()).expect("collect files");
675 assert_eq!(files.len(), 2);
676 }
677
678 #[test]
679 fn ensure_dir_with_policy_max_bytes() {
680 let tmp = TempDir::new().expect("tempdir");
681 let cache = CacheRoot::from_root(tmp.path());
682 let group = cache.group("artifacts");
683 group.ensure_dir().expect("ensure dir");
684
685 fs::write(group.entry_path("a.bin"), vec![1u8; 5]).expect("write a");
686 fs::write(group.entry_path("b.bin"), vec![1u8; 5]).expect("write b");
687 fs::write(group.entry_path("c.bin"), vec![1u8; 5]).expect("write c");
688
689 let policy = EvictPolicy {
690 max_bytes: Some(10),
691 ..EvictPolicy::default()
692 };
693 group
694 .ensure_dir_with_policy(Some(&policy))
695 .expect("ensure with policy");
696
697 let total: u64 = collect_files(group.path())
698 .expect("collect files")
699 .iter()
700 .map(|f| f.len)
701 .sum();
702 assert!(total <= 10);
703 }
704
705 #[test]
706 fn ensure_dir_with_policy_max_age_zero_evicts_all() {
707 let tmp = TempDir::new().expect("tempdir");
708 let cache = CacheRoot::from_root(tmp.path());
709 let group = cache.group("artifacts");
710 group.ensure_dir().expect("ensure dir");
711
712 fs::write(group.entry_path("a.txt"), b"1").expect("write a");
713 fs::write(group.entry_path("b.txt"), b"1").expect("write b");
714
715 let policy = EvictPolicy {
716 max_age: Some(Duration::ZERO),
717 ..EvictPolicy::default()
718 };
719 group
720 .ensure_dir_with_policy(Some(&policy))
721 .expect("ensure with policy");
722
723 let files = collect_files(group.path()).expect("collect files");
724 assert!(files.is_empty());
725 }
726
727 #[test]
728 fn eviction_report_matches_applied_evictions() {
729 let tmp = TempDir::new().expect("tempdir");
730 let cache = CacheRoot::from_root(tmp.path());
731 let group = cache.group("artifacts");
732 group.ensure_dir().expect("ensure dir");
733
734 fs::write(group.entry_path("a.bin"), vec![1u8; 5]).expect("write a");
735 fs::write(group.entry_path("b.bin"), vec![1u8; 5]).expect("write b");
736 fs::write(group.entry_path("c.bin"), vec![1u8; 5]).expect("write c");
737
738 let policy = EvictPolicy {
739 max_bytes: Some(10),
740 ..EvictPolicy::default()
741 };
742
743 let before: BTreeSet<PathBuf> = collect_files(group.path())
744 .expect("collect before")
745 .into_iter()
746 .map(|f| f.path)
747 .collect();
748
749 let report = group.eviction_report(&policy).expect("eviction report");
750 let planned: BTreeSet<PathBuf> = report.marked_for_eviction.iter().cloned().collect();
751
752 group
753 .ensure_dir_with_policy(Some(&policy))
754 .expect("ensure with policy");
755
756 let after: BTreeSet<PathBuf> = collect_files(group.path())
757 .expect("collect after")
758 .into_iter()
759 .map(|f| f.path)
760 .collect();
761
762 let expected_after: BTreeSet<PathBuf> = before.difference(&planned).cloned().collect();
763 assert_eq!(after, expected_after);
764 }
765
766 #[test]
767 fn no_policy_and_default_policy_report_do_not_mark_evictions() {
768 let tmp = TempDir::new().expect("tempdir");
769 let cache = CacheRoot::from_root(tmp.path());
770 let group = cache.group("artifacts");
771 group.ensure_dir().expect("ensure dir");
772
773 fs::write(group.entry_path("a.txt"), b"1").expect("write a");
774 fs::write(group.entry_path("b.txt"), b"1").expect("write b");
775
776 let report = group
777 .eviction_report(&EvictPolicy::default())
778 .expect("eviction report");
779 assert!(report.marked_for_eviction.is_empty());
780
781 group
782 .ensure_dir_with_policy(None)
783 .expect("ensure with no policy");
784
785 let files = collect_files(group.path()).expect("collect files");
786 assert_eq!(files.len(), 2);
787 }
788
789 #[test]
790 fn eviction_policy_applies_in_documented_order() {
791 let tmp = TempDir::new().expect("tempdir");
792 let cache = CacheRoot::from_root(tmp.path());
793 let group = cache.group("artifacts");
794 group.ensure_dir().expect("ensure dir");
795
796 fs::write(group.entry_path("old.txt"), vec![1u8; 1]).expect("write old");
797
798 std::thread::sleep(Duration::from_millis(300));
799
800 fs::write(group.entry_path("b.bin"), vec![1u8; 7]).expect("write b");
801 fs::write(group.entry_path("c.bin"), vec![1u8; 6]).expect("write c");
802 fs::write(group.entry_path("d.bin"), vec![1u8; 1]).expect("write d");
803
804 let policy = EvictPolicy {
805 max_age: Some(Duration::from_millis(200)),
806 max_files: Some(2),
807 max_bytes: Some(5),
808 };
809
810 let report = group.eviction_report(&policy).expect("eviction report");
811 let evicted_names: Vec<String> = report
812 .marked_for_eviction
813 .iter()
814 .map(|path| {
815 path.file_name()
816 .and_then(|name| name.to_str())
817 .expect("evicted file name")
818 .to_string()
819 })
820 .collect();
821
822 assert_eq!(evicted_names, vec!["old.txt", "b.bin", "c.bin"]);
823
824 group
825 .ensure_dir_with_policy(Some(&policy))
826 .expect("apply policy");
827
828 let remaining_names: BTreeSet<String> = collect_files(group.path())
829 .expect("collect remaining")
830 .into_iter()
831 .map(|entry| {
832 entry
833 .path
834 .file_name()
835 .and_then(|name| name.to_str())
836 .expect("remaining file name")
837 .to_string()
838 })
839 .collect();
840
841 assert_eq!(remaining_names, BTreeSet::from(["d.bin".to_string()]));
842 }
843
844 #[test]
845 fn sort_entries_uses_path_as_tie_break_for_equal_modified_time() {
846 let same_time = UNIX_EPOCH + Duration::from_secs(1_234_567);
847 let mut entries = vec![
848 FileEntry {
849 path: PathBuf::from("z.bin"),
850 modified: same_time,
851 len: 1,
852 },
853 FileEntry {
854 path: PathBuf::from("a.bin"),
855 modified: same_time,
856 len: 1,
857 },
858 FileEntry {
859 path: PathBuf::from("m.bin"),
860 modified: same_time,
861 len: 1,
862 },
863 ];
864
865 sort_entries_oldest_first(&mut entries);
866
867 let ordered_paths: Vec<PathBuf> = entries.into_iter().map(|entry| entry.path).collect();
868 assert_eq!(
869 ordered_paths,
870 vec![
871 PathBuf::from("a.bin"),
872 PathBuf::from("m.bin"),
873 PathBuf::from("z.bin")
874 ]
875 );
876 }
877
878 #[test]
879 fn single_root_supports_distinct_policies_per_subdirectory() {
880 let tmp = TempDir::new().expect("tempdir");
881 let cache = CacheRoot::from_root(tmp.path());
882
883 let images = cache.group("artifacts/images");
884 let reports = cache.group("artifacts/reports");
885
886 images.ensure_dir().expect("ensure images dir");
887 reports.ensure_dir().expect("ensure reports dir");
888
889 fs::write(images.entry_path("img1.bin"), vec![1u8; 5]).expect("write img1");
890 fs::write(images.entry_path("img2.bin"), vec![1u8; 5]).expect("write img2");
891 fs::write(images.entry_path("img3.bin"), vec![1u8; 5]).expect("write img3");
892
893 fs::write(reports.entry_path("a.txt"), b"1").expect("write report a");
894 fs::write(reports.entry_path("b.txt"), b"1").expect("write report b");
895 fs::write(reports.entry_path("c.txt"), b"1").expect("write report c");
896
897 let images_policy = EvictPolicy {
898 max_bytes: Some(10),
899 ..EvictPolicy::default()
900 };
901 let reports_policy = EvictPolicy {
902 max_files: Some(1),
903 ..EvictPolicy::default()
904 };
905
906 images
907 .ensure_dir_with_policy(Some(&images_policy))
908 .expect("apply images policy");
909 reports
910 .ensure_dir_with_policy(Some(&reports_policy))
911 .expect("apply reports policy");
912
913 let images_total: u64 = collect_files(images.path())
914 .expect("collect images files")
915 .iter()
916 .map(|f| f.len)
917 .sum();
918 assert!(images_total <= 10);
919
920 let reports_files = collect_files(reports.path()).expect("collect reports files");
921 assert_eq!(reports_files.len(), 1);
922 }
923
924 #[test]
925 fn group_path_and_ensure_group_create_expected_directory() {
926 let tmp = TempDir::new().expect("tempdir");
927 let cache = CacheRoot::from_root(tmp.path());
928
929 let expected = tmp.path().join("a/b/c");
930 assert_eq!(cache.group_path("a/b/c"), expected);
931
932 let ensured = cache.ensure_group("a/b/c").expect("ensure group");
933 assert_eq!(ensured, expected);
934 assert!(ensured.is_dir());
935 }
936
937 #[test]
938 fn ensure_group_with_policy_applies_eviction_rules() {
939 let tmp = TempDir::new().expect("tempdir");
940 let cache = CacheRoot::from_root(tmp.path());
941
942 cache
943 .ensure_group_with_policy("artifacts", None)
944 .expect("ensure group without policy");
945
946 let group = cache.group("artifacts");
947 fs::write(group.entry_path("a.bin"), vec![1u8; 1]).expect("write a");
948 fs::write(group.entry_path("b.bin"), vec![1u8; 1]).expect("write b");
949 fs::write(group.entry_path("c.bin"), vec![1u8; 1]).expect("write c");
950
951 let policy = EvictPolicy {
952 max_files: Some(1),
953 ..EvictPolicy::default()
954 };
955
956 let ensured = cache
957 .ensure_group_with_policy("artifacts", Some(&policy))
958 .expect("ensure group with policy");
959 assert_eq!(ensured, group.path());
960
961 let files = collect_files(group.path()).expect("collect files");
962 assert_eq!(files.len(), 1);
963 }
964
965 #[test]
966 fn cache_path_joins_relative_paths_under_group() {
967 let tmp = TempDir::new().expect("tempdir");
968 let cache = CacheRoot::from_root(tmp.path());
969
970 let got = cache.cache_path(CACHE_DIR_NAME, "tool/v1/data.bin");
971 let expected = tmp
972 .path()
973 .join(CACHE_DIR_NAME)
974 .join("tool")
975 .join("v1")
976 .join("data.bin");
977 assert_eq!(got, expected);
978 }
979
980 #[test]
981 fn subgroup_touch_creates_parent_directories() {
982 let tmp = TempDir::new().expect("tempdir");
983 let cache = CacheRoot::from_root(tmp.path());
984 let subgroup = cache.group("artifacts").subgroup("json/v1");
985
986 let touched = subgroup
987 .touch("nested/output.bin")
988 .expect("touch subgroup entry");
989
990 assert!(touched.is_file());
991 assert!(subgroup.path().join("nested").is_dir());
992 }
993
994 #[test]
995 fn eviction_report_errors_when_group_directory_is_missing() {
996 let tmp = TempDir::new().expect("tempdir");
997 let cache = CacheRoot::from_root(tmp.path());
998 let missing = cache.group("does-not-exist");
999
1000 let err = missing
1001 .eviction_report(&EvictPolicy::default())
1002 .expect_err("eviction report should fail for missing directory");
1003 assert_eq!(err.kind(), io::ErrorKind::NotFound);
1004 }
1005
1006 #[test]
1007 fn eviction_policy_scans_nested_subdirectories_recursively() {
1008 let tmp = TempDir::new().expect("tempdir");
1009 let cache = CacheRoot::from_root(tmp.path());
1010 let group = cache.group("artifacts");
1011 group.ensure_dir().expect("ensure dir");
1012
1013 fs::create_dir_all(group.entry_path("nested/deeper")).expect("create nested dirs");
1014 fs::write(group.entry_path("root.bin"), vec![1u8; 1]).expect("write root");
1015 fs::write(group.entry_path("nested/a.bin"), vec![1u8; 1]).expect("write nested a");
1016 fs::write(group.entry_path("nested/deeper/b.bin"), vec![1u8; 1]).expect("write nested b");
1017
1018 let policy = EvictPolicy {
1019 max_files: Some(1),
1020 ..EvictPolicy::default()
1021 };
1022
1023 group
1024 .ensure_dir_with_policy(Some(&policy))
1025 .expect("apply recursive policy");
1026
1027 let remaining = collect_files(group.path()).expect("collect remaining");
1028 assert_eq!(remaining.len(), 1);
1029 }
1030
1031 #[cfg(unix)]
1032 #[test]
1033 fn collect_files_recursive_ignores_non_file_non_directory_entries() {
1034 use std::os::unix::net::UnixListener;
1035
1036 let tmp = TempDir::new().expect("tempdir");
1037 let cache = CacheRoot::from_root(tmp.path());
1038 let group = cache.group("artifacts");
1039 group.ensure_dir().expect("ensure dir");
1040
1041 let socket_path = group.entry_path("live.sock");
1042 let _listener = UnixListener::bind(&socket_path).expect("bind unix socket");
1043
1044 fs::write(group.entry_path("a.bin"), vec![1u8; 1]).expect("write file");
1045
1046 let files = collect_files(group.path()).expect("collect files");
1047 assert_eq!(files.len(), 1);
1048 assert_eq!(files[0].path, group.entry_path("a.bin"));
1049 }
1050
1051 #[test]
1052 fn max_bytes_policy_under_threshold_does_not_evict() {
1053 let tmp = TempDir::new().expect("tempdir");
1054 let cache = CacheRoot::from_root(tmp.path());
1055 let group = cache.group("artifacts");
1056 group.ensure_dir().expect("ensure dir");
1057
1058 fs::write(group.entry_path("a.bin"), vec![1u8; 2]).expect("write a");
1059 fs::write(group.entry_path("b.bin"), vec![1u8; 3]).expect("write b");
1060
1061 let policy = EvictPolicy {
1062 max_bytes: Some(10),
1063 ..EvictPolicy::default()
1064 };
1065
1066 let report = group.eviction_report(&policy).expect("eviction report");
1067 assert!(report.marked_for_eviction.is_empty());
1068
1069 group
1070 .ensure_dir_with_policy(Some(&policy))
1071 .expect("ensure with policy");
1072
1073 let files = collect_files(group.path()).expect("collect files");
1074 assert_eq!(files.len(), 2);
1075 }
1076
1077 #[test]
1078 fn cwd_guard_swap_to_returns_error_for_missing_directory() {
1079 let tmp = TempDir::new().expect("tempdir");
1080 let missing = tmp.path().join("missing-dir");
1081
1082 let result = CwdGuard::swap_to(&missing);
1083 assert!(result.is_err());
1084 assert_eq!(
1085 result.err().expect("expected missing-dir error").kind(),
1086 io::ErrorKind::NotFound
1087 );
1088 }
1089
1090 #[test]
1091 fn ensure_dir_equals_ensure_dir_with_policy_none() {
1092 let tmp = TempDir::new().expect("tempdir");
1093 let cache = CacheRoot::from_root(tmp.path());
1094 let group = cache.group("artifacts/eq");
1095
1096 let p1 = group.ensure_dir().expect("ensure dir");
1097 fs::write(group.entry_path("keep.txt"), b"keep").expect("write file");
1100
1101 let p2 = group
1102 .ensure_dir_with_policy(None)
1103 .expect("ensure dir with None policy");
1104
1105 assert_eq!(p1, p2);
1106 assert!(group.entry_path("keep.txt").exists());
1107 }
1108
1109 #[test]
1110 fn ensure_group_equals_ensure_group_with_policy_none() {
1111 let tmp = TempDir::new().expect("tempdir");
1112 let cache = CacheRoot::from_root(tmp.path());
1113
1114 let p1 = cache.ensure_group("artifacts/roots").expect("ensure group");
1115 let group = cache.group("artifacts/roots");
1116 fs::write(group.entry_path("keep_root.txt"), b"keep").expect("write file");
1118
1119 let p2 = cache
1120 .ensure_group_with_policy("artifacts/roots", None)
1121 .expect("ensure group with None policy");
1122
1123 assert_eq!(p1, p2);
1124 assert!(group.entry_path("keep_root.txt").exists());
1125 }
1126
1127 #[cfg(feature = "process-scoped-cache")]
1128 #[test]
1129 fn process_scoped_cache_respects_root_and_group_assignments() {
1130 let tmp = TempDir::new().expect("tempdir");
1131 let root = CacheRoot::from_root(tmp.path().join("custom-root"));
1132
1133 let scoped = ProcessScopedCacheGroup::new(&root, "artifacts/session").expect("create");
1134 let expected_prefix = root.group("artifacts/session").path().to_path_buf();
1135
1136 assert!(scoped.path().starts_with(&expected_prefix));
1137 assert!(scoped.path().exists());
1138 }
1139
1140 #[cfg(feature = "process-scoped-cache")]
1141 #[test]
1142 fn process_scoped_cache_deletes_directory_on_drop() {
1143 let tmp = TempDir::new().expect("tempdir");
1144 let root = CacheRoot::from_root(tmp.path());
1145
1146 let process_dir = {
1147 let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create");
1148 let p = scoped.path().to_path_buf();
1149 assert!(p.exists());
1150 p
1151 };
1152
1153 assert!(!process_dir.exists());
1154 }
1155
1156 #[cfg(feature = "process-scoped-cache")]
1157 #[test]
1158 fn process_scoped_cache_thread_group_is_stable_per_thread() {
1159 let tmp = TempDir::new().expect("tempdir");
1160 let root = CacheRoot::from_root(tmp.path());
1161 let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create");
1162
1163 let first = scoped.thread_group().path().to_path_buf();
1164 let second = scoped.thread_group().path().to_path_buf();
1165
1166 assert_eq!(first, second);
1167 }
1168
1169 #[cfg(feature = "process-scoped-cache")]
1170 #[test]
1171 fn process_scoped_cache_thread_group_differs_across_threads() {
1172 let tmp = TempDir::new().expect("tempdir");
1173 let root = CacheRoot::from_root(tmp.path());
1174 let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create");
1175
1176 let main_thread_group = scoped.thread_group().path().to_path_buf();
1177 let other_thread_group = std::thread::spawn(current_thread_cache_group_id)
1178 .join()
1179 .expect("join thread");
1180
1181 let expected_other = scoped
1182 .process_group()
1183 .subgroup(format!("thread-{other_thread_group}"))
1184 .path()
1185 .to_path_buf();
1186
1187 assert_ne!(main_thread_group, expected_other);
1188 }
1189
1190 #[cfg(feature = "process-scoped-cache")]
1191 #[test]
1192 fn process_scoped_cache_from_group_uses_given_base_group() {
1193 let tmp = TempDir::new().expect("tempdir");
1194 let root = CacheRoot::from_root(tmp.path());
1195 let base_group = root.group("artifacts/custom-base");
1196
1197 let scoped = ProcessScopedCacheGroup::from_group(base_group.clone()).expect("create");
1198
1199 assert!(scoped.path().starts_with(base_group.path()));
1200 assert_eq!(scoped.process_group().path(), scoped.path());
1201 }
1202
1203 #[cfg(feature = "process-scoped-cache")]
1204 #[test]
1205 fn process_scoped_cache_thread_entry_path_matches_touch_location() {
1206 let tmp = TempDir::new().expect("tempdir");
1207 let root = CacheRoot::from_root(tmp.path());
1208 let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create");
1209
1210 let planned = scoped.thread_entry_path("nested/data.bin");
1211 let touched = scoped
1212 .touch_thread_entry("nested/data.bin")
1213 .expect("touch thread entry");
1214
1215 assert_eq!(planned, touched);
1216 assert!(touched.exists());
1217 }
1218
1219 #[cfg(feature = "process-scoped-cache")]
1220 #[test]
1221 fn touch_thread_entry_creates_entry_under_thread_group() {
1222 let tmp = TempDir::new().expect("tempdir");
1223 let root = CacheRoot::from_root(tmp.path());
1224 let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create");
1225
1226 let entry = scoped
1227 .touch_thread_entry("nested/data.bin")
1228 .expect("touch thread entry");
1229
1230 assert!(entry.exists());
1231 assert!(entry.starts_with(scoped.path()));
1232 let thread_group = scoped.thread_group().path().to_path_buf();
1233 assert!(entry.starts_with(&thread_group));
1234 }
1235}