Skip to main content

Crate cache_manager

Crate cache_manager 

Source
Expand description

§cache-manager

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

Directory-based cache and artifact path management with discovered .cache roots, grouped cache paths, and optional eviction on directory initialization.

This crate was built to solve a recurring workspace problem we had before adopting it.
Previously, several crates wrote artifacts to different locations with inconsistent eviction policy management.
cache-manager provides a single, consistent cache/artifact path layer across the workspace (and also works outside of cargo environments).

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

  • Licensing

    • Open-source + commercial-friendly: dual-licensed under MIT or Apache-2.0.

Tested on macOS, Linux, and Windows.

§Usage

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

§Quick start

Using touch (convenient when you want this crate to create the file):

use cache_manager::{CacheGroup, CacheRoot};

let root: CacheRoot = CacheRoot::from_root("/tmp/project");
let group: CacheGroup = root.group("artifacts/json");

// Create the group directory if needed
group.ensure_dir().expect("ensure group");

// `index.bin` is just an example artifact filename that another program might generate
let entry: std::path::PathBuf = group.touch("v1/index.bin").expect("touch entry");

let expected: std::path::PathBuf = root
	.path()
	.join("artifacts")
	.join("json")
	.join("v1")
	.join("index.bin");
assert_eq!(entry, expected);

// Example output path
println!("{}", entry.display());

Without touch (compute the path for a separate tool, then write with your own I/O):

use cache_manager::{CacheGroup, CacheRoot};
use std::fs;

let root: CacheRoot = CacheRoot::from_root("/tmp/project");
let group: CacheGroup = root.group("artifacts/json");

group.ensure_dir().expect("ensure group");

// This is the path you can hand to another tool/process
let entry_without_touch: std::path::PathBuf = group.entry_path("v1/index.bin");

let expected: std::path::PathBuf = root
	.path()
	.join("artifacts")
	.join("json")
	.join("v1")
	.join("index.bin");
assert_eq!(entry_without_touch, expected);

fs::create_dir_all(entry_without_touch.parent().expect("entry parent"))
	.expect("create entry parent");
fs::write(&entry_without_touch, b"artifact bytes").expect("write artifact");
println!("{}", entry_without_touch.display());

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

§Discovering cache paths

Discover a cache path for the current crate/workspace and resolve an entry path.

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 and uses <crate-root>/.cache when found; otherwise it falls back to <cwd>/.cache.
  • The discovered anchor (crate root or cwd) is canonicalized when possible to avoid surprising differences between logically-equal paths.
  • If the relative_path argument is absolute, it is returned unchanged.
use cache_manager::CacheRoot;
use std::path::Path;

// Compute a path like <crate-root>/.cache/tool/data.bin without creating it
let cache_path: std::path::PathBuf = CacheRoot::from_discovery()
	.expect("discover cache root")
	.cache_path("tool", "data.bin");
println!("cache path: {}", cache_path.display());

// Expected relative location under the discovered crate 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());

// If you already have an absolute entry path, it's returned unchanged:
let absolute: std::path::PathBuf = std::path::PathBuf::from("/tmp/custom/cache.json");
let kept: std::path::PathBuf = CacheRoot::from_discovery()
	.expect("discover cache root")
	.cache_path("tool", &absolute);
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(...).

§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:

use cache_manager::CacheRoot;

let root = CacheRoot::from_project_dirs("com", "ExampleOrg", "ExampleApp")
	.expect("discover OS cache dir");

let group = root.group("artifacts");
group.ensure_dir().expect("ensure group");

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:

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

§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 root: CacheRoot = CacheRoot::from_root("/tmp/project");
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 root: CacheRoot = CacheRoot::from_root("/tmp/project");
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};

let root: CacheRoot = CacheRoot::from_root("/tmp/project");
let group: cache_manager::CacheGroup = root.group("artifacts");
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).

§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
	let root: CacheRoot = CacheRoot::from_root("/tmp/project");
	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
	assert!(entry.starts_with(base_group.path()));
	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 root: CacheRoot = CacheRoot::from_root("/tmp/project");
	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.

§Additional examples

Create or update a cache entry (ensures parent directories exist):

use cache_manager::{CacheGroup, CacheRoot};

let root: CacheRoot = CacheRoot::from_root("/tmp/project");
let group: CacheGroup = root.group("artifacts/json");

let entry: std::path::PathBuf = group.touch("v1/index.bin").expect("touch entry");

let expected: std::path::PathBuf = root
	.path()
	.join("artifacts")
	.join("json")
	.join("v1")
	.join("index.bin");
assert_eq!(entry, expected);

println!("touched: {}", entry.display());
§Per-subdirectory policies

Different subdirectories under the same CacheRoot can use independent policies; call ensure_dir_with_policy on each CacheGroup separately to apply per-group rules.

Note: calling CacheGroup::ensure_dir() is equivalent to CacheGroup::ensure_dir_with_policy(None). Likewise, CacheRoot::ensure_group(...) behaves the same as CacheRoot::ensure_group_with_policy(..., None).

§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).

See LICENSE-APACHE and LICENSE-MIT for details.

Structs§

CacheGroup
A group (subdirectory) under a CacheRoot that manages cache entries.
CacheRoot
Represents a discovered or explicit cache root directory.
EvictPolicy
Optional eviction controls applied by CacheGroup::ensure_dir_with_policy and CacheRoot::ensure_group_with_policy.
EvictionReport
Files selected for eviction by policy evaluation.