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 tempfile::TempDir;
342
343 struct CwdGuard {
344 previous: PathBuf,
345 }
346
347 impl CwdGuard {
348 fn swap_to(path: &Path) -> io::Result<Self> {
349 let previous = env::current_dir()?;
350 match env::set_current_dir(path) {
354 Ok(()) => Ok(Self { previous }),
355 Err(e) => {
356 if let Ok(canon) = path.canonicalize() {
357 env::set_current_dir(&canon)?;
358 Ok(Self { previous })
359 } else {
360 Err(e)
361 }
362 }
363 }
364 }
365 }
366
367 impl Drop for CwdGuard {
368 fn drop(&mut self) {
369 let _ = env::set_current_dir(&self.previous);
370 }
371 }
372
373 #[test]
374 fn discover_falls_back_to_cwd_when_no_cargo_toml() {
375 let tmp = TempDir::new().expect("tempdir");
376 let _guard = CwdGuard::swap_to(tmp.path()).expect("set cwd");
377
378 let cache = CacheRoot::discover().expect("discover");
379 let got = cache
380 .path()
381 .canonicalize()
382 .expect("canonicalize discovered root");
383 let expected = tmp.path().canonicalize().expect("canonicalize temp path");
384 assert_eq!(got, expected);
385 }
386
387 #[test]
388 fn discover_prefers_nearest_crate_root() {
389 let tmp = TempDir::new().expect("tempdir");
390 let crate_root = tmp.path().join("workspace");
391 let nested = crate_root.join("src").join("nested");
392 fs::create_dir_all(&nested).expect("create nested");
393 fs::write(
394 crate_root.join("Cargo.toml"),
395 "[package]\nname='x'\nversion='0.1.0'\nedition='2024'\n",
396 )
397 .expect("write cargo");
398
399 let _guard = CwdGuard::swap_to(&nested).expect("set cwd");
400 let cache = CacheRoot::discover().expect("discover");
401 let got = cache
402 .path()
403 .canonicalize()
404 .expect("canonicalize discovered root");
405 let expected = crate_root.canonicalize().expect("canonicalize crate root");
406 assert_eq!(got, expected);
407 }
408
409 #[test]
410 fn from_root_supports_arbitrary_path_and_grouping() {
411 let tmp = TempDir::new().expect("tempdir");
412 let root = CacheRoot::from_root(tmp.path().join("custom-cache-root"));
413 let group = root.group("taxonomy/v1");
414
415 assert_eq!(group.path(), root.path().join("taxonomy/v1").as_path());
416 }
417
418 #[test]
419 fn group_path_building_and_dir_creation() {
420 let tmp = TempDir::new().expect("tempdir");
421 let cache = CacheRoot::from_root(tmp.path());
422 let group = cache.group("artifacts/json");
423
424 let nested_group = group.subgroup("v1");
425 let ensured = nested_group.ensure_dir().expect("ensure nested dir");
426 let expected_group_suffix = Path::new("artifacts").join("json").join("v1");
427 assert!(ensured.ends_with(&expected_group_suffix));
428 assert!(ensured.exists());
429
430 let entry = nested_group.entry_path("a/b/cache.json");
431 let expected_entry_suffix = Path::new("artifacts")
432 .join("json")
433 .join("v1")
434 .join("a")
435 .join("b")
436 .join("cache.json");
437 assert!(entry.ends_with(&expected_entry_suffix));
438 }
439
440 #[test]
441 fn touch_creates_blank_file_and_is_idempotent() {
442 let tmp = TempDir::new().expect("tempdir");
443 let cache = CacheRoot::from_root(tmp.path());
444 let group = cache.group("artifacts/json");
445
446 let touched = group.touch("a/b/cache.json").expect("touch file");
447 assert!(touched.exists());
448 let meta = fs::metadata(&touched).expect("metadata");
449 assert_eq!(meta.len(), 0);
450
451 let touched_again = group.touch("a/b/cache.json").expect("touch file again");
452 assert_eq!(touched_again, touched);
453 let meta_again = fs::metadata(&touched_again).expect("metadata again");
454 assert_eq!(meta_again.len(), 0);
455 }
456
457 #[test]
458 fn discover_cache_path_uses_root_and_group() {
459 let tmp = TempDir::new().expect("tempdir");
460 let crate_root = tmp.path().join("workspace");
461 let nested = crate_root.join("src").join("nested");
462 fs::create_dir_all(&nested).expect("create nested");
463 fs::write(
464 crate_root.join("Cargo.toml"),
465 "[package]\nname='x'\nversion='0.1.0'\nedition='2024'\n",
466 )
467 .expect("write cargo");
468
469 let _guard = CwdGuard::swap_to(&nested).expect("set cwd");
470 let p = CacheRoot::discover_cache_path(".cache", "taxonomy/taxonomy_cache.json");
471 let parent = p.parent().expect("cache path parent");
472 fs::create_dir_all(parent).expect("create cache parent");
473 let expected_dir = crate_root.join(".cache").join("taxonomy");
477 fs::create_dir_all(&expected_dir).expect("create expected cache parent");
478 let got_parent = p
479 .parent()
480 .expect("cache path parent")
481 .canonicalize()
482 .expect("canonicalize cache parent");
483 let expected_parent = crate_root
484 .join(".cache")
485 .join("taxonomy")
486 .canonicalize()
487 .expect("canonicalize expected parent");
488 assert_eq!(got_parent, expected_parent);
489 assert_eq!(
490 p.file_name().and_then(|s| s.to_str()),
491 Some("taxonomy_cache.json")
492 );
493 }
494
495 #[test]
496 fn cache_path_preserves_absolute_paths() {
497 let root = CacheRoot::from_root("/tmp/project");
498 let absolute = PathBuf::from("/tmp/custom/cache.json");
499 let resolved = root.cache_path(".cache", &absolute);
500 assert_eq!(resolved, absolute);
501 }
502
503 #[test]
504 fn ensure_dir_with_policy_max_files() {
505 let tmp = TempDir::new().expect("tempdir");
506 let cache = CacheRoot::from_root(tmp.path());
507 let group = cache.group("artifacts");
508 group.ensure_dir().expect("ensure dir");
509
510 fs::write(group.entry_path("a.txt"), b"1").expect("write a");
511 fs::write(group.entry_path("b.txt"), b"1").expect("write b");
512 fs::write(group.entry_path("c.txt"), b"1").expect("write c");
513
514 let policy = EvictPolicy {
515 max_files: Some(2),
516 ..EvictPolicy::default()
517 };
518 group
519 .ensure_dir_with_policy(Some(&policy))
520 .expect("ensure with policy");
521
522 let files = collect_files(group.path()).expect("collect files");
523 assert_eq!(files.len(), 2);
524 }
525
526 #[test]
527 fn ensure_dir_with_policy_max_bytes() {
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.bin"), vec![1u8; 5]).expect("write a");
534 fs::write(group.entry_path("b.bin"), vec![1u8; 5]).expect("write b");
535 fs::write(group.entry_path("c.bin"), vec![1u8; 5]).expect("write c");
536
537 let policy = EvictPolicy {
538 max_bytes: Some(10),
539 ..EvictPolicy::default()
540 };
541 group
542 .ensure_dir_with_policy(Some(&policy))
543 .expect("ensure with policy");
544
545 let total: u64 = collect_files(group.path())
546 .expect("collect files")
547 .iter()
548 .map(|f| f.len)
549 .sum();
550 assert!(total <= 10);
551 }
552
553 #[test]
554 fn ensure_dir_with_policy_max_age_zero_evicts_all() {
555 let tmp = TempDir::new().expect("tempdir");
556 let cache = CacheRoot::from_root(tmp.path());
557 let group = cache.group("artifacts");
558 group.ensure_dir().expect("ensure dir");
559
560 fs::write(group.entry_path("a.txt"), b"1").expect("write a");
561 fs::write(group.entry_path("b.txt"), b"1").expect("write b");
562
563 let policy = EvictPolicy {
564 max_age: Some(Duration::ZERO),
565 ..EvictPolicy::default()
566 };
567 group
568 .ensure_dir_with_policy(Some(&policy))
569 .expect("ensure with policy");
570
571 let files = collect_files(group.path()).expect("collect files");
572 assert!(files.is_empty());
573 }
574
575 #[test]
576 fn eviction_report_matches_applied_evictions() {
577 let tmp = TempDir::new().expect("tempdir");
578 let cache = CacheRoot::from_root(tmp.path());
579 let group = cache.group("artifacts");
580 group.ensure_dir().expect("ensure dir");
581
582 fs::write(group.entry_path("a.bin"), vec![1u8; 5]).expect("write a");
583 fs::write(group.entry_path("b.bin"), vec![1u8; 5]).expect("write b");
584 fs::write(group.entry_path("c.bin"), vec![1u8; 5]).expect("write c");
585
586 let policy = EvictPolicy {
587 max_bytes: Some(10),
588 ..EvictPolicy::default()
589 };
590
591 let before: BTreeSet<PathBuf> = collect_files(group.path())
592 .expect("collect before")
593 .into_iter()
594 .map(|f| f.path)
595 .collect();
596
597 let report = group.eviction_report(&policy).expect("eviction report");
598 let planned: BTreeSet<PathBuf> = report.marked_for_eviction.iter().cloned().collect();
599
600 group
601 .ensure_dir_with_policy(Some(&policy))
602 .expect("ensure with policy");
603
604 let after: BTreeSet<PathBuf> = collect_files(group.path())
605 .expect("collect after")
606 .into_iter()
607 .map(|f| f.path)
608 .collect();
609
610 let expected_after: BTreeSet<PathBuf> = before.difference(&planned).cloned().collect();
611 assert_eq!(after, expected_after);
612 }
613
614 #[test]
615 fn no_policy_and_default_policy_report_do_not_mark_evictions() {
616 let tmp = TempDir::new().expect("tempdir");
617 let cache = CacheRoot::from_root(tmp.path());
618 let group = cache.group("artifacts");
619 group.ensure_dir().expect("ensure dir");
620
621 fs::write(group.entry_path("a.txt"), b"1").expect("write a");
622 fs::write(group.entry_path("b.txt"), b"1").expect("write b");
623
624 let report = group
625 .eviction_report(&EvictPolicy::default())
626 .expect("eviction report");
627 assert!(report.marked_for_eviction.is_empty());
628
629 group
630 .ensure_dir_with_policy(None)
631 .expect("ensure with no policy");
632
633 let files = collect_files(group.path()).expect("collect files");
634 assert_eq!(files.len(), 2);
635 }
636
637 #[test]
638 fn single_root_supports_distinct_policies_per_subdirectory() {
639 let tmp = TempDir::new().expect("tempdir");
640 let cache = CacheRoot::from_root(tmp.path());
641
642 let images = cache.group("artifacts/images");
643 let reports = cache.group("artifacts/reports");
644
645 images.ensure_dir().expect("ensure images dir");
646 reports.ensure_dir().expect("ensure reports dir");
647
648 fs::write(images.entry_path("img1.bin"), vec![1u8; 5]).expect("write img1");
649 fs::write(images.entry_path("img2.bin"), vec![1u8; 5]).expect("write img2");
650 fs::write(images.entry_path("img3.bin"), vec![1u8; 5]).expect("write img3");
651
652 fs::write(reports.entry_path("a.txt"), b"1").expect("write report a");
653 fs::write(reports.entry_path("b.txt"), b"1").expect("write report b");
654 fs::write(reports.entry_path("c.txt"), b"1").expect("write report c");
655
656 let images_policy = EvictPolicy {
657 max_bytes: Some(10),
658 ..EvictPolicy::default()
659 };
660 let reports_policy = EvictPolicy {
661 max_files: Some(1),
662 ..EvictPolicy::default()
663 };
664
665 images
666 .ensure_dir_with_policy(Some(&images_policy))
667 .expect("apply images policy");
668 reports
669 .ensure_dir_with_policy(Some(&reports_policy))
670 .expect("apply reports policy");
671
672 let images_total: u64 = collect_files(images.path())
673 .expect("collect images files")
674 .iter()
675 .map(|f| f.len)
676 .sum();
677 assert!(images_total <= 10);
678
679 let reports_files = collect_files(reports.path()).expect("collect reports files");
680 assert_eq!(reports_files.len(), 1);
681 }
682}