cache-manager provides a single, consistent cache/artifact path layer for Rust Cargo workspaces, with OS-native per-user cache directories as a fallback for installed binaries running outside any workspace.
Directory-based cache and artifact path management with discovered .cache roots, grouped cache paths, and optional eviction on directory initialization.
Tested on macOS, Linux, and Windows.
cargo add cache-manageruse cache_manager::CacheRoot;
// Discover the workspace or crate root, anchor .cache there
let root = CacheRoot::from_discovery().expect("discover cache root");
// Create (or resume) a group for artifacts
let group = root.group("artifacts/json");
group.ensure_dir().expect("ensure group dir");
// Create or refresh a cache entry (creates parent dirs automatically)
let entry = group.touch("v1/index.bin").expect("touch entry");
println!("{}", entry.display());Composing explicit paths without touch (hand the path to another tool):
use cache_manager::CacheRoot;
use std::fs;
let root = CacheRoot::from_discovery().expect("discover cache root");
let group = root.group("artifacts/json");
group.ensure_dir().expect("ensure group dir");
let entry = group.entry_path("v1/index.bin");
fs::create_dir_all(entry.parent().expect("entry parent"))
.expect("create entry parent");
fs::write(&entry, b"artifact bytes").expect("write artifact");
println!("{}", entry.display());- Tool-agnostic: any tool or library that can write to the filesystem can use
cache-manageras a managed cache/artifact path layout layer. - Zero default runtime dependencies: the standard install uses only the Rust standard library (optional features do add additional dependencies).
- Built-in eviction policies: enforce cache limits by file age, file count, and total bytes, with deterministic oldest-first trimming.
- Predictable discovery + root control: discover
<workspace-or-crate-root>/.cacheautomatically or pin an explicit root withCacheRoot::from_root(...). - Composable cache layout API: create groups/subgroups and entry paths consistently across tools without custom path-joining logic.
- Artifact-friendly: suitable for build outputs, generated files, and intermediate data.
- Workspace-friendly: suitable for monorepos or multi-crate workspaces that need centralized cache/artifact management via a shared root (for example with
CacheRoot::from_root(...)).
-
process-scoped-cache: addstempfileand enables process/thread scoped caches. -
os-cache-dir: addsdirectoriesand enables OS-native per-user cache roots. -
Open-source + commercial-friendly: dual-licensed under MIT or Apache-2.0.
CacheRoot: project/workspace anchor path.CacheGroup: subdirectory under a root where a class of cache files lives.- Entries: files under a group (for example
v1/index.bin).
CacheRoot and CacheGroup are lightweight path objects. Constructing them does not create directories.
-
Core APIs (always available):
CacheRoot::from_root,CacheRoot::from_discovery,CacheRoot::cache_path,CacheRoot::groupCacheGroup::subgroup,CacheGroup::entry_pathCacheRoot::ensure_group,CacheGroup::ensure_dirCacheRoot::ensure_group_with_policy,CacheGroup::ensure_dir_with_policyCacheGroup::touch
-
Feature
os-cache-dir:CacheRoot::from_project_dirs
-
Feature
process-scoped-cache:CacheRoot::from_tempdirProcessScopedCacheGroup::new,ProcessScopedCacheGroup::from_groupProcessScopedCacheGroup::thread_group,ProcessScopedCacheGroup::ensure_thread_groupProcessScopedCacheGroup::thread_entry_path,ProcessScopedCacheGroup::touch_thread_entry
Note: eviction only runs when you pass a policy to the
*_with_policymethods.
Discover a cache root by searching parent directories for a Cargo workspace or crate root.
Note:
CacheRoot::from_discovery()?.cache_path(...)only computes a filesystem path — it does not create directories or files.
Behavior:
- Searches upward from the current working directory for a
Cargo.toml. - If a
Cargo.tomlcontaining[workspace]is found, uses<workspace-root>/.cache. - Otherwise uses the nearest
<crate-root>/.cache. - Falls back to
<cwd>/.cachewhen noCargo.tomlexists. - The discovered anchor is canonicalized when possible.
- If the
relative_pathargument is absolute, it is returned unchanged.
use cache_manager::CacheRoot;
use std::path::Path;
// Compute a path like <workspace-root>/.cache/tool/data.bin
let cache_path = CacheRoot::from_discovery()
.expect("discover cache root")
.cache_path("tool", "data.bin");
println!("cache path: {}", cache_path.display());
// Relative location under the discovered root:
assert!(cache_path.ends_with(Path::new(".cache").join("tool").join("data.bin")));
// The call only computes the path; it does not create files or directories
assert!(!cache_path.exists());
// Absolute paths are returned unchanged. NOTE: `Path::new("/tmp/...")` is
// NOT absolute on Windows (drive-relative), so build the probe from the
// system temp dir, which is absolute on every platform.
let absolute = std::env::temp_dir().join("cache.json");
let kept = CacheRoot::from_discovery()
.expect("discover cache root")
.cache_path("tool", absolute.clone());
assert_eq!(kept, absolute);Notes on discovery behavior
CacheRoot::from_discovery() deterministically anchors discovered cache
paths under the configured CACHE_DIR_NAME (default: .cache). It does
not scan for arbitrary directory names — creating a directory named
.cache-v2 at the crate root will not cause from_discovery() to use it.
If you want to use a custom cache root, construct it explicitly with
CacheRoot::from_root(...).
from_discovery() falls back to <cwd>/.cache when no workspace is found —
convenient in dev, but an installed binary run from an arbitrary directory
then scatters a fresh multi-GB .cache per shell CWD. CacheResolver
replaces hand-rolled match env::var(...) chains with one fixed precedence
and reports the winner so binaries can announce it:
explicit— a CLI--dirpath; wins over everything.env_var— a non-empty env var value.- Cargo workspace discovery (
<workspace>/.cache), unlessallow_project_discoveryis false. - OS user cache dir for
project_dirs(os-cache-dirfeature).
No match returns Err(NotFound) naming the env var / identity that would fix
it — never a silent CWD fallback. Opt into that legacy behavior per call site
with allow_cwd_fallback: true (default false).
use cache_manager::{CacheResolver, CacheSource};
// Installed shape: dev checkouts resolve under the repo, installed runs
// under the OS user cache, explicit flags/env still win.
let resolver = CacheResolver {
explicit: None,
env_var: Some("MYTOOL_CACHE_DIR".to_string()),
#[cfg(feature = "os-cache-dir")]
project_dirs: Some((
"com".to_string(),
"ExampleOrg".to_string(),
"ExampleApp".to_string(),
)),
..CacheResolver::default()
};Recommended convention: announce the winner on stderr at startup
(cache: <path> (<source>)) — cache placement must never be silent.
Enable feature flag:
cargo add cache-manager --features os-cache-dirThen construct a CacheRoot from platform-native user cache directories:
#[cfg(feature = "os-cache-dir")]
fn run() -> std::io::Result<()> {
use cache_manager::CacheRoot;
let root = CacheRoot::from_project_dirs("com", "ExampleOrg", "ExampleApp")
.expect("discover OS cache dir");
let group = root.group("cache-manager-readme-example");
group.ensure_dir().expect("ensure group");
std::fs::remove_dir_all(group.path()).expect("cleanup example group");
Ok(())
}
#[cfg(not(feature = "os-cache-dir"))]
fn run() -> std::io::Result<()> {
// `from_project_dirs` needs the `os-cache-dir` feature; the real path
// above runs under `cargo test --all-features`.
Ok(())
}
run().expect("example");from_project_dirs uses directories::ProjectDirs and typically resolves to:
- macOS:
~/Library/Caches/<app> - Linux:
$XDG_CACHE_HOME/<app>or~/.cache/<app> - Windows:
%LOCALAPPDATA%\\<org>\\<app>\\cache
from_project_dirs(qualifier, organization, application) parameters:
qualifier: a DNS-like namespace component (commonly"com"or"org")organization: vendor/team name (for example"ExampleOrg")application: app/tool identifier (for example"ExampleApp")
Example identity tuple:
#[cfg(feature = "os-cache-dir")]
fn run() -> std::io::Result<()> {
use cache_manager::CacheRoot;
use directories::ProjectDirs;
use std::fs;
let root: CacheRoot = CacheRoot::from_project_dirs("com", "Acme", "WidgetTool")
.expect("discover OS cache dir");
let got: std::path::PathBuf = root.path().to_path_buf();
let expected: std::path::PathBuf = ProjectDirs::from("com", "Acme", "WidgetTool")
.expect("resolve project dirs")
.cache_dir()
.to_path_buf();
assert_eq!(got, expected);
// If the example writes anything, keep it scoped and remove it explicitly.
let example_group = root.group("cache-manager-readme-example");
let probe = example_group.touch("probe.txt").expect("write probe");
assert!(probe.exists());
fs::remove_dir_all(example_group.path()).expect("cleanup example group");
Ok(())
}
#[cfg(not(feature = "os-cache-dir"))]
fn run() -> std::io::Result<()> {
// Same note as the example above: real path runs with the feature on.
Ok(())
}
run().expect("example");Use EvictPolicy with:
CacheGroup::ensure_dir_with_policy(...)CacheRoot::ensure_group_with_policy(...)CacheGroup::eviction_report(...)to preview which files would be evicted.
Apply policy directly to a CacheGroup:
use cache_manager::{CacheRoot, EvictPolicy};
let dir = tempfile::tempdir().expect("tempdir");
let root: CacheRoot = CacheRoot::from_root(dir.path());
let group: cache_manager::CacheGroup = root.group("artifacts");
let policy: EvictPolicy = EvictPolicy {
max_files: Some(100),
..Default::default()
};
group
.ensure_dir_with_policy(Some(&policy))
.expect("ensure and evict");Apply policy through CacheRoot convenience API:
use cache_manager::{CacheRoot, EvictPolicy};
use std::time::Duration;
let dir = tempfile::tempdir().expect("tempdir");
let root: CacheRoot = CacheRoot::from_root(dir.path());
let policy: EvictPolicy = EvictPolicy {
max_age: Some(Duration::from_secs(60 * 60 * 24 * 30)), // 30 days
..Default::default()
};
root
.ensure_group_with_policy("artifacts", Some(&policy))
.expect("ensure group and evict");Preview evictions without deleting files:
use cache_manager::{CacheRoot, EvictPolicy, EvictionReport};
// Self-contained: doctests share one process CWD with no isolation, so a
// fixed path like `/tmp/project` would make this example order-dependent on
// whichever example created the dir first. Use a tempdir instead.
let dir = tempfile::tempdir().expect("tempdir");
let root: CacheRoot = CacheRoot::from_root(dir.path());
let group: cache_manager::CacheGroup = root.group("artifacts");
group.ensure_dir().expect("ensure group");
group.touch("old.bin").expect("seed file");
let policy: EvictPolicy = EvictPolicy {
max_bytes: Some(10_000_000),
..Default::default()
};
let report: EvictionReport = group.eviction_report(&policy).expect("eviction report");
for path in report.marked_for_eviction {
println!("would remove: {}", path.display());
}Policy fields:
max_age: remove files older than or equal to the age threshold.max_files: keep at most N files.max_bytes: keep total file bytes at or below the threshold.
Policies can be combined by setting multiple fields in one EvictPolicy.
When combined, all configured limits are enforced in order.
use cache_manager::EvictPolicy;
use std::time::Duration;
let combined: EvictPolicy = EvictPolicy {
max_age: Some(Duration::from_secs(60 * 60 * 24 * 30)), // 30 days
max_files: Some(200),
max_bytes: Some(500 * 1024 * 1024), // 500 MB
};Eviction order is always:
max_agemax_filesmax_bytes
For max_files and max_bytes, files are evicted oldest-first by modified time (ascending), then by path for deterministic tie-breaking.
eviction_report(...) and ensure_*_with_policy(...) use the same selection logic.
- Scans regular files recursively under the managed directory.
- Sums
metadata.len()across those files. - If total exceeds
max_bytes, removes files oldest-first until total is<= max_bytes. - Directories are not counted as bytes.
- Enforcement happens only during policy-aware
ensure_*_with_policycalls (not continuously in the background).
ensure_dir() only fixes what exists at call time. Unix has no "recursive
future permissions": tar re-applies archived modes on unpack, so a writable
group dir still ends up with 0444 members. Call ensure_writable_tree()
again after unpack, before post-extract steps (xattr, rotation, deletion):
use cache_manager::{CacheRoot, ensure_writable_tree};
let dir = tempfile::tempdir().expect("tempdir");
let root: CacheRoot = CacheRoot::from_root(dir.path());
let group = root.group("artifacts");
group.ensure_dir().expect("ensure group");
// ... unpack tarball into group.path() ...
// tar::Archive::new(...).unpack(group.path()).expect("unpack");
ensure_writable_tree(group.path()).expect("normalize permissions");
// now safe: xattr strips, file writes, rotation deletesUnix adds owner-write only (owner rwx for dirs, owner-write for files). Non-Unix clears the readonly flag. Symlinks are never followed or modified.
Enable feature flag:
cargo add cache-manager --features process-scoped-cacheOr, if editing Cargo.toml manually:
[dependencies]
cache-manager = { version = "<latest>", features = ["process-scoped-cache"] }Create a temporary cache root backed by a persisted temp directory:
#[cfg(feature = "process-scoped-cache")]
fn example_temp_root() {
let root = cache_manager::CacheRoot::from_tempdir().expect("temp cache root");
let group = root.group("artifacts");
group.ensure_dir().expect("ensure group");
// `from_tempdir` intentionally persists the directory; clean up when done.
std::fs::remove_dir_all(root.path()).expect("cleanup temp root");
}Use this constructor when you have a CacheRoot plus a relative group path.
It creates a process-scoped directory under root.group(...).
#[cfg(feature = "process-scoped-cache")]
fn main() {
use cache_manager::{CacheGroup, CacheRoot, ProcessScopedCacheGroup};
use std::path::Path;
// 1) Build the root and the base group where process directories will live.
// Self-contained tempdir (not a fixed `/tmp/...` path): doctests share one
// process CWD with no isolation, and fixed paths are drive-relative — i.e.
// not absolute — on Windows.
let dir = tempfile::tempdir().expect("tempdir");
let root: CacheRoot = CacheRoot::from_root(dir.path());
let base_group: CacheGroup = root.group("artifacts/session");
// 2) Create a process-scoped directory (name starts with `pid-<pid>-...`)
let scoped: ProcessScopedCacheGroup = ProcessScopedCacheGroup::new(&root, "artifacts/session")
.expect("create process-scoped cache");
// 3) Resolve this thread's subgroup and touch an entry under it
let thread_group: CacheGroup = scoped.ensure_thread_group().expect("ensure thread group");
let entry: std::path::PathBuf = thread_group.touch("v1/index.bin").expect("touch thread entry");
// 4) Verify the static pieces of the structure. Canonicalize both sides:
// tempfile may return verbatim (`\\?\`) / symlink-resolved paths that
// string-compare unequal to the uncanonicalized base on Windows/macOS.
let base_canon: std::path::PathBuf =
base_group.path().canonicalize().expect("canonicalize base");
let entry_canon: std::path::PathBuf =
entry.canonicalize().expect("canonicalize entry");
assert!(entry_canon.starts_with(&base_canon));
assert!(entry.ends_with(Path::new("v1/index.bin")));
// 5) Verify the dynamic thread segment (`thread-<n>`)
let thread_dir: &Path = entry
.parent()
.and_then(|p| p.parent())
.expect("thread dir");
assert!(thread_dir
.file_name()
.and_then(|s| s.to_str())
.expect("thread dir name")
.starts_with("thread-"));
// 6) Verify the dynamic process segment (`pid-<current-pid>-<random>`)
let process_dir: &Path = thread_dir.parent().expect("process dir");
let expected_pid_prefix: String = format!("pid-{}-", std::process::id());
assert!(process_dir
.file_name()
.and_then(|s| s.to_str())
.expect("process dir name")
.starts_with(&expected_pid_prefix));
// Example output path
println!("{}", entry.display());
}
#[cfg(not(feature = "process-scoped-cache"))]
fn main() {}Use this constructor when you already have a CacheGroup (for example,
shared or precomputed by higher-level setup) and want process scoping from
that existing group.
#[cfg(feature = "process-scoped-cache")]
fn from_group_example() {
use cache_manager::{CacheGroup, CacheRoot, ProcessScopedCacheGroup};
let dir = tempfile::tempdir().expect("tempdir");
let root: CacheRoot = CacheRoot::from_root(dir.path());
let base_group: CacheGroup = root.group("artifacts/session");
let scoped: ProcessScopedCacheGroup =
ProcessScopedCacheGroup::from_group(base_group).expect("create process-scoped cache");
let thread_entry = scoped
.touch_thread_entry("v1/index.bin")
.expect("touch thread entry");
assert!(thread_entry.starts_with(scoped.path()));
}Behavior notes:
- Respects all configured roots/groups because process-scoped paths are always created under your provided
CacheRoot/CacheGroup. - The process subdirectory is deleted when the handle is dropped during normal process shutdown.
- Cleanup is best-effort; abnormal termination (for example
SIGKILLor crash) can leave stale directories.
To obtain the underlying filesystem path for a CacheRoot, use path():
use cache_manager::CacheRoot;
let root: CacheRoot = CacheRoot::from_root("/tmp/project");
let root_path: &std::path::Path = root.path();
println!("root path: {}", root_path.display());Also obtain a CacheGroup path and resolve an entry path under that group:
use cache_manager::{CacheGroup, CacheRoot};
let root: CacheRoot = CacheRoot::from_root("/tmp/project");
let group: CacheGroup = root.group("artifacts");
let group_path: &std::path::Path = group.path();
println!("group path: {}", group_path.display());
let entry_path: std::path::PathBuf = group.entry_path("v1/index.bin");
println!("entry path: {}", entry_path.display());cache-manager is primarily distributed under the terms of both the MIT license and the Apache License (Version 2.0) .