Skip to main content

cache_manager/
lib.rs

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/// Optional eviction controls applied by `CacheGroup::ensure_dir_with_policy`
12/// and `CacheRoot::ensure_group_with_policy`.
13///
14/// Rules are enforced in this order:
15/// 1. `max_age` (remove files older than or equal to threshold)
16/// 2. `max_files` (keep at most N files)
17/// 3. `max_bytes` (keep total bytes at or below threshold)
18///
19/// For `max_files` and `max_bytes`, candidates are ordered by modified time
20/// ascending (oldest first), then by path for deterministic tie-breaking.
21#[derive(Clone, Debug, PartialEq, Eq, Default)]
22pub struct EvictPolicy {
23    /// Maximum number of files to keep under the managed directory tree.
24    ///
25    /// If exceeded, the oldest files are removed first until the count is
26    /// `<= max_files`.
27    pub max_files: Option<usize>,
28    /// Maximum total size in bytes to keep under the managed directory tree.
29    ///
30    /// If exceeded, files are removed oldest-first until total bytes are
31    /// `<= max_bytes`.
32    ///
33    /// Notes:
34    /// - The limit applies to regular files recursively under the directory.
35    /// - Directories are not counted toward the byte total.
36    /// - Enforced only when using a policy-aware `ensure_*_with_policy` call.
37    pub max_bytes: Option<u64>,
38    /// Maximum file age allowed under the managed directory tree.
39    ///
40    /// Files with age `>= max_age` are removed.
41    pub max_age: Option<Duration>,
42}
43
44/// Files selected for eviction by policy evaluation.
45#[derive(Clone, Debug, PartialEq, Eq, Default)]
46pub struct EvictionReport {
47    /// Absolute paths marked for eviction, in the order they would be applied.
48    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)]
59/// Represents a discovered or explicit cache root directory.
60///
61/// Use `CacheRoot::discover()` to find the nearest crate root from the
62/// current working directory, or `CacheRoot::from_root(...)` to construct one
63/// from an explicit path.
64pub struct CacheRoot {
65    root: PathBuf,
66}
67
68impl CacheRoot {
69    /// Discover the cache root by searching parent directories for `Cargo.toml`.
70    ///
71    /// Falls back to the current working directory when no crate root is found.
72    pub fn discover() -> io::Result<Self> {
73        let cwd = env::current_dir()?;
74        let root = find_crate_root(&cwd).unwrap_or(cwd);
75        // Prefer a canonicalized path when possible to avoid surprising
76        // differences between logically-equal paths (symlinks, tempdir
77        // representations, etc.) used by callers and tests.
78        let root = root.canonicalize().unwrap_or(root);
79        Ok(Self { root })
80    }
81
82    /// Create a `CacheRoot` from an explicit filesystem path.
83    pub fn from_root<P: Into<PathBuf>>(root: P) -> Self {
84        Self { root: root.into() }
85    }
86
87    /// Like `discover()` but never returns an `io::Result` — falls back to `.` on error.
88    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    /// Return the underlying path for this `CacheRoot`.
96    pub fn path(&self) -> &Path {
97        &self.root
98    }
99
100    /// Build a `CacheGroup` for a relative subdirectory under this root.
101    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    /// Resolve a relative group path to an absolute `PathBuf` under this root.
107    pub fn group_path<P: AsRef<Path>>(&self, relative_group: P) -> PathBuf {
108        self.root.join(relative_group.as_ref())
109    }
110
111    /// Ensure the given group directory exists, creating parents as required.
112    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    /// Ensure the given group exists and optionally apply an eviction policy.
119    ///
120    /// When `policy` is `Some`, files will be evaluated and removed according
121    /// to the `EvictPolicy` rules. Passing `None` performs only directory creation.
122    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    /// Resolve a cache entry path given a cache directory (relative to the root)
133    /// and a relative entry path. Absolute `relative_path` values are returned
134    /// unchanged.
135    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    /// Discover the crate root (or use cwd) and resolve a cache entry path.
148    ///
149    /// Convenience wrapper for `CacheRoot::discover_or_cwd().cache_path(...)`.
150    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)]
159/// A group (subdirectory) under a `CacheRoot` that manages cache entries.
160///
161/// Use `CacheRoot::group(...)` to construct a `CacheGroup` rooted under a
162/// `CacheRoot`.
163pub struct CacheGroup {
164    path: PathBuf,
165}
166
167impl CacheGroup {
168    /// Return the path of this cache group.
169    pub fn path(&self) -> &Path {
170        &self.path
171    }
172
173    /// Ensure the group directory exists on disk, creating parents as needed.
174    pub fn ensure_dir(&self) -> io::Result<&Path> {
175        fs::create_dir_all(&self.path)?;
176        Ok(&self.path)
177    }
178
179    /// Ensures this directory exists, then applies optional eviction.
180    ///
181    /// Eviction is applied recursively to files under this directory. The
182    /// policy is best-effort for removals: individual delete failures are
183    /// ignored so initialization can continue.
184    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    /// Returns a report of files that would be evicted under `policy`.
193    ///
194    /// This does not delete files. The selection order matches the internal
195    /// order used by `ensure_dir_with_policy`.
196    /// Return a report of files that would be evicted by `policy`.
197    ///
198    /// The report is non-destructive and mirrors the selection used by
199    /// `ensure_dir_with_policy` so it can be used for previewing or testing.
200    pub fn eviction_report(&self, policy: &EvictPolicy) -> io::Result<EvictionReport> {
201        build_eviction_report(&self.path, policy)
202    }
203
204    /// Create a nested subgroup under this group.
205    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    /// Resolve a relative entry path under this group.
212    pub fn entry_path<P: AsRef<Path>>(&self, relative_file: P) -> PathBuf {
213        self.path.join(relative_file.as_ref())
214    }
215
216    /// Create or update (touch) a file under this group, creating parent
217    /// directories as needed. Returns the absolute path to the entry.
218    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    // Serialize tests that mutate the process working directory.
342    //
343    // Many unit tests temporarily call `env::set_current_dir` to exercise
344    // discovery behavior. Because the process CWD is global, those tests
345    // can race when run in parallel and cause flaky failures (different
346    // tests observing different CWDs). We use a global `Mutex<()>` to
347    // serialize CWD-changing tests so they run one at a time.
348    use std::sync::{Mutex, MutexGuard, OnceLock};
349    use tempfile::TempDir;
350
351    /// Return the global mutex used to serialize tests that change the
352    /// process current working directory. Stored in a `OnceLock` so it is
353    /// initialized on first use and lives for the duration of the process.
354    fn cwd_lock() -> &'static Mutex<()> {
355        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
356        LOCK.get_or_init(|| Mutex::new(()))
357    }
358
359    /// Test helper that temporarily changes the process current working
360    /// directory and restores it when dropped. While alive it also holds
361    /// the global `cwd_lock()` so no two tests can race by changing the
362    /// CWD concurrently.
363    struct CwdGuard {
364        previous: PathBuf,
365        // Hold the guard so the lock remains taken for the lifetime of
366        // this `CwdGuard` instance.
367        _cwd_lock: MutexGuard<'static, ()>,
368    }
369
370    impl CwdGuard {
371        fn swap_to(path: &Path) -> io::Result<Self> {
372            // Acquire the global lock before mutating the CWD to avoid
373            // races with other tests that also change the CWD.
374            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        // Ensure the expected (non-canonicalized) parent path also exists
500        // so canonicalization succeeds on platforms where temporary paths
501        // may differ from the discovered/canonicalized root.
502        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}