Skip to main content

cache_manager/
lib.rs

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/// Optional eviction controls applied by `CacheGroup::ensure_dir_with_policy`
16/// and `CacheRoot::ensure_group_with_policy`.
17///
18/// Rules are enforced in this order:
19/// 1. `max_age` (remove files older than or equal to threshold)
20/// 2. `max_files` (keep at most N files)
21/// 3. `max_bytes` (keep total bytes at or below threshold)
22///
23/// For `max_files` and `max_bytes`, candidates are ordered by modified time
24/// ascending (oldest first), then by path for deterministic tie-breaking.
25#[derive(Clone, Debug, PartialEq, Eq, Default)]
26pub struct EvictPolicy {
27    /// Maximum number of files to keep under the managed directory tree.
28    ///
29    /// If exceeded, the oldest files are removed first until the count is
30    /// `<= max_files`.
31    pub max_files: Option<usize>,
32    /// Maximum total size in bytes to keep under the managed directory tree.
33    ///
34    /// If exceeded, files are removed oldest-first until total bytes are
35    /// `<= max_bytes`.
36    ///
37    /// Notes:
38    /// - The limit applies to regular files recursively under the directory.
39    /// - Directories are not counted toward the byte total.
40    /// - Enforced only when using a policy-aware `ensure_*_with_policy` call.
41    pub max_bytes: Option<u64>,
42    /// Maximum file age allowed under the managed directory tree.
43    ///
44    /// Files with age `>= max_age` are removed.
45    pub max_age: Option<Duration>,
46}
47
48/// Files selected for eviction by policy evaluation.
49#[derive(Clone, Debug, PartialEq, Eq, Default)]
50pub struct EvictionReport {
51    /// Absolute paths marked for eviction, in the order they would be applied.
52    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)]
63/// Represents a discovered or explicit cache root directory.
64///
65/// Use `CacheRoot::from_discovery()` to find the nearest crate root from the
66/// current working directory and anchor caches under `.cache`, or
67/// `CacheRoot::from_root(...)` to construct one from an explicit path.
68pub struct CacheRoot {
69    root: PathBuf,
70}
71
72impl CacheRoot {
73    /// Discover the cache root by searching parent directories for `Cargo.toml`.
74    ///
75    /// The discovered cache root is always `<crate-root-or-cwd>/.cache`.
76    ///
77    /// Note: `from_discovery` only uses the configured `CACHE_DIR_NAME` (by
78    /// default `.cache`) as the discovered cache directory. It does not
79    /// detect or prefer other custom directory names (for example
80    /// `.cache-v2`). To use a non-standard cache root name use
81    /// `CacheRoot::from_root(...)` with an explicit path.
82    ///
83    /// Falls back to the current working directory when no crate root is found.
84    pub fn from_discovery() -> io::Result<Self> {
85        let cwd = env::current_dir()?;
86        let anchor = find_crate_root(&cwd).unwrap_or(cwd);
87        // Prefer a canonicalized anchor when possible to avoid surprising
88        // differences between logically-equal paths (symlinks, tempdir
89        // representations, etc.) used by callers and tests.
90        let anchor = anchor.canonicalize().unwrap_or(anchor);
91        let root = anchor.join(CACHE_DIR_NAME);
92        Ok(Self { root })
93    }
94
95    /// Create a `CacheRoot` from an explicit filesystem path.
96    pub fn from_root<P: Into<PathBuf>>(root: P) -> Self {
97        Self { root: root.into() }
98    }
99
100    /// Return the underlying path for this `CacheRoot`.
101    pub fn path(&self) -> &Path {
102        &self.root
103    }
104
105    /// Build a `CacheGroup` for a relative subdirectory under this root.
106    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    /// Resolve a relative group path to an absolute `PathBuf` under this root.
112    pub fn group_path<P: AsRef<Path>>(&self, relative_group: P) -> PathBuf {
113        self.root.join(relative_group.as_ref())
114    }
115
116    /// Ensure the given group directory exists, creating parents as required.
117    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    /// Ensure the given group exists and optionally apply an eviction policy.
122    ///
123    /// When `policy` is `Some`, files will be evaluated and removed according
124    /// to the `EvictPolicy` rules. Passing `None` performs only directory creation.
125    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    /// Resolve a cache entry path given a cache directory (relative to the root)
136    /// and a relative entry path. Absolute `relative_path` values are returned
137    /// unchanged.
138    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)]
152/// A group (subdirectory) under a `CacheRoot` that manages cache entries.
153///
154/// Use `CacheRoot::group(...)` to construct a `CacheGroup` rooted under a
155/// `CacheRoot`.
156pub struct CacheGroup {
157    path: PathBuf,
158}
159
160impl CacheGroup {
161    /// Return the path of this cache group.
162    pub fn path(&self) -> &Path {
163        &self.path
164    }
165
166    /// Ensure the group directory exists on disk, creating parents as needed.
167    pub fn ensure_dir(&self) -> io::Result<&Path> {
168        self.ensure_dir_with_policy(None)
169    }
170
171    /// Ensures this directory exists, then applies optional eviction.
172    ///
173    /// Eviction is applied recursively to files under this directory. The
174    /// policy is best-effort for removals: individual delete failures are
175    /// ignored so initialization can continue.
176    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    /// Returns a report of files that would be evicted under `policy`.
185    ///
186    /// This does not delete files. The selection order matches the internal
187    /// order used by `ensure_dir_with_policy`.
188    /// Return a report of files that would be evicted by `policy`.
189    ///
190    /// The report is non-destructive and mirrors the selection used by
191    /// `ensure_dir_with_policy` so it can be used for previewing or testing.
192    pub fn eviction_report(&self, policy: &EvictPolicy) -> io::Result<EvictionReport> {
193        build_eviction_report(&self.path, policy)
194    }
195
196    /// Create a nested subgroup under this group.
197    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    /// Resolve a relative entry path under this group.
204    pub fn entry_path<P: AsRef<Path>>(&self, relative_file: P) -> PathBuf {
205        self.path.join(relative_file.as_ref())
206    }
207
208    /// Create or update (touch) a file under this group, creating parent
209    /// directories as needed. Returns the absolute path to the entry.
210    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    // Serialize tests that mutate the process working directory.
334    //
335    // Many unit tests temporarily call `env::set_current_dir` to exercise
336    // discovery behavior. Because the process CWD is global, those tests
337    // can race when run in parallel and cause flaky failures (different
338    // tests observing different CWDs). We use a global `Mutex<()>` to
339    // serialize CWD-changing tests so they run one at a time.
340    use std::sync::{Mutex, MutexGuard, OnceLock};
341    use tempfile::TempDir;
342
343    /// Return the global mutex used to serialize tests that change the
344    /// process current working directory. Stored in a `OnceLock` so it is
345    /// initialized on first use and lives for the duration of the process.
346    fn cwd_lock() -> &'static Mutex<()> {
347        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
348        LOCK.get_or_init(|| Mutex::new(()))
349    }
350
351    /// Test helper that temporarily changes the process current working
352    /// directory and restores it when dropped. While alive it also holds
353    /// the global `cwd_lock()` so no two tests can race by changing the
354    /// CWD concurrently.
355    struct CwdGuard {
356        previous: PathBuf,
357        // Hold the guard so the lock remains taken for the lifetime of
358        // this `CwdGuard` instance.
359        _cwd_lock: MutexGuard<'static, ()>,
360    }
361
362    impl CwdGuard {
363        fn swap_to(path: &Path) -> io::Result<Self> {
364            // Acquire the global lock before mutating the CWD to avoid
365            // races with other tests that also change the CWD.
366            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        // Ensure the expected (non-canonicalized) parent path also exists
495        // so canonicalization succeeds on platforms where temporary paths
496        // may differ from the discovered/canonicalized root.
497        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        // Create a non-standard cache directory name at the crate root.
529        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        // from_discovery should still resolve to `<crate_root>/.cache` (not `.cache-v2`).
535        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        // create a file so we can verify calling the policy-aware
973        // variant with `None` does not remove or alter contents.
974        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        // create a file to ensure no-op policy does not remove it
992        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}