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