use super::namespace::home_dir_short_hash;
use super::{CACHE_DIR_ENV, COLOCATE_ENV, DAEMON_NAMESPACE_ENV, DAEMON_STATE_DIR_ENV};
use crate::core::NormalizedPath;
use std::ffi::OsString;
use std::path::Path;
#[must_use]
pub fn default_cache_dir() -> NormalizedPath {
default_cache_dir_from_env_value(std::env::var_os(CACHE_DIR_ENV))
}
#[must_use]
pub fn daemon_state_dir() -> NormalizedPath {
let cache_dir = default_cache_dir();
if let Some(value) = std::env::var_os(DAEMON_STATE_DIR_ENV).filter(|value| !value.is_empty()) {
return normalize_cache_dir_override(std::path::Path::new(&value));
}
daemon_state_dir_from_cache_dir(&cache_dir)
}
#[must_use]
pub fn daemon_state_dir_from_cache_dir(cache_dir: &NormalizedPath) -> NormalizedPath {
if let Some(value) = std::env::var_os(DAEMON_STATE_DIR_ENV).filter(|value| !value.is_empty()) {
return normalize_cache_dir_override(std::path::Path::new(&value));
}
daemon_state_dir_from_cache_dir_with_namespace(
cache_dir,
std::env::var_os(DAEMON_NAMESPACE_ENV).and_then(|value| value.into_string().ok()),
)
}
#[must_use]
pub fn daemon_state_dir_from_cache_dir_with_namespace(
cache_dir: &NormalizedPath,
namespace: Option<String>,
) -> NormalizedPath {
let Some(namespace) = namespace
.as_deref()
.and_then(super::namespace::sanitize_daemon_namespace)
else {
return cache_dir.clone();
};
if cache_dir
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == namespace)
&& cache_dir
.parent()
.and_then(|parent| parent.file_name())
.and_then(|name| name.to_str())
.is_some_and(|name| name == "daemon-state")
{
return cache_dir.clone();
}
cache_dir.join("daemon-state").join(namespace)
}
pub(super) fn default_cache_dir_from_env_value(value: Option<OsString>) -> NormalizedPath {
resolve_cache_root_from_env_value(value).0
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheRootSource {
Env,
Colocated,
Default,
}
impl CacheRootSource {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Env => "env:ZCCACHE_CACHE_DIR",
Self::Colocated => "colocate:cross_volume",
Self::Default => "default:platform_dirs",
}
}
}
impl std::fmt::Display for CacheRootSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[must_use]
pub fn resolve_cache_root() -> (NormalizedPath, CacheRootSource) {
resolve_cache_root_from_env_value(std::env::var_os(CACHE_DIR_ENV))
}
pub(super) fn resolve_cache_root_from_env_value(
value: Option<OsString>,
) -> (NormalizedPath, CacheRootSource) {
let (root, source) = resolve_cache_root_top_level_from_env_value(value);
(effective_cache_root_from_top_level(&root), source)
}
#[must_use]
pub fn resolve_cache_root_top_level() -> (NormalizedPath, CacheRootSource) {
resolve_cache_root_top_level_from_env_value(std::env::var_os(CACHE_DIR_ENV))
}
pub(super) fn resolve_cache_root_top_level_from_env_value(
value: Option<OsString>,
) -> (NormalizedPath, CacheRootSource) {
if let Some(p) = cache_dir_from_env_value(value) {
return (p, CacheRootSource::Env);
}
let home = dirs_fallback();
if colocate_enabled() {
if let Some(p) = colocated_cache_dir(&home) {
return (p, CacheRootSource::Colocated);
}
}
(home.join(".zccache"), CacheRootSource::Default)
}
#[must_use]
pub fn versioned_subdir() -> String {
format!("v{}", crate::core::VERSION)
}
#[must_use]
pub fn effective_cache_root_from_top_level(cache_root: &NormalizedPath) -> NormalizedPath {
let version = versioned_subdir();
if cache_root
.file_name()
.and_then(|segment| segment.to_str())
.is_some_and(|segment| segment == version)
{
return cache_root.clone();
}
cache_root.join(version)
}
pub const LAST_VERSION_MARKER: &str = "last-version.txt";
pub fn write_last_version_marker() -> std::io::Result<()> {
write_last_version_marker_in(resolve_cache_root_top_level().0.as_path())
}
pub fn write_last_version_marker_in(top_level: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(top_level)?;
std::fs::write(
top_level.join(LAST_VERSION_MARKER),
format!("{}\n", crate::core::VERSION),
)
}
#[must_use]
pub fn read_last_version_marker() -> Option<String> {
let (top, _) = resolve_cache_root_top_level();
std::fs::read_to_string(top.join(LAST_VERSION_MARKER).as_path())
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
#[must_use]
pub fn is_version_dir_name(name: &str) -> bool {
let Some(rest) = name.strip_prefix('v') else {
return false;
};
let parts: Vec<&str> = rest.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
}
#[derive(Debug, Default, Clone)]
pub struct PruneReport {
pub removed: Vec<String>,
pub skipped: Vec<String>,
}
pub fn prune_stale_version_dirs() -> PruneReport {
prune_stale_version_dirs_in(
resolve_cache_root_top_level().0.as_path(),
&versioned_subdir(),
)
}
pub fn prune_stale_version_dirs_in(top_level: &std::path::Path, keep: &str) -> PruneReport {
let mut report = PruneReport::default();
let entries = match std::fs::read_dir(top_level) {
Ok(e) => e,
Err(_) => return report,
};
for entry in entries.flatten() {
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if name == keep || !is_version_dir_name(&name) {
continue;
}
match std::fs::remove_dir_all(entry.path()) {
Ok(()) => report.removed.push(name),
Err(_) => report.skipped.push(name),
}
}
report
}
pub(super) fn colocate_enabled() -> bool {
std::env::var(COLOCATE_ENV)
.ok()
.is_some_and(|v| !v.is_empty() && v != "0")
}
fn colocated_cache_dir(home: &NormalizedPath) -> Option<NormalizedPath> {
let cwd = std::env::current_dir().ok()?;
let home_path: &Path = home.as_path();
let home_vol = volume_root(home_path)?;
let cwd_vol = volume_root(&cwd)?;
if same_volume_root(&home_vol, &cwd_vol) {
return None;
}
let basename = home_path
.file_name()
.and_then(|n| n.to_str())
.map(sanitize_path_component)
.unwrap_or_default();
let stem = if basename.is_empty() {
format!(".zccache-{}", home_dir_short_hash(home_path))
} else {
format!(".zccache-{}-{}", basename, home_dir_short_hash(home_path))
};
Some(NormalizedPath::from(cwd_vol.join(stem)))
}
pub(super) fn volume_root(path: &Path) -> Option<std::path::PathBuf> {
use std::path::Component;
let mut root = std::path::PathBuf::new();
let mut saw_anchor = false;
for c in path.components() {
match c {
Component::Prefix(p) => {
root.push(p.as_os_str());
saw_anchor = true;
}
Component::RootDir => {
root.push(c);
saw_anchor = true;
break;
}
_ => break,
}
}
if saw_anchor {
Some(root)
} else {
None
}
}
pub(super) fn same_volume_root(a: &Path, b: &Path) -> bool {
let a_str = a.to_string_lossy();
let b_str = b.to_string_lossy();
if cfg!(windows) {
a_str.eq_ignore_ascii_case(&b_str)
} else {
a_str == b_str
}
}
pub(super) fn sanitize_path_component(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.take(32)
.collect()
}
#[must_use]
pub fn cache_dir_override() -> Option<NormalizedPath> {
cache_dir_from_env_value(std::env::var_os(CACHE_DIR_ENV))
}
fn dirs_fallback() -> NormalizedPath {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map(NormalizedPath::from)
.unwrap_or_else(|_| ".".into())
}
pub(super) 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 version_hygiene_tests {
use super::*;
#[test]
fn is_version_dir_name_accepts_semver_dirs() {
assert!(is_version_dir_name("v1.12.15"));
assert!(is_version_dir_name("v0.0.0"));
assert!(is_version_dir_name("v10.200.3000"));
}
#[test]
fn is_version_dir_name_rejects_non_version_dirs() {
assert!(!is_version_dir_name("v1.12")); assert!(!is_version_dir_name("v1.12.15.1")); assert!(!is_version_dir_name("1.12.15")); assert!(!is_version_dir_name("vX.Y.Z")); assert!(!is_version_dir_name("v1.12.")); assert!(!is_version_dir_name("logs"));
assert!(!is_version_dir_name("runtime-binaries"));
assert!(!is_version_dir_name("v"));
}
#[test]
fn prune_removes_stale_siblings_keeps_current_and_non_version_dirs() {
let tmp = tempfile::tempdir().expect("tempdir");
let top = tmp.path();
for name in ["v1.12.13", "v1.12.14", "v1.12.15", "logs", "crashes"] {
std::fs::create_dir_all(top.join(name)).unwrap();
std::fs::write(top.join(name).join("marker"), b"x").unwrap();
}
let report = prune_stale_version_dirs_in(top, "v1.12.15");
let mut removed = report.removed.clone();
removed.sort();
assert_eq!(removed, vec!["v1.12.13", "v1.12.14"]);
assert!(report.skipped.is_empty());
assert!(top.join("v1.12.15").is_dir(), "current version kept");
assert!(top.join("logs").is_dir(), "non-version dir kept");
assert!(top.join("crashes").is_dir(), "non-version dir kept");
assert!(!top.join("v1.12.13").exists());
assert!(!top.join("v1.12.14").exists());
}
#[test]
fn write_last_version_marker_writes_current_version() {
let tmp = tempfile::tempdir().expect("tempdir");
let top = tmp.path().join("nested-top");
write_last_version_marker_in(&top).expect("marker write");
let contents = std::fs::read_to_string(top.join(LAST_VERSION_MARKER)).expect("read marker");
assert_eq!(contents.trim(), crate::core::VERSION);
}
#[test]
fn prune_is_noop_on_missing_top_level() {
let tmp = tempfile::tempdir().expect("tempdir");
let missing = tmp.path().join("does-not-exist");
let report = prune_stale_version_dirs_in(&missing, "v1.12.15");
assert!(report.removed.is_empty() && report.skipped.is_empty());
}
#[test]
fn daemon_state_namespace_is_stable_and_distinct() {
let root = NormalizedPath::from("/tmp/zccache-root");
let a = daemon_state_dir_from_cache_dir_with_namespace(&root, Some("broker-a".into()));
let b = daemon_state_dir_from_cache_dir_with_namespace(&root, Some("broker-b".into()));
assert_ne!(a, b);
assert_eq!(
a,
daemon_state_dir_from_cache_dir_with_namespace(&root, Some("broker-a".into()))
);
assert!(a.as_path().ends_with("daemon-state/broker-a"));
}
#[test]
fn daemon_state_default_preserves_legacy_layout() {
let root = NormalizedPath::from("/tmp/zccache-root");
assert_eq!(
daemon_state_dir_from_cache_dir_with_namespace(&root, None),
root
);
}
#[test]
fn daemon_state_namespace_path_is_idempotent() {
let root = NormalizedPath::from("/tmp/zccache-root");
let state = daemon_state_dir_from_cache_dir_with_namespace(&root, Some("broker-a".into()));
assert_eq!(
daemon_state_dir_from_cache_dir_with_namespace(&state, Some("broker-a".into())),
state
);
}
}