cache-manager 0.4.0

Simple managed directory system for project-scoped caches with optional eviction policies.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
# cache-manager

[![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.

> 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 `<crate-root>/.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.

## Usage

### 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.

### Quick start

Using `touch` (convenient when you want this crate to create the file):

```rust
use cache_manager::{CacheGroup, CacheRoot};

let root: CacheRoot = CacheRoot::from_root("/tmp/project");
let group: CacheGroup = root.group("artifacts/json");

// 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
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);

// Example output path
println!("{}", entry.display());
```

Without `touch` (compute the path for a separate tool, then write with your own I/O):

```rust
use cache_manager::{CacheGroup, CacheRoot};
use std::fs;

let root: CacheRoot = CacheRoot::from_root("/tmp/project");
let group: CacheGroup = root.group("artifacts/json");

group.ensure_dir().expect("ensure group");

// This is the path you can hand to another tool/process
let entry_without_touch: std::path::PathBuf = group.entry_path("v1/index.bin");

let expected: std::path::PathBuf = root
	.path()
	.join("artifacts")
	.join("json")
	.join("v1")
	.join("index.bin");
assert_eq!(entry_without_touch, expected);

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());
```

### Filesystem effects

- **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`

- **Feature `os-cache-dir`:**
	- `CacheRoot::from_project_dirs`

- **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.

### Discovering cache paths

Discover a cache path for the current crate/workspace and resolve an entry path.

> 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 `<crate-root>/.cache` when found; otherwise it falls back to `<cwd>/.cache`.
- The discovered anchor (`crate root` or `cwd`) is canonicalized when possible to avoid surprising
  differences between logically-equal paths.
- If the `relative_path` argument is absolute, it is returned unchanged.

```rust
use cache_manager::CacheRoot;
use std::path::Path;

// Compute a path like <crate-root>/.cache/tool/data.bin without creating it
let cache_path: std::path::PathBuf = 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
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()
	.expect("discover cache root")
	.cache_path("tool", &absolute);
assert_eq!(kept, absolute);
```

**Notes on discovery behavior**

`CacheRoot::from_discovery()` deterministically anchors discovered cache
paths under the configured `CACHE_DIR_NAME` (default: `.cache`). It does
not scan for arbitrary directory names — creating a directory named
`.cache-v2` at the crate root will not cause `from_discovery()` to use it.
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/<app>`
- Linux: `$XDG_CACHE_HOME/<app>` or `~/.cache/<app>`
- Windows: `%LOCALAPPDATA%\\<org>\\<app>\\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

Use `EvictPolicy` with:

- `CacheGroup::ensure_dir_with_policy(...)`
- `CacheRoot::ensure_group_with_policy(...)`
- `CacheGroup::eviction_report(...)` to preview which files would be evicted.

Apply policy directly to a `CacheGroup`:

```rust
use cache_manager::{CacheRoot, EvictPolicy};

let root: CacheRoot = CacheRoot::from_root("/tmp/project");
let group: cache_manager::CacheGroup = root.group("artifacts");

let policy: EvictPolicy = EvictPolicy {
	max_files: Some(100),
	..Default::default()
};

group
	.ensure_dir_with_policy(Some(&policy))
	.expect("ensure and evict");
```

Apply policy through `CacheRoot` convenience API:

```rust
use cache_manager::{CacheRoot, EvictPolicy};
use std::time::Duration;

let root: CacheRoot = CacheRoot::from_root("/tmp/project");
let policy: EvictPolicy = EvictPolicy {
	max_age: Some(Duration::from_secs(60 * 60 * 24 * 30)), // 30 days
	..Default::default()
};

root
	.ensure_group_with_policy("artifacts", Some(&policy))
	.expect("ensure group and evict");
```

Preview evictions without deleting files:

```rust
use cache_manager::{CacheRoot, EvictPolicy, EvictionReport};

let root: CacheRoot = CacheRoot::from_root("/tmp/project");
let group: cache_manager::CacheGroup = root.group("artifacts");
let policy: EvictPolicy = EvictPolicy {
	max_bytes: Some(10_000_000),
	..Default::default()
};

let report: EvictionReport = group.eviction_report(&policy).expect("eviction report");
for path in report.marked_for_eviction {
	println!("would remove: {}", path.display());
}
```

Policy fields:

- `max_age`: remove files older than or equal to the age threshold.
- `max_files`: keep at most N files.
- `max_bytes`: keep total file bytes at or below the threshold.

Policies can be combined by setting multiple fields in one `EvictPolicy`.
When combined, all configured limits are enforced in order.

```rust
use cache_manager::EvictPolicy;
use std::time::Duration;

let combined: EvictPolicy = EvictPolicy {
	max_age: Some(Duration::from_secs(60 * 60 * 24 * 30)), // 30 days
	max_files: Some(200),
	max_bytes: Some(500 * 1024 * 1024), // 500 MB
};
```

Eviction order is always:

1. `max_age`
2. `max_files`
3. `max_bytes`

For `max_files` and `max_bytes`, files are evicted oldest-first by modified time (ascending), then by path for deterministic tie-breaking.

`eviction_report(...)` and `ensure_*_with_policy(...)` use the same selection logic.

#### How `max_bytes` works

- Scans regular files recursively under the managed directory.
- Sums `metadata.len()` across those files.
- If total exceeds `max_bytes`, removes files oldest-first until total is `<= max_bytes`.
- 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 = "<latest>", features = ["process-scoped-cache"] }
```

#### 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")]
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");
	let base_group: CacheGroup = root.group("artifacts/session");

	// 2) Create a process-scoped directory (name starts with `pid-<pid>-...`)
	let scoped: ProcessScopedCacheGroup = 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: 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()));
	assert!(entry.ends_with(Path::new("v1/index.bin")));

	// 5) Verify the dynamic thread segment (`thread-<n>`)
	let thread_dir: &Path = 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-<current-pid>-<random>`)
	let process_dir: &Path = thread_dir.parent().expect("process dir");
	let expected_pid_prefix: String = 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() {}
```

#### 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`.
- 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)`.

#### Get the root path

To obtain the underlying filesystem path for a `CacheRoot`, use `path()`:

```rust
use cache_manager::CacheRoot;

let root: CacheRoot = CacheRoot::from_root("/tmp/project");
let root_path: &std::path::Path = root.path();
println!("root path: {}", root_path.display());
```

Also obtain a `CacheGroup` path and resolve an entry path under that group:

```rust
use cache_manager::{CacheGroup, CacheRoot};

let root: CacheRoot = CacheRoot::from_root("/tmp/project");
let group: CacheGroup = root.group("artifacts");

let group_path: &std::path::Path = group.path();
println!("group path: {}", group_path.display());

let entry_path: std::path::PathBuf = group.entry_path("v1/index.bin");
println!("entry path: {}", entry_path.display());
```

## License

`cache-manager` is primarily distributed under the terms of both the MIT license and the Apache License (Version 2.0).

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

[crates-page]: https://crates.io/crates/cache-manager
[crates-badge]: https://img.shields.io/crates/v/cache-manager.svg

[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]: 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
[coveralls-badge]: https://img.shields.io/coveralls/github/jzombie/rust-cache-manager