diff --git a/Cargo.lock b/Cargo.lock index 8df15f2..60692ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,7 +16,7 @@ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "cache-manager" -version = "0.2.1" +version = "0.3.0" dependencies = [ "tempfile", ] @@ -257,9 +257,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom", diff --git a/Cargo.toml b/Cargo.toml index c97d5cf..d6edd16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,21 +1,24 @@ [package] name = "cache-manager" -version = "0.2.1" +version = "0.3.0" edition = "2024" description = "Simple managed directory system for project-scoped caches with optional eviction policies." license = "MIT OR Apache-2.0" -authors = ["Jeremy "] +authors = ["Jeremy Harris "] repository = "https://github.com/jzombie/rust-cache-manager" categories = ["development-tools", "caching", "filesystem"] -keywords = [ - "cache", - "artifact-cache", - "eviction-policy", - "filesystem", - "workspace", -] +keywords = ["cache", "artifact-cache", "eviction-policy", "filesystem", "workspace"] + +# Optional/test dependencies +[workspace.dependencies] +tempfile = "3.27.0" + +[features] +default = [] +process-scoped-cache = ["dep:tempfile"] [dependencies] +tempfile = { workspace = true, optional = true } [dev-dependencies] -tempfile = "3.25.0" +tempfile.workspace = true diff --git a/README.md b/README.md index bc161e2..34c2917 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,15 @@ Directory-based cache and artifact path management with discovered `.cache` roots, grouped cache paths, and optional eviction on directory initialization. -**This crate is intentionally tool-agnostic** — it only manages cache/artifact directory layout and paths and does not assume or depend on any specific consumer tooling. Any tool or library that reads or writes files can use `cache-manager` to compute/manage project-scoped cache paths and apply eviction rules. - -**It has zero runtime dependencies (standard library only for library consumers).** - -It is suitable for: - -- Artifact storage (build outputs, generated files, intermediate data, etc.). -- Monorepos or multi-crate workspaces that need centralized cache/artifact management via a shared root (for example with `CacheRoot::from_root(...)`). +- **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 runtime dependencies** in the standard install (library consumers use only the Rust standard library). +- **Optional feature `process-scoped-cache`:** adds one runtime dependency, [`tempfile`](https://docs.rs/tempfile), to support process/thread scoped sub-caches with automatic cleanup on normal shutdown. +- **Open-source + commercial-friendly licensing:** dual-licensed under MIT or Apache-2.0, so it can be used in open-source and commercial projects. +- **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. +- **Suitable for artifact storage** (build outputs, generated files, intermediate data, etc.). +- **Suitable for monorepos or multi-crate workspaces** that need centralized cache/artifact management via a shared root (for example with `CacheRoot::from_root(...)`). _This tool was designed to facilitate common cache directory management in a multi-crate workspace._ > Tested on macOS, Linux, and Windows. @@ -35,10 +36,10 @@ use cache_manager::CacheRoot; let root = CacheRoot::from_root("/tmp/project"); let group = root.group("artifacts/json"); -// Create the group directory if needed. +// 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. +// `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"); println!("{}", entry.display()); ``` @@ -69,6 +70,12 @@ println!("{}", entry_without_touch.display()); - **Create dirs + optional eviction:** `CacheRoot::ensure_group_with_policy`, `CacheGroup::ensure_dir_with_policy` - **Create file (creates parents):** `CacheGroup::touch` +With feature `process-scoped-cache` enabled: + +- **Process-scoped group:** `ProcessScopedCacheGroup::new`, `ProcessScopedCacheGroup::from_group` +- **Per-thread subgroup:** `ProcessScopedCacheGroup::thread_group`, `ProcessScopedCacheGroup::ensure_thread_group` +- **Per-thread entry helpers:** `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 @@ -88,14 +95,16 @@ Behavior: use cache_manager::CacheRoot; use std::path::Path; -// Compute a path like /.cache/tool/data.bin without creating it. +// Compute a path like /.cache/tool/data.bin without creating it 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: 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. + +// 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: @@ -215,6 +224,82 @@ For `max_files` and `max_bytes`, files are evicted oldest-first by modified time - 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: + +```bash +cargo add cache-manager --features process-scoped-cache +``` + +Or, if editing `Cargo.toml` manually: + +```toml +[dependencies] +cache-manager = { version = "", features = ["process-scoped-cache"] } +``` + +Use `ProcessScopedCacheGroup` to create an auto-generated process subdirectory +under your assigned root/group, then derive a stable subgroup for each thread: + +```rust +#[cfg(feature = "process-scoped-cache")] +fn main() { + use cache_manager::{CacheRoot, ProcessScopedCacheGroup}; + use std::path::Path; + + // 1) Build the root and the base group where process directories will live + let root = CacheRoot::from_root("/tmp/project"); + let base_group = root.group("artifacts/session"); + + // 2) Create a process-scoped directory (name starts with `pid--...`) + let scoped = 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 = scoped.ensure_thread_group().expect("ensure thread group"); + let entry = 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-`) + let thread_dir = 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--`) + let process_dir = thread_dir.parent().expect("process dir"); + let expected_pid_prefix = 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() {} +``` + +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): diff --git a/src/lib.rs b/src/lib.rs index 69aa1db..cc4c6b5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,14 +3,20 @@ mod constants; +#[cfg(feature = "process-scoped-cache")] +use std::cell::Cell; use std::env; use std::fs; use std::fs::OpenOptions; use std::io; use std::path::{Path, PathBuf}; +#[cfg(feature = "process-scoped-cache")] +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use constants::{CACHE_DIR_NAME, CARGO_TOML_FILE_NAME}; +#[cfg(feature = "process-scoped-cache")] +use tempfile::{Builder, TempDir}; /// Optional eviction controls applied by `CacheGroup::ensure_dir_with_policy` /// and `CacheRoot::ensure_group_with_policy`. @@ -217,6 +223,105 @@ impl CacheGroup { } } +/// Process-scoped cache group handle with per-thread subgroup helpers. +/// +/// This type is available when the `process-scoped-cache` feature is enabled. +/// +/// It creates an auto-generated process subdirectory under a user-selected +/// base cache group. The backing directory is removed when this handle is +/// dropped during normal process shutdown. +/// +/// Notes: +/// - Cleanup is best-effort and is not guaranteed after abnormal termination +/// (for example `SIGKILL` or process crash). +/// - All paths still respect the caller-provided `CacheRoot` and base group. +#[cfg(feature = "process-scoped-cache")] +#[derive(Debug)] +pub struct ProcessScopedCacheGroup { + process_group: CacheGroup, + _temp_dir: TempDir, +} + +#[cfg(feature = "process-scoped-cache")] +impl ProcessScopedCacheGroup { + /// Create a process-scoped cache handle under `root.group(relative_group)`. + pub fn new>(root: &CacheRoot, relative_group: P) -> io::Result { + Self::from_group(root.group(relative_group)) + } + + /// Create a process-scoped cache handle under an existing base group. + pub fn from_group(base_group: CacheGroup) -> io::Result { + base_group.ensure_dir()?; + let pid = std::process::id(); + let temp_dir = Builder::new() + .prefix(&format!("pid-{pid}-")) + .tempdir_in(base_group.path())?; + let process_group = CacheGroup { + path: temp_dir.path().to_path_buf(), + }; + + Ok(Self { + process_group, + _temp_dir: temp_dir, + }) + } + + /// Return the process-scoped directory path. + pub fn path(&self) -> &Path { + self.process_group.path() + } + + /// Return the process-scoped cache group. + pub fn process_group(&self) -> CacheGroup { + self.process_group.clone() + } + + /// Return the subgroup for the current thread. + /// + /// Each thread gets a stable, process-local incremental id (`thread-`) + /// for the process lifetime. + pub fn thread_group(&self) -> CacheGroup { + self.process_group + .subgroup(format!("thread-{}", current_thread_cache_group_id())) + } + + /// Ensure and return the subgroup for the current thread. + pub fn ensure_thread_group(&self) -> io::Result { + let group = self.thread_group(); + group.ensure_dir()?; + Ok(group) + } + + /// Build an entry path inside the current thread subgroup. + pub fn thread_entry_path>(&self, relative_file: P) -> PathBuf { + self.thread_group().entry_path(relative_file) + } + + /// Touch an entry inside the current thread subgroup. + pub fn touch_thread_entry>(&self, relative_file: P) -> io::Result { + self.ensure_thread_group()?.touch(relative_file) + } +} + +#[cfg(feature = "process-scoped-cache")] +fn current_thread_cache_group_id() -> u64 { + thread_local! { + static THREAD_GROUP_ID: Cell> = const { Cell::new(None) }; + } + + static NEXT_THREAD_GROUP_ID: AtomicU64 = AtomicU64::new(1); + + THREAD_GROUP_ID.with(|slot| { + if let Some(id) = slot.get() { + id + } else { + let id = NEXT_THREAD_GROUP_ID.fetch_add(1, Ordering::Relaxed); + slot.set(Some(id)); + id + } + }) +} + fn find_crate_root(start: &Path) -> Option { let mut current = start.to_path_buf(); loop { @@ -923,6 +1028,26 @@ mod tests { assert_eq!(remaining.len(), 1); } + #[cfg(unix)] + #[test] + fn collect_files_recursive_ignores_non_file_non_directory_entries() { + use std::os::unix::net::UnixListener; + + let tmp = TempDir::new().expect("tempdir"); + let cache = CacheRoot::from_root(tmp.path()); + let group = cache.group("artifacts"); + group.ensure_dir().expect("ensure dir"); + + let socket_path = group.entry_path("live.sock"); + let _listener = UnixListener::bind(&socket_path).expect("bind unix socket"); + + fs::write(group.entry_path("a.bin"), vec![1u8; 1]).expect("write file"); + + let files = collect_files(group.path()).expect("collect files"); + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, group.entry_path("a.bin")); + } + #[test] fn max_bytes_policy_under_threshold_does_not_evict() { let tmp = TempDir::new().expect("tempdir"); @@ -998,4 +1123,113 @@ mod tests { assert_eq!(p1, p2); assert!(group.entry_path("keep_root.txt").exists()); } + + #[cfg(feature = "process-scoped-cache")] + #[test] + fn process_scoped_cache_respects_root_and_group_assignments() { + let tmp = TempDir::new().expect("tempdir"); + let root = CacheRoot::from_root(tmp.path().join("custom-root")); + + let scoped = ProcessScopedCacheGroup::new(&root, "artifacts/session").expect("create"); + let expected_prefix = root.group("artifacts/session").path().to_path_buf(); + + assert!(scoped.path().starts_with(&expected_prefix)); + assert!(scoped.path().exists()); + } + + #[cfg(feature = "process-scoped-cache")] + #[test] + fn process_scoped_cache_deletes_directory_on_drop() { + let tmp = TempDir::new().expect("tempdir"); + let root = CacheRoot::from_root(tmp.path()); + + let process_dir = { + let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create"); + let p = scoped.path().to_path_buf(); + assert!(p.exists()); + p + }; + + assert!(!process_dir.exists()); + } + + #[cfg(feature = "process-scoped-cache")] + #[test] + fn process_scoped_cache_thread_group_is_stable_per_thread() { + let tmp = TempDir::new().expect("tempdir"); + let root = CacheRoot::from_root(tmp.path()); + let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create"); + + let first = scoped.thread_group().path().to_path_buf(); + let second = scoped.thread_group().path().to_path_buf(); + + assert_eq!(first, second); + } + + #[cfg(feature = "process-scoped-cache")] + #[test] + fn process_scoped_cache_thread_group_differs_across_threads() { + let tmp = TempDir::new().expect("tempdir"); + let root = CacheRoot::from_root(tmp.path()); + let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create"); + + let main_thread_group = scoped.thread_group().path().to_path_buf(); + let other_thread_group = std::thread::spawn(current_thread_cache_group_id) + .join() + .expect("join thread"); + + let expected_other = scoped + .process_group() + .subgroup(format!("thread-{other_thread_group}")) + .path() + .to_path_buf(); + + assert_ne!(main_thread_group, expected_other); + } + + #[cfg(feature = "process-scoped-cache")] + #[test] + fn process_scoped_cache_from_group_uses_given_base_group() { + let tmp = TempDir::new().expect("tempdir"); + let root = CacheRoot::from_root(tmp.path()); + let base_group = root.group("artifacts/custom-base"); + + let scoped = ProcessScopedCacheGroup::from_group(base_group.clone()).expect("create"); + + assert!(scoped.path().starts_with(base_group.path())); + assert_eq!(scoped.process_group().path(), scoped.path()); + } + + #[cfg(feature = "process-scoped-cache")] + #[test] + fn process_scoped_cache_thread_entry_path_matches_touch_location() { + let tmp = TempDir::new().expect("tempdir"); + let root = CacheRoot::from_root(tmp.path()); + let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create"); + + let planned = scoped.thread_entry_path("nested/data.bin"); + let touched = scoped + .touch_thread_entry("nested/data.bin") + .expect("touch thread entry"); + + assert_eq!(planned, touched); + assert!(touched.exists()); + } + + #[cfg(feature = "process-scoped-cache")] + #[test] + fn touch_thread_entry_creates_entry_under_thread_group() { + let tmp = TempDir::new().expect("tempdir"); + let root = CacheRoot::from_root(tmp.path()); + let scoped = ProcessScopedCacheGroup::new(&root, "artifacts").expect("create"); + + let entry = scoped + .touch_thread_entry("nested/data.bin") + .expect("touch thread entry"); + + assert!(entry.exists()); + assert!(entry.starts_with(scoped.path())); + let thread_group = scoped.thread_group().path().to_path_buf(); + assert!(entry.starts_with(&thread_group)); + } }