From 293707831e594cdf68f111d62db8b6f4824f21c9 Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 4 Jun 2026 11:34:27 -0500 Subject: [PATCH 1/4] Fix issue where workspace root was not respected from sub-crates --- .gitignore | 1 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 178 +++++++++++++++++++++-------------------------------- src/lib.rs | 47 ++++++++++++-- 5 files changed, 117 insertions(+), 113 deletions(-) diff --git a/.gitignore b/.gitignore index ea8c4bf..521fda5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.cache 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" diff --git a/README.md b/README.md index 957e617..6a271ff 100644 --- a/README.md +++ b/README.md @@ -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 `/.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 `/.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 @@ -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 `/.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 +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()`: 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 32f73ac130795e63c1b86a23063b9d6e7fae92f5 Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 4 Jun 2026 11:51:11 -0500 Subject: [PATCH 2/4] Add fine-grained Unix test for permission denied Cargo read --- src/lib.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index eaf212a..4a5f39b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -624,6 +624,40 @@ mod tests { assert_eq!(got, expected); } + #[cfg(unix)] + #[test] + fn from_discovery_skips_permission_denied_cargo_toml_and_falls_back_to_cwd() { + use std::os::unix::fs::PermissionsExt; + + let tmp = TempDir::new().expect("tempdir"); + fs::write(tmp.path().join(CARGO_TOML_FILE_NAME), "[workspace]") + .expect("write Cargo.toml"); + // Remove read permission — is_file() returns true but + // read_to_string fails with PermissionDenied. + fs::set_permissions( + tmp.path().join(CARGO_TOML_FILE_NAME), + std::fs::Permissions::from_mode(0o000), + ) + .expect("set permissions"); + + let _guard = CwdGuard::swap_to(tmp.path()).expect("set cwd"); + let cache = CacheRoot::from_discovery().expect("discover"); + // Falls back to cwd/.cache since Cargo.toml can't be read. + let expected = tmp + .path() + .canonicalize() + .expect("canonicalize") + .join(CACHE_DIR_NAME); + assert_eq!(cache.path(), expected); + + // Restore so TempDir cleanup can remove the file. + fs::set_permissions( + tmp.path().join(CARGO_TOML_FILE_NAME), + std::fs::Permissions::from_mode(0o644), + ) + .expect("restore permissions"); + } + #[test] fn from_root_supports_arbitrary_path_and_grouping() { let tmp = TempDir::new().expect("tempdir"); From 780ede300409bcbc82a109b093001b6cee8fac14 Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 4 Jun 2026 11:55:23 -0500 Subject: [PATCH 3/4] cargo fmt --all --- src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 4a5f39b..71d50ce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -630,8 +630,7 @@ mod tests { use std::os::unix::fs::PermissionsExt; let tmp = TempDir::new().expect("tempdir"); - fs::write(tmp.path().join(CARGO_TOML_FILE_NAME), "[workspace]") - .expect("write Cargo.toml"); + fs::write(tmp.path().join(CARGO_TOML_FILE_NAME), "[workspace]").expect("write Cargo.toml"); // Remove read permission — is_file() returns true but // read_to_string fails with PermissionDenied. fs::set_permissions( From 0e5024b1cf32662acf2d787934d03392dfe956b7 Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 4 Jun 2026 11:58:08 -0500 Subject: [PATCH 4/4] Resolve Clippy warnings --- src/lib.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 71d50ce..f2e8ace 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -396,10 +396,10 @@ fn find_crate_root(start: &Path) -> Option { 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 let Ok(content) = fs::read_to_string(&cargo_path) + && content.lines().any(|line| line.trim() == "[workspace]") + { + return Some(current); } } if !current.pop() {