From 4989bfc7a4c8bc7f6864386430b4ffdc1a568c5e Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 1 Oct 2026 15:17:48 -0500 Subject: [PATCH 1/6] Add CacheResolver for fixed-precedence cache routing Introduce `CacheResolver` and `CacheSource` to provide a clear, fixed-precedence cache root resolution mechanism with no silent CWD fallback by default. Update tests, workflows, and documentation. --- .github/workflows/rust-tests.yml | 8 +- .gitignore | 1 + CHANGELOG.md | 26 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 111 +++++++-- src/lib.rs | 410 +++++++++++++++++++++++++++++++ 7 files changed, 534 insertions(+), 26 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index 1ac28ac..ade0cd9 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -71,7 +71,13 @@ jobs: ${{ runner.os }}-cargo-${{ steps.rustc.outputs.hash }}-${{ steps.lockfile.outputs.hash }}- - name: Test - run: cargo test --workspace --all-features --lib --bins --tests --examples -- --include-ignored + run: cargo test --workspace --all-features --lib --bins --tests --examples --doc -- --include-ignored + + - name: Test doctests with default features + # Feature-gated README examples must also compile with default + # features (os-cache-dir off) — the all-features run above cannot + # catch that class of breakage. + run: cargo test --workspace --doc test: name: test diff --git a/.gitignore b/.gitignore index 521fda5..a99e933 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target .cache +*.patch diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6437032 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/) and this project adheres to + (or is loosely based on) Semantic Versioning. + +## [0.5.0] - 2026-10-01 + +### Added + +- `CacheResolver` + `CacheSource`: combined cache-root resolution with fixed + precedence (explicit path → env var → Cargo workspace discovery → OS user + cache) and no silent `/.cache` scattering. Unresolvable configurations + return `Err(NotFound)` naming the env var / OS identity that would fix it; + the `/.cache` last resort is an explicit `allow_cwd_fallback` opt-in. + The winning source is always returned so binaries can announce cache + placement. Includes resolver unit tests (precedence matrix, `NotFound` + defaults, panic-safe env guards) and a README section documenting the + contract and the announce-the-winner convention. + +### Fixed + +- README doctests for `from_project_dirs` failed under default features + (`directories` unavailable, method compiled out). Examples are now + feature-gated `run()` functions: fully compiled and executed wherever the + `os-cache-dir` feature is enabled, never `ignore`d, never stale. diff --git a/Cargo.lock b/Cargo.lock index 193559c..6a6f0ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,7 +16,7 @@ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "cache-manager" -version = "0.4.1" +version = "0.5.0" dependencies = [ "directories", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 5eefcc0..da5ce4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cache-manager" -version = "0.4.1" +version = "0.5.0" 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 6a271ff..ff6f5c8 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,45 @@ not scan for arbitrary directory names — creating a directory named If you want to use a custom cache root, construct it explicitly with `CacheRoot::from_root(...)`. +### Combined resolution: `CacheResolver` (no silent `/.cache`) + +`from_discovery()` falls back to `/.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 (`/.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`). + +```rust +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: ()`) — cache placement must never be silent. + ### OS-native user cache root (optional) Enable feature flag: @@ -164,13 +203,27 @@ cargo add cache-manager --features os-cache-dir Then construct a `CacheRoot` from platform-native user cache directories: ```rust -use cache_manager::CacheRoot; +#[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 root = CacheRoot::from_project_dirs("com", "ExampleOrg", "ExampleApp") + .expect("discover OS cache dir"); -let group = root.group("artifacts"); -group.ensure_dir().expect("ensure group"); + 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: @@ -188,26 +241,38 @@ group.ensure_dir().expect("ensure group"); Example identity tuple: ```rust -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(); +#[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(()) +} -assert_eq!(got, expected); +#[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(()) +} -// 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"); +run().expect("example"); ``` diff --git a/src/lib.rs b/src/lib.rs index f2e8ace..42cdc46 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -173,6 +173,13 @@ impl CacheRoot { &self.root } + // --- combined resolution ------------------------------------------------- + // + // [`CacheResolver`] implements the "no surprises" contract documented on + // that type: fixed precedence, no silent `/.cache` scattering, and + // the winning source always reported. Prefer it over hand-rolled + // `match env::var(...)` chains in binaries. + /// Build a `CacheGroup` for a relative subdirectory under this root. pub fn group>(&self, relative_group: P) -> CacheGroup { let path = self.root.join(relative_group.as_ref()); @@ -219,6 +226,168 @@ impl CacheRoot { } } +/// Where a [`CacheResolver`]-resolved cache root came from. Returned alongside +/// the root so binaries can announce it (e.g. +/// `cache: ~/Library/Caches/com.acme.tool (os-cache-dir)`) — cache placement +/// must never be silent. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CacheSource { + /// Explicit `--dir`-style path; wins over everything. + Explicit, + /// Environment variable (see [`CacheResolver::env_var`]). + EnvVar, + /// Cargo workspace/crate discovery (`/.cache`). + ProjectDiscovery, + /// OS-native per-user cache dir (`os-cache-dir` feature). + OsCacheDir, + /// Last-resort `/.cache`; only reachable when the caller explicitly + /// sets [`CacheResolver::allow_cwd_fallback`]. + CwdFallback, +} + +/// Combined cache-root resolution with a fixed, documented precedence and no +/// silent `/.cache` scattering. +/// +/// Precedence (first hit wins, the rest are never consulted): +/// 1. [`CacheResolver::explicit`] — an explicit path (CLI `--dir`). +/// 2. [`CacheResolver::env_var`] — a non-empty env var value. +/// 3. Cargo workspace discovery (`/.cache`), unless +/// [`CacheResolver::allow_project_discovery`] is false. +/// 4. OS user cache dir for [`CacheResolver::project_dirs`] (requires the +/// `os-cache-dir` feature; without it this arm is compiled out). +/// +/// When nothing matches, `resolve()` returns `Err` (kind `NotFound`) naming +/// the env var and OS identity that would fix it — it NEVER silently falls +/// back to `/.cache`. Opt into that legacy behavior per call site with +/// [`CacheResolver::allow_cwd_fallback`] (default `false`). +/// +/// Recommended convention: binaries announce the winner on stderr at startup, +/// e.g. `tracing::info!("cache: {} ({:?})", root.path().display(), source)`. +/// +/// Zero breaking change: [`CacheRoot::from_discovery`] keeps its exact +/// current semantics (including the CWD fallback) for existing users. +#[derive(Clone, Debug)] +pub struct CacheResolver { + /// Explicit path; wins over everything. Usually a CLI `--dir` flag. + pub explicit: Option, + /// Env var name consulted next (e.g. `"WINDOWS_HEADLESS_QEMU_DIR"`). + /// Only non-empty values count; unset-or-empty falls through. + pub env_var: Option, + /// Consult Cargo workspace discovery. Default `true`. + pub allow_project_discovery: bool, + /// OS cache identity `(qualifier, organization, application)` passed to + /// `ProjectDirs::from`. `None` (default) disables the OS-cache arm. + /// Available only with the `os-cache-dir` feature. + #[cfg(feature = "os-cache-dir")] + pub project_dirs: Option<(String, String, String)>, + /// DANGEROUS, default `false`: allow `/.cache` as a last resort + /// (the [`CacheSource::CwdFallback`] source). Enabling this is a + /// conscious, greppable opt-in — each shell CWD grows its own cache. + pub allow_cwd_fallback: bool, +} + +impl Default for CacheResolver { + fn default() -> Self { + Self { + explicit: None, + env_var: None, + allow_project_discovery: true, + #[cfg(feature = "os-cache-dir")] + project_dirs: None, + allow_cwd_fallback: false, + } + } +} + +impl CacheResolver { + /// Resolve to `(root, source)` following the documented precedence. + pub fn resolve(&self) -> io::Result<(CacheRoot, CacheSource)> { + // 1. Explicit path. + if let Some(p) = &self.explicit { + return Ok((CacheRoot::from_root(p.clone()), CacheSource::Explicit)); + } + // 2. Env var (non-empty values only). + if let Some(name) = &self.env_var + && let Ok(v) = env::var(name) + && !v.is_empty() + { + return Ok((CacheRoot::from_root(v), CacheSource::EnvVar)); + } + // 3. Cargo workspace discovery (distinguishes "found a workspace" + // from "would fall back to CWD" — unlike from_discovery, no silent + // fallback here). + if self.allow_project_discovery + && let Some(anchor) = discover_anchor()? + { + return Ok(( + CacheRoot { + root: anchor.join(CACHE_DIR_NAME), + }, + CacheSource::ProjectDiscovery, + )); + } + // 4. OS user cache dir. + #[cfg(feature = "os-cache-dir")] + if let Some((q, o, a)) = &self.project_dirs { + let dirs = project_dirs_or_not_found(ProjectDirs::from(q, o, a))?; + return Ok(( + CacheRoot { + root: dirs.cache_dir().to_path_buf(), + }, + CacheSource::OsCacheDir, + )); + } + // 5. Explicit last-resort opt-in only. + if self.allow_cwd_fallback { + let cwd = env::current_dir()?; + let anchor = cwd.canonicalize().unwrap_or(cwd); + return Ok(( + CacheRoot { + root: anchor.join(CACHE_DIR_NAME), + }, + CacheSource::CwdFallback, + )); + } + Err(io::Error::new( + io::ErrorKind::NotFound, + format!( + "no cache location: {}{}; {}", + if self.allow_project_discovery { + "no Cargo workspace above the working directory" + } else { + "project discovery is disabled" + }, + match &self.env_var { + Some(n) => format!(" and ${n} is unset"), + None => " and no env-var override is configured".to_string(), + }, + match self.os_identity_hint() { + Some(h) => format!("no OS cache identity matched ({h})"), + None => "no OS cache identity is configured".to_string(), + } + ), + )) + } + + /// Human hint naming the configured OS identity (or `None`). + fn os_identity_hint(&self) -> Option { + #[cfg(feature = "os-cache-dir")] + if let Some((q, o, a)) = &self.project_dirs { + return Some(format!("{q}.{o}.{a}")); + } + let _ = &self.env_var; + None + } +} + +/// Anchor dir for project discovery (`Some`) or `None` when no `Cargo.toml` +/// exists above the CWD. Split out of [`find_crate_root`] so the resolver can +/// tell "found a workspace" from "would fall back to CWD". +fn discover_anchor() -> io::Result> { + let cwd = env::current_dir()?; + Ok(find_crate_root(&cwd).map(|a| a.canonicalize().unwrap_or(a))) +} + #[derive(Clone, Debug, PartialEq, Eq)] /// A group (subdirectory) under a `CacheRoot` that manages cache entries. /// @@ -788,6 +957,247 @@ mod tests { assert_eq!(resolved, absolute); } + // --- CacheResolver ------------------------------------------------------ + // + // Every test below holds CwdGuard (global CWD mutex) even when it only + // mutates env vars: env is process-global too, and the shared lock is + // what serializes these tests against each other and the CWD tests. + + /// RAII guard for a process-global env var: captures the prior value on + /// creation and restores it in `Drop`, so a panicking assertion can never + /// poison the environment for later tests. Bind as `_env_guard` and keep + /// it alive for the whole test. + struct EnvGuard { + name: String, + prior: Option, + } + + impl EnvGuard { + fn take(name: &str) -> Self { + Self { + name: name.to_string(), + prior: env::var(name).ok(), + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + unsafe { + match &self.prior { + Some(v) => env::set_var(&self.name, v), + None => env::remove_var(&self.name), + } + } + } + } + + #[test] + fn resolver_explicit_wins_over_everything() { + let tmp = TempDir::new().expect("tempdir"); + let _guard = CwdGuard::swap_to(tmp.path()).expect("set cwd"); + let name = "CACHE_MANAGER_TEST_RESOLVER_EXPLICIT"; + let _env_guard = EnvGuard::take(name); + unsafe { env::set_var(name, tmp.path().join("env-root")) }; + // Also plant a workspace: explicit must still win over discovery. + fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").expect("write cargo"); + + let explicit = tmp.path().join("explicit-root"); + let r = CacheResolver { + explicit: Some(explicit.clone()), + env_var: Some(name.to_string()), + ..CacheResolver::default() + }; + let (root, source) = r.resolve().expect("resolve"); + assert_eq!(source, CacheSource::Explicit); + assert_eq!(root.path(), explicit.as_path()); + } + + #[test] + fn resolver_env_var_second_and_skips_empty() { + let tmp = TempDir::new().expect("tempdir"); + let _guard = CwdGuard::swap_to(tmp.path()).expect("set cwd"); + let name = "CACHE_MANAGER_TEST_RESOLVER_ENV"; + let _env_guard = EnvGuard::take(name); + + // Empty counts as unset → NotFound (no workspace, no identity). + unsafe { env::set_var(name, "") }; + let r = CacheResolver { + env_var: Some(name.to_string()), + ..CacheResolver::default() + }; + let err = r.resolve().expect_err("empty env must fall through"); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + assert!( + format!("{err}").contains(name), + "error must name the env var: {err}" + ); + + // Non-empty wins. + let env_root = tmp.path().join("env-root"); + unsafe { env::set_var(name, &env_root) }; + let (root, source) = r.resolve().expect("resolve"); + assert_eq!(source, CacheSource::EnvVar); + assert_eq!(root.path(), env_root.as_path()); + } + + #[test] + fn resolver_project_discovery_third() { + let tmp = TempDir::new().expect("tempdir"); + let crate_root = tmp.path().join("workspace"); + let nested = crate_root.join("src").join("nested"); + fs::create_dir_all(&nested).expect("create nested"); + fs::write( + crate_root.join(CARGO_TOML_FILE_NAME), + "[package]\nname='x'\nversion='0.1.0'\nedition='2024'\n", + ) + .expect("write cargo"); + let _guard = CwdGuard::swap_to(&nested).expect("set cwd"); + + let (root, source) = CacheResolver::default().resolve().expect("resolve"); + assert_eq!(source, CacheSource::ProjectDiscovery); + let expected = crate_root + .canonicalize() + .expect("canonicalize") + .join(CACHE_DIR_NAME); + assert_eq!(root.path(), expected.as_path()); + } + + #[test] + fn resolver_no_workspace_without_identity_is_not_found_not_cwd() { + // Bare dir, no Cargo.toml anywhere above, no identity configured: + // the whole point of the resolver — NEVER silently /.cache. + let tmp = TempDir::new().expect("tempdir"); + let bare = tmp.path().join("bare"); + fs::create_dir_all(&bare).expect("create bare"); + let _guard = CwdGuard::swap_to(&bare).expect("set cwd"); + + let err = CacheResolver::default() + .resolve() + .expect_err("must not fall back to CWD"); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + assert!( + !bare.join(CACHE_DIR_NAME).exists(), + "resolver must not create anything on failure" + ); + } + + #[test] + fn resolver_cwd_fallback_is_explicit_opt_in() { + let tmp = TempDir::new().expect("tempdir"); + let bare = tmp.path().join("bare"); + fs::create_dir_all(&bare).expect("create bare"); + let _guard = CwdGuard::swap_to(&bare).expect("set cwd"); + + let r = CacheResolver { + allow_cwd_fallback: true, + ..CacheResolver::default() + }; + let (root, source) = r.resolve().expect("resolve"); + assert_eq!(source, CacheSource::CwdFallback); + let expected = bare + .canonicalize() + .expect("canonicalize") + .join(CACHE_DIR_NAME); + assert_eq!(root.path(), expected.as_path()); + } + + #[test] + fn resolver_discovery_opt_out_skips_to_not_found() { + let tmp = TempDir::new().expect("tempdir"); + let crate_root = tmp.path().join("workspace"); + fs::create_dir_all(&crate_root).expect("create root"); + fs::write(crate_root.join(CARGO_TOML_FILE_NAME), "[workspace]\n").expect("write cargo"); + let _guard = CwdGuard::swap_to(&crate_root).expect("set cwd"); + + let r = CacheResolver { + allow_project_discovery: false, + ..CacheResolver::default() + }; + let err = r.resolve().expect_err("discovery disabled → NotFound"); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + // Error must describe the actual path taken: discovery was skipped, + // not attempted-and-failed. + let msg = format!("{err}"); + assert!( + msg.contains("project discovery is disabled"), + "wrong error state: {msg}" + ); + assert!( + !msg.contains("no Cargo workspace above"), + "false audit trail: {msg}" + ); + } + + #[cfg(feature = "os-cache-dir")] + #[test] + fn resolver_os_cache_dir_when_no_workspace() { + let tmp = TempDir::new().expect("tempdir"); + let bare = tmp.path().join("bare"); + fs::create_dir_all(&bare).expect("create bare"); + let _guard = CwdGuard::swap_to(&bare).expect("set cwd"); + + let r = CacheResolver { + project_dirs: Some(( + "com".to_string(), + "CacheManagerTests".to_string(), + "ResolverOsCache".to_string(), + )), + ..CacheResolver::default() + }; + let (root, source) = r.resolve().expect("resolve"); + assert_eq!(source, CacheSource::OsCacheDir); + let expected = ProjectDirs::from("com", "CacheManagerTests", "ResolverOsCache") + .expect("project dirs") + .cache_dir() + .to_path_buf(); + assert_eq!(root.path(), expected.as_path()); + } + + #[cfg(feature = "os-cache-dir")] + #[test] + fn resolver_env_beats_os_cache_and_explicit_beats_env() { + let tmp = TempDir::new().expect("tempdir"); + let bare = tmp.path().join("bare"); + fs::create_dir_all(&bare).expect("create bare"); + let _guard = CwdGuard::swap_to(&bare).expect("set cwd"); + let name = "CACHE_MANAGER_TEST_RESOLVER_PRECEDENCE"; + let _env_guard = EnvGuard::take(name); + let env_root = tmp.path().join("env-root"); + unsafe { env::set_var(name, &env_root) }; + + let os_only = CacheResolver { + project_dirs: Some(( + "com".to_string(), + "CacheManagerTests".to_string(), + "ResolverPrecedence".to_string(), + )), + ..CacheResolver::default() + }; + // Env not consulted (no env_var configured) → OS cache. + let (_, source) = os_only.resolve().expect("resolve"); + assert_eq!(source, CacheSource::OsCacheDir); + + // Env configured → env wins over OS cache. + let with_env = CacheResolver { + env_var: Some(name.to_string()), + ..os_only.clone() + }; + let (root, source) = with_env.resolve().expect("resolve"); + assert_eq!(source, CacheSource::EnvVar); + assert_eq!(root.path(), env_root.as_path()); + + // Explicit wins over env. + let explicit = tmp.path().join("explicit-root"); + let with_explicit = CacheResolver { + explicit: Some(explicit.clone()), + ..with_env + }; + let (root, source) = with_explicit.resolve().expect("resolve"); + assert_eq!(source, CacheSource::Explicit); + assert_eq!(root.path(), explicit.as_path()); + } + #[cfg(feature = "os-cache-dir")] #[test] fn from_project_dirs_matches_directories_cache_dir() { From 42b0936d2547b43cab132974e0e8e3bed21d0940 Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 1 Oct 2026 15:27:30 -0500 Subject: [PATCH 2/6] Simplify cargo test invocation in CI workflow --- .github/workflows/rust-tests.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index ade0cd9..9835ece 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -71,7 +71,9 @@ jobs: ${{ runner.os }}-cargo-${{ steps.rustc.outputs.hash }}-${{ steps.lockfile.outputs.hash }}- - name: Test - run: cargo test --workspace --all-features --lib --bins --tests --examples --doc -- --include-ignored + # No target selectors: runs lib, bins, tests, examples AND doctests. + # (`--doc` cannot be mixed with `--lib/--bins/--tests/--examples`.) + run: cargo test --workspace --all-features -- --include-ignored - name: Test doctests with default features # Feature-gated README examples must also compile with default From 04217deb72156516e6cac7fe5560541af6c2d41d Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 1 Oct 2026 15:31:59 -0500 Subject: [PATCH 3/6] Fix README doctest path and documentation typo --- README.md | 9 ++++++++- src/lib.rs | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ff6f5c8..3ad6732 100644 --- a/README.md +++ b/README.md @@ -324,8 +324,15 @@ Preview evictions without deleting files: ```rust use cache_manager::{CacheRoot, EvictPolicy, EvictionReport}; -let root: CacheRoot = CacheRoot::from_root("/tmp/project"); +// 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() diff --git a/src/lib.rs b/src/lib.rs index 42cdc46..c50aa9c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -253,7 +253,7 @@ pub enum CacheSource { /// 2. [`CacheResolver::env_var`] — a non-empty env var value. /// 3. Cargo workspace discovery (`/.cache`), unless /// [`CacheResolver::allow_project_discovery`] is false. -/// 4. OS user cache dir for [`CacheResolver::project_dirs`] (requires the +/// 4. OS user cache dir for the `project_dirs` identity (requires the /// `os-cache-dir` feature; without it this arm is compiled out). /// /// When nothing matches, `resolve()` returns `Err` (kind `NotFound`) naming From e7cd43b7a5cfd815e45b56a93538f5b2dece6f81 Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 1 Oct 2026 15:39:04 -0500 Subject: [PATCH 4/6] Update README --- README.md | 46 ++++++++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 3ad6732..dbd01c2 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ [![made-with-rust][rust-logo]][rust-src-page] [![crates.io][crates-badge]][crates-page] [![MIT licensed][mit-license-badge]][mit-license-page] [![Apache 2.0 licensed][apache-2.0-license-badge]][apache-2.0-license-page] [![Coverage][coveralls-badge]][coveralls-page] -Directory-based cache and artifact path management with discovered `.cache` roots, grouped cache paths, and optional eviction on directory initialization. +`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. -> 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)_. ## Quick start @@ -73,8 +74,6 @@ println!("{}", entry.display()); - **Open-source + commercial-friendly:** dual-licensed under [MIT][mit-license-page] or [Apache-2.0][apache-2.0-license-page]. -> Tested on macOS, Linux, and Windows. - ## Reference ### Mental model: root -> groups -> entries @@ -136,11 +135,13 @@ 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: -let absolute = Path::new("/tmp/custom/cache.json"); +// 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); + .cache_path("tool", absolute.clone()); assert_eq!(kept, absolute); ``` @@ -289,7 +290,8 @@ Apply policy directly to a `CacheGroup`: ```rust use cache_manager::{CacheRoot, EvictPolicy}; -let root: CacheRoot = CacheRoot::from_root("/tmp/project"); +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 { @@ -308,7 +310,8 @@ Apply policy through `CacheRoot` convenience API: use cache_manager::{CacheRoot, EvictPolicy}; use std::time::Duration; -let root: CacheRoot = CacheRoot::from_root("/tmp/project"); +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() @@ -424,8 +427,12 @@ 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"); + // 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--...`) @@ -436,8 +443,14 @@ fn main() { 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())); + // 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-`) @@ -481,7 +494,8 @@ that existing group. fn from_group_example() { use cache_manager::{CacheGroup, CacheRoot, ProcessScopedCacheGroup}; - let root: CacheRoot = CacheRoot::from_root("/tmp/project"); + 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 = From f1fe1b7c8bec4697336797315c9a52aab183617b Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 1 Oct 2026 15:42:00 -0500 Subject: [PATCH 5/6] Update description tagline --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index da5ce4d..35791a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "cache-manager" version = "0.5.0" edition = "2024" -description = "Simple managed directory system for project-scoped caches with optional eviction policies." +description = "Managed caches, with optional eviction, for Rust project artifacts." license = "MIT OR Apache-2.0" authors = ["Jeremy Harris "] repository = "https://github.com/jzombie/rust-cache-manager" From 88139e5a36c41c098eb7b6ea27c283fcfda2bf5c Mon Sep 17 00:00:00 2001 From: Jeremy Harris Date: Thu, 1 Oct 2026 15:46:37 -0500 Subject: [PATCH 6/6] Simplify cache error message and improve test safety comments --- src/lib.rs | 76 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index c50aa9c..26eb3df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -351,7 +351,7 @@ impl CacheResolver { Err(io::Error::new( io::ErrorKind::NotFound, format!( - "no cache location: {}{}; {}", + "no cache location: {}{}; no OS cache identity is configured", if self.allow_project_discovery { "no Cargo workspace above the working directory" } else { @@ -361,23 +361,9 @@ impl CacheResolver { Some(n) => format!(" and ${n} is unset"), None => " and no env-var override is configured".to_string(), }, - match self.os_identity_hint() { - Some(h) => format!("no OS cache identity matched ({h})"), - None => "no OS cache identity is configured".to_string(), - } ), )) } - - /// Human hint naming the configured OS identity (or `None`). - fn os_identity_hint(&self) -> Option { - #[cfg(feature = "os-cache-dir")] - if let Some((q, o, a)) = &self.project_dirs { - return Some(format!("{q}.{o}.{a}")); - } - let _ = &self.env_var; - None - } } /// Anchor dir for project discovery (`Some`) or `None` when no `Cargo.toml` @@ -983,6 +969,10 @@ mod tests { impl Drop for EnvGuard { fn drop(&mut self) { + // SAFETY: process-global env mutation is sound here because every + // test that touches env holds the shared `cwd_lock()`, so no two + // tests can race, and this exact var is restored to its prior + // value (no other thread observes these test-scoped names). unsafe { match &self.prior { Some(v) => env::set_var(&self.name, v), @@ -998,6 +988,7 @@ mod tests { let _guard = CwdGuard::swap_to(tmp.path()).expect("set cwd"); let name = "CACHE_MANAGER_TEST_RESOLVER_EXPLICIT"; let _env_guard = EnvGuard::take(name); + // SAFETY: serialized by CwdGuard's global lock; test-scoped name. unsafe { env::set_var(name, tmp.path().join("env-root")) }; // Also plant a workspace: explicit must still win over discovery. fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").expect("write cargo"); @@ -1018,27 +1009,39 @@ mod tests { let tmp = TempDir::new().expect("tempdir"); let _guard = CwdGuard::swap_to(tmp.path()).expect("set cwd"); let name = "CACHE_MANAGER_TEST_RESOLVER_ENV"; - let _env_guard = EnvGuard::take(name); - - // Empty counts as unset → NotFound (no workspace, no identity). - unsafe { env::set_var(name, "") }; - let r = CacheResolver { - env_var: Some(name.to_string()), - ..CacheResolver::default() - }; - let err = r.resolve().expect_err("empty env must fall through"); - assert_eq!(err.kind(), io::ErrorKind::NotFound); - assert!( - format!("{err}").contains(name), - "error must name the env var: {err}" - ); - - // Non-empty wins. - let env_root = tmp.path().join("env-root"); - unsafe { env::set_var(name, &env_root) }; - let (root, source) = r.resolve().expect("resolve"); - assert_eq!(source, CacheSource::EnvVar); - assert_eq!(root.path(), env_root.as_path()); + // Pre-seed a sentinel: proves EnvGuard restores a prior value + // (not just the unset case) when it drops. + // SAFETY: serialized by CwdGuard's global lock; test-scoped name. + unsafe { env::set_var(name, "sentinel") }; + { + let _env_guard = EnvGuard::take(name); + + // Empty counts as unset → NotFound (no workspace, no identity). + // SAFETY: same serialization; guard restores on unwind. + unsafe { env::set_var(name, "") }; + let r = CacheResolver { + env_var: Some(name.to_string()), + ..CacheResolver::default() + }; + let err = r.resolve().expect_err("empty env must fall through"); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + assert!( + format!("{err}").contains(name), + "error must name the env var: {err}" + ); + + // Non-empty wins. + let env_root = tmp.path().join("env-root"); + // SAFETY: same serialization; guard restores on unwind. + unsafe { env::set_var(name, &env_root) }; + let (root, source) = r.resolve().expect("resolve"); + assert_eq!(source, CacheSource::EnvVar); + assert_eq!(root.path(), env_root.as_path()); + } + // Guard dropped here: prior sentinel value must be back. + assert_eq!(env::var(name).as_deref(), Ok("sentinel")); + // SAFETY: test cleanup of a test-scoped name; suite holds the lock. + unsafe { env::remove_var(name) }; } #[test] @@ -1164,6 +1167,7 @@ mod tests { let name = "CACHE_MANAGER_TEST_RESOLVER_PRECEDENCE"; let _env_guard = EnvGuard::take(name); let env_root = tmp.path().join("env-root"); + // SAFETY: serialized by CwdGuard's global lock; test-scoped name. unsafe { env::set_var(name, &env_root) }; let os_only = CacheResolver {