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