Skip to main content

zccache_core/
config.rs

1//! Configuration types for zccache.
2
3use crate::NormalizedPath;
4
5/// Top-level configuration for zccache.
6#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
7#[serde(default)]
8pub struct Config {
9    /// Path to the artifact cache directory.
10    pub cache_dir: NormalizedPath,
11    /// Maximum artifact cache size in bytes.
12    pub max_cache_size: u64,
13    /// Daemon idle timeout in seconds before auto-shutdown.
14    pub idle_timeout_secs: u64,
15    /// Whether to enable the file watcher.
16    pub enable_watcher: bool,
17    /// Whether to use polling fallback for file watching.
18    pub watcher_poll_fallback: bool,
19    /// Log level filter (e.g., "info", "debug", "trace").
20    pub log_level: String,
21    /// Maximum in-memory cache budget in bytes (default: 1 GB).
22    pub max_memory_bytes: u64,
23    /// How often (in seconds) the memory eviction background task runs (default: 30).
24    pub eviction_interval_secs: u64,
25    /// How often (in seconds) the disk artifact GC task runs (default: 300).
26    pub disk_gc_interval_secs: u64,
27}
28
29impl Default for Config {
30    fn default() -> Self {
31        Self {
32            cache_dir: default_cache_dir(),
33            max_cache_size: 10 * 1024 * 1024 * 1024, // 10 GB
34            idle_timeout_secs: 3600,
35            enable_watcher: true,
36            watcher_poll_fallback: false,
37            log_level: String::from("info"),
38            max_memory_bytes: 1_073_741_824, // 1 GB
39            eviction_interval_secs: 30,
40            disk_gc_interval_secs: 300,
41        }
42    }
43}
44
45/// Returns the default cache directory path: `~/.zccache` on all platforms.
46#[must_use]
47pub fn default_cache_dir() -> NormalizedPath {
48    dirs_fallback().join(".zccache")
49}
50
51/// Returns the directory for content-addressed compiled outputs.
52#[must_use]
53pub fn artifacts_dir() -> NormalizedPath {
54    default_cache_dir().join("artifacts")
55}
56
57/// Returns the directory for in-progress artifact writes (cleaned on startup).
58#[must_use]
59pub fn tmp_dir() -> NormalizedPath {
60    default_cache_dir().join("tmp")
61}
62
63/// Returns the base directory for compiler-injected depfiles.
64///
65/// Each daemon instance creates a `{pid}-{instance}` subdirectory here.
66/// Stale subdirectories from dead daemon processes are cleaned on startup.
67#[must_use]
68pub fn depfile_dir() -> NormalizedPath {
69    tmp_dir().join("depfiles")
70}
71
72/// Remove stale depfile directories from previous (dead) daemon instances.
73///
74/// Scans [`depfile_dir()`] for subdirectories matching `{pid}-{instance}`.
75/// If the PID is no longer alive (per `is_alive`), removes the directory.
76/// Returns the number of directories cleaned up.
77pub fn cleanup_stale_depfile_dirs<F>(is_alive: F) -> usize
78where
79    F: Fn(u32) -> bool,
80{
81    let base = depfile_dir();
82    let entries = match std::fs::read_dir(&base) {
83        Ok(entries) => entries,
84        Err(_) => return 0,
85    };
86
87    let mut cleaned = 0;
88    for entry in entries.flatten() {
89        let path = entry.path();
90        if !path.is_dir() {
91            continue;
92        }
93        let name = match path.file_name().and_then(|n| n.to_str()) {
94            Some(n) => n,
95            None => continue,
96        };
97        let pid: u32 = match name.split('-').next().and_then(|s| s.parse().ok()) {
98            Some(p) => p,
99            None => continue,
100        };
101        if !is_alive(pid) {
102            match std::fs::remove_dir_all(&path) {
103                Ok(()) => {
104                    cleaned += 1;
105                    tracing::info!(path = %path.display(), "removed stale depfile dir");
106                }
107                Err(e) => {
108                    tracing::warn!(
109                        path = %path.display(),
110                        "failed to remove stale depfile dir: {e}"
111                    );
112                }
113            }
114        }
115    }
116    cleaned
117}
118
119/// Returns the directory for serialized dependency graph storage (future).
120#[must_use]
121pub fn depgraph_dir() -> NormalizedPath {
122    default_cache_dir().join("depgraph")
123}
124
125/// Returns the path to the artifact index database.
126#[must_use]
127pub fn index_path() -> NormalizedPath {
128    default_cache_dir().join("index.redb")
129}
130
131/// Returns the directory for crash dump files.
132#[must_use]
133pub fn crash_dump_dir() -> NormalizedPath {
134    default_cache_dir().join("crashes")
135}
136
137/// Returns the directory for daemon log files.
138#[must_use]
139pub fn log_dir() -> NormalizedPath {
140    default_cache_dir().join("logs")
141}
142
143fn dirs_fallback() -> NormalizedPath {
144    std::env::var("HOME")
145        .or_else(|_| std::env::var("USERPROFILE"))
146        .map(NormalizedPath::from)
147        .unwrap_or_else(|_| ".".into())
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn default_cache_dir_ends_with_zccache() {
156        let dir = default_cache_dir();
157        assert!(dir.ends_with(".zccache"));
158    }
159
160    #[test]
161    fn crash_dump_dir_ends_with_crashes() {
162        let dir = crash_dump_dir();
163        assert!(dir.ends_with("crashes"));
164    }
165
166    #[test]
167    fn crash_dump_dir_is_under_cache_dir() {
168        let cache = default_cache_dir();
169        let crashes = crash_dump_dir();
170        assert!(crashes.starts_with(&cache));
171    }
172
173    #[test]
174    fn log_dir_ends_with_logs() {
175        let dir = log_dir();
176        assert!(dir.ends_with("logs"));
177    }
178
179    #[test]
180    fn log_dir_is_under_cache_dir() {
181        let cache = default_cache_dir();
182        let logs = log_dir();
183        assert!(logs.starts_with(&cache));
184    }
185
186    #[test]
187    fn artifacts_dir_ends_with_artifacts() {
188        let dir = artifacts_dir();
189        assert!(dir.ends_with("artifacts"));
190        assert!(dir.starts_with(default_cache_dir()));
191    }
192
193    #[test]
194    fn tmp_dir_ends_with_tmp() {
195        let dir = tmp_dir();
196        assert!(dir.ends_with("tmp"));
197        assert!(dir.starts_with(default_cache_dir()));
198    }
199
200    #[test]
201    fn depgraph_dir_ends_with_depgraph() {
202        let dir = depgraph_dir();
203        assert!(dir.ends_with("depgraph"));
204        assert!(dir.starts_with(default_cache_dir()));
205    }
206
207    #[test]
208    fn depfile_dir_under_tmp() {
209        let dir = depfile_dir();
210        assert!(dir.ends_with("depfiles"));
211        assert!(dir.starts_with(tmp_dir()));
212    }
213
214    #[test]
215    fn cleanup_stale_depfile_dirs_removes_dead() {
216        let base = tempfile::tempdir().unwrap();
217        let depfiles = base.path().join("depfiles");
218        std::fs::create_dir_all(&depfiles).unwrap();
219
220        // Create a "dead" dir (PID 99999999 unlikely alive).
221        std::fs::create_dir(depfiles.join("99999999-0")).unwrap();
222        // Create a non-matching dir (should be left alone).
223        std::fs::create_dir(depfiles.join("not-a-pid")).unwrap();
224
225        let entries = std::fs::read_dir(&depfiles).unwrap();
226        let dirs: Vec<_> = entries.flatten().collect();
227        assert_eq!(dirs.len(), 2);
228
229        // Use a custom is_alive that says nothing is alive.
230        let cleaned = cleanup_stale_with_base(&depfiles, |_| false);
231        assert_eq!(cleaned, 1); // only the parseable one removed
232
233        // "not-a-pid" should still exist.
234        assert!(depfiles.join("not-a-pid").is_dir());
235        assert!(!depfiles.join("99999999-0").exists());
236    }
237
238    #[test]
239    fn cleanup_stale_depfile_dirs_skips_alive() {
240        let base = tempfile::tempdir().unwrap();
241        let depfiles = base.path().join("depfiles");
242        std::fs::create_dir_all(&depfiles).unwrap();
243        std::fs::create_dir(depfiles.join("12345-0")).unwrap();
244
245        let cleaned = cleanup_stale_with_base(&depfiles, |_| true);
246        assert_eq!(cleaned, 0);
247        assert!(depfiles.join("12345-0").is_dir());
248    }
249
250    #[test]
251    fn cleanup_stale_depfile_dirs_empty() {
252        // Non-existent directory returns 0.
253        let cleaned = cleanup_stale_with_base(std::path::Path::new("/nonexistent/path"), |_| false);
254        assert_eq!(cleaned, 0);
255    }
256
257    /// Test helper: runs cleanup logic against an arbitrary base dir.
258    fn cleanup_stale_with_base<F>(base: &std::path::Path, is_alive: F) -> usize
259    where
260        F: Fn(u32) -> bool,
261    {
262        let entries = match std::fs::read_dir(base) {
263            Ok(entries) => entries,
264            Err(_) => return 0,
265        };
266        let mut cleaned = 0;
267        for entry in entries.flatten() {
268            let path = entry.path();
269            if !path.is_dir() {
270                continue;
271            }
272            let name = match path.file_name().and_then(|n| n.to_str()) {
273                Some(n) => n,
274                None => continue,
275            };
276            let pid: u32 = match name.split('-').next().and_then(|s| s.parse().ok()) {
277                Some(p) => p,
278                None => continue,
279            };
280            if !is_alive(pid) && std::fs::remove_dir_all(&path).is_ok() {
281                cleaned += 1;
282            }
283        }
284        cleaned
285    }
286
287    #[test]
288    fn disk_gc_interval_default() {
289        let config = Config::default();
290        assert_eq!(config.disk_gc_interval_secs, 300);
291    }
292
293    #[test]
294    fn index_path_ends_with_redb() {
295        let p = index_path();
296        assert!(p.ends_with("index.redb"));
297        assert!(p.starts_with(default_cache_dir()));
298    }
299}