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