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