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