use crate::NormalizedPath;
use std::ffi::OsString;
use std::path::Path;
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 {
default_cache_dir_from_env_value(std::env::var_os(CACHE_DIR_ENV))
}
fn default_cache_dir_from_env_value(value: Option<OsString>) -> NormalizedPath {
cache_dir_from_env_value(value).unwrap_or_else(|| 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 {
artifacts_dir_from_cache_dir(&default_cache_dir())
}
#[must_use]
pub fn tmp_dir() -> NormalizedPath {
tmp_dir_from_cache_dir(&default_cache_dir())
}
#[must_use]
pub fn depfile_dir() -> NormalizedPath {
depfile_dir_from_cache_dir(&default_cache_dir())
}
pub fn cleanup_legacy_temp_root_state<F>(
temp_root: &Path,
current_cache_dir: &Path,
is_alive: F,
) -> usize
where
F: Fn(u32) -> bool,
{
let mut cleaned = cleanup_legacy_temp_cache_dir(temp_root, current_cache_dir);
cleaned += cleanup_legacy_temp_depfile_dirs(temp_root, is_alive);
cleaned
}
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
}
fn cleanup_legacy_temp_cache_dir(temp_root: &Path, current_cache_dir: &Path) -> usize {
let legacy_cache_dir = temp_root.join(".zccache");
if path_is_or_contains(&legacy_cache_dir, current_cache_dir) {
return 0;
}
if !is_real_dir(&legacy_cache_dir) {
return 0;
}
match std::fs::remove_dir_all(&legacy_cache_dir) {
Ok(()) => {
tracing::info!(path = %legacy_cache_dir.display(), "removed legacy temp cache dir");
1
}
Err(e) => {
tracing::warn!(
path = %legacy_cache_dir.display(),
"failed to remove legacy temp cache dir: {e}"
);
0
}
}
}
fn cleanup_legacy_temp_depfile_dirs<F>(temp_root: &Path, is_alive: F) -> usize
where
F: Fn(u32) -> bool,
{
let entries = match std::fs::read_dir(temp_root) {
Ok(entries) => entries,
Err(_) => return 0,
};
let mut cleaned = 0;
for entry in entries.flatten() {
let path = entry.path();
let file_name = entry.file_name();
let name = match file_name.to_str() {
Some(name) if name.starts_with("zccache-depfiles-") => name,
_ => continue,
};
if !is_real_dir(&path) {
continue;
}
let pid = match legacy_temp_depfile_pid(name) {
Some(pid) => pid,
None => continue,
};
if is_alive(pid) {
continue;
}
match std::fs::remove_dir_all(&path) {
Ok(()) => {
cleaned += 1;
tracing::info!(path = %path.display(), "removed legacy temp depfile dir");
}
Err(e) => {
tracing::warn!(
path = %path.display(),
"failed to remove legacy temp depfile dir: {e}"
);
}
}
}
cleaned
}
fn legacy_temp_depfile_pid(name: &str) -> Option<u32> {
let suffix = name.strip_prefix("zccache-depfiles-")?;
suffix.split('-').next()?.parse().ok()
}
fn is_real_dir(path: &Path) -> bool {
std::fs::symlink_metadata(path)
.map(|meta| meta.file_type().is_dir())
.unwrap_or(false)
}
fn path_is_or_contains(parent: &Path, child: &Path) -> bool {
if child.starts_with(parent) {
return true;
}
let parent = match std::fs::canonicalize(parent) {
Ok(parent) => parent,
Err(_) => return false,
};
std::fs::canonicalize(child)
.map(|child| child.starts_with(parent))
.unwrap_or(false)
}
#[must_use]
pub fn depgraph_dir() -> NormalizedPath {
depgraph_dir_from_cache_dir(&default_cache_dir())
}
#[must_use]
pub fn index_path() -> NormalizedPath {
index_path_from_cache_dir(&default_cache_dir())
}
#[must_use]
pub fn crash_dump_dir() -> NormalizedPath {
crash_dump_dir_from_cache_dir(&default_cache_dir())
}
#[must_use]
pub fn log_dir() -> NormalizedPath {
log_dir_from_cache_dir(&default_cache_dir())
}
fn artifacts_dir_from_cache_dir(cache_dir: &NormalizedPath) -> NormalizedPath {
cache_dir.join("artifacts")
}
fn tmp_dir_from_cache_dir(cache_dir: &NormalizedPath) -> NormalizedPath {
cache_dir.join("tmp")
}
fn depfile_dir_from_cache_dir(cache_dir: &NormalizedPath) -> NormalizedPath {
tmp_dir_from_cache_dir(cache_dir).join("depfiles")
}
fn depgraph_dir_from_cache_dir(cache_dir: &NormalizedPath) -> NormalizedPath {
cache_dir.join("depgraph")
}
fn index_path_from_cache_dir(cache_dir: &NormalizedPath) -> NormalizedPath {
cache_dir.join("index.redb")
}
fn crash_dump_dir_from_cache_dir(cache_dir: &NormalizedPath) -> NormalizedPath {
cache_dir.join("crashes")
}
fn log_dir_from_cache_dir(cache_dir: &NormalizedPath) -> NormalizedPath {
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::*;
#[test]
fn default_cache_dir_ends_with_zccache() {
let dir = default_cache_dir_from_env_value(None);
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 cache_dir =
default_cache_dir_from_env_value(Some(override_dir.clone().into_os_string()));
assert_eq!(cache_dir, override_dir);
assert_eq!(
artifacts_dir_from_cache_dir(&cache_dir),
override_dir.join("artifacts")
);
assert_eq!(tmp_dir_from_cache_dir(&cache_dir), override_dir.join("tmp"));
assert_eq!(
depgraph_dir_from_cache_dir(&cache_dir),
override_dir.join("depgraph")
);
assert_eq!(
index_path_from_cache_dir(&cache_dir),
override_dir.join("index.redb")
);
assert_eq!(
crash_dump_dir_from_cache_dir(&cache_dir),
override_dir.join("crashes")
);
assert_eq!(
log_dir_from_cache_dir(&cache_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 (_temp, cache) = temp_cache_dir();
let dir = crash_dump_dir_from_cache_dir(&cache);
assert!(dir.ends_with("crashes"));
}
#[test]
fn crash_dump_dir_is_under_cache_dir() {
let (_temp, cache) = temp_cache_dir();
let crashes = crash_dump_dir_from_cache_dir(&cache);
assert!(crashes.starts_with(&cache));
}
#[test]
fn log_dir_ends_with_logs() {
let (_temp, cache) = temp_cache_dir();
let dir = log_dir_from_cache_dir(&cache);
assert!(dir.ends_with("logs"));
}
#[test]
fn log_dir_is_under_cache_dir() {
let (_temp, cache) = temp_cache_dir();
let logs = log_dir_from_cache_dir(&cache);
assert!(logs.starts_with(&cache));
}
#[test]
fn artifacts_dir_ends_with_artifacts() {
let (_temp, cache) = temp_cache_dir();
let dir = artifacts_dir_from_cache_dir(&cache);
assert!(dir.ends_with("artifacts"));
assert!(dir.starts_with(cache));
}
#[test]
fn tmp_dir_ends_with_tmp() {
let (_temp, cache) = temp_cache_dir();
let dir = tmp_dir_from_cache_dir(&cache);
assert!(dir.ends_with("tmp"));
assert!(dir.starts_with(cache));
}
#[test]
fn depgraph_dir_ends_with_depgraph() {
let (_temp, cache) = temp_cache_dir();
let dir = depgraph_dir_from_cache_dir(&cache);
assert!(dir.ends_with("depgraph"));
assert!(dir.starts_with(cache));
}
#[test]
fn depfile_dir_under_tmp() {
let (_temp, cache) = temp_cache_dir();
let tmp = tmp_dir_from_cache_dir(&cache);
let dir = depfile_dir_from_cache_dir(&cache);
assert!(dir.ends_with("depfiles"));
assert!(dir.starts_with(tmp));
}
#[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);
}
#[test]
fn cleanup_legacy_temp_root_state_removes_legacy_dirs() {
let temp_root = tempfile::tempdir().unwrap();
let current_cache_dir = tempfile::tempdir().unwrap();
let legacy_cache = temp_root.path().join(".zccache");
std::fs::create_dir_all(&legacy_cache).unwrap();
std::fs::write(legacy_cache.join("sentinel"), "legacy").unwrap();
let dead_depfile = temp_root.path().join("zccache-depfiles-1234-0");
std::fs::create_dir_all(&dead_depfile).unwrap();
std::fs::write(dead_depfile.join("sentinel"), "dead").unwrap();
let live_depfile = temp_root.path().join("zccache-depfiles-4321-0");
std::fs::create_dir_all(&live_depfile).unwrap();
let unrelated = temp_root.path().join("not-legacy");
std::fs::create_dir_all(&unrelated).unwrap();
let cleaned =
cleanup_legacy_temp_root_state(temp_root.path(), current_cache_dir.path(), |pid| {
pid != 1234
});
assert_eq!(cleaned, 2);
assert!(!legacy_cache.exists());
assert!(!dead_depfile.exists());
assert!(live_depfile.exists());
assert!(unrelated.exists());
}
#[test]
fn cleanup_legacy_temp_root_state_skips_current_cache_dir() {
let temp_root = tempfile::tempdir().unwrap();
let current_cache_dir = temp_root.path().join(".zccache");
std::fs::create_dir_all(¤t_cache_dir).unwrap();
std::fs::write(current_cache_dir.join("sentinel"), "keep").unwrap();
let cleaned =
cleanup_legacy_temp_root_state(temp_root.path(), ¤t_cache_dir, |_| false);
assert_eq!(cleaned, 0);
assert!(current_cache_dir.exists());
assert_eq!(
std::fs::read_to_string(current_cache_dir.join("sentinel")).unwrap(),
"keep"
);
}
#[test]
fn cleanup_legacy_temp_root_state_skips_parent_of_current_cache_dir() {
let temp_root = tempfile::tempdir().unwrap();
let current_cache_dir = temp_root.path().join(".zccache").join("current");
std::fs::create_dir_all(¤t_cache_dir).unwrap();
std::fs::write(current_cache_dir.join("sentinel"), "keep").unwrap();
let cleaned =
cleanup_legacy_temp_root_state(temp_root.path(), ¤t_cache_dir, |_| false);
assert_eq!(cleaned, 0);
assert!(current_cache_dir.exists());
assert_eq!(
std::fs::read_to_string(current_cache_dir.join("sentinel")).unwrap(),
"keep"
);
}
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 (_temp, cache) = temp_cache_dir();
let p = index_path_from_cache_dir(&cache);
assert!(p.ends_with("index.redb"));
assert!(p.starts_with(cache));
}
fn temp_cache_dir() -> (tempfile::TempDir, NormalizedPath) {
let temp = tempfile::tempdir().unwrap();
let cache = NormalizedPath::from(temp.path());
(temp, cache)
}
}