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 = "os-cache-dir")]
19use directories::ProjectDirs;
20#[cfg(feature = "process-scoped-cache")]
21use tempfile::{Builder, TempDir};
22
23#[cfg(feature = "os-cache-dir")]
24fn project_dirs_or_not_found(project_dirs: Option<ProjectDirs>) -> io::Result<ProjectDirs> {
25 project_dirs.ok_or_else(|| {
26 io::Error::new(
27 io::ErrorKind::NotFound,
28 "could not resolve an OS cache directory for the provided project identity",
29 )
30 })
31}
32
33#[derive(Clone, Debug, PartialEq, Eq, Default)]
44pub struct EvictPolicy {
45 pub max_files: Option<usize>,
50 pub max_bytes: Option<u64>,
60 pub max_age: Option<Duration>,
64}
65
66#[derive(Clone, Debug, PartialEq, Eq, Default)]
68pub struct EvictionReport {
69 pub marked_for_eviction: Vec<PathBuf>,
71}
72
73#[derive(Clone, Debug)]
74struct FileEntry {
75 path: PathBuf,
76 modified: SystemTime,
77 len: u64,
78}
79
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct CacheRoot {
87 root: PathBuf,
88}
89
90impl CacheRoot {
91 pub fn from_discovery() -> io::Result<Self> {
104 let cwd = env::current_dir()?;
105 let anchor = find_crate_root(&cwd).unwrap_or(cwd);
106 let anchor = anchor.canonicalize().unwrap_or(anchor);
110 let root = anchor.join(CACHE_DIR_NAME);
111 Ok(Self { root })
112 }
113
114 pub fn from_root<P: Into<PathBuf>>(root: P) -> Self {
116 Self { root: root.into() }
117 }
118
119 #[cfg(feature = "os-cache-dir")]
142 pub fn from_project_dirs(
143 qualifier: &str,
144 organization: &str,
145 application: &str,
146 ) -> io::Result<Self> {
147 let project_dirs =
148 project_dirs_or_not_found(ProjectDirs::from(qualifier, organization, application))?;
149
150 Ok(Self {
151 root: project_dirs.cache_dir().to_path_buf(),
152 })
153 }
154
155 #[cfg(feature = "process-scoped-cache")]
166 pub fn from_tempdir() -> io::Result<Self> {
167 let root = TempDir::new()?.keep();
168 Ok(Self { root })
169 }
170
171 pub fn path(&self) -> &Path {
173 &self.root
174 }
175
176 pub fn group<P: AsRef<Path>>(&self, relative_group: P) -> CacheGroup {
178 let path = self.root.join(relative_group.as_ref());
179 CacheGroup { path }
180 }
181
182 pub fn group_path<P: AsRef<Path>>(&self, relative_group: P) -> PathBuf {
184 self.root.join(relative_group.as_ref())
185 }
186
187 pub fn ensure_group<P: AsRef<Path>>(&self, relative_group: P) -> io::Result<PathBuf> {
189 self.ensure_group_with_policy(relative_group, None)
190 }
191
192 pub fn ensure_group_with_policy<P: AsRef<Path>>(
197 &self,
198 relative_group: P,
199 policy: Option<&EvictPolicy>,
200 ) -> io::Result<PathBuf> {
201 let group = self.group(relative_group);
202 group.ensure_dir_with_policy(policy)?;
203 Ok(group.path().to_path_buf())
204 }
205
206 pub fn cache_path<P: AsRef<Path>, Q: AsRef<Path>>(
210 &self,
211 cache_dir: P,
212 relative_path: Q,
213 ) -> PathBuf {
214 let rel = relative_path.as_ref();
215 if rel.is_absolute() {
216 return rel.to_path_buf();
217 }
218 self.group(cache_dir).entry_path(rel)
219 }
220}
221
222#[derive(Clone, Debug, PartialEq, Eq)]
223pub struct CacheGroup {
228 path: PathBuf,
229}
230
231impl CacheGroup {
232 pub fn path(&self) -> &Path {
234 &self.path
235 }
236
237 pub fn ensure_dir(&self) -> io::Result<&Path> {
239 self.ensure_dir_with_policy(None)
240 }
241
242 pub fn ensure_dir_with_policy(&self, policy: Option<&EvictPolicy>) -> io::Result<&Path> {
248 fs::create_dir_all(&self.path)?;
249 if let Some(policy) = policy {
250 apply_evict_policy(&self.path, policy)?;
251 }
252 Ok(&self.path)
253 }
254
255 pub fn eviction_report(&self, policy: &EvictPolicy) -> io::Result<EvictionReport> {
264 build_eviction_report(&self.path, policy)
265 }
266
267 pub fn subgroup<P: AsRef<Path>>(&self, relative_group: P) -> Self {
269 Self {
270 path: self.path.join(relative_group.as_ref()),
271 }
272 }
273
274 pub fn entry_path<P: AsRef<Path>>(&self, relative_file: P) -> PathBuf {
276 self.path.join(relative_file.as_ref())
277 }
278
279 pub fn touch<P: AsRef<Path>>(&self, relative_file: P) -> io::Result<PathBuf> {
282 let entry = self.entry_path(relative_file);
283 if let Some(parent) = entry.parent() {
284 fs::create_dir_all(parent)?;
285 }
286 OpenOptions::new().create(true).append(true).open(&entry)?;
287 Ok(entry)
288 }
289}
290
291#[cfg(feature = "process-scoped-cache")]
304#[derive(Debug)]
305pub struct ProcessScopedCacheGroup {
306 process_group: CacheGroup,
307 _temp_dir: TempDir,
308}
309
310#[cfg(feature = "process-scoped-cache")]
311impl ProcessScopedCacheGroup {
312 pub fn new<P: AsRef<Path>>(root: &CacheRoot, relative_group: P) -> io::Result<Self> {
314 Self::from_group(root.group(relative_group))
315 }
316
317 pub fn from_group(base_group: CacheGroup) -> io::Result<Self> {
319 base_group.ensure_dir()?;
320 let pid = std::process::id();
321 let temp_dir = Builder::new()
322 .prefix(&format!("pid-{pid}-"))
323 .tempdir_in(base_group.path())?;
324 let process_group = CacheGroup {
325 path: temp_dir.path().to_path_buf(),
326 };
327
328 Ok(Self {
329 process_group,
330 _temp_dir: temp_dir,
331 })
332 }
333
334 pub fn path(&self) -> &Path {
336 self.process_group.path()
337 }
338
339 pub fn process_group(&self) -> CacheGroup {
341 self.process_group.clone()
342 }
343
344 pub fn thread_group(&self) -> CacheGroup {
349 self.process_group
350 .subgroup(format!("thread-{}", current_thread_cache_group_id()))
351 }
352
353 pub fn ensure_thread_group(&self) -> io::Result<CacheGroup> {
355 let group = self.thread_group();
356 group.ensure_dir()?;
357 Ok(group)
358 }
359
360 pub fn thread_entry_path<P: AsRef<Path>>(&self, relative_file: P) -> PathBuf {
362 self.thread_group().entry_path(relative_file)
363 }
364
365 pub fn touch_thread_entry<P: AsRef<Path>>(&self, relative_file: P) -> io::Result<PathBuf> {
367 self.ensure_thread_group()?.touch(relative_file)
368 }
369}
370
371#[cfg(feature = "process-scoped-cache")]
372fn current_thread_cache_group_id() -> u64 {
373 thread_local! {
374 static THREAD_GROUP_ID: Cell<Option<u64>> = const { Cell::new(None) };
375 }
376
377 static NEXT_THREAD_GROUP_ID: AtomicU64 = AtomicU64::new(1);
378
379 THREAD_GROUP_ID.with(|slot| {
380 if let Some(id) = slot.get() {
381 id
382 } else {
383 let id = NEXT_THREAD_GROUP_ID.fetch_add(1, Ordering::Relaxed);
384 slot.set(Some(id));
385 id
386 }
387 })
388}
389
390fn find_crate_root(start: &Path) -> Option<PathBuf> {
391 let mut current = start.to_path_buf();
392 let mut nearest: Option<PathBuf> = None;
393 loop {
394 let cargo_path = current.join(CARGO_TOML_FILE_NAME);
395 if cargo_path.is_file() {
396 if nearest.is_none() {
397 nearest = Some(current.clone());
398 }
399 if let Ok(content) = fs::read_to_string(&cargo_path)
400 && content.lines().any(|line| line.trim() == "[workspace]")
401 {
402 return Some(current);
403 }
404 }
405 if !current.pop() {
406 return nearest;
407 }
408 }
409}
410
411fn apply_evict_policy(root: &Path, policy: &EvictPolicy) -> io::Result<()> {
412 let report = build_eviction_report(root, policy)?;
413
414 for path in report.marked_for_eviction {
415 let _ = fs::remove_file(path);
416 }
417
418 Ok(())
419}
420
421fn sort_entries_oldest_first(entries: &mut [FileEntry]) {
422 entries.sort_by(|a, b| {
423 let ta = a
424 .modified
425 .duration_since(UNIX_EPOCH)
426 .unwrap_or(Duration::ZERO);
427 let tb = b
428 .modified
429 .duration_since(UNIX_EPOCH)
430 .unwrap_or(Duration::ZERO);
431 ta.cmp(&tb).then_with(|| a.path.cmp(&b.path))
432 });
433}
434
435fn build_eviction_report(root: &Path, policy: &EvictPolicy) -> io::Result<EvictionReport> {
436 let mut entries = collect_files(root)?;
437 let mut marked_for_eviction = Vec::new();
438
439 if let Some(max_age) = policy.max_age {
440 let now = SystemTime::now();
441 let mut survivors = Vec::with_capacity(entries.len());
442 for entry in entries {
443 let age = now.duration_since(entry.modified).unwrap_or(Duration::ZERO);
444 if age >= max_age {
445 marked_for_eviction.push(entry.path);
446 } else {
447 survivors.push(entry);
448 }
449 }
450 entries = survivors;
451 }
452
453 sort_entries_oldest_first(&mut entries);
454
455 if let Some(max_files) = policy.max_files
456 && entries.len() > max_files
457 {
458 let to_remove = entries.len() - max_files;
459 for entry in entries.iter().take(to_remove) {
460 marked_for_eviction.push(entry.path.clone());
461 }
462 entries = entries.into_iter().skip(to_remove).collect();
463 sort_entries_oldest_first(&mut entries);
464 }
465
466 if let Some(max_bytes) = policy.max_bytes {
467 let mut total: u64 = entries.iter().map(|e| e.len).sum();
468 if total > max_bytes {
469 for entry in &entries {
470 if total <= max_bytes {
471 break;
472 }
473 marked_for_eviction.push(entry.path.clone());
474 total = total.saturating_sub(entry.len);
475 }
476 }
477 }
478
479 Ok(EvictionReport {
480 marked_for_eviction,
481 })
482}
483
484fn collect_files(root: &Path) -> io::Result<Vec<FileEntry>> {
485 let mut out = Vec::new();
486 collect_files_recursive(root, &mut out)?;
487 Ok(out)
488}
489
490fn collect_files_recursive(dir: &Path, out: &mut Vec<FileEntry>) -> io::Result<()> {
491 for entry in fs::read_dir(dir)? {
492 let entry = entry?;
493 let path = entry.path();
494 let meta = entry.metadata()?;
495 if meta.is_dir() {
496 collect_files_recursive(&path, out)?;
497 } else if meta.is_file() {
498 out.push(FileEntry {
499 path,
500 modified: meta.modified().unwrap_or(UNIX_EPOCH),
501 len: meta.len(),
502 });
503 }
504 }
505 Ok(())
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 use std::collections::BTreeSet;
512 use std::sync::{Mutex, MutexGuard, OnceLock};
520 use tempfile::TempDir;
521
522 fn cwd_lock() -> &'static Mutex<()> {
526 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
527 LOCK.get_or_init(|| Mutex::new(()))
528 }
529
530 struct CwdGuard {
535 previous: PathBuf,
536 _cwd_lock: MutexGuard<'static, ()>,
539 }
540
541 impl CwdGuard {
542 fn swap_to(path: &Path) -> io::Result<Self> {
543 let cwd_lock_guard = cwd_lock().lock().expect("acquire cwd test lock");
546 let previous = env::current_dir()?;
547 env::set_current_dir(path)?;
548 Ok(Self {
549 previous,
550 _cwd_lock: cwd_lock_guard,
551 })
552 }
553 }
554
555 impl Drop for CwdGuard {
556 fn drop(&mut self) {
557 let _ = env::set_current_dir(&self.previous);
558 }
559 }
560
561 #[test]
562 fn from_discovery_uses_cwd_dot_cache_when_no_cargo_toml() {
563 let tmp = TempDir::new().expect("tempdir");
564 let _guard = CwdGuard::swap_to(tmp.path()).expect("set cwd");
565
566 let cache = CacheRoot::from_discovery().expect("discover");
567 let got = cache.path().to_path_buf();
568 let expected = tmp
569 .path()
570 .canonicalize()
571 .expect("canonicalize temp path")
572 .join(CACHE_DIR_NAME);
573 assert_eq!(got, expected);
574 }
575
576 #[test]
577 fn from_discovery_prefers_nearest_crate_root() {
578 let tmp = TempDir::new().expect("tempdir");
579 let crate_root = tmp.path().join("workspace");
580 let nested = crate_root.join("src").join("nested");
581 fs::create_dir_all(&nested).expect("create nested");
582 fs::write(
583 crate_root.join(CARGO_TOML_FILE_NAME),
584 "[package]\nname='x'\nversion='0.1.0'\nedition='2024'\n",
585 )
586 .expect("write cargo");
587
588 let _guard = CwdGuard::swap_to(&nested).expect("set cwd");
589 let cache = CacheRoot::from_discovery().expect("discover");
590 let got = cache.path().to_path_buf();
591 let expected = crate_root
592 .canonicalize()
593 .expect("canonicalize crate root")
594 .join(CACHE_DIR_NAME);
595 assert_eq!(got, expected);
596 }
597
598 #[test]
599 fn from_discovery_prefers_workspace_root_over_subcrate() {
600 let tmp = TempDir::new().expect("tempdir");
601 let workspace_root = tmp.path().join("workspace");
602 let sub_crate = workspace_root.join("crates").join("my-crate");
603 fs::create_dir_all(&sub_crate).expect("create sub-crate");
604 fs::write(
606 workspace_root.join(CARGO_TOML_FILE_NAME),
607 "[workspace]\n[package]\nname='workspace-root'\nversion='0.1.0'\nedition='2024'\n",
608 )
609 .expect("write workspace Cargo.toml");
610 fs::write(
612 sub_crate.join(CARGO_TOML_FILE_NAME),
613 "[package]\nname='my-crate'\nversion='0.1.0'\nedition='2024'\n",
614 )
615 .expect("write sub-crate Cargo.toml");
616
617 let _guard = CwdGuard::swap_to(&sub_crate).expect("set cwd");
618 let cache = CacheRoot::from_discovery().expect("discover");
619 let got = cache.path().to_path_buf();
620 let expected = workspace_root
621 .canonicalize()
622 .expect("canonicalize workspace root")
623 .join(CACHE_DIR_NAME);
624 assert_eq!(got, expected);
625 }
626
627 #[cfg(unix)]
628 #[test]
629 fn from_discovery_skips_permission_denied_cargo_toml_and_falls_back_to_cwd() {
630 use std::os::unix::fs::PermissionsExt;
631
632 let tmp = TempDir::new().expect("tempdir");
633 fs::write(tmp.path().join(CARGO_TOML_FILE_NAME), "[workspace]").expect("write Cargo.toml");
634 fs::set_permissions(
637 tmp.path().join(CARGO_TOML_FILE_NAME),
638 std::fs::Permissions::from_mode(0o000),
639 )
640 .expect("set permissions");
641
642 let _guard = CwdGuard::swap_to(tmp.path()).expect("set cwd");
643 let cache = CacheRoot::from_discovery().expect("discover");
644 let expected = tmp
646 .path()
647 .canonicalize()
648 .expect("canonicalize")
649 .join(CACHE_DIR_NAME);
650 assert_eq!(cache.path(), expected);
651
652 fs::set_permissions(
654 tmp.path().join(CARGO_TOML_FILE_NAME),
655 std::fs::Permissions::from_mode(0o644),
656 )
657 .expect("restore permissions");
658 }
659
660 #[test]
661 fn from_root_supports_arbitrary_path_and_grouping() {
662 let tmp = TempDir::new().expect("tempdir");
663 let root = CacheRoot::from_root(tmp.path().join("custom-cache-root"));
664 let group = root.group("taxonomy/v1");
665
666 assert_eq!(group.path(), root.path().join("taxonomy/v1").as_path());
667 }
668
669 #[test]
670 fn group_path_building_and_dir_creation() {
671 let tmp = TempDir::new().expect("tempdir");
672 let cache = CacheRoot::from_root(tmp.path());
673 let group = cache.group("artifacts/json");
674
675 let nested_group = group.subgroup("v1");
676 let ensured = nested_group.ensure_dir().expect("ensure nested dir");
677 let expected_group_suffix = Path::new("artifacts").join("json").join("v1");
678 assert!(ensured.ends_with(&expected_group_suffix));
679 assert!(ensured.exists());
680
681 let entry = nested_group.entry_path("a/b/cache.json");
682 let expected_entry_suffix = Path::new("artifacts")
683 .join("json")
684 .join("v1")
685 .join("a")
686 .join("b")
687 .join("cache.json");
688 assert!(entry.ends_with(&expected_entry_suffix));
689 }
690
691 #[test]
692 fn touch_creates_blank_file_and_is_idempotent() {
693 let tmp = TempDir::new().expect("tempdir");
694 let cache = CacheRoot::from_root(tmp.path());
695 let group = cache.group("artifacts/json");
696
697 let touched = group.touch("a/b/cache.json").expect("touch file");
698 assert!(touched.exists());
699 let meta = fs::metadata(&touched).expect("metadata");
700 assert_eq!(meta.len(), 0);
701
702 let touched_again = group.touch("a/b/cache.json").expect("touch file again");
703 assert_eq!(touched_again, touched);
704 let meta_again = fs::metadata(&touched_again).expect("metadata again");
705 assert_eq!(meta_again.len(), 0);
706 }
707
708 #[test]
709 fn touch_with_root_group_and_empty_relative_path_errors() {
710 let root = CacheRoot::from_root("/");
711 let group = root.group("");
712
713 let result = group.touch("");
714 assert!(result.is_err());
715 }
716
717 #[test]
718 fn from_discovery_cache_path_uses_root_and_group() {
719 let tmp = TempDir::new().expect("tempdir");
720 let crate_root = tmp.path().join("workspace");
721 let nested = crate_root.join("src").join("nested");
722 fs::create_dir_all(&nested).expect("create nested");
723 fs::write(
724 crate_root.join(CARGO_TOML_FILE_NAME),
725 "[package]\nname='x'\nversion='0.1.0'\nedition='2024'\n",
726 )
727 .expect("write cargo");
728
729 let _guard = CwdGuard::swap_to(&nested).expect("set cwd");
730 let p = CacheRoot::from_discovery()
731 .expect("discover")
732 .cache_path("taxonomy", "taxonomy_cache.json");
733 let parent = p.parent().expect("cache path parent");
734 fs::create_dir_all(parent).expect("create cache parent");
735 let expected_dir = crate_root.join(CACHE_DIR_NAME).join("taxonomy");
739 fs::create_dir_all(&expected_dir).expect("create expected cache parent");
740 let got_parent = p
741 .parent()
742 .expect("cache path parent")
743 .canonicalize()
744 .expect("canonicalize cache parent");
745 let expected_parent = crate_root
746 .join(CACHE_DIR_NAME)
747 .join("taxonomy")
748 .canonicalize()
749 .expect("canonicalize expected parent");
750 assert_eq!(got_parent, expected_parent);
751 assert_eq!(
752 p.file_name().and_then(|s| s.to_str()),
753 Some("taxonomy_cache.json")
754 );
755 }
756
757 #[test]
758 fn from_discovery_ignores_other_custom_cache_dir_names() {
759 let tmp = TempDir::new().expect("tempdir");
760 let crate_root = tmp.path().join("workspace");
761 let nested = crate_root.join("src").join("nested");
762 fs::create_dir_all(&nested).expect("create nested");
763 fs::write(
764 crate_root.join(CARGO_TOML_FILE_NAME),
765 "[package]\nname='x'\nversion='0.1.0'\nedition='2024'\n",
766 )
767 .expect("write cargo");
768
769 fs::create_dir_all(crate_root.join(".cache-v2")).expect("create custom cache dir");
771
772 let _guard = CwdGuard::swap_to(&nested).expect("set cwd");
773 let cache = CacheRoot::from_discovery().expect("discover");
774
775 let expected = crate_root
777 .canonicalize()
778 .expect("canonicalize crate root")
779 .join(CACHE_DIR_NAME);
780 assert_eq!(cache.path(), expected);
781 }
782
783 #[test]
784 fn cache_path_preserves_absolute_paths() {
785 let root = CacheRoot::from_root("/tmp/project");
786 let absolute = PathBuf::from("/tmp/custom/cache.json");
787 let resolved = root.cache_path(CACHE_DIR_NAME, &absolute);
788 assert_eq!(resolved, absolute);
789 }
790
791 #[cfg(feature = "os-cache-dir")]
792 #[test]
793 fn from_project_dirs_matches_directories_cache_dir() {
794 let qualifier = "com";
795 let organization = "CacheManagerTests";
796 let application = "CacheManagerOsCacheRoot";
797
798 let expected = ProjectDirs::from(qualifier, organization, application)
799 .expect("project dirs")
800 .cache_dir()
801 .to_path_buf();
802
803 let root = CacheRoot::from_project_dirs(qualifier, organization, application)
804 .expect("from project dirs");
805
806 assert_eq!(root.path(), expected.as_path());
807 }
808
809 #[cfg(feature = "os-cache-dir")]
810 #[test]
811 fn project_dirs_or_not_found_returns_not_found_for_none() {
812 let err =
813 project_dirs_or_not_found(None).expect_err("none project dirs should map to not found");
814 assert_eq!(err.kind(), io::ErrorKind::NotFound);
815 }
816
817 #[cfg(feature = "process-scoped-cache")]
818 #[test]
819 fn from_tempdir_creates_existing_writable_root() {
820 let root = CacheRoot::from_tempdir().expect("from tempdir");
821 assert!(root.path().is_dir());
822
823 let probe_group = root.group("probe");
824 let probe_file = probe_group.touch("writable.txt").expect("touch probe");
825 assert!(probe_file.is_file());
826
827 fs::remove_dir_all(root.path()).expect("cleanup temp root");
828 }
829
830 #[test]
831 fn ensure_dir_with_policy_max_files() {
832 let tmp = TempDir::new().expect("tempdir");
833 let cache = CacheRoot::from_root(tmp.path());
834 let group = cache.group("artifacts");
835 group.ensure_dir().expect("ensure dir");
836
837 fs::write(group.entry_path("a.txt"), b"1").expect("write a");
838 fs::write(group.entry_path("b.txt"), b"1").expect("write b");
839 fs::write(group.entry_path("c.txt"), b"1").expect("write c");
840
841 let policy = EvictPolicy {
842 max_files: Some(2),
843 ..EvictPolicy::default()
844 };
845 group
846 .ensure_dir_with_policy(Some(&policy))
847 .expect("ensure with policy");
848
849 let files = collect_files(group.path()).expect("collect files");
850 assert_eq!(files.len(), 2);
851 }
852
853 #[test]
854 fn ensure_dir_with_policy_max_bytes() {
855 let tmp = TempDir::new().expect("tempdir");
856 let cache = CacheRoot::from_root(tmp.path());
857 let group = cache.group("artifacts");
858 group.ensure_dir().expect("ensure dir");
859
860 fs::write(group.entry_path("a.bin"), vec![1u8; 5]).expect("write a");
861 fs::write(group.entry_path("b.bin"), vec![1u8; 5]).expect("write b");
862 fs::write(group.entry_path("c.bin"), vec![1u8; 5]).expect("write c");
863
864 let policy = EvictPolicy {
865 max_bytes: Some(10),
866 ..EvictPolicy::default()
867 };
868 group
869 .ensure_dir_with_policy(Some(&policy))
870 .expect("ensure with policy");
871
872 let total: u64 = collect_files(group.path())
873 .expect("collect files")
874 .iter()
875 .map(|f| f.len)
876 .sum();
877 assert!(total <= 10);
878 }
879
880 #[test]
881 fn ensure_dir_with_policy_max_age_zero_evicts_all() {
882 let tmp = TempDir::new().expect("tempdir");
883 let cache = CacheRoot::from_root(tmp.path());
884 let group = cache.group("artifacts");
885 group.ensure_dir().expect("ensure dir");
886
887 fs::write(group.entry_path("a.txt"), b"1").expect("write a");
888 fs::write(group.entry_path("b.txt"), b"1").expect("write b");
889
890 let policy = EvictPolicy {
891 max_age: Some(Duration::ZERO),
892 ..EvictPolicy::default()
893 };
894 group
895 .ensure_dir_with_policy(Some(&policy))
896 .expect("ensure with policy");
897
898 let files = collect_files(group.path()).expect("collect files");
899 assert!(files.is_empty());
900 }
901
902 #[test]
903 fn eviction_report_matches_applied_evictions() {
904 let tmp = TempDir::new().expect("tempdir");
905 let cache = CacheRoot::from_root(tmp.path());
906 let group = cache.group("artifacts");
907 group.ensure_dir().expect("ensure dir");
908
909 fs::write(group.entry_path("a.bin"), vec![1u8; 5]).expect("write a");
910 fs::write(group.entry_path("b.bin"), vec![1u8; 5]).expect("write b");
911 fs::write(group.entry_path("c.bin"), vec![1u8; 5]).expect("write c");
912
913 let policy = EvictPolicy {
914 max_bytes: Some(10),
915 ..EvictPolicy::default()
916 };
917
918 let before: BTreeSet<PathBuf> = collect_files(group.path())
919 .expect("collect before")
920 .into_iter()
921 .map(|f| f.path)
922 .collect();
923
924 let report = group.eviction_report(&policy).expect("eviction report");
925 let planned: BTreeSet<PathBuf> = report.marked_for_eviction.iter().cloned().collect();
926
927 group
928 .ensure_dir_with_policy(Some(&policy))
929 .expect("ensure with policy");
930
931 let after: BTreeSet<PathBuf> = collect_files(group.path())
932 .expect("collect after")
933 .into_iter()
934 .map(|f| f.path)
935 .collect();
936
937 let expected_after: BTreeSet<PathBuf> = before.difference(&planned).cloned().collect();
938 assert_eq!(after, expected_after);
939 }
940
941 #[test]
942 fn no_policy_and_default_policy_report_do_not_mark_evictions() {
943 let tmp = TempDir::new().expect("tempdir");
944 let cache = CacheRoot::from_root(tmp.path());
945 let group = cache.group("artifacts");
946 group.ensure_dir().expect("ensure dir");
947
948 fs::write(group.entry_path("a.txt"), b"1").expect("write a");
949 fs::write(group.entry_path("b.txt"), b"1").expect("write b");
950
951 let report = group
952 .eviction_report(&EvictPolicy::default())
953 .expect("eviction report");
954 assert!(report.marked_for_eviction.is_empty());
955
956 group
957 .ensure_dir_with_policy(None)
958 .expect("ensure with no policy");
959
960 let files = collect_files(group.path()).expect("collect files");
961 assert_eq!(files.len(), 2);
962 }
963
964 #[test]
965 fn eviction_policy_applies_in_documented_order() {
966 let tmp = TempDir::new().expect("tempdir");
967 let cache = CacheRoot::from_root(tmp.path());
968 let group = cache.group("artifacts");
969 group.ensure_dir().expect("ensure dir");
970
971 fs::write(group.entry_path("old.txt"), vec![1u8; 1]).expect("write old");
972
973 std::thread::sleep(Duration::from_millis(300));
974
975 fs::write(group.entry_path("b.bin"), vec![1u8; 7]).expect("write b");
976 fs::write(group.entry_path("c.bin"), vec![1u8; 6]).expect("write c");
977 fs::write(group.entry_path("d.bin"), vec![1u8; 1]).expect("write d");
978
979 let policy = EvictPolicy {
980 max_age: Some(Duration::from_millis(200)),
981 max_files: Some(2),
982 max_bytes: Some(5),
983 };
984
985 let report = group.eviction_report(&policy).expect("eviction report");
986 let evicted_names: Vec<String> = report
987 .marked_for_eviction
988 .iter()
989 .map(|path| {
990 path.file_name()
991 .and_then(|name| name.to_str())
992 .expect("evicted file name")
993 .to_string()
994 })
995 .collect();
996
997 assert_eq!(evicted_names, vec!["old.txt", "b.bin", "c.bin"]);
998
999 group
1000 .ensure_dir_with_policy(Some(&policy))
1001 .expect("apply policy");
1002
1003 let remaining_names: BTreeSet<String> = collect_files(group.path())
1004 .expect("collect remaining")
1005 .into_iter()
1006 .map(|entry| {
1007 entry
1008 .path
1009 .file_name()
1010 .and_then(|name| name.to_str())
1011 .expect("remaining file name")
1012 .to_string()
1013 })
1014 .collect();
1015
1016 assert_eq!(remaining_names, BTreeSet::from(["d.bin".to_string()]));
1017 }
1018
1019 #[test]
1020 fn sort_entries_uses_path_as_tie_break_for_equal_modified_time() {
1021 let same_time = UNIX_EPOCH + Duration::from_secs(1_234_567);
1022 let mut entries = vec![
1023 FileEntry {
1024 path: PathBuf::from("z.bin"),
1025 modified: same_time,
1026 len: 1,
1027 },
1028 FileEntry {
1029 path: PathBuf::from("a.bin"),
1030 modified: same_time,
1031 len: 1,
1032 },
1033 FileEntry {
1034 path: PathBuf::from("m.bin"),
1035 modified: same_time,
1036 len: 1,
1037 },
1038 ];
1039
1040 sort_entries_oldest_first(&mut entries);
1041
1042 let ordered_paths: Vec<PathBuf> = entries.into_iter().map(|entry| entry.path).collect();
1043 assert_eq!(
1044 ordered_paths,
1045 vec![
1046 PathBuf::from("a.bin"),
1047 PathBuf::from("m.bin"),
1048 PathBuf::from("z.bin")
1049 ]
1050 );
1051 }
1052
1053 #[test]
1054 fn single_root_supports_distinct_policies_per_subdirectory() {
1055 let tmp = TempDir::new().expect("tempdir");
1056 let cache = CacheRoot::from_root(tmp.path());
1057
1058 let images = cache.group("artifacts/images");
1059 let reports = cache.group("artifacts/reports");
1060
1061 images.ensure_dir().expect("ensure images dir");
1062 reports.ensure_dir().expect("ensure reports dir");
1063
1064 fs::write(images.entry_path("img1.bin"), vec![1u8; 5]).expect("write img1");
1065 fs::write(images.entry_path("img2.bin"), vec![1u8; 5]).expect("write img2");
1066 fs::write(images.entry_path("img3.bin"), vec![1u8; 5]).expect("write img3");
1067
1068 fs::write(reports.entry_path("a.txt"), b"1").expect("write report a");
1069 fs::write(reports.entry_path("b.txt"), b"1").expect("write report b");
1070 fs::write(reports.entry_path("c.txt"), b"1").expect("write report c");
1071
1072 let images_policy = EvictPolicy {
1073 max_bytes: Some(10),
1074 ..EvictPolicy::default()
1075 };
1076 let reports_policy = EvictPolicy {
1077 max_files: Some(1),
1078 ..EvictPolicy::default()
1079 };
1080
1081 images
1082 .ensure_dir_with_policy(Some(&images_policy))
1083 .expect("apply images policy");
1084 reports
1085 .ensure_dir_with_policy(Some(&reports_policy))
1086 .expect("apply reports policy");
1087
1088 let images_total: u64 = collect_files(images.path())
1089 .expect("collect images files")
1090 .iter()
1091 .map(|f| f.len)
1092 .sum();
1093 assert!(images_total <= 10);
1094
1095 let reports_files = collect_files(reports.path()).expect("collect reports files");
1096 assert_eq!(reports_files.len(), 1);
1097 }
1098
1099 #[test]
1100 fn group_path_and_ensure_group_create_expected_directory() {
1101 let tmp = TempDir::new().expect("tempdir");
1102 let cache = CacheRoot::from_root(tmp.path());
1103
1104 let expected = tmp.path().join("a/b/c");
1105 assert_eq!(cache.group_path("a/b/c"), expected);
1106
1107 let ensured = cache.ensure_group("a/b/c").expect("ensure group");
1108 assert_eq!(ensured, expected);
1109 assert!(ensured.is_dir());
1110 }
1111
1112 #[test]
1113 fn ensure_group_with_policy_applies_eviction_rules() {
1114 let tmp = TempDir::new().expect("tempdir");
1115 let cache = CacheRoot::from_root(tmp.path());
1116
1117 cache
1118 .ensure_group_with_policy("artifacts", None)
1119 .expect("ensure group without policy");
1120
1121 let group = cache.group("artifacts");
1122 fs::write(group.entry_path("a.bin"), vec![1u8; 1]).expect("write a");
1123 fs::write(group.entry_path("b.bin"), vec![1u8; 1]).expect("write b");
1124 fs::write(group.entry_path("c.bin"), vec![1u8; 1]).expect("write c");
1125
1126 let policy = EvictPolicy {
1127 max_files: Some(1),
1128 ..EvictPolicy::default()
1129 };
1130
1131 let ensured = cache
1132 .ensure_group_with_policy("artifacts", Some(&policy))
1133 .expect("ensure group with policy");
1134 assert_eq!(ensured, group.path());
1135
1136 let files = collect_files(group.path()).expect("collect files");
1137 assert_eq!(files.len(), 1);
1138 }
1139
1140 #[test]
1141 fn cache_path_joins_relative_paths_under_group() {
1142 let tmp = TempDir::new().expect("tempdir");
1143 let cache = CacheRoot::from_root(tmp.path());
1144
1145 let got = cache.cache_path(CACHE_DIR_NAME, "tool/v1/data.bin");
1146 let expected = tmp
1147 .path()
1148 .join(CACHE_DIR_NAME)
1149 .join("tool")
1150 .join("v1")
1151 .join("data.bin");
1152 assert_eq!(got, expected);
1153 }
1154
1155 #[test]
1156 fn subgroup_touch_creates_parent_directories() {
1157 let tmp = TempDir::new().expect("tempdir");
1158 let cache = CacheRoot::from_root(tmp.path());
1159 let subgroup = cache.group("artifacts").subgroup("json/v1");
1160
1161 let touched = subgroup
1162 .touch("nested/output.bin")
1163 .expect("touch subgroup entry");
1164
1165 assert!(touched.is_file());
1166 assert!(subgroup.path().join("nested").is_dir());
1167 }
1168
1169 #[test]
1170 fn eviction_report_errors_when_group_directory_is_missing() {
1171 let tmp = TempDir::new().expect("tempdir");
1172 let cache = CacheRoot::from_root(tmp.path());
1173 let missing = cache.group("does-not-exist");
1174
1175 let err = missing
1176 .eviction_report(&EvictPolicy::default())
1177 .expect_err("eviction report should fail for missing directory");
1178 assert_eq!(err.kind(), io::ErrorKind::NotFound);
1179 }
1180
1181 #[test]
1182 fn eviction_policy_scans_nested_subdirectories_recursively() {
1183 let tmp = TempDir::new().expect("tempdir");
1184 let cache = CacheRoot::from_root(tmp.path());
1185 let group = cache.group("artifacts");
1186 group.ensure_dir().expect("ensure dir");
1187
1188 fs::create_dir_all(group.entry_path("nested/deeper")).expect("create nested dirs");
1189 fs::write(group.entry_path("root.bin"), vec![1u8; 1]).expect("write root");
1190 fs::write(group.entry_path("nested/a.bin"), vec![1u8; 1]).expect("write nested a");
1191 fs::write(group.entry_path("nested/deeper/b.bin"), vec![1u8; 1]).expect("write nested b");
1192
1193 let policy = EvictPolicy {
1194 max_files: Some(1),
1195 ..EvictPolicy::default()
1196 };
1197
1198 group
1199 .ensure_dir_with_policy(Some(&policy))
1200 .expect("apply recursive policy");
1201
1202 let remaining = collect_files(group.path()).expect("collect remaining");
1203 assert_eq!(remaining.len(), 1);
1204 }
1205
1206 #[cfg(unix)]
1207 #[test]
1208 fn collect_files_recursive_ignores_non_file_non_directory_entries() {
1209 use std::os::unix::net::UnixListener;
1210
1211 let tmp = TempDir::new().expect("tempdir");
1212 let cache = CacheRoot::from_root(tmp.path());
1213 let group = cache.group("artifacts");
1214 group.ensure_dir().expect("ensure dir");
1215
1216 let socket_path = group.entry_path("live.sock");
1217 let _listener = UnixListener::bind(&socket_path).expect("bind unix socket");
1218
1219 fs::write(group.entry_path("a.bin"), vec![1u8; 1]).expect("write file");
1220
1221 let files = collect_files(group.path()).expect("collect files");
1222 assert_eq!(files.len(), 1);
1223 assert_eq!(files[0].path, group.entry_path("a.bin"));
1224 }
1225
1226 #[test]
1227 fn max_bytes_policy_under_threshold_does_not_evict() {
1228 let tmp = TempDir::new().expect("tempdir");
1229 let cache = CacheRoot::from_root(tmp.path());
1230 let group = cache.group("artifacts");
1231 group.ensure_dir().expect("ensure dir");
1232
1233 fs::write(group.entry_path("a.bin"), vec![1u8; 2]).expect("write a");
1234 fs::write(group.entry_path("b.bin"), vec![1u8; 3]).expect("write b");
1235
1236 let policy = EvictPolicy {
1237 max_bytes: Some(10),
1238 ..EvictPolicy::default()
1239 };
1240
1241 let report = group.eviction_report(&policy).expect("eviction report");
1242 assert!(report.marked_for_eviction.is_empty());
1243
1244 group
1245 .ensure_dir_with_policy(Some(&policy))
1246 .expect("ensure with policy");
1247
1248 let files = collect_files(group.path()).expect("collect files");
1249 assert_eq!(files.len(), 2);
1250 }
1251
1252 #[test]
1253 fn cwd_guard_swap_to_returns_error_for_missing_directory() {
1254 let tmp = TempDir::new().expect("tempdir");
1255 let missing = tmp.path().join("missing-dir");
1256
1257 let result = CwdGuard::swap_to(&missing);
1258 assert!(result.is_err());
1259 assert_eq!(
1260 result.err().expect("expected missing-dir error").kind(),
1261 io::ErrorKind::NotFound
1262 );
1263 }
1264
1265 #[test]
1266 fn ensure_dir_equals_ensure_dir_with_policy_none() {
1267 let tmp = TempDir::new().expect("tempdir");
1268 let cache = CacheRoot::from_root(tmp.path());
1269 let group = cache.group("artifacts/eq");
1270
1271 let p1 = group.ensure_dir().expect("ensure dir");
1272 fs::write(group.entry_path("keep.txt"), b"keep").expect("write file");
1275
1276 let p2 = group
1277 .ensure_dir_with_policy(None)
1278 .expect("ensure dir with None policy");
1279
1280 assert_eq!(p1, p2);
1281 assert!(group.entry_path("keep.txt").exists());
1282 }
1283
1284 #[test]
1285 fn ensure_group_equals_ensure_group_with_policy_none() {
1286 let tmp = TempDir::new().expect("tempdir");
1287 let cache = CacheRoot::from_root(tmp.path());
1288
1289 let p1 = cache.ensure_group("artifacts/roots").expect("ensure group");
1290 let group = cache.group("artifacts/roots");
1291 fs::write(group.entry_path("keep_root.txt"), b"keep").expect("write file");
1293
1294 let p2 = cache
1295 .ensure_group_with_policy("artifacts/roots", None)
1296 .expect("ensure group with None policy");
1297
1298 assert_eq!(p1, p2);
1299 assert!(group.entry_path("keep_root.txt").exists());
1300 }
1301
1302 #[cfg(feature = "process-scoped-cache")]
1303 #[test]
1304 fn process_scoped_cache_respects_root_and_group_assignments() {
1305 let tmp = TempDir::new().expect("tempdir");
1306 let root = CacheRoot::from_root(tmp.path().join("custom-root"));
1307
1308 let scoped = ProcessScopedCacheGroup::new(&root, "artifacts/session").expect("create");
1309 let expected_prefix = root.group("artifacts/session").path().to_path_buf();
1310
1311 assert!(scoped.path().starts_with(&expected_prefix));
1312 assert!(scoped.path().exists());
1313 }
1314
1315 #[cfg(feature = "process-scoped-cache")]
1316 #[test]
1317 fn process_scoped_cache_deletes_directory_on_drop() {
1318 let tmp = TempDir::new().expect("tempdir");
1319 let root = CacheRoot::from_root(tmp.path());
1320
1321 let process_dir = {
1322 let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create");
1323 let p = scoped.path().to_path_buf();
1324 assert!(p.exists());
1325 p
1326 };
1327
1328 assert!(!process_dir.exists());
1329 }
1330
1331 #[cfg(feature = "process-scoped-cache")]
1332 #[test]
1333 fn process_scoped_cache_thread_group_is_stable_per_thread() {
1334 let tmp = TempDir::new().expect("tempdir");
1335 let root = CacheRoot::from_root(tmp.path());
1336 let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create");
1337
1338 let first = scoped.thread_group().path().to_path_buf();
1339 let second = scoped.thread_group().path().to_path_buf();
1340
1341 assert_eq!(first, second);
1342 }
1343
1344 #[cfg(feature = "process-scoped-cache")]
1345 #[test]
1346 fn process_scoped_cache_thread_group_differs_across_threads() {
1347 let tmp = TempDir::new().expect("tempdir");
1348 let root = CacheRoot::from_root(tmp.path());
1349 let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create");
1350
1351 let main_thread_group = scoped.thread_group().path().to_path_buf();
1352 let other_thread_group = std::thread::spawn(current_thread_cache_group_id)
1353 .join()
1354 .expect("join thread");
1355
1356 let expected_other = scoped
1357 .process_group()
1358 .subgroup(format!("thread-{other_thread_group}"))
1359 .path()
1360 .to_path_buf();
1361
1362 assert_ne!(main_thread_group, expected_other);
1363 }
1364
1365 #[cfg(feature = "process-scoped-cache")]
1366 #[test]
1367 fn process_scoped_cache_from_group_uses_given_base_group() {
1368 let tmp = TempDir::new().expect("tempdir");
1369 let root = CacheRoot::from_root(tmp.path());
1370 let base_group = root.group("artifacts/custom-base");
1371
1372 let scoped = ProcessScopedCacheGroup::from_group(base_group.clone()).expect("create");
1373
1374 assert!(scoped.path().starts_with(base_group.path()));
1375 assert_eq!(scoped.process_group().path(), scoped.path());
1376 }
1377
1378 #[cfg(feature = "process-scoped-cache")]
1379 #[test]
1380 fn process_scoped_cache_thread_entry_path_matches_touch_location() {
1381 let tmp = TempDir::new().expect("tempdir");
1382 let root = CacheRoot::from_root(tmp.path());
1383 let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create");
1384
1385 let planned = scoped.thread_entry_path("nested/data.bin");
1386 let touched = scoped
1387 .touch_thread_entry("nested/data.bin")
1388 .expect("touch thread entry");
1389
1390 assert_eq!(planned, touched);
1391 assert!(touched.exists());
1392 }
1393
1394 #[cfg(feature = "process-scoped-cache")]
1395 #[test]
1396 fn touch_thread_entry_creates_entry_under_thread_group() {
1397 let tmp = TempDir::new().expect("tempdir");
1398 let root = CacheRoot::from_root(tmp.path());
1399 let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create");
1400
1401 let entry = scoped
1402 .touch_thread_entry("nested/data.bin")
1403 .expect("touch thread entry");
1404
1405 assert!(entry.exists());
1406 assert!(entry.starts_with(scoped.path()));
1407 let thread_group = scoped.thread_group().path().to_path_buf();
1408 assert!(entry.starts_with(&thread_group));
1409 }
1410}