diff --git a/Cargo.lock b/Cargo.lock index f3166b1..6dd7bcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,8 +16,9 @@ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "cache-manager" -version = "0.3.1" +version = "0.4.0" dependencies = [ + "directories", "tempfile", ] @@ -27,6 +28,27 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -55,6 +77,17 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.1" @@ -125,6 +158,15 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -149,6 +191,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "prettyplease" version = "0.2.37" @@ -183,6 +231,17 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "rustix" version = "1.1.4" @@ -262,12 +321,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.1", "once_cell", "rustix", "windows-sys", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -280,6 +359,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" diff --git a/Cargo.toml b/Cargo.toml index 883099f..75826a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cache-manager" -version = "0.3.1" +version = "0.4.0" edition = "2024" description = "Simple managed directory system for project-scoped caches with optional eviction policies." license = "MIT OR Apache-2.0" @@ -16,8 +16,10 @@ tempfile = "3.27.0" [features] default = [] process-scoped-cache = ["dep:tempfile"] +os-cache-dir = ["dep:directories"] [dependencies] +directories = { version = "6.0.0", optional = true } tempfile = { workspace = true, optional = true } [dev-dependencies] diff --git a/README.md b/README.md index 5d89152..957e617 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,29 @@ Directory-based cache and artifact path management with discovered `.cache` roots, grouped cache paths, and optional eviction on directory initialization. -- **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._ +> 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)_. + +- **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]. > Tested on macOS, Linux, and Windows. @@ -50,6 +64,7 @@ let expected: std::path::PathBuf = root .join("index.bin"); assert_eq!(entry, expected); +// Example output path println!("{}", entry.display()); ``` @@ -83,17 +98,21 @@ println!("{}", entry_without_touch.display()); ### Filesystem effects -- **Pure path operations:** `CacheRoot::from_root`, `CacheRoot::cache_path`, `CacheRoot::group`, `CacheGroup::entry_path`, `CacheGroup::subgroup` -- **Discovery helper (cwd/crate-root based):** `CacheRoot::from_discovery` -- **Create dirs:** `CacheRoot::ensure_group`, `CacheGroup::ensure_dir` -- **Create dirs + optional eviction:** `CacheRoot::ensure_group_with_policy`, `CacheGroup::ensure_dir_with_policy` -- **Create file (creates parents):** `CacheGroup::touch` +- **Core APIs (always available):** + - `CacheRoot::from_root`, `CacheRoot::from_discovery`, `CacheRoot::cache_path`, `CacheRoot::group` + - `CacheGroup::subgroup`, `CacheGroup::entry_path` + - `CacheRoot::ensure_group`, `CacheGroup::ensure_dir` + - `CacheRoot::ensure_group_with_policy`, `CacheGroup::ensure_dir_with_policy` + - `CacheGroup::touch` -With feature `process-scoped-cache` enabled: +- **Feature `os-cache-dir`:** + - `CacheRoot::from_project_dirs` -- **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` +- **Feature `process-scoped-cache`:** + - `CacheRoot::from_tempdir` + - `ProcessScopedCacheGroup::new`, `ProcessScopedCacheGroup::from_group` + - `ProcessScopedCacheGroup::thread_group`, `ProcessScopedCacheGroup::ensure_thread_group` + - `ProcessScopedCacheGroup::thread_entry_path`, `ProcessScopedCacheGroup::touch_thread_entry` > Note: eviction only runs when you pass a policy to the `*_with_policy` methods. @@ -143,6 +162,63 @@ 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(...)`. +### OS-native user cache root (optional) + +Enable feature flag: + +```bash +cargo add cache-manager --features os-cache-dir +``` + +Then construct a `CacheRoot` from platform-native user cache directories: + +```rust +use cache_manager::CacheRoot; + +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"); +``` + +`from_project_dirs` uses `directories::ProjectDirs` and typically resolves to: + +- macOS: `~/Library/Caches/` +- Linux: `$XDG_CACHE_HOME/` or `~/.cache/` +- Windows: `%LOCALAPPDATA%\\\\\\cache` + +`from_project_dirs(qualifier, organization, application)` parameters: + +- `qualifier`: a DNS-like namespace component (commonly `"com"` or `"org"`) +- `organization`: vendor/team name (for example `"ExampleOrg"`) +- `application`: app/tool identifier (for example `"ExampleApp"`) + +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(); + +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"); +``` + ### Eviction Policy @@ -258,8 +334,26 @@ Or, if editing `Cargo.toml` manually: 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: +#### CacheRoot from tempdir + +Create a temporary cache root backed by a persisted temp directory: + +```rust +#[cfg(feature = "process-scoped-cache")] +fn example_temp_root() { + let root = cache_manager::CacheRoot::from_tempdir().expect("temp cache root"); + let group = root.group("artifacts"); + group.ensure_dir().expect("ensure group"); + + // `from_tempdir` intentionally persists the directory; clean up when done. + std::fs::remove_dir_all(root.path()).expect("cleanup temp root"); +} +``` + +#### ProcessScopedCacheGroup from root and group path + +Use this constructor when you have a `CacheRoot` plus a relative group path. +It creates a process-scoped directory under `root.group(...)`. ```rust #[cfg(feature = "process-scoped-cache")] @@ -313,6 +407,30 @@ fn main() { fn main() {} ``` +#### ProcessScopedCacheGroup from existing group + +Use this constructor when you already have a `CacheGroup` (for example, +shared or precomputed by higher-level setup) and want process scoping from +that existing group. + +```rust +#[cfg(feature = "process-scoped-cache")] +fn from_group_example() { + use cache_manager::{CacheGroup, CacheRoot, ProcessScopedCacheGroup}; + + let root: CacheRoot = CacheRoot::from_root("/tmp/project"); + let base_group: CacheGroup = root.group("artifacts/session"); + + let scoped: ProcessScopedCacheGroup = + ProcessScopedCacheGroup::from_group(base_group).expect("create process-scoped cache"); + let thread_entry = scoped + .touch_thread_entry("v1/index.bin") + .expect("touch thread entry"); + + assert!(thread_entry.starts_with(scoped.path())); +} +``` + Behavior notes: - Respects all configured roots/groups because process-scoped paths are always created under your provided `CacheRoot`/`CacheGroup`. @@ -379,7 +497,7 @@ println!("entry path: {}", entry_path.display()); `cache-manager` is primarily distributed under the terms of both the MIT license and the Apache License (Version 2.0). -See [LICENSE-APACHE](./LICENSE-APACHE) and [LICENSE-MIT](./LICENSE-MIT) for details. +See [LICENSE-APACHE][apache-2.0-license-page] and [LICENSE-MIT][mit-license-page] for details. [rust-src-page]: https://www.rust-lang.org/ [rust-logo]: https://img.shields.io/badge/Made%20with-Rust-black @@ -387,10 +505,10 @@ See [LICENSE-APACHE](./LICENSE-APACHE) and [LICENSE-MIT](./LICENSE-MIT) for deta [crates-page]: https://crates.io/crates/cache-manager [crates-badge]: https://img.shields.io/crates/v/cache-manager.svg -[mit-license-page]: ./LICENSE-MIT +[mit-license-page]: https://raw.githubusercontent.com/jzombie/rust-cache-manager/refs/heads/main/LICENSE-MIT [mit-license-badge]: https://img.shields.io/badge/license-MIT-blue.svg -[apache-2.0-license-page]: ./LICENSE-APACHE +[apache-2.0-license-page]: https://raw.githubusercontent.com/jzombie/rust-cache-manager/refs/heads/main/LICENSE-APACHE [apache-2.0-license-badge]: https://img.shields.io/badge/license-Apache%202.0-blue.svg [coveralls-page]: https://coveralls.io/github/jzombie/rust-cache-manager?branch=main diff --git a/src/lib.rs b/src/lib.rs index cc4c6b5..f6dd9c6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,9 +15,21 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use constants::{CACHE_DIR_NAME, CARGO_TOML_FILE_NAME}; +#[cfg(feature = "os-cache-dir")] +use directories::ProjectDirs; #[cfg(feature = "process-scoped-cache")] use tempfile::{Builder, TempDir}; +#[cfg(feature = "os-cache-dir")] +fn project_dirs_or_not_found(project_dirs: Option) -> io::Result { + project_dirs.ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "could not resolve an OS cache directory for the provided project identity", + ) + }) +} + /// Optional eviction controls applied by `CacheGroup::ensure_dir_with_policy` /// and `CacheRoot::ensure_group_with_policy`. /// @@ -103,6 +115,58 @@ impl CacheRoot { Self { root: root.into() } } + /// Create a `CacheRoot` from an OS-native per-user cache directory for + /// the given project identity. + /// + /// This API is available when the `os-cache-dir` feature is enabled and + /// uses [`directories::ProjectDirs`] internally. + /// + /// The returned path is OS-specific and typically resolves to: + /// - macOS: `~/Library/Caches/` + /// - Linux: `$XDG_CACHE_HOME/` or `~/.cache/` + /// - Windows: `%LOCALAPPDATA%\\\\\\cache` + /// + /// Parameters are passed directly to `ProjectDirs::from(qualifier, + /// organization, application)`. + /// + /// `qualifier` is a DNS-like namespace component used primarily on some + /// platforms (notably macOS) to form a unique app identity. Common values + /// include: + /// - `"com"` (for apps under a `com..` naming scheme) + /// - `"org"` (for apps under an `org..` naming scheme) + /// + /// Example: + /// `CacheRoot::from_project_dirs("com", "Acme", "WidgetTool")`. + #[cfg(feature = "os-cache-dir")] + pub fn from_project_dirs( + qualifier: &str, + organization: &str, + application: &str, + ) -> io::Result { + let project_dirs = + project_dirs_or_not_found(ProjectDirs::from(qualifier, organization, application))?; + + Ok(Self { + root: project_dirs.cache_dir().to_path_buf(), + }) + } + + /// Create a `CacheRoot` using a newly-created directory under the system + /// temporary directory. + /// + /// This API is available when the `process-scoped-cache` feature is + /// enabled (which provides the `tempfile` dependency). + /// + /// The directory is intentionally persisted and returned as the cache root, + /// so it is **not** automatically deleted when this function returns. + /// Callers who want cleanup should remove `root.path()` explicitly when + /// finished. + #[cfg(feature = "process-scoped-cache")] + pub fn from_tempdir() -> io::Result { + let root = TempDir::new()?.keep(); + Ok(Self { root }) + } + /// Return the underlying path for this `CacheRoot`. pub fn path(&self) -> &Path { &self.root @@ -652,6 +716,45 @@ mod tests { assert_eq!(resolved, absolute); } + #[cfg(feature = "os-cache-dir")] + #[test] + fn from_project_dirs_matches_directories_cache_dir() { + let qualifier = "com"; + let organization = "CacheManagerTests"; + let application = "CacheManagerOsCacheRoot"; + + let expected = ProjectDirs::from(qualifier, organization, application) + .expect("project dirs") + .cache_dir() + .to_path_buf(); + + let root = CacheRoot::from_project_dirs(qualifier, organization, application) + .expect("from project dirs"); + + assert_eq!(root.path(), expected.as_path()); + } + + #[cfg(feature = "os-cache-dir")] + #[test] + fn project_dirs_or_not_found_returns_not_found_for_none() { + let err = + project_dirs_or_not_found(None).expect_err("none project dirs should map to not found"); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + #[cfg(feature = "process-scoped-cache")] + #[test] + fn from_tempdir_creates_existing_writable_root() { + let root = CacheRoot::from_tempdir().expect("from tempdir"); + assert!(root.path().is_dir()); + + let probe_group = root.group("probe"); + let probe_file = probe_group.touch("writable.txt").expect("touch probe"); + assert!(probe_file.is_file()); + + fs::remove_dir_all(root.path()).expect("cleanup temp root"); + } + #[test] fn ensure_dir_with_policy_max_files() { let tmp = TempDir::new().expect("tempdir");