use crate::NormalizedPath;
use std::ffi::OsString;
pub const CACHE_DIR_ENV: &str = "ZCCACHE_CACHE_DIR";
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Config {
pub cache_dir: NormalizedPath,
pub max_cache_size: u64,
pub idle_timeout_secs: u64,
pub enable_watcher: bool,
pub watcher_poll_fallback: bool,
pub log_level: String,
pub max_memory_bytes: u64,
pub eviction_interval_secs: u64,
pub disk_gc_interval_secs: u64,
}
impl Default for Config {
fn default() -> Self {
Self {
cache_dir: default_cache_dir(),
max_cache_size: 10 * 1024 * 1024 * 1024, idle_timeout_secs: 3600,
enable_watcher: true,
watcher_poll_fallback: false,
log_level: String::from("info"),
max_memory_bytes: 1_073_741_824, eviction_interval_secs: 30,
disk_gc_interval_secs: 300,
}
}
}
#[must_use]
pub fn default_cache_dir() -> NormalizedPath {
if let Some(cache_dir) = cache_dir_override() {
return cache_dir;
}
dirs_fallback().join(".zccache")
}
#[must_use]
pub fn cache_dir_override() -> Option<NormalizedPath> {
cache_dir_from_env_value(std::env::var_os(CACHE_DIR_ENV))
}
#[must_use]
pub fn artifacts_dir() -> NormalizedPath {
default_cache_dir().join("artifacts")
}
#[must_use]
pub fn tmp_dir() -> NormalizedPath {
default_cache_dir().join("tmp")
}
#[must_use]
pub fn depfile_dir() -> NormalizedPath {
tmp_dir().join("depfiles")
}
pub fn cleanup_stale_depfile_dirs<F>(is_alive: F) -> usize
where
F: Fn(u32) -> bool,
{
let base = depfile_dir();
let entries = match std::fs::read_dir(&base) {
Ok(entries) => entries,
Err(_) => return 0,
};
let mut cleaned = 0;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
let pid: u32 = match name.split('-').next().and_then(|s| s.parse().ok()) {
Some(p) => p,
None => continue,
};
if !is_alive(pid) {
match std::fs::remove_dir_all(&path) {
Ok(()) => {
cleaned += 1;
tracing::info!(path = %path.display(), "removed stale depfile dir");
}
Err(e) => {
tracing::warn!(
path = %path.display(),
"failed to remove stale depfile dir: {e}"
);
}
}
}
}
cleaned
}
#[must_use]
pub fn depgraph_dir() -> NormalizedPath {
default_cache_dir().join("depgraph")
}
#[must_use]
pub fn index_path() -> NormalizedPath {
default_cache_dir().join("index.redb")
}
#[must_use]
pub fn crash_dump_dir() -> NormalizedPath {
default_cache_dir().join("crashes")
}
#[must_use]
pub fn log_dir() -> NormalizedPath {
default_cache_dir().join("logs")
}
fn dirs_fallback() -> NormalizedPath {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map(NormalizedPath::from)
.unwrap_or_else(|_| ".".into())
}
fn cache_dir_from_env_value(value: Option<OsString>) -> Option<NormalizedPath> {
let value = value?;
if value.is_empty() {
return None;
}
Some(normalize_cache_dir_override(std::path::Path::new(&value)))
}
fn normalize_cache_dir_override(path: &std::path::Path) -> NormalizedPath {
if path.is_absolute() {
path.into()
} else {
std::env::current_dir()
.unwrap_or_default()
.join(path)
.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
_lock: MutexGuard<'static, ()>,
previous: Option<OsString>,
}
impl EnvGuard {
fn set_cache_dir(value: &std::path::Path) -> Self {
let lock = ENV_LOCK.lock().unwrap();
let previous = std::env::var_os(CACHE_DIR_ENV);
std::env::set_var(CACHE_DIR_ENV, value);
Self {
_lock: lock,
previous,
}
}
fn remove_cache_dir() -> Self {
let lock = ENV_LOCK.lock().unwrap();
let previous = std::env::var_os(CACHE_DIR_ENV);
std::env::remove_var(CACHE_DIR_ENV);
Self {
_lock: lock,
previous,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.previous {
Some(value) => std::env::set_var(CACHE_DIR_ENV, value),
None => std::env::remove_var(CACHE_DIR_ENV),
}
}
}
#[test]
fn default_cache_dir_ends_with_zccache() {
let _env = EnvGuard::remove_cache_dir();
let dir = default_cache_dir();
assert!(dir.ends_with(".zccache"));
}
#[test]
fn cache_dir_override_uses_non_empty_env_value() {
let root = tempfile::tempdir().unwrap();
let override_dir = root.path().join("zc");
let _env = EnvGuard::set_cache_dir(&override_dir);
assert_eq!(default_cache_dir(), override_dir);
assert_eq!(artifacts_dir(), override_dir.join("artifacts"));
assert_eq!(tmp_dir(), override_dir.join("tmp"));
assert_eq!(depgraph_dir(), override_dir.join("depgraph"));
assert_eq!(index_path(), override_dir.join("index.redb"));
assert_eq!(crash_dump_dir(), override_dir.join("crashes"));
assert_eq!(log_dir(), override_dir.join("logs"));
}
#[test]
fn cache_dir_override_ignores_empty_env_value() {
assert!(cache_dir_from_env_value(Some(OsString::new())).is_none());
}
#[test]
fn relative_cache_dir_override_is_made_absolute() {
let override_dir = cache_dir_from_env_value(Some(OsString::from("target/../zc"))).unwrap();
assert!(override_dir.is_absolute());
assert!(override_dir.ends_with("zc"));
}
#[test]
fn crash_dump_dir_ends_with_crashes() {
let dir = crash_dump_dir();
assert!(dir.ends_with("crashes"));
}
#[test]
fn crash_dump_dir_is_under_cache_dir() {
let cache = default_cache_dir();
let crashes = crash_dump_dir();
assert!(crashes.starts_with(&cache));
}
#[test]
fn log_dir_ends_with_logs() {
let dir = log_dir();
assert!(dir.ends_with("logs"));
}
#[test]
fn log_dir_is_under_cache_dir() {
let cache = default_cache_dir();
let logs = log_dir();
assert!(logs.starts_with(&cache));
}
#[test]
fn artifacts_dir_ends_with_artifacts() {
let dir = artifacts_dir();
assert!(dir.ends_with("artifacts"));
assert!(dir.starts_with(default_cache_dir()));
}
#[test]
fn tmp_dir_ends_with_tmp() {
let dir = tmp_dir();
assert!(dir.ends_with("tmp"));
assert!(dir.starts_with(default_cache_dir()));
}
#[test]
fn depgraph_dir_ends_with_depgraph() {
let dir = depgraph_dir();
assert!(dir.ends_with("depgraph"));
assert!(dir.starts_with(default_cache_dir()));
}
#[test]
fn depfile_dir_under_tmp() {
let dir = depfile_dir();
assert!(dir.ends_with("depfiles"));
assert!(dir.starts_with(tmp_dir()));
}
#[test]
fn cleanup_stale_depfile_dirs_removes_dead() {
let base = tempfile::tempdir().unwrap();
let depfiles = base.path().join("depfiles");
std::fs::create_dir_all(&depfiles).unwrap();
std::fs::create_dir(depfiles.join("99999999-0")).unwrap();
std::fs::create_dir(depfiles.join("not-a-pid")).unwrap();
let entries = std::fs::read_dir(&depfiles).unwrap();
let dirs: Vec<_> = entries.flatten().collect();
assert_eq!(dirs.len(), 2);
let cleaned = cleanup_stale_with_base(&depfiles, |_| false);
assert_eq!(cleaned, 1);
assert!(depfiles.join("not-a-pid").is_dir());
assert!(!depfiles.join("99999999-0").exists());
}
#[test]
fn cleanup_stale_depfile_dirs_skips_alive() {
let base = tempfile::tempdir().unwrap();
let depfiles = base.path().join("depfiles");
std::fs::create_dir_all(&depfiles).unwrap();
std::fs::create_dir(depfiles.join("12345-0")).unwrap();
let cleaned = cleanup_stale_with_base(&depfiles, |_| true);
assert_eq!(cleaned, 0);
assert!(depfiles.join("12345-0").is_dir());
}
#[test]
fn cleanup_stale_depfile_dirs_empty() {
let cleaned = cleanup_stale_with_base(std::path::Path::new("/nonexistent/path"), |_| false);
assert_eq!(cleaned, 0);
}
fn cleanup_stale_with_base<F>(base: &std::path::Path, is_alive: F) -> usize
where
F: Fn(u32) -> bool,
{
let entries = match std::fs::read_dir(base) {
Ok(entries) => entries,
Err(_) => return 0,
};
let mut cleaned = 0;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
let pid: u32 = match name.split('-').next().and_then(|s| s.parse().ok()) {
Some(p) => p,
None => continue,
};
if !is_alive(pid) && std::fs::remove_dir_all(&path).is_ok() {
cleaned += 1;
}
}
cleaned
}
#[test]
fn disk_gc_interval_default() {
let config = Config::default();
assert_eq!(config.disk_gc_interval_secs, 300);
}
#[test]
fn index_path_ends_with_redb() {
let p = index_path();
assert!(p.ends_with("index.redb"));
assert!(p.starts_with(default_cache_dir()));
}
}