Skip to main content

cache_manager/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4mod constants;
5
6#[cfg(feature = "process-scoped-cache")]
7use std::cell::Cell;
8use std::env;
9use std::fs;
10use std::fs::OpenOptions;
11use std::io;
12use std::path::{Path, PathBuf};
13#[cfg(feature = "process-scoped-cache")]
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16
17use constants::{CACHE_DIR_NAME, CARGO_TOML_FILE_NAME};
18#[cfg(feature = "process-scoped-cache")]
19use tempfile::{Builder, TempDir};
20
21/// Optional eviction controls applied by `CacheGroup::ensure_dir_with_policy`
22/// and `CacheRoot::ensure_group_with_policy`.
23///
24/// Rules are enforced in this order:
25/// 1. `max_age` (remove files older than or equal to threshold)
26/// 2. `max_files` (keep at most N files)
27/// 3. `max_bytes` (keep total bytes at or below threshold)
28///
29/// For `max_files` and `max_bytes`, candidates are ordered by modified time
30/// ascending (oldest first), then by path for deterministic tie-breaking.
31#[derive(Clone, Debug, PartialEq, Eq, Default)]
32pub struct EvictPolicy {
33    /// Maximum number of files to keep under the managed directory tree.
34    ///
35    /// If exceeded, the oldest files are removed first until the count is
36    /// `<= max_files`.
37    pub max_files: Option<usize>,
38    /// Maximum total size in bytes to keep under the managed directory tree.
39    ///
40    /// If exceeded, files are removed oldest-first until total bytes are
41    /// `<= max_bytes`.
42    ///
43    /// Notes:
44    /// - The limit applies to regular files recursively under the directory.
45    /// - Directories are not counted toward the byte total.
46    /// - Enforced only when using a policy-aware `ensure_*_with_policy` call.
47    pub max_bytes: Option<u64>,
48    /// Maximum file age allowed under the managed directory tree.
49    ///
50    /// Files with age `>= max_age` are removed.
51    pub max_age: Option<Duration>,
52}
53
54/// Files selected for eviction by policy evaluation.
55#[derive(Clone, Debug, PartialEq, Eq, Default)]
56pub struct EvictionReport {
57    /// Absolute paths marked for eviction, in the order they would be applied.
58    pub marked_for_eviction: Vec<PathBuf>,
59}
60
61#[derive(Clone, Debug)]
62struct FileEntry {
63    path: PathBuf,
64    modified: SystemTime,
65    len: u64,
66}
67
68#[derive(Clone, Debug, PartialEq, Eq)]
69/// Represents a discovered or explicit cache root directory.
70///
71/// Use `CacheRoot::from_discovery()` to find the nearest crate root from the
72/// current working directory and anchor caches under `.cache`, or
73/// `CacheRoot::from_root(...)` to construct one from an explicit path.
74pub struct CacheRoot {
75    root: PathBuf,
76}
77
78impl CacheRoot {
79    /// Discover the cache root by searching parent directories for `Cargo.toml`.
80    ///
81    /// The discovered cache root is always `<crate-root-or-cwd>/.cache`.
82    ///
83    /// Note: `from_discovery` only uses the configured `CACHE_DIR_NAME` (by
84    /// default `.cache`) as the discovered cache directory. It does not
85    /// detect or prefer other custom directory names (for example
86    /// `.cache-v2`). To use a non-standard cache root name use
87    /// `CacheRoot::from_root(...)` with an explicit path.
88    ///
89    /// Falls back to the current working directory when no crate root is found.
90    pub fn from_discovery() -> io::Result<Self> {
91        let cwd = env::current_dir()?;
92        let anchor = find_crate_root(&cwd).unwrap_or(cwd);
93        // Prefer a canonicalized anchor when possible to avoid surprising
94        // differences between logically-equal paths (symlinks, tempdir
95        // representations, etc.) used by callers and tests.
96        let anchor = anchor.canonicalize().unwrap_or(anchor);
97        let root = anchor.join(CACHE_DIR_NAME);
98        Ok(Self { root })
99    }
100
101    /// Create a `CacheRoot` from an explicit filesystem path.
102    pub fn from_root<P: Into<PathBuf>>(root: P) -> Self {
103        Self { root: root.into() }
104    }
105
106    /// Return the underlying path for this `CacheRoot`.
107    pub fn path(&self) -> &Path {
108        &self.root
109    }
110
111    /// Build a `CacheGroup` for a relative subdirectory under this root.
112    pub fn group<P: AsRef<Path>>(&self, relative_group: P) -> CacheGroup {
113        let path = self.root.join(relative_group.as_ref());
114        CacheGroup { path }
115    }
116
117    /// Resolve a relative group path to an absolute `PathBuf` under this root.
118    pub fn group_path<P: AsRef<Path>>(&self, relative_group: P) -> PathBuf {
119        self.root.join(relative_group.as_ref())
120    }
121
122    /// Ensure the given group directory exists, creating parents as required.
123    pub fn ensure_group<P: AsRef<Path>>(&self, relative_group: P) -> io::Result<PathBuf> {
124        self.ensure_group_with_policy(relative_group, None)
125    }
126
127    /// Ensure the given group exists and optionally apply an eviction policy.
128    ///
129    /// When `policy` is `Some`, files will be evaluated and removed according
130    /// to the `EvictPolicy` rules. Passing `None` performs only directory creation.
131    pub fn ensure_group_with_policy<P: AsRef<Path>>(
132        &self,
133        relative_group: P,
134        policy: Option<&EvictPolicy>,
135    ) -> io::Result<PathBuf> {
136        let group = self.group(relative_group);
137        group.ensure_dir_with_policy(policy)?;
138        Ok(group.path().to_path_buf())
139    }
140
141    /// Resolve a cache entry path given a cache directory (relative to the root)
142    /// and a relative entry path. Absolute `relative_path` values are returned
143    /// unchanged.
144    pub fn cache_path<P: AsRef<Path>, Q: AsRef<Path>>(
145        &self,
146        cache_dir: P,
147        relative_path: Q,
148    ) -> PathBuf {
149        let rel = relative_path.as_ref();
150        if rel.is_absolute() {
151            return rel.to_path_buf();
152        }
153        self.group(cache_dir).entry_path(rel)
154    }
155}
156
157#[derive(Clone, Debug, PartialEq, Eq)]
158/// A group (subdirectory) under a `CacheRoot` that manages cache entries.
159///
160/// Use `CacheRoot::group(...)` to construct a `CacheGroup` rooted under a
161/// `CacheRoot`.
162pub struct CacheGroup {
163    path: PathBuf,
164}
165
166impl CacheGroup {
167    /// Return the path of this cache group.
168    pub fn path(&self) -> &Path {
169        &self.path
170    }
171
172    /// Ensure the group directory exists on disk, creating parents as needed.
173    pub fn ensure_dir(&self) -> io::Result<&Path> {
174        self.ensure_dir_with_policy(None)
175    }
176
177    /// Ensures this directory exists, then applies optional eviction.
178    ///
179    /// Eviction is applied recursively to files under this directory. The
180    /// policy is best-effort for removals: individual delete failures are
181    /// ignored so initialization can continue.
182    pub fn ensure_dir_with_policy(&self, policy: Option<&EvictPolicy>) -> io::Result<&Path> {
183        fs::create_dir_all(&self.path)?;
184        if let Some(policy) = policy {
185            apply_evict_policy(&self.path, policy)?;
186        }
187        Ok(&self.path)
188    }
189
190    /// Returns a report of files that would be evicted under `policy`.
191    ///
192    /// This does not delete files. The selection order matches the internal
193    /// order used by `ensure_dir_with_policy`.
194    /// Return a report of files that would be evicted by `policy`.
195    ///
196    /// The report is non-destructive and mirrors the selection used by
197    /// `ensure_dir_with_policy` so it can be used for previewing or testing.
198    pub fn eviction_report(&self, policy: &EvictPolicy) -> io::Result<EvictionReport> {
199        build_eviction_report(&self.path, policy)
200    }
201
202    /// Create a nested subgroup under this group.
203    pub fn subgroup<P: AsRef<Path>>(&self, relative_group: P) -> Self {
204        Self {
205            path: self.path.join(relative_group.as_ref()),
206        }
207    }
208
209    /// Resolve a relative entry path under this group.
210    pub fn entry_path<P: AsRef<Path>>(&self, relative_file: P) -> PathBuf {
211        self.path.join(relative_file.as_ref())
212    }
213
214    /// Create or update (touch) a file under this group, creating parent
215    /// directories as needed. Returns the absolute path to the entry.
216    pub fn touch<P: AsRef<Path>>(&self, relative_file: P) -> io::Result<PathBuf> {
217        let entry = self.entry_path(relative_file);
218        if let Some(parent) = entry.parent() {
219            fs::create_dir_all(parent)?;
220        }
221        OpenOptions::new().create(true).append(true).open(&entry)?;
222        Ok(entry)
223    }
224}
225
226/// Process-scoped cache group handle with per-thread subgroup helpers.
227///
228/// This type is available when the `process-scoped-cache` feature is enabled.
229///
230/// It creates an auto-generated process subdirectory under a user-selected
231/// base cache group. The backing directory is removed when this handle is
232/// dropped during normal process shutdown.
233///
234/// Notes:
235/// - Cleanup is best-effort and is not guaranteed after abnormal termination
236///   (for example `SIGKILL` or process crash).
237/// - All paths still respect the caller-provided `CacheRoot` and base group.
238#[cfg(feature = "process-scoped-cache")]
239#[derive(Debug)]
240pub struct ProcessScopedCacheGroup {
241    process_group: CacheGroup,
242    _temp_dir: TempDir,
243}
244
245#[cfg(feature = "process-scoped-cache")]
246impl ProcessScopedCacheGroup {
247    /// Create a process-scoped cache handle under `root.group(relative_group)`.
248    pub fn new<P: AsRef<Path>>(root: &CacheRoot, relative_group: P) -> io::Result<Self> {
249        Self::from_group(root.group(relative_group))
250    }
251
252    /// Create a process-scoped cache handle under an existing base group.
253    pub fn from_group(base_group: CacheGroup) -> io::Result<Self> {
254        base_group.ensure_dir()?;
255        let pid = std::process::id();
256        let temp_dir = Builder::new()
257            .prefix(&format!("pid-{pid}-"))
258            .tempdir_in(base_group.path())?;
259        let process_group = CacheGroup {
260            path: temp_dir.path().to_path_buf(),
261        };
262
263        Ok(Self {
264            process_group,
265            _temp_dir: temp_dir,
266        })
267    }
268
269    /// Return the process-scoped directory path.
270    pub fn path(&self) -> &Path {
271        self.process_group.path()
272    }
273
274    /// Return the process-scoped cache group.
275    pub fn process_group(&self) -> CacheGroup {
276        self.process_group.clone()
277    }
278
279    /// Return the subgroup for the current thread.
280    ///
281    /// Each thread gets a stable, process-local incremental id (`thread-<n>`)
282    /// for the process lifetime.
283    pub fn thread_group(&self) -> CacheGroup {
284        self.process_group
285            .subgroup(format!("thread-{}", current_thread_cache_group_id()))
286    }
287
288    /// Ensure and return the subgroup for the current thread.
289    pub fn ensure_thread_group(&self) -> io::Result<CacheGroup> {
290        let group = self.thread_group();
291        group.ensure_dir()?;
292        Ok(group)
293    }
294
295    /// Build an entry path inside the current thread subgroup.
296    pub fn thread_entry_path<P: AsRef<Path>>(&self, relative_file: P) -> PathBuf {
297        self.thread_group().entry_path(relative_file)
298    }
299
300    /// Touch an entry inside the current thread subgroup.
301    pub fn touch_thread_entry<P: AsRef<Path>>(&self, relative_file: P) -> io::Result<PathBuf> {
302        self.ensure_thread_group()?.touch(relative_file)
303    }
304}
305
306#[cfg(feature = "process-scoped-cache")]
307fn current_thread_cache_group_id() -> u64 {
308    thread_local! {
309        static THREAD_GROUP_ID: Cell<Option<u64>> = const { Cell::new(None) };
310    }
311
312    static NEXT_THREAD_GROUP_ID: AtomicU64 = AtomicU64::new(1);
313
314    THREAD_GROUP_ID.with(|slot| {
315        if let Some(id) = slot.get() {
316            id
317        } else {
318            let id = NEXT_THREAD_GROUP_ID.fetch_add(1, Ordering::Relaxed);
319            slot.set(Some(id));
320            id
321        }
322    })
323}
324
325fn find_crate_root(start: &Path) -> Option<PathBuf> {
326    let mut current = start.to_path_buf();
327    loop {
328        if current.join(CARGO_TOML_FILE_NAME).is_file() {
329            return Some(current);
330        }
331        if !current.pop() {
332            return None;
333        }
334    }
335}
336
337fn apply_evict_policy(root: &Path, policy: &EvictPolicy) -> io::Result<()> {
338    let report = build_eviction_report(root, policy)?;
339
340    for path in report.marked_for_eviction {
341        let _ = fs::remove_file(path);
342    }
343
344    Ok(())
345}
346
347fn sort_entries_oldest_first(entries: &mut [FileEntry]) {
348    entries.sort_by(|a, b| {
349        let ta = a
350            .modified
351            .duration_since(UNIX_EPOCH)
352            .unwrap_or(Duration::ZERO);
353        let tb = b
354            .modified
355            .duration_since(UNIX_EPOCH)
356            .unwrap_or(Duration::ZERO);
357        ta.cmp(&tb).then_with(|| a.path.cmp(&b.path))
358    });
359}
360
361fn build_eviction_report(root: &Path, policy: &EvictPolicy) -> io::Result<EvictionReport> {
362    let mut entries = collect_files(root)?;
363    let mut marked_for_eviction = Vec::new();
364
365    if let Some(max_age) = policy.max_age {
366        let now = SystemTime::now();
367        let mut survivors = Vec::with_capacity(entries.len());
368        for entry in entries {
369            let age = now.duration_since(entry.modified).unwrap_or(Duration::ZERO);
370            if age >= max_age {
371                marked_for_eviction.push(entry.path);
372            } else {
373                survivors.push(entry);
374            }
375        }
376        entries = survivors;
377    }
378
379    sort_entries_oldest_first(&mut entries);
380
381    if let Some(max_files) = policy.max_files
382        && entries.len() > max_files
383    {
384        let to_remove = entries.len() - max_files;
385        for entry in entries.iter().take(to_remove) {
386            marked_for_eviction.push(entry.path.clone());
387        }
388        entries = entries.into_iter().skip(to_remove).collect();
389        sort_entries_oldest_first(&mut entries);
390    }
391
392    if let Some(max_bytes) = policy.max_bytes {
393        let mut total: u64 = entries.iter().map(|e| e.len).sum();
394        if total > max_bytes {
395            for entry in &entries {
396                if total <= max_bytes {
397                    break;
398                }
399                marked_for_eviction.push(entry.path.clone());
400                total = total.saturating_sub(entry.len);
401            }
402        }
403    }
404
405    Ok(EvictionReport {
406        marked_for_eviction,
407    })
408}
409
410fn collect_files(root: &Path) -> io::Result<Vec<FileEntry>> {
411    let mut out = Vec::new();
412    collect_files_recursive(root, &mut out)?;
413    Ok(out)
414}
415
416fn collect_files_recursive(dir: &Path, out: &mut Vec<FileEntry>) -> io::Result<()> {
417    for entry in fs::read_dir(dir)? {
418        let entry = entry?;
419        let path = entry.path();
420        let meta = entry.metadata()?;
421        if meta.is_dir() {
422            collect_files_recursive(&path, out)?;
423        } else if meta.is_file() {
424            out.push(FileEntry {
425                path,
426                modified: meta.modified().unwrap_or(UNIX_EPOCH),
427                len: meta.len(),
428            });
429        }
430    }
431    Ok(())
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use std::collections::BTreeSet;
438    // Serialize tests that mutate the process working directory.
439    //
440    // Many unit tests temporarily call `env::set_current_dir` to exercise
441    // discovery behavior. Because the process CWD is global, those tests
442    // can race when run in parallel and cause flaky failures (different
443    // tests observing different CWDs). We use a global `Mutex<()>` to
444    // serialize CWD-changing tests so they run one at a time.
445    use std::sync::{Mutex, MutexGuard, OnceLock};
446    use tempfile::TempDir;
447
448    /// Return the global mutex used to serialize tests that change the
449    /// process current working directory. Stored in a `OnceLock` so it is
450    /// initialized on first use and lives for the duration of the process.
451    fn cwd_lock() -> &'static Mutex<()> {
452        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
453        LOCK.get_or_init(|| Mutex::new(()))
454    }
455
456    /// Test helper that temporarily changes the process current working
457    /// directory and restores it when dropped. While alive it also holds
458    /// the global `cwd_lock()` so no two tests can race by changing the
459    /// CWD concurrently.
460    struct CwdGuard {
461        previous: PathBuf,
462        // Hold the guard so the lock remains taken for the lifetime of
463        // this `CwdGuard` instance.
464        _cwd_lock: MutexGuard<'static, ()>,
465    }
466
467    impl CwdGuard {
468        fn swap_to(path: &Path) -> io::Result<Self> {
469            // Acquire the global lock before mutating the CWD to avoid
470            // races with other tests that also change the CWD.
471            let cwd_lock_guard = cwd_lock().lock().expect("acquire cwd test lock");
472            let previous = env::current_dir()?;
473            env::set_current_dir(path)?;
474            Ok(Self {
475                previous,
476                _cwd_lock: cwd_lock_guard,
477            })
478        }
479    }
480
481    impl Drop for CwdGuard {
482        fn drop(&mut self) {
483            let _ = env::set_current_dir(&self.previous);
484        }
485    }
486
487    #[test]
488    fn from_discovery_uses_cwd_dot_cache_when_no_cargo_toml() {
489        let tmp = TempDir::new().expect("tempdir");
490        let _guard = CwdGuard::swap_to(tmp.path()).expect("set cwd");
491
492        let cache = CacheRoot::from_discovery().expect("discover");
493        let got = cache.path().to_path_buf();
494        let expected = tmp
495            .path()
496            .canonicalize()
497            .expect("canonicalize temp path")
498            .join(CACHE_DIR_NAME);
499        assert_eq!(got, expected);
500    }
501
502    #[test]
503    fn from_discovery_prefers_nearest_crate_root() {
504        let tmp = TempDir::new().expect("tempdir");
505        let crate_root = tmp.path().join("workspace");
506        let nested = crate_root.join("src").join("nested");
507        fs::create_dir_all(&nested).expect("create nested");
508        fs::write(
509            crate_root.join(CARGO_TOML_FILE_NAME),
510            "[package]\nname='x'\nversion='0.1.0'\nedition='2024'\n",
511        )
512        .expect("write cargo");
513
514        let _guard = CwdGuard::swap_to(&nested).expect("set cwd");
515        let cache = CacheRoot::from_discovery().expect("discover");
516        let got = cache.path().to_path_buf();
517        let expected = crate_root
518            .canonicalize()
519            .expect("canonicalize crate root")
520            .join(CACHE_DIR_NAME);
521        assert_eq!(got, expected);
522    }
523
524    #[test]
525    fn from_root_supports_arbitrary_path_and_grouping() {
526        let tmp = TempDir::new().expect("tempdir");
527        let root = CacheRoot::from_root(tmp.path().join("custom-cache-root"));
528        let group = root.group("taxonomy/v1");
529
530        assert_eq!(group.path(), root.path().join("taxonomy/v1").as_path());
531    }
532
533    #[test]
534    fn group_path_building_and_dir_creation() {
535        let tmp = TempDir::new().expect("tempdir");
536        let cache = CacheRoot::from_root(tmp.path());
537        let group = cache.group("artifacts/json");
538
539        let nested_group = group.subgroup("v1");
540        let ensured = nested_group.ensure_dir().expect("ensure nested dir");
541        let expected_group_suffix = Path::new("artifacts").join("json").join("v1");
542        assert!(ensured.ends_with(&expected_group_suffix));
543        assert!(ensured.exists());
544
545        let entry = nested_group.entry_path("a/b/cache.json");
546        let expected_entry_suffix = Path::new("artifacts")
547            .join("json")
548            .join("v1")
549            .join("a")
550            .join("b")
551            .join("cache.json");
552        assert!(entry.ends_with(&expected_entry_suffix));
553    }
554
555    #[test]
556    fn touch_creates_blank_file_and_is_idempotent() {
557        let tmp = TempDir::new().expect("tempdir");
558        let cache = CacheRoot::from_root(tmp.path());
559        let group = cache.group("artifacts/json");
560
561        let touched = group.touch("a/b/cache.json").expect("touch file");
562        assert!(touched.exists());
563        let meta = fs::metadata(&touched).expect("metadata");
564        assert_eq!(meta.len(), 0);
565
566        let touched_again = group.touch("a/b/cache.json").expect("touch file again");
567        assert_eq!(touched_again, touched);
568        let meta_again = fs::metadata(&touched_again).expect("metadata again");
569        assert_eq!(meta_again.len(), 0);
570    }
571
572    #[test]
573    fn touch_with_root_group_and_empty_relative_path_errors() {
574        let root = CacheRoot::from_root("/");
575        let group = root.group("");
576
577        let result = group.touch("");
578        assert!(result.is_err());
579    }
580
581    #[test]
582    fn from_discovery_cache_path_uses_root_and_group() {
583        let tmp = TempDir::new().expect("tempdir");
584        let crate_root = tmp.path().join("workspace");
585        let nested = crate_root.join("src").join("nested");
586        fs::create_dir_all(&nested).expect("create nested");
587        fs::write(
588            crate_root.join(CARGO_TOML_FILE_NAME),
589            "[package]\nname='x'\nversion='0.1.0'\nedition='2024'\n",
590        )
591        .expect("write cargo");
592
593        let _guard = CwdGuard::swap_to(&nested).expect("set cwd");
594        let p = CacheRoot::from_discovery()
595            .expect("discover")
596            .cache_path("taxonomy", "taxonomy_cache.json");
597        let parent = p.parent().expect("cache path parent");
598        fs::create_dir_all(parent).expect("create cache parent");
599        // Ensure the expected (non-canonicalized) parent path also exists
600        // so canonicalization succeeds on platforms where temporary paths
601        // may differ from the discovered/canonicalized root.
602        let expected_dir = crate_root.join(CACHE_DIR_NAME).join("taxonomy");
603        fs::create_dir_all(&expected_dir).expect("create expected cache parent");
604        let got_parent = p
605            .parent()
606            .expect("cache path parent")
607            .canonicalize()
608            .expect("canonicalize cache parent");
609        let expected_parent = crate_root
610            .join(CACHE_DIR_NAME)
611            .join("taxonomy")
612            .canonicalize()
613            .expect("canonicalize expected parent");
614        assert_eq!(got_parent, expected_parent);
615        assert_eq!(
616            p.file_name().and_then(|s| s.to_str()),
617            Some("taxonomy_cache.json")
618        );
619    }
620
621    #[test]
622    fn from_discovery_ignores_other_custom_cache_dir_names() {
623        let tmp = TempDir::new().expect("tempdir");
624        let crate_root = tmp.path().join("workspace");
625        let nested = crate_root.join("src").join("nested");
626        fs::create_dir_all(&nested).expect("create nested");
627        fs::write(
628            crate_root.join(CARGO_TOML_FILE_NAME),
629            "[package]\nname='x'\nversion='0.1.0'\nedition='2024'\n",
630        )
631        .expect("write cargo");
632
633        // Create a non-standard cache directory name at the crate root.
634        fs::create_dir_all(crate_root.join(".cache-v2")).expect("create custom cache dir");
635
636        let _guard = CwdGuard::swap_to(&nested).expect("set cwd");
637        let cache = CacheRoot::from_discovery().expect("discover");
638
639        // from_discovery should still resolve to `<crate_root>/.cache` (not `.cache-v2`).
640        let expected = crate_root
641            .canonicalize()
642            .expect("canonicalize crate root")
643            .join(CACHE_DIR_NAME);
644        assert_eq!(cache.path(), expected);
645    }
646
647    #[test]
648    fn cache_path_preserves_absolute_paths() {
649        let root = CacheRoot::from_root("/tmp/project");
650        let absolute = PathBuf::from("/tmp/custom/cache.json");
651        let resolved = root.cache_path(CACHE_DIR_NAME, &absolute);
652        assert_eq!(resolved, absolute);
653    }
654
655    #[test]
656    fn ensure_dir_with_policy_max_files() {
657        let tmp = TempDir::new().expect("tempdir");
658        let cache = CacheRoot::from_root(tmp.path());
659        let group = cache.group("artifacts");
660        group.ensure_dir().expect("ensure dir");
661
662        fs::write(group.entry_path("a.txt"), b"1").expect("write a");
663        fs::write(group.entry_path("b.txt"), b"1").expect("write b");
664        fs::write(group.entry_path("c.txt"), b"1").expect("write c");
665
666        let policy = EvictPolicy {
667            max_files: Some(2),
668            ..EvictPolicy::default()
669        };
670        group
671            .ensure_dir_with_policy(Some(&policy))
672            .expect("ensure with policy");
673
674        let files = collect_files(group.path()).expect("collect files");
675        assert_eq!(files.len(), 2);
676    }
677
678    #[test]
679    fn ensure_dir_with_policy_max_bytes() {
680        let tmp = TempDir::new().expect("tempdir");
681        let cache = CacheRoot::from_root(tmp.path());
682        let group = cache.group("artifacts");
683        group.ensure_dir().expect("ensure dir");
684
685        fs::write(group.entry_path("a.bin"), vec![1u8; 5]).expect("write a");
686        fs::write(group.entry_path("b.bin"), vec![1u8; 5]).expect("write b");
687        fs::write(group.entry_path("c.bin"), vec![1u8; 5]).expect("write c");
688
689        let policy = EvictPolicy {
690            max_bytes: Some(10),
691            ..EvictPolicy::default()
692        };
693        group
694            .ensure_dir_with_policy(Some(&policy))
695            .expect("ensure with policy");
696
697        let total: u64 = collect_files(group.path())
698            .expect("collect files")
699            .iter()
700            .map(|f| f.len)
701            .sum();
702        assert!(total <= 10);
703    }
704
705    #[test]
706    fn ensure_dir_with_policy_max_age_zero_evicts_all() {
707        let tmp = TempDir::new().expect("tempdir");
708        let cache = CacheRoot::from_root(tmp.path());
709        let group = cache.group("artifacts");
710        group.ensure_dir().expect("ensure dir");
711
712        fs::write(group.entry_path("a.txt"), b"1").expect("write a");
713        fs::write(group.entry_path("b.txt"), b"1").expect("write b");
714
715        let policy = EvictPolicy {
716            max_age: Some(Duration::ZERO),
717            ..EvictPolicy::default()
718        };
719        group
720            .ensure_dir_with_policy(Some(&policy))
721            .expect("ensure with policy");
722
723        let files = collect_files(group.path()).expect("collect files");
724        assert!(files.is_empty());
725    }
726
727    #[test]
728    fn eviction_report_matches_applied_evictions() {
729        let tmp = TempDir::new().expect("tempdir");
730        let cache = CacheRoot::from_root(tmp.path());
731        let group = cache.group("artifacts");
732        group.ensure_dir().expect("ensure dir");
733
734        fs::write(group.entry_path("a.bin"), vec![1u8; 5]).expect("write a");
735        fs::write(group.entry_path("b.bin"), vec![1u8; 5]).expect("write b");
736        fs::write(group.entry_path("c.bin"), vec![1u8; 5]).expect("write c");
737
738        let policy = EvictPolicy {
739            max_bytes: Some(10),
740            ..EvictPolicy::default()
741        };
742
743        let before: BTreeSet<PathBuf> = collect_files(group.path())
744            .expect("collect before")
745            .into_iter()
746            .map(|f| f.path)
747            .collect();
748
749        let report = group.eviction_report(&policy).expect("eviction report");
750        let planned: BTreeSet<PathBuf> = report.marked_for_eviction.iter().cloned().collect();
751
752        group
753            .ensure_dir_with_policy(Some(&policy))
754            .expect("ensure with policy");
755
756        let after: BTreeSet<PathBuf> = collect_files(group.path())
757            .expect("collect after")
758            .into_iter()
759            .map(|f| f.path)
760            .collect();
761
762        let expected_after: BTreeSet<PathBuf> = before.difference(&planned).cloned().collect();
763        assert_eq!(after, expected_after);
764    }
765
766    #[test]
767    fn no_policy_and_default_policy_report_do_not_mark_evictions() {
768        let tmp = TempDir::new().expect("tempdir");
769        let cache = CacheRoot::from_root(tmp.path());
770        let group = cache.group("artifacts");
771        group.ensure_dir().expect("ensure dir");
772
773        fs::write(group.entry_path("a.txt"), b"1").expect("write a");
774        fs::write(group.entry_path("b.txt"), b"1").expect("write b");
775
776        let report = group
777            .eviction_report(&EvictPolicy::default())
778            .expect("eviction report");
779        assert!(report.marked_for_eviction.is_empty());
780
781        group
782            .ensure_dir_with_policy(None)
783            .expect("ensure with no policy");
784
785        let files = collect_files(group.path()).expect("collect files");
786        assert_eq!(files.len(), 2);
787    }
788
789    #[test]
790    fn eviction_policy_applies_in_documented_order() {
791        let tmp = TempDir::new().expect("tempdir");
792        let cache = CacheRoot::from_root(tmp.path());
793        let group = cache.group("artifacts");
794        group.ensure_dir().expect("ensure dir");
795
796        fs::write(group.entry_path("old.txt"), vec![1u8; 1]).expect("write old");
797
798        std::thread::sleep(Duration::from_millis(300));
799
800        fs::write(group.entry_path("b.bin"), vec![1u8; 7]).expect("write b");
801        fs::write(group.entry_path("c.bin"), vec![1u8; 6]).expect("write c");
802        fs::write(group.entry_path("d.bin"), vec![1u8; 1]).expect("write d");
803
804        let policy = EvictPolicy {
805            max_age: Some(Duration::from_millis(200)),
806            max_files: Some(2),
807            max_bytes: Some(5),
808        };
809
810        let report = group.eviction_report(&policy).expect("eviction report");
811        let evicted_names: Vec<String> = report
812            .marked_for_eviction
813            .iter()
814            .map(|path| {
815                path.file_name()
816                    .and_then(|name| name.to_str())
817                    .expect("evicted file name")
818                    .to_string()
819            })
820            .collect();
821
822        assert_eq!(evicted_names, vec!["old.txt", "b.bin", "c.bin"]);
823
824        group
825            .ensure_dir_with_policy(Some(&policy))
826            .expect("apply policy");
827
828        let remaining_names: BTreeSet<String> = collect_files(group.path())
829            .expect("collect remaining")
830            .into_iter()
831            .map(|entry| {
832                entry
833                    .path
834                    .file_name()
835                    .and_then(|name| name.to_str())
836                    .expect("remaining file name")
837                    .to_string()
838            })
839            .collect();
840
841        assert_eq!(remaining_names, BTreeSet::from(["d.bin".to_string()]));
842    }
843
844    #[test]
845    fn sort_entries_uses_path_as_tie_break_for_equal_modified_time() {
846        let same_time = UNIX_EPOCH + Duration::from_secs(1_234_567);
847        let mut entries = vec![
848            FileEntry {
849                path: PathBuf::from("z.bin"),
850                modified: same_time,
851                len: 1,
852            },
853            FileEntry {
854                path: PathBuf::from("a.bin"),
855                modified: same_time,
856                len: 1,
857            },
858            FileEntry {
859                path: PathBuf::from("m.bin"),
860                modified: same_time,
861                len: 1,
862            },
863        ];
864
865        sort_entries_oldest_first(&mut entries);
866
867        let ordered_paths: Vec<PathBuf> = entries.into_iter().map(|entry| entry.path).collect();
868        assert_eq!(
869            ordered_paths,
870            vec![
871                PathBuf::from("a.bin"),
872                PathBuf::from("m.bin"),
873                PathBuf::from("z.bin")
874            ]
875        );
876    }
877
878    #[test]
879    fn single_root_supports_distinct_policies_per_subdirectory() {
880        let tmp = TempDir::new().expect("tempdir");
881        let cache = CacheRoot::from_root(tmp.path());
882
883        let images = cache.group("artifacts/images");
884        let reports = cache.group("artifacts/reports");
885
886        images.ensure_dir().expect("ensure images dir");
887        reports.ensure_dir().expect("ensure reports dir");
888
889        fs::write(images.entry_path("img1.bin"), vec![1u8; 5]).expect("write img1");
890        fs::write(images.entry_path("img2.bin"), vec![1u8; 5]).expect("write img2");
891        fs::write(images.entry_path("img3.bin"), vec![1u8; 5]).expect("write img3");
892
893        fs::write(reports.entry_path("a.txt"), b"1").expect("write report a");
894        fs::write(reports.entry_path("b.txt"), b"1").expect("write report b");
895        fs::write(reports.entry_path("c.txt"), b"1").expect("write report c");
896
897        let images_policy = EvictPolicy {
898            max_bytes: Some(10),
899            ..EvictPolicy::default()
900        };
901        let reports_policy = EvictPolicy {
902            max_files: Some(1),
903            ..EvictPolicy::default()
904        };
905
906        images
907            .ensure_dir_with_policy(Some(&images_policy))
908            .expect("apply images policy");
909        reports
910            .ensure_dir_with_policy(Some(&reports_policy))
911            .expect("apply reports policy");
912
913        let images_total: u64 = collect_files(images.path())
914            .expect("collect images files")
915            .iter()
916            .map(|f| f.len)
917            .sum();
918        assert!(images_total <= 10);
919
920        let reports_files = collect_files(reports.path()).expect("collect reports files");
921        assert_eq!(reports_files.len(), 1);
922    }
923
924    #[test]
925    fn group_path_and_ensure_group_create_expected_directory() {
926        let tmp = TempDir::new().expect("tempdir");
927        let cache = CacheRoot::from_root(tmp.path());
928
929        let expected = tmp.path().join("a/b/c");
930        assert_eq!(cache.group_path("a/b/c"), expected);
931
932        let ensured = cache.ensure_group("a/b/c").expect("ensure group");
933        assert_eq!(ensured, expected);
934        assert!(ensured.is_dir());
935    }
936
937    #[test]
938    fn ensure_group_with_policy_applies_eviction_rules() {
939        let tmp = TempDir::new().expect("tempdir");
940        let cache = CacheRoot::from_root(tmp.path());
941
942        cache
943            .ensure_group_with_policy("artifacts", None)
944            .expect("ensure group without policy");
945
946        let group = cache.group("artifacts");
947        fs::write(group.entry_path("a.bin"), vec![1u8; 1]).expect("write a");
948        fs::write(group.entry_path("b.bin"), vec![1u8; 1]).expect("write b");
949        fs::write(group.entry_path("c.bin"), vec![1u8; 1]).expect("write c");
950
951        let policy = EvictPolicy {
952            max_files: Some(1),
953            ..EvictPolicy::default()
954        };
955
956        let ensured = cache
957            .ensure_group_with_policy("artifacts", Some(&policy))
958            .expect("ensure group with policy");
959        assert_eq!(ensured, group.path());
960
961        let files = collect_files(group.path()).expect("collect files");
962        assert_eq!(files.len(), 1);
963    }
964
965    #[test]
966    fn cache_path_joins_relative_paths_under_group() {
967        let tmp = TempDir::new().expect("tempdir");
968        let cache = CacheRoot::from_root(tmp.path());
969
970        let got = cache.cache_path(CACHE_DIR_NAME, "tool/v1/data.bin");
971        let expected = tmp
972            .path()
973            .join(CACHE_DIR_NAME)
974            .join("tool")
975            .join("v1")
976            .join("data.bin");
977        assert_eq!(got, expected);
978    }
979
980    #[test]
981    fn subgroup_touch_creates_parent_directories() {
982        let tmp = TempDir::new().expect("tempdir");
983        let cache = CacheRoot::from_root(tmp.path());
984        let subgroup = cache.group("artifacts").subgroup("json/v1");
985
986        let touched = subgroup
987            .touch("nested/output.bin")
988            .expect("touch subgroup entry");
989
990        assert!(touched.is_file());
991        assert!(subgroup.path().join("nested").is_dir());
992    }
993
994    #[test]
995    fn eviction_report_errors_when_group_directory_is_missing() {
996        let tmp = TempDir::new().expect("tempdir");
997        let cache = CacheRoot::from_root(tmp.path());
998        let missing = cache.group("does-not-exist");
999
1000        let err = missing
1001            .eviction_report(&EvictPolicy::default())
1002            .expect_err("eviction report should fail for missing directory");
1003        assert_eq!(err.kind(), io::ErrorKind::NotFound);
1004    }
1005
1006    #[test]
1007    fn eviction_policy_scans_nested_subdirectories_recursively() {
1008        let tmp = TempDir::new().expect("tempdir");
1009        let cache = CacheRoot::from_root(tmp.path());
1010        let group = cache.group("artifacts");
1011        group.ensure_dir().expect("ensure dir");
1012
1013        fs::create_dir_all(group.entry_path("nested/deeper")).expect("create nested dirs");
1014        fs::write(group.entry_path("root.bin"), vec![1u8; 1]).expect("write root");
1015        fs::write(group.entry_path("nested/a.bin"), vec![1u8; 1]).expect("write nested a");
1016        fs::write(group.entry_path("nested/deeper/b.bin"), vec![1u8; 1]).expect("write nested b");
1017
1018        let policy = EvictPolicy {
1019            max_files: Some(1),
1020            ..EvictPolicy::default()
1021        };
1022
1023        group
1024            .ensure_dir_with_policy(Some(&policy))
1025            .expect("apply recursive policy");
1026
1027        let remaining = collect_files(group.path()).expect("collect remaining");
1028        assert_eq!(remaining.len(), 1);
1029    }
1030
1031    #[cfg(unix)]
1032    #[test]
1033    fn collect_files_recursive_ignores_non_file_non_directory_entries() {
1034        use std::os::unix::net::UnixListener;
1035
1036        let tmp = TempDir::new().expect("tempdir");
1037        let cache = CacheRoot::from_root(tmp.path());
1038        let group = cache.group("artifacts");
1039        group.ensure_dir().expect("ensure dir");
1040
1041        let socket_path = group.entry_path("live.sock");
1042        let _listener = UnixListener::bind(&socket_path).expect("bind unix socket");
1043
1044        fs::write(group.entry_path("a.bin"), vec![1u8; 1]).expect("write file");
1045
1046        let files = collect_files(group.path()).expect("collect files");
1047        assert_eq!(files.len(), 1);
1048        assert_eq!(files[0].path, group.entry_path("a.bin"));
1049    }
1050
1051    #[test]
1052    fn max_bytes_policy_under_threshold_does_not_evict() {
1053        let tmp = TempDir::new().expect("tempdir");
1054        let cache = CacheRoot::from_root(tmp.path());
1055        let group = cache.group("artifacts");
1056        group.ensure_dir().expect("ensure dir");
1057
1058        fs::write(group.entry_path("a.bin"), vec![1u8; 2]).expect("write a");
1059        fs::write(group.entry_path("b.bin"), vec![1u8; 3]).expect("write b");
1060
1061        let policy = EvictPolicy {
1062            max_bytes: Some(10),
1063            ..EvictPolicy::default()
1064        };
1065
1066        let report = group.eviction_report(&policy).expect("eviction report");
1067        assert!(report.marked_for_eviction.is_empty());
1068
1069        group
1070            .ensure_dir_with_policy(Some(&policy))
1071            .expect("ensure with policy");
1072
1073        let files = collect_files(group.path()).expect("collect files");
1074        assert_eq!(files.len(), 2);
1075    }
1076
1077    #[test]
1078    fn cwd_guard_swap_to_returns_error_for_missing_directory() {
1079        let tmp = TempDir::new().expect("tempdir");
1080        let missing = tmp.path().join("missing-dir");
1081
1082        let result = CwdGuard::swap_to(&missing);
1083        assert!(result.is_err());
1084        assert_eq!(
1085            result.err().expect("expected missing-dir error").kind(),
1086            io::ErrorKind::NotFound
1087        );
1088    }
1089
1090    #[test]
1091    fn ensure_dir_equals_ensure_dir_with_policy_none() {
1092        let tmp = TempDir::new().expect("tempdir");
1093        let cache = CacheRoot::from_root(tmp.path());
1094        let group = cache.group("artifacts/eq");
1095
1096        let p1 = group.ensure_dir().expect("ensure dir");
1097        // create a file so we can verify calling the policy-aware
1098        // variant with `None` does not remove or alter contents.
1099        fs::write(group.entry_path("keep.txt"), b"keep").expect("write file");
1100
1101        let p2 = group
1102            .ensure_dir_with_policy(None)
1103            .expect("ensure dir with None policy");
1104
1105        assert_eq!(p1, p2);
1106        assert!(group.entry_path("keep.txt").exists());
1107    }
1108
1109    #[test]
1110    fn ensure_group_equals_ensure_group_with_policy_none() {
1111        let tmp = TempDir::new().expect("tempdir");
1112        let cache = CacheRoot::from_root(tmp.path());
1113
1114        let p1 = cache.ensure_group("artifacts/roots").expect("ensure group");
1115        let group = cache.group("artifacts/roots");
1116        // create a file to ensure no-op policy does not remove it
1117        fs::write(group.entry_path("keep_root.txt"), b"keep").expect("write file");
1118
1119        let p2 = cache
1120            .ensure_group_with_policy("artifacts/roots", None)
1121            .expect("ensure group with None policy");
1122
1123        assert_eq!(p1, p2);
1124        assert!(group.entry_path("keep_root.txt").exists());
1125    }
1126
1127    #[cfg(feature = "process-scoped-cache")]
1128    #[test]
1129    fn process_scoped_cache_respects_root_and_group_assignments() {
1130        let tmp = TempDir::new().expect("tempdir");
1131        let root = CacheRoot::from_root(tmp.path().join("custom-root"));
1132
1133        let scoped = ProcessScopedCacheGroup::new(&root, "artifacts/session").expect("create");
1134        let expected_prefix = root.group("artifacts/session").path().to_path_buf();
1135
1136        assert!(scoped.path().starts_with(&expected_prefix));
1137        assert!(scoped.path().exists());
1138    }
1139
1140    #[cfg(feature = "process-scoped-cache")]
1141    #[test]
1142    fn process_scoped_cache_deletes_directory_on_drop() {
1143        let tmp = TempDir::new().expect("tempdir");
1144        let root = CacheRoot::from_root(tmp.path());
1145
1146        let process_dir = {
1147            let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create");
1148            let p = scoped.path().to_path_buf();
1149            assert!(p.exists());
1150            p
1151        };
1152
1153        assert!(!process_dir.exists());
1154    }
1155
1156    #[cfg(feature = "process-scoped-cache")]
1157    #[test]
1158    fn process_scoped_cache_thread_group_is_stable_per_thread() {
1159        let tmp = TempDir::new().expect("tempdir");
1160        let root = CacheRoot::from_root(tmp.path());
1161        let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create");
1162
1163        let first = scoped.thread_group().path().to_path_buf();
1164        let second = scoped.thread_group().path().to_path_buf();
1165
1166        assert_eq!(first, second);
1167    }
1168
1169    #[cfg(feature = "process-scoped-cache")]
1170    #[test]
1171    fn process_scoped_cache_thread_group_differs_across_threads() {
1172        let tmp = TempDir::new().expect("tempdir");
1173        let root = CacheRoot::from_root(tmp.path());
1174        let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create");
1175
1176        let main_thread_group = scoped.thread_group().path().to_path_buf();
1177        let other_thread_group = std::thread::spawn(current_thread_cache_group_id)
1178            .join()
1179            .expect("join thread");
1180
1181        let expected_other = scoped
1182            .process_group()
1183            .subgroup(format!("thread-{other_thread_group}"))
1184            .path()
1185            .to_path_buf();
1186
1187        assert_ne!(main_thread_group, expected_other);
1188    }
1189
1190    #[cfg(feature = "process-scoped-cache")]
1191    #[test]
1192    fn process_scoped_cache_from_group_uses_given_base_group() {
1193        let tmp = TempDir::new().expect("tempdir");
1194        let root = CacheRoot::from_root(tmp.path());
1195        let base_group = root.group("artifacts/custom-base");
1196
1197        let scoped = ProcessScopedCacheGroup::from_group(base_group.clone()).expect("create");
1198
1199        assert!(scoped.path().starts_with(base_group.path()));
1200        assert_eq!(scoped.process_group().path(), scoped.path());
1201    }
1202
1203    #[cfg(feature = "process-scoped-cache")]
1204    #[test]
1205    fn process_scoped_cache_thread_entry_path_matches_touch_location() {
1206        let tmp = TempDir::new().expect("tempdir");
1207        let root = CacheRoot::from_root(tmp.path());
1208        let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create");
1209
1210        let planned = scoped.thread_entry_path("nested/data.bin");
1211        let touched = scoped
1212            .touch_thread_entry("nested/data.bin")
1213            .expect("touch thread entry");
1214
1215        assert_eq!(planned, touched);
1216        assert!(touched.exists());
1217    }
1218
1219    #[cfg(feature = "process-scoped-cache")]
1220    #[test]
1221    fn touch_thread_entry_creates_entry_under_thread_group() {
1222        let tmp = TempDir::new().expect("tempdir");
1223        let root = CacheRoot::from_root(tmp.path());
1224        let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create");
1225
1226        let entry = scoped
1227            .touch_thread_entry("nested/data.bin")
1228            .expect("touch thread entry");
1229
1230        assert!(entry.exists());
1231        assert!(entry.starts_with(scoped.path()));
1232        let thread_group = scoped.thread_group().path().to_path_buf();
1233        assert!(entry.starts_with(&thread_group));
1234    }
1235}