From ed995f78dff7da9620a1a4604e45293d533cc6af Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 4 Jun 2026 10:53:00 -0500 Subject: [PATCH 1/9] Ignore .opencode --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ea8c4bf..aaef0ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.opencode From 77bfcaef705ad5b30fdbef29d70b60661f5793a8 Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 4 Jun 2026 10:57:00 -0500 Subject: [PATCH 2/9] Fix issue where cache root did not respect subcrates --- .gitignore | 1 - src/lib.rs | 47 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index aaef0ac..ea8c4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ /target -.opencode diff --git a/src/lib.rs b/src/lib.rs index f6dd9c6..eaf212a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 `/.cache`. + /// The discovered cache root is always + /// `/.cache`. /// /// Note: `from_discovery` only uses the configured `CACHE_DIR_NAME` (by /// default `.cache`) as the discovered cache directory. It does not @@ -388,12 +389,21 @@ fn current_thread_cache_group_id() -> u64 { fn find_crate_root(start: &Path) -> Option { let mut current = start.to_path_buf(); + let mut nearest: Option = 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; } } } @@ -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"); From ce1ce6c2b08969de61ad1c6ab22185e4341d8d13 Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 4 Jun 2026 10:58:45 -0500 Subject: [PATCH 3/9] Bump to 0.4.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6dd7bcc..193559c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,7 +16,7 @@ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "cache-manager" -version = "0.4.0" +version = "0.4.1" dependencies = [ "directories", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 75826a5..5eefcc0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" From d73d6986fc484f2400d58b941ffdaa838a1285ef Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 4 Jun 2026 11:26:27 -0500 Subject: [PATCH 4/9] Draft README updates --- README.md | 172 ++++++++++++++++++++---------------------------------- 1 file changed, 63 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 957e617..7fe9b50 100644 --- a/README.md +++ b/README.md @@ -8,93 +8,72 @@ 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 `/.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. +```rust +use cache_manager::CacheRoot; -## Usage +// Discover the workspace or crate root, anchor .cache there +let root = CacheRoot::from_discovery().expect("discover cache root"); -### Mental model: root -> groups -> entries +// Create (or resume) a group for artifacts +let group = root.group("artifacts/json"); +group.ensure_dir().expect("ensure group dir"); -- `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 +// Create or refresh a cache entry (creates parent dirs automatically) +let entry = group.touch("v1/index.bin").expect("touch entry"); +println!("{}", entry.display()); +``` -Using `touch` (convenient when you want this crate to create the file): +Composing explicit paths without `touch` (hand the path to another tool): ```rust -use cache_manager::{CacheGroup, CacheRoot}; +use cache_manager::CacheRoot; +use std::fs; -let root: CacheRoot = CacheRoot::from_root("/tmp/project"); -let group: CacheGroup = root.group("artifacts/json"); +let root = CacheRoot::from_discovery().expect("discover cache root"); +let group = root.group("artifacts/json"); +group.ensure_dir().expect("ensure group dir"); -// Create the group directory if needed -group.ensure_dir().expect("ensure group"); +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()); +``` -// `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"); +## Core capabilities -let expected: std::path::PathBuf = root - .path() - .join("artifacts") - .join("json") - .join("v1") - .join("index.bin"); -assert_eq!(entry, expected); +- **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 `/.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(...)`). -// Example output path -println!("{}", entry.display()); -``` +## Optional features -Without `touch` (compute the path for a separate tool, then write with your own I/O): +- **`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) -```rust -use cache_manager::{CacheGroup, CacheRoot}; -use std::fs; +- **Open-source + commercial-friendly:** dual-licensed under [MIT][mit-license-page] or [Apache-2.0][apache-2.0-license-page]. -let root: CacheRoot = CacheRoot::from_root("/tmp/project"); -let group: CacheGroup = root.group("artifacts/json"); +> Tested on macOS, Linux, and Windows. -group.ensure_dir().expect("ensure group"); +## Usage -// This is the path you can hand to another tool/process -let entry_without_touch: std::path::PathBuf = group.entry_path("v1/index.bin"); +### Mental model: root -> groups -> entries -let expected: std::path::PathBuf = root - .path() - .join("artifacts") - .join("json") - .join("v1") - .join("index.bin"); -assert_eq!(entry_without_touch, expected); +- `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`). -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()); -``` +`CacheRoot` and `CacheGroup` are lightweight path objects. Constructing them does not create directories. ### Filesystem effects @@ -116,40 +95,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 `/.cache` when found; otherwise it falls back to `/.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 `/.cache`. +- Otherwise uses the nearest `/.cache`. +- Falls back to `/.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 /.cache/tool/data.bin without creating it -let cache_path: std::path::PathBuf = CacheRoot::from_discovery() +// Compute a path like /.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); ``` @@ -437,36 +418,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()`: From 1acd5e60f53e79fa610ff9e1b8cc982f2444b159 Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 4 Jun 2026 11:26:56 -0500 Subject: [PATCH 5/9] Ignore its own cache --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ea8c4bf..521fda5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.cache From b3b868832752658abb425dc103f486a0cbbf7107 Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 4 Jun 2026 11:28:53 -0500 Subject: [PATCH 6/9] Add install example --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 7fe9b50..5e0797d 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ Directory-based cache and artifact path management with discovered `.cache` root ## Quick start +```bash +cargo add cache-manager +``` + ```rust use cache_manager::CacheRoot; From bc2a8eab6bf4f9b0a37c5524bedbe07a7fe37e31 Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 4 Jun 2026 11:31:01 -0500 Subject: [PATCH 7/9] Add headings --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 5e0797d..4485e88 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,14 @@ Directory-based cache and artifact path management with discovered `.cache` root ## Quick start +### Add cache-manager to your project + ```bash cargo add cache-manager ``` +### Create a cache entry + ```rust use cache_manager::CacheRoot; @@ -29,6 +33,8 @@ 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): ```rust From b4a251158e8d4bb7351ec3f15bc2ef0b642af897 Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 4 Jun 2026 11:31:30 -0500 Subject: [PATCH 8/9] Add backticks --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4485e88..a21376a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Directory-based cache and artifact path management with discovered `.cache` root ## Quick start -### Add cache-manager to your project +### Add `cache-manager` to your project ```bash cargo add cache-manager From 6938c36a2d8ac08e6fb7c04925b8ae8732b82e01 Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 4 Jun 2026 11:32:59 -0500 Subject: [PATCH 9/9] Rename subsequent usage section to reference --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a21376a..6a271ff 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ println!("{}", entry.display()); > Tested on macOS, Linux, and Windows. -## Usage +## Reference ### Mental model: root -> groups -> entries