1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4use std::env;
5use std::fs;
6use std::fs::OpenOptions;
7use std::io;
8use std::path::{Path, PathBuf};
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11#[derive(Clone, Debug, PartialEq, Eq, Default)]
22pub struct EvictPolicy {
23 pub max_files: Option<usize>,
28 pub max_bytes: Option<u64>,
38 pub max_age: Option<Duration>,
42}
43
44#[derive(Clone, Debug, PartialEq, Eq, Default)]
46pub struct EvictionReport {
47 pub marked_for_eviction: Vec<PathBuf>,
49}
50
51#[derive(Clone, Debug)]
52struct FileEntry {
53 path: PathBuf,
54 modified: SystemTime,
55 len: u64,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct CacheRoot {
65 root: PathBuf,
66}
67
68impl CacheRoot {
69 pub fn discover() -> io::Result<Self> {
73 let cwd = env::current_dir()?;
74 let root = find_crate_root(&cwd).unwrap_or(cwd);
75 let root = root.canonicalize().unwrap_or(root);
79 Ok(Self { root })
80 }
81
82 pub fn from_root<P: Into<PathBuf>>(root: P) -> Self {
84 Self { root: root.into() }
85 }
86
87 pub fn discover_or_cwd() -> Self {
89 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
90 let root = find_crate_root(&cwd).unwrap_or(cwd);
91 let root = root.canonicalize().unwrap_or(root);
92 Self { root }
93 }
94
95 pub fn path(&self) -> &Path {
97 &self.root
98 }
99
100 pub fn group<P: AsRef<Path>>(&self, relative_group: P) -> CacheGroup {
102 let path = self.root.join(relative_group.as_ref());
103 CacheGroup { path }
104 }
105
106 pub fn group_path<P: AsRef<Path>>(&self, relative_group: P) -> PathBuf {
108 self.root.join(relative_group.as_ref())
109 }
110
111 pub fn ensure_group<P: AsRef<Path>>(&self, relative_group: P) -> io::Result<PathBuf> {
113 self.ensure_group_with_policy(relative_group, None)
114 }
115
116 pub fn ensure_group_with_policy<P: AsRef<Path>>(
121 &self,
122 relative_group: P,
123 policy: Option<&EvictPolicy>,
124 ) -> io::Result<PathBuf> {
125 let group = self.group(relative_group);
126 group.ensure_dir_with_policy(policy)?;
127 Ok(group.path().to_path_buf())
128 }
129
130 pub fn cache_path<P: AsRef<Path>, Q: AsRef<Path>>(
134 &self,
135 cache_dir: P,
136 relative_path: Q,
137 ) -> PathBuf {
138 let rel = relative_path.as_ref();
139 if rel.is_absolute() {
140 return rel.to_path_buf();
141 }
142 self.group(cache_dir).entry_path(rel)
143 }
144
145 pub fn discover_cache_path<P: AsRef<Path>, Q: AsRef<Path>>(
149 cache_dir: P,
150 relative_path: Q,
151 ) -> PathBuf {
152 Self::discover_or_cwd().cache_path(cache_dir, relative_path)
153 }
154}
155
156#[derive(Clone, Debug, PartialEq, Eq)]
157pub struct CacheGroup {
162 path: PathBuf,
163}
164
165impl CacheGroup {
166 pub fn path(&self) -> &Path {
168 &self.path
169 }
170
171 pub fn ensure_dir(&self) -> io::Result<&Path> {
173 self.ensure_dir_with_policy(None)
174 }
175
176 pub fn ensure_dir_with_policy(&self, policy: Option<&EvictPolicy>) -> io::Result<&Path> {
182 fs::create_dir_all(&self.path)?;
183 if let Some(policy) = policy {
184 apply_evict_policy(&self.path, policy)?;
185 }
186 Ok(&self.path)
187 }
188
189 pub fn eviction_report(&self, policy: &EvictPolicy) -> io::Result<EvictionReport> {
198 build_eviction_report(&self.path, policy)
199 }
200
201 pub fn subgroup<P: AsRef<Path>>(&self, relative_group: P) -> Self {
203 Self {
204 path: self.path.join(relative_group.as_ref()),
205 }
206 }
207
208 pub fn entry_path<P: AsRef<Path>>(&self, relative_file: P) -> PathBuf {
210 self.path.join(relative_file.as_ref())
211 }
212
213 pub fn touch<P: AsRef<Path>>(&self, relative_file: P) -> io::Result<PathBuf> {
216 let entry = self.entry_path(relative_file);
217 if let Some(parent) = entry.parent() {
218 fs::create_dir_all(parent)?;
219 }
220 OpenOptions::new().create(true).append(true).open(&entry)?;
221 Ok(entry)
222 }
223}
224
225fn find_crate_root(start: &Path) -> Option<PathBuf> {
226 let mut current = start.to_path_buf();
227 loop {
228 if current.join("Cargo.toml").is_file() {
229 return Some(current);
230 }
231 if !current.pop() {
232 return None;
233 }
234 }
235}
236
237fn apply_evict_policy(root: &Path, policy: &EvictPolicy) -> io::Result<()> {
238 let report = build_eviction_report(root, policy)?;
239
240 for path in report.marked_for_eviction {
241 let _ = fs::remove_file(path);
242 }
243
244 Ok(())
245}
246
247fn sort_entries_oldest_first(entries: &mut [FileEntry]) {
248 entries.sort_by(|a, b| {
249 let ta = a
250 .modified
251 .duration_since(UNIX_EPOCH)
252 .unwrap_or(Duration::ZERO);
253 let tb = b
254 .modified
255 .duration_since(UNIX_EPOCH)
256 .unwrap_or(Duration::ZERO);
257 ta.cmp(&tb).then_with(|| a.path.cmp(&b.path))
258 });
259}
260
261fn build_eviction_report(root: &Path, policy: &EvictPolicy) -> io::Result<EvictionReport> {
262 let mut entries = collect_files(root)?;
263 let mut marked_for_eviction = Vec::new();
264
265 if let Some(max_age) = policy.max_age {
266 let now = SystemTime::now();
267 let mut survivors = Vec::with_capacity(entries.len());
268 for entry in entries {
269 let age = now.duration_since(entry.modified).unwrap_or(Duration::ZERO);
270 if age >= max_age {
271 marked_for_eviction.push(entry.path);
272 } else {
273 survivors.push(entry);
274 }
275 }
276 entries = survivors;
277 }
278
279 sort_entries_oldest_first(&mut entries);
280
281 if let Some(max_files) = policy.max_files
282 && entries.len() > max_files
283 {
284 let to_remove = entries.len() - max_files;
285 for entry in entries.iter().take(to_remove) {
286 marked_for_eviction.push(entry.path.clone());
287 }
288 entries = entries.into_iter().skip(to_remove).collect();
289 sort_entries_oldest_first(&mut entries);
290 }
291
292 if let Some(max_bytes) = policy.max_bytes {
293 let mut total: u64 = entries.iter().map(|e| e.len).sum();
294 if total > max_bytes {
295 for entry in &entries {
296 if total <= max_bytes {
297 break;
298 }
299 marked_for_eviction.push(entry.path.clone());
300 total = total.saturating_sub(entry.len);
301 }
302 }
303 }
304
305 Ok(EvictionReport {
306 marked_for_eviction,
307 })
308}
309
310fn collect_files(root: &Path) -> io::Result<Vec<FileEntry>> {
311 let mut out = Vec::new();
312 collect_files_recursive(root, &mut out)?;
313 Ok(out)
314}
315
316fn collect_files_recursive(dir: &Path, out: &mut Vec<FileEntry>) -> io::Result<()> {
317 for entry in fs::read_dir(dir)? {
318 let entry = entry?;
319 let path = entry.path();
320 let meta = entry.metadata()?;
321 if meta.is_dir() {
322 collect_files_recursive(&path, out)?;
323 } else if meta.is_file() {
324 out.push(FileEntry {
325 path,
326 modified: meta.modified().unwrap_or(UNIX_EPOCH),
327 len: meta.len(),
328 });
329 }
330 }
331 Ok(())
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use std::collections::BTreeSet;
338 use std::sync::{Mutex, MutexGuard, OnceLock};
346 use tempfile::TempDir;
347
348 fn cwd_lock() -> &'static Mutex<()> {
352 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
353 LOCK.get_or_init(|| Mutex::new(()))
354 }
355
356 struct CwdGuard {
361 previous: PathBuf,
362 _cwd_lock: MutexGuard<'static, ()>,
365 }
366
367 impl CwdGuard {
368 fn swap_to(path: &Path) -> io::Result<Self> {
369 let cwd_lock_guard = cwd_lock().lock().expect("acquire cwd test lock");
372 let previous = env::current_dir()?;
373 env::set_current_dir(path)?;
374 Ok(Self {
375 previous,
376 _cwd_lock: cwd_lock_guard,
377 })
378 }
379 }
380
381 impl Drop for CwdGuard {
382 fn drop(&mut self) {
383 let _ = env::set_current_dir(&self.previous);
384 }
385 }
386
387 #[test]
388 fn discover_falls_back_to_cwd_when_no_cargo_toml() {
389 let tmp = TempDir::new().expect("tempdir");
390 let _guard = CwdGuard::swap_to(tmp.path()).expect("set cwd");
391
392 let cache = CacheRoot::discover().expect("discover");
393 let got = cache
394 .path()
395 .canonicalize()
396 .expect("canonicalize discovered root");
397 let expected = tmp.path().canonicalize().expect("canonicalize temp path");
398 assert_eq!(got, expected);
399 }
400
401 #[test]
402 fn discover_prefers_nearest_crate_root() {
403 let tmp = TempDir::new().expect("tempdir");
404 let crate_root = tmp.path().join("workspace");
405 let nested = crate_root.join("src").join("nested");
406 fs::create_dir_all(&nested).expect("create nested");
407 fs::write(
408 crate_root.join("Cargo.toml"),
409 "[package]\nname='x'\nversion='0.1.0'\nedition='2024'\n",
410 )
411 .expect("write cargo");
412
413 let _guard = CwdGuard::swap_to(&nested).expect("set cwd");
414 let cache = CacheRoot::discover().expect("discover");
415 let got = cache
416 .path()
417 .canonicalize()
418 .expect("canonicalize discovered root");
419 let expected = crate_root.canonicalize().expect("canonicalize crate root");
420 assert_eq!(got, expected);
421 }
422
423 #[test]
424 fn from_root_supports_arbitrary_path_and_grouping() {
425 let tmp = TempDir::new().expect("tempdir");
426 let root = CacheRoot::from_root(tmp.path().join("custom-cache-root"));
427 let group = root.group("taxonomy/v1");
428
429 assert_eq!(group.path(), root.path().join("taxonomy/v1").as_path());
430 }
431
432 #[test]
433 fn group_path_building_and_dir_creation() {
434 let tmp = TempDir::new().expect("tempdir");
435 let cache = CacheRoot::from_root(tmp.path());
436 let group = cache.group("artifacts/json");
437
438 let nested_group = group.subgroup("v1");
439 let ensured = nested_group.ensure_dir().expect("ensure nested dir");
440 let expected_group_suffix = Path::new("artifacts").join("json").join("v1");
441 assert!(ensured.ends_with(&expected_group_suffix));
442 assert!(ensured.exists());
443
444 let entry = nested_group.entry_path("a/b/cache.json");
445 let expected_entry_suffix = Path::new("artifacts")
446 .join("json")
447 .join("v1")
448 .join("a")
449 .join("b")
450 .join("cache.json");
451 assert!(entry.ends_with(&expected_entry_suffix));
452 }
453
454 #[test]
455 fn touch_creates_blank_file_and_is_idempotent() {
456 let tmp = TempDir::new().expect("tempdir");
457 let cache = CacheRoot::from_root(tmp.path());
458 let group = cache.group("artifacts/json");
459
460 let touched = group.touch("a/b/cache.json").expect("touch file");
461 assert!(touched.exists());
462 let meta = fs::metadata(&touched).expect("metadata");
463 assert_eq!(meta.len(), 0);
464
465 let touched_again = group.touch("a/b/cache.json").expect("touch file again");
466 assert_eq!(touched_again, touched);
467 let meta_again = fs::metadata(&touched_again).expect("metadata again");
468 assert_eq!(meta_again.len(), 0);
469 }
470
471 #[test]
472 fn touch_with_root_group_and_empty_relative_path_errors() {
473 let root = CacheRoot::from_root("/");
474 let group = root.group("");
475
476 let result = group.touch("");
477 assert!(result.is_err());
478 }
479
480 #[test]
481 fn discover_cache_path_uses_root_and_group() {
482 let tmp = TempDir::new().expect("tempdir");
483 let crate_root = tmp.path().join("workspace");
484 let nested = crate_root.join("src").join("nested");
485 fs::create_dir_all(&nested).expect("create nested");
486 fs::write(
487 crate_root.join("Cargo.toml"),
488 "[package]\nname='x'\nversion='0.1.0'\nedition='2024'\n",
489 )
490 .expect("write cargo");
491
492 let _guard = CwdGuard::swap_to(&nested).expect("set cwd");
493 let p = CacheRoot::discover_cache_path(".cache", "taxonomy/taxonomy_cache.json");
494 let parent = p.parent().expect("cache path parent");
495 fs::create_dir_all(parent).expect("create cache parent");
496 let expected_dir = crate_root.join(".cache").join("taxonomy");
500 fs::create_dir_all(&expected_dir).expect("create expected cache parent");
501 let got_parent = p
502 .parent()
503 .expect("cache path parent")
504 .canonicalize()
505 .expect("canonicalize cache parent");
506 let expected_parent = crate_root
507 .join(".cache")
508 .join("taxonomy")
509 .canonicalize()
510 .expect("canonicalize expected parent");
511 assert_eq!(got_parent, expected_parent);
512 assert_eq!(
513 p.file_name().and_then(|s| s.to_str()),
514 Some("taxonomy_cache.json")
515 );
516 }
517
518 #[test]
519 fn cache_path_preserves_absolute_paths() {
520 let root = CacheRoot::from_root("/tmp/project");
521 let absolute = PathBuf::from("/tmp/custom/cache.json");
522 let resolved = root.cache_path(".cache", &absolute);
523 assert_eq!(resolved, absolute);
524 }
525
526 #[test]
527 fn ensure_dir_with_policy_max_files() {
528 let tmp = TempDir::new().expect("tempdir");
529 let cache = CacheRoot::from_root(tmp.path());
530 let group = cache.group("artifacts");
531 group.ensure_dir().expect("ensure dir");
532
533 fs::write(group.entry_path("a.txt"), b"1").expect("write a");
534 fs::write(group.entry_path("b.txt"), b"1").expect("write b");
535 fs::write(group.entry_path("c.txt"), b"1").expect("write c");
536
537 let policy = EvictPolicy {
538 max_files: Some(2),
539 ..EvictPolicy::default()
540 };
541 group
542 .ensure_dir_with_policy(Some(&policy))
543 .expect("ensure with policy");
544
545 let files = collect_files(group.path()).expect("collect files");
546 assert_eq!(files.len(), 2);
547 }
548
549 #[test]
550 fn ensure_dir_with_policy_max_bytes() {
551 let tmp = TempDir::new().expect("tempdir");
552 let cache = CacheRoot::from_root(tmp.path());
553 let group = cache.group("artifacts");
554 group.ensure_dir().expect("ensure dir");
555
556 fs::write(group.entry_path("a.bin"), vec![1u8; 5]).expect("write a");
557 fs::write(group.entry_path("b.bin"), vec![1u8; 5]).expect("write b");
558 fs::write(group.entry_path("c.bin"), vec![1u8; 5]).expect("write c");
559
560 let policy = EvictPolicy {
561 max_bytes: Some(10),
562 ..EvictPolicy::default()
563 };
564 group
565 .ensure_dir_with_policy(Some(&policy))
566 .expect("ensure with policy");
567
568 let total: u64 = collect_files(group.path())
569 .expect("collect files")
570 .iter()
571 .map(|f| f.len)
572 .sum();
573 assert!(total <= 10);
574 }
575
576 #[test]
577 fn ensure_dir_with_policy_max_age_zero_evicts_all() {
578 let tmp = TempDir::new().expect("tempdir");
579 let cache = CacheRoot::from_root(tmp.path());
580 let group = cache.group("artifacts");
581 group.ensure_dir().expect("ensure dir");
582
583 fs::write(group.entry_path("a.txt"), b"1").expect("write a");
584 fs::write(group.entry_path("b.txt"), b"1").expect("write b");
585
586 let policy = EvictPolicy {
587 max_age: Some(Duration::ZERO),
588 ..EvictPolicy::default()
589 };
590 group
591 .ensure_dir_with_policy(Some(&policy))
592 .expect("ensure with policy");
593
594 let files = collect_files(group.path()).expect("collect files");
595 assert!(files.is_empty());
596 }
597
598 #[test]
599 fn eviction_report_matches_applied_evictions() {
600 let tmp = TempDir::new().expect("tempdir");
601 let cache = CacheRoot::from_root(tmp.path());
602 let group = cache.group("artifacts");
603 group.ensure_dir().expect("ensure dir");
604
605 fs::write(group.entry_path("a.bin"), vec![1u8; 5]).expect("write a");
606 fs::write(group.entry_path("b.bin"), vec![1u8; 5]).expect("write b");
607 fs::write(group.entry_path("c.bin"), vec![1u8; 5]).expect("write c");
608
609 let policy = EvictPolicy {
610 max_bytes: Some(10),
611 ..EvictPolicy::default()
612 };
613
614 let before: BTreeSet<PathBuf> = collect_files(group.path())
615 .expect("collect before")
616 .into_iter()
617 .map(|f| f.path)
618 .collect();
619
620 let report = group.eviction_report(&policy).expect("eviction report");
621 let planned: BTreeSet<PathBuf> = report.marked_for_eviction.iter().cloned().collect();
622
623 group
624 .ensure_dir_with_policy(Some(&policy))
625 .expect("ensure with policy");
626
627 let after: BTreeSet<PathBuf> = collect_files(group.path())
628 .expect("collect after")
629 .into_iter()
630 .map(|f| f.path)
631 .collect();
632
633 let expected_after: BTreeSet<PathBuf> = before.difference(&planned).cloned().collect();
634 assert_eq!(after, expected_after);
635 }
636
637 #[test]
638 fn no_policy_and_default_policy_report_do_not_mark_evictions() {
639 let tmp = TempDir::new().expect("tempdir");
640 let cache = CacheRoot::from_root(tmp.path());
641 let group = cache.group("artifacts");
642 group.ensure_dir().expect("ensure dir");
643
644 fs::write(group.entry_path("a.txt"), b"1").expect("write a");
645 fs::write(group.entry_path("b.txt"), b"1").expect("write b");
646
647 let report = group
648 .eviction_report(&EvictPolicy::default())
649 .expect("eviction report");
650 assert!(report.marked_for_eviction.is_empty());
651
652 group
653 .ensure_dir_with_policy(None)
654 .expect("ensure with no policy");
655
656 let files = collect_files(group.path()).expect("collect files");
657 assert_eq!(files.len(), 2);
658 }
659
660 #[test]
661 fn eviction_policy_applies_in_documented_order() {
662 let tmp = TempDir::new().expect("tempdir");
663 let cache = CacheRoot::from_root(tmp.path());
664 let group = cache.group("artifacts");
665 group.ensure_dir().expect("ensure dir");
666
667 fs::write(group.entry_path("old.txt"), vec![1u8; 1]).expect("write old");
668
669 std::thread::sleep(Duration::from_millis(300));
670
671 fs::write(group.entry_path("b.bin"), vec![1u8; 7]).expect("write b");
672 fs::write(group.entry_path("c.bin"), vec![1u8; 6]).expect("write c");
673 fs::write(group.entry_path("d.bin"), vec![1u8; 1]).expect("write d");
674
675 let policy = EvictPolicy {
676 max_age: Some(Duration::from_millis(200)),
677 max_files: Some(2),
678 max_bytes: Some(5),
679 };
680
681 let report = group.eviction_report(&policy).expect("eviction report");
682 let evicted_names: Vec<String> = report
683 .marked_for_eviction
684 .iter()
685 .map(|path| {
686 path.file_name()
687 .and_then(|name| name.to_str())
688 .expect("evicted file name")
689 .to_string()
690 })
691 .collect();
692
693 assert_eq!(evicted_names, vec!["old.txt", "b.bin", "c.bin"]);
694
695 group
696 .ensure_dir_with_policy(Some(&policy))
697 .expect("apply policy");
698
699 let remaining_names: BTreeSet<String> = collect_files(group.path())
700 .expect("collect remaining")
701 .into_iter()
702 .map(|entry| {
703 entry
704 .path
705 .file_name()
706 .and_then(|name| name.to_str())
707 .expect("remaining file name")
708 .to_string()
709 })
710 .collect();
711
712 assert_eq!(remaining_names, BTreeSet::from(["d.bin".to_string()]));
713 }
714
715 #[test]
716 fn sort_entries_uses_path_as_tie_break_for_equal_modified_time() {
717 let same_time = UNIX_EPOCH + Duration::from_secs(1_234_567);
718 let mut entries = vec![
719 FileEntry {
720 path: PathBuf::from("z.bin"),
721 modified: same_time,
722 len: 1,
723 },
724 FileEntry {
725 path: PathBuf::from("a.bin"),
726 modified: same_time,
727 len: 1,
728 },
729 FileEntry {
730 path: PathBuf::from("m.bin"),
731 modified: same_time,
732 len: 1,
733 },
734 ];
735
736 sort_entries_oldest_first(&mut entries);
737
738 let ordered_paths: Vec<PathBuf> = entries.into_iter().map(|entry| entry.path).collect();
739 assert_eq!(
740 ordered_paths,
741 vec![
742 PathBuf::from("a.bin"),
743 PathBuf::from("m.bin"),
744 PathBuf::from("z.bin")
745 ]
746 );
747 }
748
749 #[test]
750 fn single_root_supports_distinct_policies_per_subdirectory() {
751 let tmp = TempDir::new().expect("tempdir");
752 let cache = CacheRoot::from_root(tmp.path());
753
754 let images = cache.group("artifacts/images");
755 let reports = cache.group("artifacts/reports");
756
757 images.ensure_dir().expect("ensure images dir");
758 reports.ensure_dir().expect("ensure reports dir");
759
760 fs::write(images.entry_path("img1.bin"), vec![1u8; 5]).expect("write img1");
761 fs::write(images.entry_path("img2.bin"), vec![1u8; 5]).expect("write img2");
762 fs::write(images.entry_path("img3.bin"), vec![1u8; 5]).expect("write img3");
763
764 fs::write(reports.entry_path("a.txt"), b"1").expect("write report a");
765 fs::write(reports.entry_path("b.txt"), b"1").expect("write report b");
766 fs::write(reports.entry_path("c.txt"), b"1").expect("write report c");
767
768 let images_policy = EvictPolicy {
769 max_bytes: Some(10),
770 ..EvictPolicy::default()
771 };
772 let reports_policy = EvictPolicy {
773 max_files: Some(1),
774 ..EvictPolicy::default()
775 };
776
777 images
778 .ensure_dir_with_policy(Some(&images_policy))
779 .expect("apply images policy");
780 reports
781 .ensure_dir_with_policy(Some(&reports_policy))
782 .expect("apply reports policy");
783
784 let images_total: u64 = collect_files(images.path())
785 .expect("collect images files")
786 .iter()
787 .map(|f| f.len)
788 .sum();
789 assert!(images_total <= 10);
790
791 let reports_files = collect_files(reports.path()).expect("collect reports files");
792 assert_eq!(reports_files.len(), 1);
793 }
794
795 #[test]
796 fn group_path_and_ensure_group_create_expected_directory() {
797 let tmp = TempDir::new().expect("tempdir");
798 let cache = CacheRoot::from_root(tmp.path());
799
800 let expected = tmp.path().join("a/b/c");
801 assert_eq!(cache.group_path("a/b/c"), expected);
802
803 let ensured = cache.ensure_group("a/b/c").expect("ensure group");
804 assert_eq!(ensured, expected);
805 assert!(ensured.is_dir());
806 }
807
808 #[test]
809 fn ensure_group_with_policy_applies_eviction_rules() {
810 let tmp = TempDir::new().expect("tempdir");
811 let cache = CacheRoot::from_root(tmp.path());
812
813 cache
814 .ensure_group_with_policy("artifacts", None)
815 .expect("ensure group without policy");
816
817 let group = cache.group("artifacts");
818 fs::write(group.entry_path("a.bin"), vec![1u8; 1]).expect("write a");
819 fs::write(group.entry_path("b.bin"), vec![1u8; 1]).expect("write b");
820 fs::write(group.entry_path("c.bin"), vec![1u8; 1]).expect("write c");
821
822 let policy = EvictPolicy {
823 max_files: Some(1),
824 ..EvictPolicy::default()
825 };
826
827 let ensured = cache
828 .ensure_group_with_policy("artifacts", Some(&policy))
829 .expect("ensure group with policy");
830 assert_eq!(ensured, group.path());
831
832 let files = collect_files(group.path()).expect("collect files");
833 assert_eq!(files.len(), 1);
834 }
835
836 #[test]
837 fn cache_path_joins_relative_paths_under_group() {
838 let tmp = TempDir::new().expect("tempdir");
839 let cache = CacheRoot::from_root(tmp.path());
840
841 let got = cache.cache_path(".cache", "tool/v1/data.bin");
842 let expected = tmp
843 .path()
844 .join(".cache")
845 .join("tool")
846 .join("v1")
847 .join("data.bin");
848 assert_eq!(got, expected);
849 }
850
851 #[test]
852 fn subgroup_touch_creates_parent_directories() {
853 let tmp = TempDir::new().expect("tempdir");
854 let cache = CacheRoot::from_root(tmp.path());
855 let subgroup = cache.group("artifacts").subgroup("json/v1");
856
857 let touched = subgroup
858 .touch("nested/output.bin")
859 .expect("touch subgroup entry");
860
861 assert!(touched.is_file());
862 assert!(subgroup.path().join("nested").is_dir());
863 }
864
865 #[test]
866 fn eviction_report_errors_when_group_directory_is_missing() {
867 let tmp = TempDir::new().expect("tempdir");
868 let cache = CacheRoot::from_root(tmp.path());
869 let missing = cache.group("does-not-exist");
870
871 let err = missing
872 .eviction_report(&EvictPolicy::default())
873 .expect_err("eviction report should fail for missing directory");
874 assert_eq!(err.kind(), io::ErrorKind::NotFound);
875 }
876
877 #[test]
878 fn eviction_policy_scans_nested_subdirectories_recursively() {
879 let tmp = TempDir::new().expect("tempdir");
880 let cache = CacheRoot::from_root(tmp.path());
881 let group = cache.group("artifacts");
882 group.ensure_dir().expect("ensure dir");
883
884 fs::create_dir_all(group.entry_path("nested/deeper")).expect("create nested dirs");
885 fs::write(group.entry_path("root.bin"), vec![1u8; 1]).expect("write root");
886 fs::write(group.entry_path("nested/a.bin"), vec![1u8; 1]).expect("write nested a");
887 fs::write(group.entry_path("nested/deeper/b.bin"), vec![1u8; 1]).expect("write nested b");
888
889 let policy = EvictPolicy {
890 max_files: Some(1),
891 ..EvictPolicy::default()
892 };
893
894 group
895 .ensure_dir_with_policy(Some(&policy))
896 .expect("apply recursive policy");
897
898 let remaining = collect_files(group.path()).expect("collect remaining");
899 assert_eq!(remaining.len(), 1);
900 }
901
902 #[test]
903 fn max_bytes_policy_under_threshold_does_not_evict() {
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; 2]).expect("write a");
910 fs::write(group.entry_path("b.bin"), vec![1u8; 3]).expect("write b");
911
912 let policy = EvictPolicy {
913 max_bytes: Some(10),
914 ..EvictPolicy::default()
915 };
916
917 let report = group.eviction_report(&policy).expect("eviction report");
918 assert!(report.marked_for_eviction.is_empty());
919
920 group
921 .ensure_dir_with_policy(Some(&policy))
922 .expect("ensure with policy");
923
924 let files = collect_files(group.path()).expect("collect files");
925 assert_eq!(files.len(), 2);
926 }
927
928 #[test]
929 fn cwd_guard_swap_to_returns_error_for_missing_directory() {
930 let tmp = TempDir::new().expect("tempdir");
931 let missing = tmp.path().join("missing-dir");
932
933 let result = CwdGuard::swap_to(&missing);
934 assert!(result.is_err());
935 assert_eq!(
936 result.err().expect("expected missing-dir error").kind(),
937 io::ErrorKind::NotFound
938 );
939 }
940
941 #[test]
942 fn ensure_dir_equals_ensure_dir_with_policy_none() {
943 let tmp = TempDir::new().expect("tempdir");
944 let cache = CacheRoot::from_root(tmp.path());
945 let group = cache.group("artifacts/eq");
946
947 let p1 = group.ensure_dir().expect("ensure dir");
948 fs::write(group.entry_path("keep.txt"), b"keep").expect("write file");
951
952 let p2 = group
953 .ensure_dir_with_policy(None)
954 .expect("ensure dir with None policy");
955
956 assert_eq!(p1, p2);
957 assert!(group.entry_path("keep.txt").exists());
958 }
959
960 #[test]
961 fn ensure_group_equals_ensure_group_with_policy_none() {
962 let tmp = TempDir::new().expect("tempdir");
963 let cache = CacheRoot::from_root(tmp.path());
964
965 let p1 = cache.ensure_group("artifacts/roots").expect("ensure group");
966 let group = cache.group("artifacts/roots");
967 fs::write(group.entry_path("keep_root.txt"), b"keep").expect("write file");
969
970 let p2 = cache
971 .ensure_group_with_policy("artifacts/roots", None)
972 .expect("ensure group with None policy");
973
974 assert_eq!(p1, p2);
975 assert!(group.entry_path("keep_root.txt").exists());
976 }
977}