Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/target
.cache
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "cache-manager"
version = "0.4.0"
version = "0.4.1"
edition = "2024"
description = "Simple managed directory system for project-scoped caches with optional eviction policies."
license = "MIT OR Apache-2.0"
Expand Down
178 changes: 71 additions & 107 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,93 +8,82 @@ Directory-based cache and artifact path management with discovered `.cache` root
> 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**
- **`process-scoped-cache`:** adds [`tempfile`](https://docs.rs/tempfile) and enables process/thread scoped caches.
- [`CacheRoot::from_tempdir(...)`](#cacheroot-from-tempdir)
- [`ProcessScopedCacheGroup::new(...)`](#processscopedcachegroup-from-root-and-group-path)
- [`ProcessScopedCacheGroup::from_group(...)`](#processscopedcachegroup-from-existing-group)
- **`os-cache-dir`:** adds [`directories`](https://docs.rs/directories) and enables OS-native per-user cache roots.
- [`CacheRoot::from_project_dirs(...)`](#os-native-user-cache-root-optional)

- **Licensing**
- **Open-source + commercial-friendly:** dual-licensed under [MIT][mit-license-page] or [Apache-2.0][apache-2.0-license-page].
## Quick start

> Tested on macOS, Linux, and Windows.
### Add `cache-manager` to your project

## Usage
```bash
cargo add cache-manager
```

### Mental model: root -> groups -> entries
### Create a cache entry

- `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.
```rust
use cache_manager::CacheRoot;

### Quick start
// Discover the workspace or crate root, anchor .cache there
let root = CacheRoot::from_discovery().expect("discover cache root");

Using `touch` (convenient when you want this crate to create the file):
// Create (or resume) a group for artifacts
let group = root.group("artifacts/json");
group.ensure_dir().expect("ensure group dir");

```rust
use cache_manager::{CacheGroup, CacheRoot};
// Create or refresh a cache entry (creates parent dirs automatically)
let entry = group.touch("v1/index.bin").expect("touch entry");
println!("{}", entry.display());
```

let root: CacheRoot = CacheRoot::from_root("/tmp/project");
let group: CacheGroup = root.group("artifacts/json");
### Pass a path to another tool

// Create the group directory if needed
group.ensure_dir().expect("ensure group");
Composing explicit paths without `touch` (hand the path to another tool):

// `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");
```rust
use cache_manager::CacheRoot;
use std::fs;

let expected: std::path::PathBuf = root
.path()
.join("artifacts")
.join("json")
.join("v1")
.join("index.bin");
assert_eq!(entry, expected);
let root = CacheRoot::from_discovery().expect("discover cache root");
let group = root.group("artifacts/json");
group.ensure_dir().expect("ensure group dir");

// Example output path
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());
```

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

```rust
use cache_manager::{CacheGroup, CacheRoot};
use std::fs;
- **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(...)`).

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

group.ensure_dir().expect("ensure group");
- **`process-scoped-cache`:** adds [`tempfile`](https://docs.rs/tempfile) and enables process/thread scoped caches.
- [`CacheRoot::from_tempdir(...)`](#cacheroot-from-tempdir)
- [`ProcessScopedCacheGroup::new(...)`](#processscopedcachegroup-from-root-and-group-path)
- [`ProcessScopedCacheGroup::from_group(...)`](#processscopedcachegroup-from-existing-group)
- **`os-cache-dir`:** adds [`directories`](https://docs.rs/directories) and enables OS-native per-user cache roots.
- [`CacheRoot::from_project_dirs(...)`](#os-native-user-cache-root-optional)

// This is the path you can hand to another tool/process
let entry_without_touch: std::path::PathBuf = group.entry_path("v1/index.bin");
- **Open-source + commercial-friendly:** dual-licensed under [MIT][mit-license-page] or [Apache-2.0][apache-2.0-license-page].

let expected: std::path::PathBuf = root
.path()
.join("artifacts")
.join("json")
.join("v1")
.join("index.bin");
assert_eq!(entry_without_touch, expected);
> Tested on macOS, Linux, and Windows.

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

Expand All @@ -116,40 +105,42 @@ println!("{}", entry_without_touch.display());

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

### Discovering cache paths
### Cache root discovery

Discover a cache path for the current crate/workspace and resolve an entry path.
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` 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.
- 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.

```rust
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()
// 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());

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

// 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()
// Absolute paths are returned unchanged:
let absolute = Path::new("/tmp/custom/cache.json");
let kept = CacheRoot::from_discovery()
.expect("discover cache root")
.cache_path("tool", &absolute);
.cache_path("tool", absolute);
assert_eq!(kept, absolute);
```

Expand Down Expand Up @@ -437,36 +428,9 @@ Behavior notes:
- 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):

```rust
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)`.
### Per-subdirectory policies

#### Get the root path
### Get the root path

To obtain the underlying filesystem path for a `CacheRoot`, use `path()`:

Expand Down
47 changes: 43 additions & 4 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ pub struct CacheRoot {
impl CacheRoot {
/// Discover the cache root by searching parent directories for `Cargo.toml`.
///
/// The discovered cache root is always `<crate-root-or-cwd>/.cache`.
/// The discovered cache root is always
/// `<workspace-root-or-crate-root-or-cwd>/.cache`.
///
/// Note: `from_discovery` only uses the configured `CACHE_DIR_NAME` (by
/// default `.cache`) as the discovered cache directory. It does not
Expand Down Expand Up @@ -388,12 +389,21 @@ fn current_thread_cache_group_id() -> u64 {

fn find_crate_root(start: &Path) -> Option<PathBuf> {
let mut current = start.to_path_buf();
let mut nearest: Option<PathBuf> = None;
loop {
if current.join(CARGO_TOML_FILE_NAME).is_file() {
return Some(current);
let cargo_path = current.join(CARGO_TOML_FILE_NAME);
if cargo_path.is_file() {
if nearest.is_none() {
nearest = Some(current.clone());
}
if let Ok(content) = fs::read_to_string(&cargo_path) {
if content.lines().any(|line| line.trim() == "[workspace]") {
return Some(current);
}
}
}
if !current.pop() {
return None;
return nearest;
}
}
}
Expand Down Expand Up @@ -585,6 +595,35 @@ mod tests {
assert_eq!(got, expected);
}

#[test]
fn from_discovery_prefers_workspace_root_over_subcrate() {
let tmp = TempDir::new().expect("tempdir");
let workspace_root = tmp.path().join("workspace");
let sub_crate = workspace_root.join("crates").join("my-crate");
fs::create_dir_all(&sub_crate).expect("create sub-crate");
// Workspace root Cargo.toml with [workspace] section.
fs::write(
workspace_root.join(CARGO_TOML_FILE_NAME),
"[workspace]\n[package]\nname='workspace-root'\nversion='0.1.0'\nedition='2024'\n",
)
.expect("write workspace Cargo.toml");
// Sub-crate Cargo.toml without [workspace].
fs::write(
sub_crate.join(CARGO_TOML_FILE_NAME),
"[package]\nname='my-crate'\nversion='0.1.0'\nedition='2024'\n",
)
.expect("write sub-crate Cargo.toml");

let _guard = CwdGuard::swap_to(&sub_crate).expect("set cwd");
let cache = CacheRoot::from_discovery().expect("discover");
let got = cache.path().to_path_buf();
let expected = workspace_root
.canonicalize()
.expect("canonicalize workspace root")
.join(CACHE_DIR_NAME);
assert_eq!(got, expected);
}

#[test]
fn from_root_supports_arbitrary_path_and_grouping() {
let tmp = TempDir::new().expect("tempdir");
Expand Down
Loading