Skip to main content

zccache_core/
config.rs

1//! Configuration types for zccache.
2
3use crate::NormalizedPath;
4use std::ffi::OsString;
5use std::path::Path;
6
7/// Environment variable used to override the zccache cache root.
8pub const CACHE_DIR_ENV: &str = "ZCCACHE_CACHE_DIR";
9
10/// Top-level configuration for zccache.
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
12#[serde(default)]
13pub struct Config {
14    /// Path to the artifact cache directory.
15    pub cache_dir: NormalizedPath,
16    /// Maximum artifact cache size in bytes.
17    pub max_cache_size: u64,
18    /// Daemon idle timeout in seconds before auto-shutdown.
19    pub idle_timeout_secs: u64,
20    /// Whether to enable the file watcher.
21    pub enable_watcher: bool,
22    /// Whether to use polling fallback for file watching.
23    pub watcher_poll_fallback: bool,
24    /// Log level filter (e.g., "info", "debug", "trace").
25    pub log_level: String,
26    /// Maximum in-memory cache budget in bytes (default: 1 GB).
27    pub max_memory_bytes: u64,
28    /// How often (in seconds) the memory eviction background task runs (default: 30).
29    pub eviction_interval_secs: u64,
30    /// How often (in seconds) the disk artifact GC task runs (default: 300).
31    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, // 10 GB
39            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, // 1 GB
44            eviction_interval_secs: 30,
45            disk_gc_interval_secs: 300,
46        }
47    }
48}
49
50/// Returns the configured cache directory path.
51///
52/// If `ZCCACHE_CACHE_DIR` is set and non-empty, it is used as the cache root.
53/// Relative override paths are made absolute against the current working
54/// directory so the daemon and CLI derive the same subpaths when spawned
55/// together. If unset, this falls back to `~/.zccache` on all platforms.
56#[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/// Returns the cache directory override from `ZCCACHE_CACHE_DIR`, if set.
66#[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/// Returns the directory for content-addressed compiled outputs.
72#[must_use]
73pub fn artifacts_dir() -> NormalizedPath {
74    artifacts_dir_from_cache_dir(&default_cache_dir())
75}
76
77/// Returns the directory for in-progress artifact writes (cleaned on startup).
78#[must_use]
79pub fn tmp_dir() -> NormalizedPath {
80    tmp_dir_from_cache_dir(&default_cache_dir())
81}
82
83/// Returns the base directory for compiler-injected depfiles.
84///
85/// Each daemon instance creates a `{pid}-{instance}` subdirectory here.
86/// Stale subdirectories from dead daemon processes are cleaned on startup.
87#[must_use]
88pub fn depfile_dir() -> NormalizedPath {
89    depfile_dir_from_cache_dir(&default_cache_dir())
90}
91
92/// Remove legacy zccache state left directly under the OS temp directory.
93///
94/// Older builds stored the full cache root at `%TEMP%/.zccache` and created
95/// depfile directories as `%TEMP%/zccache-depfiles-*`. Those paths are safe to
96/// remove only when they are exact legacy matches and do not point at the
97/// current cache directory.
98pub 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
111/// Remove stale depfile directories from previous (dead) daemon instances.
112///
113/// Scans [`depfile_dir()`] for subdirectories matching `{pid}-{instance}`.
114/// If the PID is no longer alive (per `is_alive`), removes the directory.
115/// Returns the number of directories cleaned up.
116pub 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/// Returns the directory for serialized dependency graph storage (future).
256#[must_use]
257pub fn depgraph_dir() -> NormalizedPath {
258    depgraph_dir_from_cache_dir(&default_cache_dir())
259}
260
261/// Returns the path to the artifact index database.
262#[must_use]
263pub fn index_path() -> NormalizedPath {
264    index_path_from_cache_dir(&default_cache_dir())
265}
266
267/// Returns the directory for crash dump files.
268#[must_use]
269pub fn crash_dump_dir() -> NormalizedPath {
270    crash_dump_dir_from_cache_dir(&default_cache_dir())
271}
272
273/// Returns the directory for daemon log files.
274#[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        // Create a "dead" dir (PID 99999999 unlikely alive).
454        std::fs::create_dir(depfiles.join("99999999-0")).unwrap();
455        // Create a non-matching dir (should be left alone).
456        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        // Use a custom is_alive that says nothing is alive.
463        let cleaned = cleanup_stale_with_base(&depfiles, |_| false);
464        assert_eq!(cleaned, 1); // only the parseable one removed
465
466        // "not-a-pid" should still exist.
467        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        // Non-existent directory returns 0.
486        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(&current_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(), &current_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(&current_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(), &current_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    /// Test helper: runs cleanup logic against an arbitrary base dir.
558    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}