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