1use crate::NormalizedPath;
4use std::ffi::OsString;
5use std::path::Path;
6
7pub const CACHE_DIR_ENV: &str = "ZCCACHE_CACHE_DIR";
9
10#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
12#[serde(default)]
13pub struct Config {
14 pub cache_dir: NormalizedPath,
16 pub max_cache_size: u64,
18 pub idle_timeout_secs: u64,
20 pub enable_watcher: bool,
22 pub watcher_poll_fallback: bool,
24 pub log_level: String,
26 pub max_memory_bytes: u64,
28 pub eviction_interval_secs: u64,
30 pub disk_gc_interval_secs: u64,
32}
33
34impl Default for Config {
35 fn default() -> Self {
36 Self {
37 cache_dir: default_cache_dir(),
38 max_cache_size: 10 * 1024 * 1024 * 1024, idle_timeout_secs: 3600,
40 enable_watcher: true,
41 watcher_poll_fallback: false,
42 log_level: String::from("info"),
43 max_memory_bytes: 1_073_741_824, eviction_interval_secs: 30,
45 disk_gc_interval_secs: 300,
46 }
47 }
48}
49
50#[must_use]
57pub fn default_cache_dir() -> NormalizedPath {
58 default_cache_dir_from_env_value(std::env::var_os(CACHE_DIR_ENV))
59}
60
61fn default_cache_dir_from_env_value(value: Option<OsString>) -> NormalizedPath {
62 cache_dir_from_env_value(value).unwrap_or_else(|| dirs_fallback().join(".zccache"))
63}
64
65#[must_use]
67pub fn cache_dir_override() -> Option<NormalizedPath> {
68 cache_dir_from_env_value(std::env::var_os(CACHE_DIR_ENV))
69}
70
71#[must_use]
73pub fn artifacts_dir() -> NormalizedPath {
74 artifacts_dir_from_cache_dir(&default_cache_dir())
75}
76
77#[must_use]
79pub fn tmp_dir() -> NormalizedPath {
80 tmp_dir_from_cache_dir(&default_cache_dir())
81}
82
83#[must_use]
88pub fn depfile_dir() -> NormalizedPath {
89 depfile_dir_from_cache_dir(&default_cache_dir())
90}
91
92pub fn cleanup_legacy_temp_root_state<F>(
99 temp_root: &Path,
100 current_cache_dir: &Path,
101 is_alive: F,
102) -> usize
103where
104 F: Fn(u32) -> bool,
105{
106 let mut cleaned = cleanup_legacy_temp_cache_dir(temp_root, current_cache_dir);
107 cleaned += cleanup_legacy_temp_depfile_dirs(temp_root, is_alive);
108 cleaned
109}
110
111pub fn cleanup_stale_depfile_dirs<F>(is_alive: F) -> usize
117where
118 F: Fn(u32) -> bool,
119{
120 let base = depfile_dir();
121 let entries = match std::fs::read_dir(&base) {
122 Ok(entries) => entries,
123 Err(_) => return 0,
124 };
125
126 let mut cleaned = 0;
127 for entry in entries.flatten() {
128 let path = entry.path();
129 if !path.is_dir() {
130 continue;
131 }
132 let name = match path.file_name().and_then(|n| n.to_str()) {
133 Some(n) => n,
134 None => continue,
135 };
136 let pid: u32 = match name.split('-').next().and_then(|s| s.parse().ok()) {
137 Some(p) => p,
138 None => continue,
139 };
140 if !is_alive(pid) {
141 match std::fs::remove_dir_all(&path) {
142 Ok(()) => {
143 cleaned += 1;
144 tracing::info!(path = %path.display(), "removed stale depfile dir");
145 }
146 Err(e) => {
147 tracing::warn!(
148 path = %path.display(),
149 "failed to remove stale depfile dir: {e}"
150 );
151 }
152 }
153 }
154 }
155 cleaned
156}
157
158fn cleanup_legacy_temp_cache_dir(temp_root: &Path, current_cache_dir: &Path) -> usize {
159 let legacy_cache_dir = temp_root.join(".zccache");
160 if path_is_or_contains(&legacy_cache_dir, current_cache_dir) {
161 return 0;
162 }
163
164 if !is_real_dir(&legacy_cache_dir) {
165 return 0;
166 }
167
168 match std::fs::remove_dir_all(&legacy_cache_dir) {
169 Ok(()) => {
170 tracing::info!(path = %legacy_cache_dir.display(), "removed legacy temp cache dir");
171 1
172 }
173 Err(e) => {
174 tracing::warn!(
175 path = %legacy_cache_dir.display(),
176 "failed to remove legacy temp cache dir: {e}"
177 );
178 0
179 }
180 }
181}
182
183fn cleanup_legacy_temp_depfile_dirs<F>(temp_root: &Path, is_alive: F) -> usize
184where
185 F: Fn(u32) -> bool,
186{
187 let entries = match std::fs::read_dir(temp_root) {
188 Ok(entries) => entries,
189 Err(_) => return 0,
190 };
191
192 let mut cleaned = 0;
193 for entry in entries.flatten() {
194 let path = entry.path();
195 let file_name = entry.file_name();
196 let name = match file_name.to_str() {
197 Some(name) if name.starts_with("zccache-depfiles-") => name,
198 _ => continue,
199 };
200
201 if !is_real_dir(&path) {
202 continue;
203 }
204
205 let pid = match legacy_temp_depfile_pid(name) {
206 Some(pid) => pid,
207 None => continue,
208 };
209
210 if is_alive(pid) {
211 continue;
212 }
213
214 match std::fs::remove_dir_all(&path) {
215 Ok(()) => {
216 cleaned += 1;
217 tracing::info!(path = %path.display(), "removed legacy temp depfile dir");
218 }
219 Err(e) => {
220 tracing::warn!(
221 path = %path.display(),
222 "failed to remove legacy temp depfile dir: {e}"
223 );
224 }
225 }
226 }
227 cleaned
228}
229
230fn legacy_temp_depfile_pid(name: &str) -> Option<u32> {
231 let suffix = name.strip_prefix("zccache-depfiles-")?;
232 suffix.split('-').next()?.parse().ok()
233}
234
235fn is_real_dir(path: &Path) -> bool {
236 std::fs::symlink_metadata(path)
237 .map(|meta| meta.file_type().is_dir())
238 .unwrap_or(false)
239}
240
241fn path_is_or_contains(parent: &Path, child: &Path) -> bool {
242 if child.starts_with(parent) {
243 return true;
244 }
245
246 let parent = match std::fs::canonicalize(parent) {
247 Ok(parent) => parent,
248 Err(_) => return false,
249 };
250 std::fs::canonicalize(child)
251 .map(|child| child.starts_with(parent))
252 .unwrap_or(false)
253}
254
255#[must_use]
257pub fn depgraph_dir() -> NormalizedPath {
258 depgraph_dir_from_cache_dir(&default_cache_dir())
259}
260
261#[must_use]
263pub fn index_path() -> NormalizedPath {
264 index_path_from_cache_dir(&default_cache_dir())
265}
266
267#[must_use]
269pub fn crash_dump_dir() -> NormalizedPath {
270 crash_dump_dir_from_cache_dir(&default_cache_dir())
271}
272
273#[must_use]
275pub fn log_dir() -> NormalizedPath {
276 log_dir_from_cache_dir(&default_cache_dir())
277}
278
279fn artifacts_dir_from_cache_dir(cache_dir: &NormalizedPath) -> NormalizedPath {
280 cache_dir.join("artifacts")
281}
282
283fn tmp_dir_from_cache_dir(cache_dir: &NormalizedPath) -> NormalizedPath {
284 cache_dir.join("tmp")
285}
286
287fn depfile_dir_from_cache_dir(cache_dir: &NormalizedPath) -> NormalizedPath {
288 tmp_dir_from_cache_dir(cache_dir).join("depfiles")
289}
290
291fn depgraph_dir_from_cache_dir(cache_dir: &NormalizedPath) -> NormalizedPath {
292 cache_dir.join("depgraph")
293}
294
295fn index_path_from_cache_dir(cache_dir: &NormalizedPath) -> NormalizedPath {
296 cache_dir.join("index.redb")
297}
298
299fn crash_dump_dir_from_cache_dir(cache_dir: &NormalizedPath) -> NormalizedPath {
300 cache_dir.join("crashes")
301}
302
303fn log_dir_from_cache_dir(cache_dir: &NormalizedPath) -> NormalizedPath {
304 cache_dir.join("logs")
305}
306
307fn dirs_fallback() -> NormalizedPath {
308 std::env::var("HOME")
309 .or_else(|_| std::env::var("USERPROFILE"))
310 .map(NormalizedPath::from)
311 .unwrap_or_else(|_| ".".into())
312}
313
314fn cache_dir_from_env_value(value: Option<OsString>) -> Option<NormalizedPath> {
315 let value = value?;
316 if value.is_empty() {
317 return None;
318 }
319 Some(normalize_cache_dir_override(std::path::Path::new(&value)))
320}
321
322fn normalize_cache_dir_override(path: &std::path::Path) -> NormalizedPath {
323 if path.is_absolute() {
324 path.into()
325 } else {
326 std::env::current_dir()
327 .unwrap_or_default()
328 .join(path)
329 .into()
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn default_cache_dir_ends_with_zccache() {
339 let dir = default_cache_dir_from_env_value(None);
340 assert!(dir.ends_with(".zccache"));
341 }
342
343 #[test]
344 fn cache_dir_override_uses_non_empty_env_value() {
345 let root = tempfile::tempdir().unwrap();
346 let override_dir = root.path().join("zc");
347 let cache_dir =
348 default_cache_dir_from_env_value(Some(override_dir.clone().into_os_string()));
349
350 assert_eq!(cache_dir, override_dir);
351 assert_eq!(
352 artifacts_dir_from_cache_dir(&cache_dir),
353 override_dir.join("artifacts")
354 );
355 assert_eq!(tmp_dir_from_cache_dir(&cache_dir), override_dir.join("tmp"));
356 assert_eq!(
357 depgraph_dir_from_cache_dir(&cache_dir),
358 override_dir.join("depgraph")
359 );
360 assert_eq!(
361 index_path_from_cache_dir(&cache_dir),
362 override_dir.join("index.redb")
363 );
364 assert_eq!(
365 crash_dump_dir_from_cache_dir(&cache_dir),
366 override_dir.join("crashes")
367 );
368 assert_eq!(
369 log_dir_from_cache_dir(&cache_dir),
370 override_dir.join("logs")
371 );
372 }
373
374 #[test]
375 fn cache_dir_override_ignores_empty_env_value() {
376 assert!(cache_dir_from_env_value(Some(OsString::new())).is_none());
377 }
378
379 #[test]
380 fn relative_cache_dir_override_is_made_absolute() {
381 let override_dir = cache_dir_from_env_value(Some(OsString::from("target/../zc"))).unwrap();
382 assert!(override_dir.is_absolute());
383 assert!(override_dir.ends_with("zc"));
384 }
385
386 #[test]
387 fn crash_dump_dir_ends_with_crashes() {
388 let (_temp, cache) = temp_cache_dir();
389 let dir = crash_dump_dir_from_cache_dir(&cache);
390 assert!(dir.ends_with("crashes"));
391 }
392
393 #[test]
394 fn crash_dump_dir_is_under_cache_dir() {
395 let (_temp, cache) = temp_cache_dir();
396 let crashes = crash_dump_dir_from_cache_dir(&cache);
397 assert!(crashes.starts_with(&cache));
398 }
399
400 #[test]
401 fn log_dir_ends_with_logs() {
402 let (_temp, cache) = temp_cache_dir();
403 let dir = log_dir_from_cache_dir(&cache);
404 assert!(dir.ends_with("logs"));
405 }
406
407 #[test]
408 fn log_dir_is_under_cache_dir() {
409 let (_temp, cache) = temp_cache_dir();
410 let logs = log_dir_from_cache_dir(&cache);
411 assert!(logs.starts_with(&cache));
412 }
413
414 #[test]
415 fn artifacts_dir_ends_with_artifacts() {
416 let (_temp, cache) = temp_cache_dir();
417 let dir = artifacts_dir_from_cache_dir(&cache);
418 assert!(dir.ends_with("artifacts"));
419 assert!(dir.starts_with(cache));
420 }
421
422 #[test]
423 fn tmp_dir_ends_with_tmp() {
424 let (_temp, cache) = temp_cache_dir();
425 let dir = tmp_dir_from_cache_dir(&cache);
426 assert!(dir.ends_with("tmp"));
427 assert!(dir.starts_with(cache));
428 }
429
430 #[test]
431 fn depgraph_dir_ends_with_depgraph() {
432 let (_temp, cache) = temp_cache_dir();
433 let dir = depgraph_dir_from_cache_dir(&cache);
434 assert!(dir.ends_with("depgraph"));
435 assert!(dir.starts_with(cache));
436 }
437
438 #[test]
439 fn depfile_dir_under_tmp() {
440 let (_temp, cache) = temp_cache_dir();
441 let tmp = tmp_dir_from_cache_dir(&cache);
442 let dir = depfile_dir_from_cache_dir(&cache);
443 assert!(dir.ends_with("depfiles"));
444 assert!(dir.starts_with(tmp));
445 }
446
447 #[test]
448 fn cleanup_stale_depfile_dirs_removes_dead() {
449 let base = tempfile::tempdir().unwrap();
450 let depfiles = base.path().join("depfiles");
451 std::fs::create_dir_all(&depfiles).unwrap();
452
453 std::fs::create_dir(depfiles.join("99999999-0")).unwrap();
455 std::fs::create_dir(depfiles.join("not-a-pid")).unwrap();
457
458 let entries = std::fs::read_dir(&depfiles).unwrap();
459 let dirs: Vec<_> = entries.flatten().collect();
460 assert_eq!(dirs.len(), 2);
461
462 let cleaned = cleanup_stale_with_base(&depfiles, |_| false);
464 assert_eq!(cleaned, 1); assert!(depfiles.join("not-a-pid").is_dir());
468 assert!(!depfiles.join("99999999-0").exists());
469 }
470
471 #[test]
472 fn cleanup_stale_depfile_dirs_skips_alive() {
473 let base = tempfile::tempdir().unwrap();
474 let depfiles = base.path().join("depfiles");
475 std::fs::create_dir_all(&depfiles).unwrap();
476 std::fs::create_dir(depfiles.join("12345-0")).unwrap();
477
478 let cleaned = cleanup_stale_with_base(&depfiles, |_| true);
479 assert_eq!(cleaned, 0);
480 assert!(depfiles.join("12345-0").is_dir());
481 }
482
483 #[test]
484 fn cleanup_stale_depfile_dirs_empty() {
485 let cleaned = cleanup_stale_with_base(std::path::Path::new("/nonexistent/path"), |_| false);
487 assert_eq!(cleaned, 0);
488 }
489
490 #[test]
491 fn cleanup_legacy_temp_root_state_removes_legacy_dirs() {
492 let temp_root = tempfile::tempdir().unwrap();
493 let current_cache_dir = tempfile::tempdir().unwrap();
494
495 let legacy_cache = temp_root.path().join(".zccache");
496 std::fs::create_dir_all(&legacy_cache).unwrap();
497 std::fs::write(legacy_cache.join("sentinel"), "legacy").unwrap();
498
499 let dead_depfile = temp_root.path().join("zccache-depfiles-1234-0");
500 std::fs::create_dir_all(&dead_depfile).unwrap();
501 std::fs::write(dead_depfile.join("sentinel"), "dead").unwrap();
502
503 let live_depfile = temp_root.path().join("zccache-depfiles-4321-0");
504 std::fs::create_dir_all(&live_depfile).unwrap();
505
506 let unrelated = temp_root.path().join("not-legacy");
507 std::fs::create_dir_all(&unrelated).unwrap();
508
509 let cleaned =
510 cleanup_legacy_temp_root_state(temp_root.path(), current_cache_dir.path(), |pid| {
511 pid != 1234
512 });
513
514 assert_eq!(cleaned, 2);
515 assert!(!legacy_cache.exists());
516 assert!(!dead_depfile.exists());
517 assert!(live_depfile.exists());
518 assert!(unrelated.exists());
519 }
520
521 #[test]
522 fn cleanup_legacy_temp_root_state_skips_current_cache_dir() {
523 let temp_root = tempfile::tempdir().unwrap();
524 let current_cache_dir = temp_root.path().join(".zccache");
525 std::fs::create_dir_all(¤t_cache_dir).unwrap();
526 std::fs::write(current_cache_dir.join("sentinel"), "keep").unwrap();
527
528 let cleaned =
529 cleanup_legacy_temp_root_state(temp_root.path(), ¤t_cache_dir, |_| false);
530
531 assert_eq!(cleaned, 0);
532 assert!(current_cache_dir.exists());
533 assert_eq!(
534 std::fs::read_to_string(current_cache_dir.join("sentinel")).unwrap(),
535 "keep"
536 );
537 }
538
539 #[test]
540 fn cleanup_legacy_temp_root_state_skips_parent_of_current_cache_dir() {
541 let temp_root = tempfile::tempdir().unwrap();
542 let current_cache_dir = temp_root.path().join(".zccache").join("current");
543 std::fs::create_dir_all(¤t_cache_dir).unwrap();
544 std::fs::write(current_cache_dir.join("sentinel"), "keep").unwrap();
545
546 let cleaned =
547 cleanup_legacy_temp_root_state(temp_root.path(), ¤t_cache_dir, |_| false);
548
549 assert_eq!(cleaned, 0);
550 assert!(current_cache_dir.exists());
551 assert_eq!(
552 std::fs::read_to_string(current_cache_dir.join("sentinel")).unwrap(),
553 "keep"
554 );
555 }
556
557 fn cleanup_stale_with_base<F>(base: &std::path::Path, is_alive: F) -> usize
559 where
560 F: Fn(u32) -> bool,
561 {
562 let entries = match std::fs::read_dir(base) {
563 Ok(entries) => entries,
564 Err(_) => return 0,
565 };
566 let mut cleaned = 0;
567 for entry in entries.flatten() {
568 let path = entry.path();
569 if !path.is_dir() {
570 continue;
571 }
572 let name = match path.file_name().and_then(|n| n.to_str()) {
573 Some(n) => n,
574 None => continue,
575 };
576 let pid: u32 = match name.split('-').next().and_then(|s| s.parse().ok()) {
577 Some(p) => p,
578 None => continue,
579 };
580 if !is_alive(pid) && std::fs::remove_dir_all(&path).is_ok() {
581 cleaned += 1;
582 }
583 }
584 cleaned
585 }
586
587 #[test]
588 fn disk_gc_interval_default() {
589 let config = Config::default();
590 assert_eq!(config.disk_gc_interval_secs, 300);
591 }
592
593 #[test]
594 fn index_path_ends_with_redb() {
595 let (_temp, cache) = temp_cache_dir();
596 let p = index_path_from_cache_dir(&cache);
597 assert!(p.ends_with("index.redb"));
598 assert!(p.starts_with(cache));
599 }
600
601 fn temp_cache_dir() -> (tempfile::TempDir, NormalizedPath) {
602 let temp = tempfile::tempdir().unwrap();
603 let cache = NormalizedPath::from(temp.path());
604 (temp, cache)
605 }
606}