Skip to content

About

Managed caches, with optional eviction, for Rust project artifacts.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

16 Commits

Folders and files

Repository files navigation

cache-manager

made-with-rust crates.io MIT licensed Apache 2.0 licensed Coverage

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.

Quick start

Add cache-manager to your project

cargo add cache-manager

Create a cache entry

use 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());

Pass a path to another tool

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());

Core capabilities

  • Tool-agnostic: any tool or library that can write to the filesystem can use cache-manager as 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>/.cache automatically or pin an explicit root with CacheRoot::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(...)).

Optional features

Reference

Mental model: root -> groups -> entries

  • 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.

Filesystem effects

  • Core APIs (always available):

    • CacheRoot::from_root, CacheRoot::from_discovery, CacheRoot::cache_path, CacheRoot::group
    • CacheGroup::subgroup, CacheGroup::entry_path
    • CacheRoot::ensure_group, CacheGroup::ensure_dir
    • CacheRoot::ensure_group_with_policy, CacheGroup::ensure_dir_with_policy
    • CacheGroup::touch
  • Feature os-cache-dir:

    • CacheRoot::from_project_dirs
  • Feature process-scoped-cache:

    • CacheRoot::from_tempdir
    • ProcessScopedCacheGroup::new, ProcessScopedCacheGroup::from_group
    • ProcessScopedCacheGroup::thread_group, ProcessScopedCacheGroup::ensure_thread_group
    • ProcessScopedCacheGroup::thread_entry_path, ProcessScopedCacheGroup::touch_thread_entry

Note: eviction only runs when you pass a policy to the *_with_policy methods.

Cache root discovery

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.toml containing [workspace] is found, uses <workspace-root>/.cache.
  • Otherwise uses the nearest <crate-root>/.cache.
  • Falls back to <cwd>/.cache when no Cargo.toml exists.
  • The discovered anchor is canonicalized when possible.
  • If the relative_path argument 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(...).

Combined resolution: CacheResolver (no silent <cwd>/.cache)

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:

  1. explicit — a CLI --dir path; wins over everything.
  2. env_var — a non-empty env var value.
  3. Cargo workspace discovery (<workspace>/.cache), unless allow_project_discovery is false.
  4. OS user cache dir for project_dirs (os-cache-dir feature).

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.

OS-native user cache root (optional)

Enable feature flag:

cargo add cache-manager --features os-cache-dir

Then 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");

Eviction Policy

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:

  1. max_age
  2. max_files
  3. max_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.

How max_bytes works

  • 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_policy calls (not continuously in the background).

Normalize permissions after extracting archives

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 deletes

Unix adds owner-write only (owner rwx for dirs, owner-write for files). Non-Unix clears the readonly flag. Symlinks are never followed or modified.

Optional process/thread scoped caches

Enable feature flag:

cargo add cache-manager --features process-scoped-cache

Or, if editing Cargo.toml manually:

[dependencies]
cache-manager = { version = "<latest>", features = ["process-scoped-cache"] }

CacheRoot from tempdir

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");
}

ProcessScopedCacheGroup from root and group path

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() {}

ProcessScopedCacheGroup from existing group

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 SIGKILL or crash) can leave stale directories.

Per-subdirectory policies

Get the root path

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());

License

cache-manager is primarily distributed under the terms of both the MIT license and the Apache License (Version 2.0) .

About

Managed caches, with optional eviction, for Rust project artifacts.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages