use anyhow::{Context, Result};
use bytesize::ByteSize;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const DEFAULT_DAEMON_IDLE_TIMEOUT_SECS: u64 = 10 * 60;
pub const DEFAULT_HEARTBEAT_SECS: u64 = 30;
pub const DEFAULT_PLANNER_TIMEOUT_MS: u64 = 750;
pub const DEFAULT_S3_POOL_IDLE_SECS: u64 = 300;
pub const DEFAULT_PREFETCH_ENABLED: bool = true;
pub const DEFAULT_REMOTE_KEY_CACHE_REFRESH_SECS: u64 = 60;
pub const DEFAULT_PREFETCH_MAX_KEYS: u64 = 2000;
pub const DEFAULT_PREFETCH_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024;
pub const DEFAULT_PREFETCH_DEADLINE_SECS: u64 = 300;
#[derive(Debug, Clone)]
pub struct Config {
pub cache_dir: PathBuf,
pub max_size: u64,
pub remote: Option<RemoteConfig>,
pub remote_error: Option<String>,
pub disabled: bool,
pub cache_executables: bool,
pub clean_incremental: bool,
pub event_log_max_size: u64,
pub event_log_keep_lines: usize,
pub compression_level: i32,
pub s3_concurrency: u32,
pub prefetch_enabled: bool,
pub remote_key_cache_refresh_secs: u64,
pub prefetch_max_keys: u64,
pub prefetch_max_bytes: u64,
pub prefetch_deadline_secs: u64,
pub daemon_idle_timeout_secs: u64,
pub s3_pool_idle_secs: u64,
pub fallback: Option<String>,
pub key_salt: Option<String>,
pub path_only_env_vars: Vec<String>,
pub key_env_vars: Vec<String>,
pub base_dirs: Vec<String>,
pub cc_extra_allowlist_flags: Vec<String>,
pub local_only: bool,
pub remote_readonly: bool,
pub modified_input_guard: bool,
pub local_hit_daemon: bool,
pub windows_hardlink: bool,
pub auto_gc: bool,
pub storage_layout_advice: bool,
pub heartbeat_secs: u64,
pub explain_miss: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlannerConfig {
pub endpoint: String,
pub timeout_ms: u64,
pub token: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteConfig {
pub prefix: String,
pub backend: RemoteBackendConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoteBackendConfig {
S3(S3RemoteConfig),
Filesystem(FilesystemRemoteConfig),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct S3RemoteConfig {
pub bucket: String,
pub endpoint: Option<String>,
pub region: String,
pub profile: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FilesystemRemoteConfig {
pub root: PathBuf,
pub atomic_write_dir: PathBuf,
}
impl Config {
pub fn require_remote(&self) -> Result<&RemoteConfig> {
if let Some(remote) = &self.remote {
return Ok(remote);
}
if let Some(reason) = &self.remote_error {
anyhow::bail!("remote cache configuration is unusable: {reason}");
}
if self.local_only {
anyhow::bail!("local-only mode is enabled, so no remote cache is available");
}
anyhow::bail!("No remote configured. Run `kache config` to set one up.")
}
}
impl RemoteConfig {
pub fn backend_kind(&self) -> &'static str {
match &self.backend {
RemoteBackendConfig::S3(_) => "s3",
RemoteBackendConfig::Filesystem(_) => "filesystem",
}
}
pub fn describe(&self) -> String {
let base = match &self.backend {
RemoteBackendConfig::S3(s3) => format!("s3://{}", s3.bucket),
RemoteBackendConfig::Filesystem(fs) => format!("file://{}", fs.root.display()),
};
if self.prefix.is_empty() {
base
} else {
format!("{base}/{}", self.prefix)
}
}
#[cfg(test)]
pub(crate) fn test_s3(bucket: &str, prefix: &str) -> Self {
Self {
prefix: prefix.to_string(),
backend: RemoteBackendConfig::S3(S3RemoteConfig {
bucket: bucket.to_string(),
endpoint: None,
region: "us-east-1".to_string(),
profile: None,
}),
}
}
}
pub(crate) const V3_OBJECT_ROOT: &str = "v3";
pub(crate) fn normalize_remote_prefix(prefix: &str) -> Result<String> {
let trimmed = prefix.trim();
if trimmed.contains('\\') {
anyhow::bail!("remote prefix must not contain backslashes: {prefix:?}");
}
let mut segments = Vec::new();
for segment in trimmed.split('/') {
if segment.is_empty() {
continue;
}
if segment == "." || segment == ".." {
anyhow::bail!("remote prefix must not contain '.' or '..' path segments: {prefix:?}");
}
segments.push(segment);
}
Ok(segments.join("/"))
}
fn filesystem_staging_problem(
root: &std::path::Path,
atomic_write_dir: &std::path::Path,
prefix: &str,
) -> Option<String> {
let object_tree = join_remote_key(prefix, V3_OBJECT_ROOT);
let object_tree = root.join(object_tree);
if atomic_write_dir.starts_with(&object_tree) {
return Some(format!(
"[cache.remote] atomic_write_dir {} is inside the object tree {}; staging files would be listed as cached objects. Put it outside it (the default is <path>/.kache-tmp).",
atomic_write_dir.display(),
object_tree.display()
));
}
None
}
pub(crate) fn join_remote_key(prefix: &str, rest: &str) -> String {
if prefix.is_empty() {
rest.to_string()
} else {
format!("{prefix}/{rest}")
}
}
fn resolve_remote_prefix(configured: &str) -> Result<String> {
let normalized = normalize_remote_prefix(configured)?;
if normalized != configured {
tracing::warn!(
configured = %configured,
normalized = %normalized,
"remote prefix is not canonical; using the normalized form. Objects written under \
the previous prefix will not be found, so the remote cache repopulates once."
);
}
Ok(normalized)
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub(crate) struct FileConfig {
pub(crate) cache: Option<CacheFileConfig>,
pub(crate) cc: Option<CcFileConfig>,
pub(crate) paths: Option<PathsFileConfig>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub(crate) struct PathsFileConfig {
pub(crate) base_dirs: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub(crate) struct CcFileConfig {
pub(crate) extra_allowlist_flags: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub(crate) struct CacheFileConfig {
pub(crate) local_store: Option<String>,
pub(crate) local_max_size: Option<String>,
pub(crate) remote: Option<RemoteFileConfig>,
pub(crate) planner: Option<PlannerFileConfig>,
pub(crate) local_only: Option<bool>,
pub(crate) remote_readonly: Option<bool>,
pub(crate) modified_input_guard: Option<bool>,
pub(crate) local_hit_daemon: Option<bool>,
pub(crate) windows_hardlink: Option<bool>,
pub(crate) auto_gc: Option<bool>,
pub(crate) storage_layout_advice: Option<bool>,
pub(crate) heartbeat_secs: Option<u64>,
pub(crate) explain_miss: Option<bool>,
pub(crate) ignore_env: Option<bool>,
pub(crate) cache_executables: Option<bool>,
pub(crate) clean_incremental: Option<bool>,
pub(crate) exclude: Option<Vec<String>>,
pub(crate) event_log_max_size: Option<String>,
pub(crate) event_log_keep_lines: Option<usize>,
pub(crate) compression_level: Option<i32>,
pub(crate) s3_concurrency: Option<u32>,
pub(crate) prefetch_enabled: Option<bool>,
pub(crate) remote_key_cache_refresh_secs: Option<u64>,
pub(crate) prefetch_max_keys: Option<u64>,
pub(crate) prefetch_max_bytes: Option<String>,
pub(crate) prefetch_deadline_secs: Option<u64>,
pub(crate) daemon_idle_timeout_secs: Option<u64>,
pub(crate) s3_pool_idle_secs: Option<u64>,
pub(crate) fallback: Option<String>,
pub(crate) key_salt: Option<String>,
pub(crate) path_only_env_vars: Option<Vec<String>>,
pub(crate) key_env_vars: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub(crate) struct RemoteFileConfig {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub(crate) _type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) bucket: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) region: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) prefix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) atomic_write_dir: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub(crate) struct PlannerFileConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) token: Option<String>,
}
#[allow(dead_code)]
pub(crate) struct EnvOverrides {
pub(crate) disabled: bool,
pub(crate) cache_dir: bool,
pub(crate) max_size: bool,
pub(crate) cache_executables: bool,
pub(crate) clean_incremental: bool,
pub(crate) s3_bucket: bool,
pub(crate) s3_endpoint: bool,
pub(crate) s3_region: bool,
pub(crate) s3_prefix: bool,
pub(crate) s3_profile: bool,
pub(crate) fallback: bool,
pub(crate) key_salt: bool,
pub(crate) cc_extra_allowlist_flags: bool,
pub(crate) local_only: bool,
pub(crate) remote_readonly: bool,
}
impl EnvOverrides {
pub(crate) fn detect() -> Self {
let ignore_env = Config::ignore_env_enabled(&Config::load_file_config());
Self {
disabled: std::env::var("KACHE_DISABLED").is_ok(),
local_only: env_or_ignored("KACHE_LOCAL_ONLY", ignore_env).is_ok(),
remote_readonly: env_or_ignored("KACHE_REMOTE_READONLY", ignore_env).is_ok(),
cache_dir: env_or_ignored("KACHE_CACHE_DIR", ignore_env).is_ok(),
max_size: env_or_ignored("KACHE_MAX_SIZE", ignore_env).is_ok(),
cache_executables: env_or_ignored("KACHE_CACHE_EXECUTABLES", ignore_env).is_ok(),
clean_incremental: env_or_ignored("KACHE_CLEAN_INCREMENTAL", ignore_env).is_ok(),
s3_bucket: env_or_ignored("KACHE_S3_BUCKET", ignore_env).is_ok(),
s3_endpoint: env_or_ignored("KACHE_S3_ENDPOINT", ignore_env).is_ok(),
s3_region: env_or_ignored("KACHE_S3_REGION", ignore_env).is_ok(),
s3_prefix: env_or_ignored("KACHE_S3_PREFIX", ignore_env).is_ok(),
s3_profile: env_or_ignored("KACHE_S3_PROFILE", ignore_env).is_ok(),
fallback: env_or_ignored("KACHE_FALLBACK", ignore_env).is_ok(),
key_salt: env_or_ignored("KACHE_KEY_SALT", ignore_env).is_ok(),
cc_extra_allowlist_flags: env_or_ignored("KACHE_CC_EXTRA_ALLOWLIST_FLAGS", ignore_env)
.is_ok(),
}
}
}
fn normalize_cc_flags(raw: impl IntoIterator<Item = String>) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for flag in raw {
let trimmed = flag.trim();
if trimmed.is_empty() || out.iter().any(|f| f == trimmed) {
continue;
}
out.push(trimmed.to_string());
}
out
}
pub(crate) fn normalize_key_env_vars(
raw: impl IntoIterator<Item = String>,
source: &str,
) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for pattern in raw {
let trimmed = pattern.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.trim_end_matches('*').contains('*') {
tracing::warn!(
target: "kache::config",
"{source}: pattern {trimmed:?} contains a `*` that is not the last character; \
only a trailing `*` is a prefix glob, so that earlier `*` is matched as a \
literal character in the variable name"
);
}
out.push(trimmed.to_ascii_uppercase());
}
out.sort();
out.dedup();
out
}
fn normalize_base_dirs(raw: impl IntoIterator<Item = String>) -> Result<Vec<String>> {
let mut out = Vec::new();
for (index, value) in raw.into_iter().enumerate() {
let value = value.trim();
if value.is_empty() {
anyhow::bail!("[paths].base_dirs[{index}] must not be empty");
}
let bytes = value.as_bytes();
let windows_drive = bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'/' | b'\\');
let unc = value.starts_with("//") || value.starts_with(r"\\");
let posix = value.starts_with('/');
if !windows_drive && !unc && !posix {
anyhow::bail!("[paths].base_dirs[{index}] must be absolute, got {value:?}");
}
let components: Vec<&str> = if windows_drive || unc {
value
.split(['/', '\\'])
.filter(|component| !component.is_empty() && *component != ".")
.collect()
} else {
value
.split('/')
.filter(|component| !component.is_empty() && *component != ".")
.collect()
};
if components.contains(&"..") {
anyhow::bail!(
"[paths].base_dirs[{index}] must be normalized and must not contain `..`, got \
{value:?}"
);
}
let normalized = if windows_drive {
let drive = value[..2].to_ascii_uppercase();
let tail = components.iter().skip(1).copied().collect::<Vec<_>>();
if tail.is_empty() {
format!("{drive}/")
} else {
format!("{drive}/{}", tail.join("/"))
}
} else if unc {
if components.len() < 2 {
anyhow::bail!(
"[paths].base_dirs[{index}] UNC root must include server and share, got \
{value:?}"
);
}
format!("//{}", components.join("/"))
} else if components.is_empty() {
"/".to_string()
} else {
format!("/{}", components.join("/"))
};
out.push(normalized);
}
out.sort();
out.dedup();
Ok(out)
}
const IGNORE_ENV_GATED_VARS: &[&str] = &[
"KACHE_CACHE_DIR",
"KACHE_MAX_SIZE",
"KACHE_CACHE_EXECUTABLES",
"KACHE_CLEAN_INCREMENTAL",
"KACHE_COMPRESSION_LEVEL",
"KACHE_S3_CONCURRENCY",
"KACHE_PREFETCH_ENABLED",
"KACHE_REMOTE_KEY_CACHE_REFRESH_SECS",
"KACHE_DAEMON_IDLE_TIMEOUT",
"KACHE_S3_POOL_IDLE_SECS",
"KACHE_FALLBACK",
"KACHE_KEY_SALT",
"KACHE_CC_EXTRA_ALLOWLIST_FLAGS",
"KACHE_PATH_ONLY_ENV_VARS",
"KACHE_KEY_ENV_VARS",
"KACHE_S3_BUCKET",
"KACHE_S3_ENDPOINT",
"KACHE_S3_REGION",
"KACHE_S3_PREFIX",
"KACHE_S3_PROFILE",
"KACHE_LOCAL_ONLY",
"KACHE_REMOTE_READONLY",
"KACHE_MODIFIED_INPUT_GUARD",
"KACHE_LOCAL_HIT_DAEMON",
"KACHE_WINDOWS_HARDLINK",
"KACHE_AUTO_GC",
"KACHE_STORAGE_LAYOUT_ADVICE",
"KACHE_HEARTBEAT_SECS",
"KACHE_EXPLAIN_MISS",
"KACHE_PLANNER_ENDPOINT",
"KACHE_PLANNER_TIMEOUT_MS",
"KACHE_PLANNER_TOKEN",
];
fn env_or_ignored(name: &str, ignore_env: bool) -> Result<String, std::env::VarError> {
if ignore_env {
Err(std::env::VarError::NotPresent)
} else {
std::env::var(name)
}
}
fn prefetch_enabled_from_env(value: &str) -> bool {
value != "0" && !value.eq_ignore_ascii_case("false")
}
fn warn_ignored_env_overrides() {
let present: Vec<&str> = IGNORE_ENV_GATED_VARS
.iter()
.copied()
.filter(|name| std::env::var_os(name).is_some())
.collect();
if !present.is_empty() {
tracing::warn!(
"[cache] ignore_env = true: ignoring set env override(s) {present:?} in favor of the \
config file"
);
}
}
impl Config {
pub fn load() -> Result<Self> {
let file_config = Self::load_file_config();
let ignore_env = Self::ignore_env_enabled(&file_config);
if ignore_env {
warn_ignored_env_overrides();
}
let disabled = std::env::var("KACHE_DISABLED")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let cache_dir = env_or_ignored("KACHE_CACHE_DIR", ignore_env)
.map(|s| shellexpand(&s))
.or_else(|_| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.local_store.as_ref())
.map(|s| shellexpand(s))
.ok_or(())
})
.unwrap_or_else(|_| default_cache_dir());
let max_size = env_or_ignored("KACHE_MAX_SIZE", ignore_env)
.ok()
.and_then(|s| parse_size_checked(&s, "KACHE_MAX_SIZE"))
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.local_max_size.as_ref())
.and_then(|s| parse_size_checked(s, "[cache] local_max_size"))
})
.unwrap_or(50 * 1024 * 1024 * 1024);
let cache_executables = env_or_ignored("KACHE_CACHE_EXECUTABLES", ignore_env)
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or_else(|_| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.cache_executables)
.unwrap_or(default_cache_executables())
});
let clean_incremental = env_or_ignored("KACHE_CLEAN_INCREMENTAL", ignore_env)
.map(|v| v != "0" && !v.eq_ignore_ascii_case("false"))
.unwrap_or_else(|_| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.clean_incremental)
.unwrap_or(true)
});
let event_log_max_size = file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.event_log_max_size.as_ref())
.and_then(|s| parse_size_checked(s, "[cache] event_log_max_size"))
.unwrap_or(10 * 1024 * 1024);
let event_log_keep_lines = file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.event_log_keep_lines)
.unwrap_or(1000);
let compression_level = env_or_ignored("KACHE_COMPRESSION_LEVEL", ignore_env)
.ok()
.and_then(|s| s.parse::<i32>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.compression_level)
})
.unwrap_or(3)
.clamp(1, 22);
let prefetch_enabled = env_or_ignored("KACHE_PREFETCH_ENABLED", ignore_env)
.map(|value| prefetch_enabled_from_env(&value))
.unwrap_or_else(|_| {
file_config
.as_ref()
.ok()
.and_then(|config| config.cache.as_ref())
.and_then(|cache| cache.prefetch_enabled)
.unwrap_or(DEFAULT_PREFETCH_ENABLED)
});
let remote_key_cache_refresh_secs =
env_or_ignored("KACHE_REMOTE_KEY_CACHE_REFRESH_SECS", ignore_env)
.ok()
.and_then(|value| value.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|config| config.cache.as_ref())
.and_then(|cache| cache.remote_key_cache_refresh_secs)
})
.unwrap_or(DEFAULT_REMOTE_KEY_CACHE_REFRESH_SECS);
let prefetch_max_keys = env_or_ignored("KACHE_PREFETCH_MAX_KEYS", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.prefetch_max_keys)
})
.unwrap_or(DEFAULT_PREFETCH_MAX_KEYS);
let prefetch_max_bytes = env_or_ignored("KACHE_PREFETCH_MAX_BYTES", ignore_env)
.ok()
.and_then(|s| parse_size_checked(&s, "KACHE_PREFETCH_MAX_BYTES"))
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.prefetch_max_bytes.as_ref())
.and_then(|s| parse_size_checked(s, "[cache] prefetch_max_bytes"))
})
.unwrap_or(DEFAULT_PREFETCH_MAX_BYTES);
let prefetch_deadline_secs = env_or_ignored("KACHE_PREFETCH_DEADLINE_SECS", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.prefetch_deadline_secs)
})
.unwrap_or(DEFAULT_PREFETCH_DEADLINE_SECS);
let s3_concurrency = env_or_ignored("KACHE_S3_CONCURRENCY", ignore_env)
.ok()
.and_then(|s| s.parse::<u32>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.s3_concurrency)
})
.unwrap_or(16);
let daemon_idle_timeout_secs = env_or_ignored("KACHE_DAEMON_IDLE_TIMEOUT", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.daemon_idle_timeout_secs)
})
.unwrap_or(DEFAULT_DAEMON_IDLE_TIMEOUT_SECS);
let s3_pool_idle_secs = env_or_ignored("KACHE_S3_POOL_IDLE_SECS", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.s3_pool_idle_secs)
})
.unwrap_or(DEFAULT_S3_POOL_IDLE_SECS);
let fallback = env_or_ignored("KACHE_FALLBACK", ignore_env)
.ok()
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.fallback.clone())
})
.map(|s| s.trim().to_string())
.filter(|s| {
!s.is_empty() && !s.eq_ignore_ascii_case("off") && !s.eq_ignore_ascii_case("none")
});
let key_salt = env_or_ignored("KACHE_KEY_SALT", ignore_env)
.ok()
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.key_salt.clone())
})
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let cc_extra_allowlist_flags =
match env_or_ignored("KACHE_CC_EXTRA_ALLOWLIST_FLAGS", ignore_env) {
Ok(val) => normalize_cc_flags(val.split_whitespace().map(str::to_string)),
Err(_) => normalize_cc_flags(
file_config
.as_ref()
.ok()
.and_then(|c| c.cc.as_ref())
.and_then(|c| c.extra_allowlist_flags.clone())
.unwrap_or_default(),
),
};
let path_only_env_vars = match env_or_ignored("KACHE_PATH_ONLY_ENV_VARS", ignore_env) {
Ok(val) => val
.split([',', ' ', '\t', '\n'])
.filter(|p| !p.is_empty())
.map(str::to_string)
.collect(),
Err(_) => file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.path_only_env_vars.clone())
.unwrap_or_default(),
};
let key_env_vars = match env_or_ignored("KACHE_KEY_ENV_VARS", ignore_env) {
Ok(val) => normalize_key_env_vars(
val.split([',', ' ', '\t', '\n']).map(str::to_string),
"KACHE_KEY_ENV_VARS",
),
Err(_) => normalize_key_env_vars(
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.key_env_vars.clone())
.unwrap_or_default(),
"[cache] key_env_vars",
),
};
let base_dirs = normalize_base_dirs(
file_config
.as_ref()
.ok()
.and_then(|c| c.paths.as_ref())
.and_then(|p| p.base_dirs.clone())
.unwrap_or_default(),
)?;
for (index, path) in base_dirs.iter().enumerate() {
tracing::info!(
target: "kache::config",
"[paths].base_dirs[{index}] {} -> <BASE_DIR_{index}> / \
/kache/base-dir-{index}",
path
);
}
let local_only = Self::local_only_enabled(&file_config);
let remote_readonly = Self::remote_readonly_enabled(&file_config);
let modified_input_guard = Self::modified_input_guard_enabled(&file_config);
let local_hit_daemon = Self::local_hit_daemon_enabled(&file_config);
let windows_hardlink = Self::windows_hardlink_enabled(&file_config);
let auto_gc = Self::auto_gc_enabled(&file_config);
let storage_layout_advice = Self::storage_layout_advice_enabled(&file_config);
let heartbeat_secs = env_or_ignored("KACHE_HEARTBEAT_SECS", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.heartbeat_secs)
})
.unwrap_or(DEFAULT_HEARTBEAT_SECS);
let explain_miss = Self::explain_miss_enabled(&file_config);
let (remote, remote_error) = if local_only {
(None, None)
} else {
match Self::load_remote_config(&file_config) {
Ok(remote) => (remote, None),
Err(error) => {
let reason = format!("{error:#}");
tracing::warn!(
%reason,
"remote cache configuration is unusable — continuing without a remote \
cache. Run `kache doctor` for details."
);
(None, Some(reason))
}
}
};
Ok(Config {
cache_dir,
max_size,
remote,
remote_error,
disabled,
local_only,
remote_readonly,
modified_input_guard,
local_hit_daemon,
windows_hardlink,
auto_gc,
storage_layout_advice,
heartbeat_secs,
explain_miss,
cache_executables,
clean_incremental,
event_log_max_size,
event_log_keep_lines,
compression_level,
s3_concurrency,
prefetch_enabled,
remote_key_cache_refresh_secs,
prefetch_max_keys,
prefetch_max_bytes,
prefetch_deadline_secs,
daemon_idle_timeout_secs,
s3_pool_idle_secs,
fallback,
key_salt,
path_only_env_vars,
key_env_vars,
base_dirs,
cc_extra_allowlist_flags,
})
}
pub(crate) fn load_raw_file_config() -> (FileConfig, bool) {
Self::load_raw_file_config_from(&resolve_config_path())
}
pub(crate) fn load_raw_file_config_from(config_path: &std::path::Path) -> (FileConfig, bool) {
let existed = config_path.exists();
if !existed {
return (FileConfig::default(), false);
}
match std::fs::read_to_string(config_path) {
Ok(content) => match toml::from_str(&content) {
Ok(cfg) => (cfg, true),
Err(_) => (FileConfig::default(), true),
},
Err(_) => (FileConfig::default(), true),
}
}
pub(crate) fn save_file_config_to(config: &FileConfig, path: &std::path::Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).context("creating config directory")?;
}
let content = toml::to_string_pretty(config).context("serializing config")?;
std::fs::write(path, content).context("writing config file")?;
Ok(())
}
fn load_file_config() -> Result<FileConfig> {
let config_path = resolve_config_path();
if !config_path.exists() {
return Ok(FileConfig::default());
}
let content = std::fs::read_to_string(&config_path).context("reading kache config file")?;
toml::from_str(&content).context("parsing kache config file")
}
fn load_remote_config(file_config: &Result<FileConfig>) -> Result<Option<RemoteConfig>> {
let ignore_env = Self::ignore_env_enabled(file_config);
let file_remote = file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.remote.as_ref());
let configured_type = file_remote
.and_then(|r| r._type.as_deref())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_ascii_lowercase);
let file_has_s3_fields = file_remote.is_some_and(|r| {
[&r.bucket, &r.endpoint, &r.region, &r.profile]
.into_iter()
.any(|v| v.as_deref().is_some_and(|v| !v.trim().is_empty()))
});
let file_has_filesystem_fields = file_remote.is_some_and(|r| {
[&r.path, &r.atomic_write_dir]
.into_iter()
.any(|v| v.as_deref().is_some_and(|v| !v.trim().is_empty()))
});
let use_filesystem = match configured_type.as_deref() {
Some("filesystem" | "fs") => {
if file_has_s3_fields {
anyhow::bail!(
"[cache.remote] type = \"filesystem\" cannot include S3 bucket, endpoint, region, or profile"
);
}
true
}
Some("s3") => {
if file_has_filesystem_fields {
anyhow::bail!(
"[cache.remote] type = \"s3\" cannot include path or atomic_write_dir"
);
}
false
}
Some(other) => {
anyhow::bail!(
"unsupported [cache.remote] type {other:?}; supported types are \"s3\" and \"filesystem\""
);
}
None if file_has_s3_fields && file_has_filesystem_fields => {
anyhow::bail!(
"[cache.remote] mixes S3 and filesystem fields; set type = \"s3\" or type = \"filesystem\""
);
}
None => file_has_filesystem_fields,
};
if use_filesystem {
let path = file_remote
.and_then(|r| r.path.as_deref())
.map(str::trim)
.filter(|v| !v.is_empty())
.with_context(
|| "[cache.remote] type = \"filesystem\" requires a non-empty path",
)?;
let root = shellexpand(path);
if !root.is_absolute() {
anyhow::bail!(
"[cache.remote] filesystem path must be absolute: {}",
root.display()
);
}
let atomic_write_dir = file_remote
.and_then(|r| r.atomic_write_dir.as_deref())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(shellexpand)
.unwrap_or_else(|| root.join(".kache-tmp"));
if !atomic_write_dir.is_absolute() {
anyhow::bail!(
"[cache.remote] atomic_write_dir must be absolute: {}",
atomic_write_dir.display()
);
}
let prefix = file_remote
.and_then(|r| r.prefix.clone())
.unwrap_or_else(|| "artifacts".to_string());
let prefix = resolve_remote_prefix(&prefix)?;
if prefix.contains(':') {
anyhow::bail!(
"[cache.remote] filesystem prefix cannot contain ':' because it can escape \
the configured root or address an alternate data stream on Windows: {prefix:?}"
);
}
if let Some(problem) = filesystem_staging_problem(&root, &atomic_write_dir, &prefix) {
anyhow::bail!("{problem}");
}
return Ok(Some(RemoteConfig {
prefix,
backend: RemoteBackendConfig::Filesystem(FilesystemRemoteConfig {
root,
atomic_write_dir,
}),
}));
}
let env_bucket = env_or_ignored("KACHE_S3_BUCKET", ignore_env).ok();
let file_bucket_is_usable = file_remote
.and_then(|r| r.bucket.as_deref())
.is_some_and(|bucket| !bucket.trim().is_empty());
if let Some(env_bucket) = &env_bucket
&& env_bucket.trim().is_empty()
&& file_bucket_is_usable
{
tracing::warn!(
"KACHE_S3_BUCKET is set but empty — treating the remote cache as disabled rather \
than falling back to the configured bucket"
);
return Ok(None);
}
let bucket = env_bucket
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| {
file_remote
.and_then(|r| r.bucket.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
});
let Some(bucket) = bucket else {
if configured_type.as_deref() == Some("s3") {
anyhow::bail!("[cache.remote] type = \"s3\" requires a non-empty bucket");
}
return Ok(None);
};
let endpoint = env_or_ignored("KACHE_S3_ENDPOINT", ignore_env)
.ok()
.or_else(|| file_remote.and_then(|r| r.endpoint.clone()));
let region = env_or_ignored("KACHE_S3_REGION", ignore_env)
.ok()
.or_else(|| file_remote.and_then(|r| r.region.clone()))
.unwrap_or_else(|| "us-east-1".to_string());
let prefix = env_or_ignored("KACHE_S3_PREFIX", ignore_env)
.ok()
.or_else(|| file_remote.and_then(|r| r.prefix.clone()))
.unwrap_or_else(|| "artifacts".to_string());
let prefix = resolve_remote_prefix(&prefix)?;
let profile = env_or_ignored("KACHE_S3_PROFILE", ignore_env)
.ok()
.or_else(|| file_remote.and_then(|r| r.profile.clone()))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Ok(Some(RemoteConfig {
prefix,
backend: RemoteBackendConfig::S3(S3RemoteConfig {
bucket,
endpoint,
region,
profile,
}),
}))
}
fn ignore_env_enabled(file_config: &Result<FileConfig>) -> bool {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.ignore_env)
.unwrap_or(false)
}
fn local_only_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_LOCAL_ONLY", ignore_env) {
return v == "1" || v.eq_ignore_ascii_case("true");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.local_only)
.unwrap_or(false)
}
fn remote_readonly_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_REMOTE_READONLY", ignore_env) {
return v == "1" || v.eq_ignore_ascii_case("true");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.remote_readonly)
.unwrap_or(false)
}
fn modified_input_guard_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_MODIFIED_INPUT_GUARD", ignore_env) {
return v == "1" || v.eq_ignore_ascii_case("true");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.modified_input_guard)
.unwrap_or(false)
}
fn local_hit_daemon_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_LOCAL_HIT_DAEMON", ignore_env) {
return v == "1" || v.eq_ignore_ascii_case("true");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.local_hit_daemon)
.unwrap_or(false)
}
fn windows_hardlink_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_WINDOWS_HARDLINK", ignore_env) {
return v == "1" || v.eq_ignore_ascii_case("true");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.windows_hardlink)
.unwrap_or(false)
}
fn auto_gc_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_AUTO_GC", ignore_env) {
return v != "0" && !v.eq_ignore_ascii_case("false");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.auto_gc)
.unwrap_or(true)
}
fn storage_layout_advice_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_STORAGE_LAYOUT_ADVICE", ignore_env) {
return v != "0" && !v.eq_ignore_ascii_case("false");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.storage_layout_advice)
.unwrap_or(true)
}
fn explain_miss_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_EXPLAIN_MISS", ignore_env) {
return v == "1" || v.eq_ignore_ascii_case("true");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.explain_miss)
.unwrap_or(false)
}
pub fn load_planner_config() -> Option<PlannerConfig> {
let file_config = Self::load_file_config();
let ignore_env = Self::ignore_env_enabled(&file_config);
if Self::local_only_enabled(&file_config) {
return None;
}
let endpoint = env_or_ignored("KACHE_PLANNER_ENDPOINT", ignore_env)
.ok()
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.planner.as_ref())
.and_then(|c| c.endpoint.clone())
})
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())?;
let timeout_ms = env_or_ignored("KACHE_PLANNER_TIMEOUT_MS", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.planner.as_ref())
.and_then(|c| c.timeout_ms)
})
.unwrap_or(DEFAULT_PLANNER_TIMEOUT_MS);
let token = env_or_ignored("KACHE_PLANNER_TOKEN", ignore_env)
.ok()
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.planner.as_ref())
.and_then(|c| c.token.clone())
})
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Some(PlannerConfig {
endpoint,
timeout_ms,
token,
})
}
pub fn store_dir(&self) -> PathBuf {
self.cache_dir.join("store")
}
pub fn index_db_path(&self) -> PathBuf {
self.cache_dir.join("index.db")
}
pub fn event_log_path(&self) -> PathBuf {
self.cache_dir.join("events.jsonl")
}
pub fn transfer_log_path(&self) -> PathBuf {
self.cache_dir.join("transfers.jsonl")
}
pub fn summary_log_path(&self) -> PathBuf {
self.cache_dir.join("summaries.jsonl")
}
pub fn socket_path(&self) -> PathBuf {
self.cache_dir.join("daemon.sock")
}
pub fn source_excluded(source_path: &Path, roots: &[PathBuf]) -> bool {
let patterns = Self::load_exclude_patterns();
source_excluded_by_patterns(&patterns, source_path, roots)
}
fn load_exclude_patterns() -> Vec<String> {
Self::load_file_config()
.ok()
.and_then(|c| c.cache)
.and_then(|c| c.exclude)
.unwrap_or_default()
.into_iter()
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect()
}
}
fn source_excluded_by_patterns(patterns: &[String], source_path: &Path, roots: &[PathBuf]) -> bool {
if patterns.is_empty() {
return false;
}
let candidates = source_candidates(source_path, roots);
patterns
.iter()
.any(|pattern| exclude_pattern_matches(pattern, &candidates))
}
pub(crate) fn default_cache_executables() -> bool {
cfg!(target_os = "linux")
}
pub(crate) fn default_cache_dir() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("kache")
}
const PROJECT_CONFIG_NAME: &str = ".kache.toml";
pub(crate) fn resolve_config_path() -> PathBuf {
resolve_config_path_from(
std::env::var("KACHE_CONFIG").ok().map(|s| shellexpand(&s)),
std::env::current_dir().ok(),
)
}
pub(crate) fn config_file_fingerprint() -> u64 {
use std::hash::{Hash, Hasher};
let path = resolve_config_path();
let mut hasher = std::collections::hash_map::DefaultHasher::new();
path.to_string_lossy().hash(&mut hasher);
match std::fs::read(&path) {
Ok(bytes) => {
1u8.hash(&mut hasher); bytes.hash(&mut hasher);
}
Err(_) => 0u8.hash(&mut hasher),
}
hasher.finish()
}
fn resolve_config_path_from(
kache_config: Option<PathBuf>,
current_dir: Option<PathBuf>,
) -> PathBuf {
if let Some(p) = kache_config {
return p;
}
if let Some(path) = nearest_project_config_path(current_dir.as_deref()) {
return path;
}
config_file_path()
}
fn nearest_project_config_path(current_dir: Option<&std::path::Path>) -> Option<PathBuf> {
let current_dir = current_dir?;
for dir in current_dir.ancestors() {
let candidate = dir.join(PROJECT_CONFIG_NAME);
if candidate.exists() {
return Some(candidate);
}
}
None
}
pub(crate) fn config_file_path() -> PathBuf {
let config_base = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".config")
});
config_base.join("kache").join("config.toml")
}
pub(crate) fn shellexpand(s: &str) -> PathBuf {
if let Some(home) = dirs::home_dir() {
if s == "~" {
return home;
}
if let Some(stripped) = s.strip_prefix("~/") {
return home.join(stripped);
}
}
PathBuf::from(s)
}
fn expand_env_vars_collecting<F>(s: &str, lookup: F) -> (String, Vec<String>)
where
F: Fn(&str) -> Option<String>,
{
let mut out = String::with_capacity(s.len());
let mut unset: Vec<String> = Vec::new();
let mut note_unset = |key: &str| {
if !unset.iter().any(|k| k == key) {
unset.push(key.to_string());
}
};
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '$' {
out.push(ch);
continue;
}
if chars.peek() == Some(&'{') {
chars.next();
let mut key = String::new();
for c in chars.by_ref() {
if c == '}' {
break;
}
key.push(c);
}
if let Some(value) = lookup(&key).or_else(|| default_env_var_value(&key)) {
out.push_str(&value);
} else {
note_unset(&key);
out.push_str("${");
out.push_str(&key);
out.push('}');
}
continue;
}
let mut key = String::new();
while let Some(c) = chars.peek().copied() {
if c == '_' || c.is_ascii_alphanumeric() {
key.push(c);
chars.next();
} else {
break;
}
}
if key.is_empty() {
out.push('$');
} else if let Some(value) = lookup(&key).or_else(|| default_env_var_value(&key)) {
out.push_str(&value);
} else {
note_unset(&key);
out.push('$');
out.push_str(&key);
}
}
(out, unset)
}
fn default_env_var_value(key: &str) -> Option<String> {
match key {
"CARGO_HOME" => {
dirs::home_dir().map(|home| home.join(".cargo").to_string_lossy().into_owned())
}
_ => None,
}
}
pub(crate) fn expand_exclude_pattern(pattern: &str) -> String {
expand_exclude_pattern_collecting(pattern).0
}
pub(crate) fn expand_exclude_pattern_collecting(pattern: &str) -> (String, Vec<String>) {
let (expanded, unset) = expand_env_vars_collecting(pattern, |key| std::env::var(key).ok());
let s = shellexpand(&expanded).to_string_lossy().into_owned();
(s, unset)
}
fn push_unique(paths: &mut Vec<PathBuf>, path: PathBuf) {
if !paths.iter().any(|p| p == &path) {
paths.push(path);
}
}
fn source_candidates(source_path: &Path, roots: &[PathBuf]) -> Vec<PathBuf> {
let mut candidates = Vec::new();
push_unique(&mut candidates, source_path.to_path_buf());
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let absolute = if source_path.is_absolute() {
source_path.to_path_buf()
} else {
cwd.join(source_path)
};
push_unique(&mut candidates, absolute.clone());
if let Ok(canonical) = std::fs::canonicalize(&absolute) {
push_unique(&mut candidates, canonical);
}
for root in roots {
let root_abs = if root.is_absolute() {
root.clone()
} else {
cwd.join(root)
};
let root_forms = [
root_abs.clone(),
std::fs::canonicalize(&root_abs).unwrap_or(root_abs),
];
for root_form in root_forms {
if !source_path.is_absolute() {
push_unique(&mut candidates, root_form.join(source_path));
}
if let Ok(rel) = absolute.strip_prefix(&root_form) {
push_unique(&mut candidates, rel.to_path_buf());
}
}
}
candidates
}
fn exclude_pattern_matches(pattern: &str, candidates: &[PathBuf]) -> bool {
let expanded = expand_exclude_pattern(pattern);
let Ok(pattern) = glob::Pattern::new(&expanded) else {
tracing::warn!("ignoring invalid [cache].exclude glob pattern: {expanded}");
return false;
};
candidates
.iter()
.any(|candidate| pattern.matches_path(candidate))
}
pub(crate) fn parse_size(s: &str) -> Option<u64> {
s.parse::<ByteSize>().ok().map(|b| b.as_u64())
}
pub(crate) fn parse_size_checked(value: &str, source: &str) -> Option<u64> {
let parsed = parse_size(value);
if parsed.is_none() {
tracing::warn!(
"ignoring malformed size {value:?} from {source}: expected an integer with an \
optional unit like `50GiB`, `512MiB`, or `1000000`; falling back to the next \
configured source or the default"
);
}
parsed
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
use std::sync::{Mutex, OnceLock};
#[test]
fn cache_executables_defaults_on_for_linux_only() {
assert_eq!(
default_cache_executables(),
cfg!(target_os = "linux"),
"executables default on for Linux only (see #319)"
);
}
fn config_path_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
struct TestEnvGuard {
previous: Option<OsString>,
}
impl Drop for TestEnvGuard {
fn drop(&mut self) {
unsafe {
match self.previous.as_ref() {
Some(value) => std::env::set_var("KACHE_CONFIG", value),
None => std::env::remove_var("KACHE_CONFIG"),
}
}
}
}
fn set_kache_config_for_test(path: &std::path::Path) -> TestEnvGuard {
let previous = std::env::var_os("KACHE_CONFIG");
unsafe {
std::env::set_var("KACHE_CONFIG", path);
}
TestEnvGuard { previous }
}
struct NamedEnvGuard {
name: &'static str,
previous: Option<OsString>,
}
impl NamedEnvGuard {
fn set(name: &'static str, value: &str) -> Self {
let previous = std::env::var_os(name);
unsafe { std::env::set_var(name, value) };
Self { name, previous }
}
fn remove(name: &'static str) -> Self {
let previous = std::env::var_os(name);
unsafe { std::env::remove_var(name) };
Self { name, previous }
}
}
impl Drop for NamedEnvGuard {
fn drop(&mut self) {
unsafe {
match self.previous.as_ref() {
Some(value) => std::env::set_var(self.name, value),
None => std::env::remove_var(self.name),
}
}
}
}
#[test]
fn test_default_cache_dir() {
let dir = default_cache_dir();
assert!(dir.to_string_lossy().contains("kache"));
}
#[test]
fn prefetch_enabled_env_value_truth_table() {
assert!(!prefetch_enabled_from_env("0"));
assert!(!prefetch_enabled_from_env("false"));
assert!(!prefetch_enabled_from_env("FALSE"));
assert!(prefetch_enabled_from_env("1"));
assert!(prefetch_enabled_from_env("true"));
assert!(prefetch_enabled_from_env("yes"));
assert!(prefetch_enabled_from_env(""));
}
#[test]
fn prefetch_controls_default_and_follow_file_then_env_precedence() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let _config = set_kache_config_for_test(&config_path);
std::fs::write(
&config_path,
"[cache]
",
)
.unwrap();
{
let _enabled = NamedEnvGuard::remove("KACHE_PREFETCH_ENABLED");
let _refresh = NamedEnvGuard::remove("KACHE_REMOTE_KEY_CACHE_REFRESH_SECS");
let config = Config::load().unwrap();
assert!(config.prefetch_enabled);
assert_eq!(config.remote_key_cache_refresh_secs, 60);
}
std::fs::write(
&config_path,
"[cache]
prefetch_enabled = false
remote_key_cache_refresh_secs = 900
",
)
.unwrap();
{
let _enabled = NamedEnvGuard::remove("KACHE_PREFETCH_ENABLED");
let _refresh = NamedEnvGuard::remove("KACHE_REMOTE_KEY_CACHE_REFRESH_SECS");
let config = Config::load().unwrap();
assert!(!config.prefetch_enabled);
assert_eq!(config.remote_key_cache_refresh_secs, 900);
}
{
let _enabled = NamedEnvGuard::set("KACHE_PREFETCH_ENABLED", "true");
let _refresh = NamedEnvGuard::set("KACHE_REMOTE_KEY_CACHE_REFRESH_SECS", "0");
let config = Config::load().unwrap();
assert!(config.prefetch_enabled);
assert_eq!(config.remote_key_cache_refresh_secs, 0);
}
}
#[test]
fn ignore_env_pins_prefetch_controls_to_the_file() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[cache]
ignore_env = true
prefetch_enabled = false
remote_key_cache_refresh_secs = 900
",
)
.unwrap();
let _config = set_kache_config_for_test(&config_path);
let _enabled = NamedEnvGuard::set("KACHE_PREFETCH_ENABLED", "true");
let _refresh = NamedEnvGuard::set("KACHE_REMOTE_KEY_CACHE_REFRESH_SECS", "1");
let config = Config::load().unwrap();
assert!(!config.prefetch_enabled);
assert_eq!(config.remote_key_cache_refresh_secs, 900);
}
#[test]
fn remote_prefix_is_backend_neutral() {
assert_eq!(
normalize_remote_prefix("artifacts/team").unwrap(),
"artifacts/team"
);
for (legacy, expected) in [
("/artifacts", "artifacts"),
("artifacts/", "artifacts"),
("artifacts//team", "artifacts/team"),
(" artifacts/team ", "artifacts/team"),
("/", ""),
("", ""),
] {
assert_eq!(
normalize_remote_prefix(legacy).unwrap(),
expected,
"{legacy:?} must normalize, not fail"
);
}
for invalid in [r"artifacts\team", r"..\escape", "artifacts/../team", ".."] {
assert!(
normalize_remote_prefix(invalid).is_err(),
"{invalid:?} must be rejected"
);
}
}
#[test]
fn test_shellexpand() {
let expanded = shellexpand("~/foo");
assert!(!expanded.to_string_lossy().starts_with("~/"));
}
#[test]
fn test_parse_size() {
assert_eq!(parse_size("50GiB"), Some(50 * 1024 * 1024 * 1024));
assert_eq!(parse_size("1MiB"), Some(1024 * 1024));
assert!(parse_size("invalid").is_none());
}
#[test]
fn base_dirs_validate_and_normalize_host_independent_absolute_syntax() {
let normalized = normalize_base_dirs([
"/var//lib/./flatpak/".to_string(),
r"C:\Build\Root\.".to_string(),
r"\\server\share\app".to_string(),
"/snap".to_string(),
r"/work/a\b/./root".to_string(),
])
.unwrap();
assert_eq!(
normalized,
vec![
"//server/share/app",
"/snap",
"/var/lib/flatpak",
r"/work/a\b/root",
"C:/Build/Root",
]
);
}
#[test]
fn base_dirs_reject_relative_and_parent_traversal_entries() {
let relative = normalize_base_dirs(["build/root".to_string()]).unwrap_err();
assert!(relative.to_string().contains("must be absolute"));
let parent = normalize_base_dirs(["/work/../other".to_string()]).unwrap_err();
assert!(parent.to_string().contains("must not contain `..`"));
let windows_parent = normalize_base_dirs([r"C:\work\..\other".to_string()]).unwrap_err();
assert!(windows_parent.to_string().contains("must not contain `..`"));
}
#[test]
fn config_load_integrates_paths_base_dirs() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let _guard = set_kache_config_for_test(&config_path);
std::fs::write(
&config_path,
"[paths]\nbase_dirs = [\"/var/lib/flatpak\", \"/snap\"]\n",
)
.unwrap();
assert_eq!(
Config::load().unwrap().base_dirs,
vec!["/snap".to_string(), "/var/lib/flatpak".to_string()]
);
std::fs::write(&config_path, "[paths]\nbase_dirs = [\"relative/root\"]\n").unwrap();
assert!(Config::load().is_err());
}
#[test]
fn parse_size_checked_rejects_malformed_and_mirrors_parse_size() {
for bad in ["100 gigs", "1_000", "abc", ""] {
assert!(parse_size(bad).is_none(), "expected {bad:?} to be invalid");
assert!(parse_size_checked(bad, "KACHE_MAX_SIZE").is_none());
}
assert_eq!(
parse_size_checked("2GiB", "KACHE_MAX_SIZE"),
Some(2 * 1024 * 1024 * 1024)
);
}
#[test]
fn ignore_env_makes_file_win_over_env() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.toml");
struct SaltGuard(Option<OsString>);
impl Drop for SaltGuard {
fn drop(&mut self) {
unsafe {
match self.0.as_ref() {
Some(v) => std::env::set_var("KACHE_KEY_SALT", v),
None => std::env::remove_var("KACHE_KEY_SALT"),
}
}
}
}
let _salt = SaltGuard(std::env::var_os("KACHE_KEY_SALT"));
unsafe { std::env::set_var("KACHE_KEY_SALT", "from-env") };
let _g = set_kache_config_for_test(&cfg);
std::fs::write(
&cfg,
"[cache]\nignore_env = true\nkey_salt = \"from-file\"\n",
)
.unwrap();
let loaded = Config::load().unwrap();
assert_eq!(loaded.key_salt.as_deref(), Some("from-file"));
std::fs::write(&cfg, "[cache]\nkey_salt = \"from-file\"\n").unwrap();
let loaded = Config::load().unwrap();
assert_eq!(loaded.key_salt.as_deref(), Some("from-env"));
}
#[test]
fn config_file_fingerprint_tracks_content_and_presence() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.toml");
let _g = set_kache_config_for_test(&cfg);
let absent = config_file_fingerprint();
assert_eq!(absent, config_file_fingerprint(), "absent must be stable");
std::fs::write(&cfg, "[cache]\nlocal_max_size = \"10GiB\"\n").unwrap();
let v10 = config_file_fingerprint();
assert_ne!(absent, v10, "present must differ from absent");
assert_eq!(
v10,
config_file_fingerprint(),
"same content must be stable"
);
std::fs::write(&cfg, "[cache]\nlocal_max_size = \"20GiB\"\n").unwrap();
assert_ne!(v10, config_file_fingerprint(), "content change must re-key");
}
#[test]
fn test_file_config_roundtrip() {
let config = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
local_only: None,
remote_readonly: None,
modified_input_guard: None,
local_hit_daemon: None,
windows_hardlink: None,
auto_gc: None,
storage_layout_advice: None,
heartbeat_secs: None,
explain_miss: None,
ignore_env: None,
fallback: None,
key_salt: None,
path_only_env_vars: None,
key_env_vars: Some(vec!["BOLTFFI_*".to_string()]),
local_store: Some("~/my/cache".to_string()),
local_max_size: Some("50GiB".to_string()),
planner: None,
cache_executables: Some(true),
clean_incremental: Some(false),
exclude: Some(vec!["vendor/problem/**".to_string()]),
event_log_max_size: Some("10MiB".to_string()),
event_log_keep_lines: Some(500),
compression_level: Some(3),
s3_concurrency: Some(8),
prefetch_enabled: None,
remote_key_cache_refresh_secs: None,
prefetch_max_keys: None,
prefetch_max_bytes: None,
prefetch_deadline_secs: None,
daemon_idle_timeout_secs: None,
s3_pool_idle_secs: None,
remote: Some(RemoteFileConfig {
_type: Some("s3".to_string()),
bucket: Some("my-bucket".to_string()),
endpoint: Some("https://s3.example.com".to_string()),
region: Some("eu-west-1".to_string()),
prefix: Some("my-prefix".to_string()),
profile: None,
path: None,
atomic_write_dir: None,
}),
}),
};
let serialized = toml::to_string_pretty(&config).unwrap();
let deserialized: FileConfig = toml::from_str(&serialized).unwrap();
assert_eq!(
deserialized.cache.as_ref().unwrap().local_store.as_deref(),
Some("~/my/cache")
);
assert_eq!(
deserialized.cache.as_ref().unwrap().exclude.as_deref(),
Some(&["vendor/problem/**".to_string()][..])
);
assert_eq!(
deserialized.cache.as_ref().unwrap().key_env_vars.as_deref(),
Some(&["BOLTFFI_*".to_string()][..])
);
assert_eq!(
deserialized
.cache
.as_ref()
.unwrap()
.remote
.as_ref()
.unwrap()
.bucket
.as_deref(),
Some("my-bucket")
);
}
#[test]
fn test_file_config_empty_remote_omitted() {
let config = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
local_store: Some("~/cache".to_string()),
remote: Some(RemoteFileConfig::default()),
..Default::default()
}),
};
let serialized = toml::to_string_pretty(&config).unwrap();
assert!(!serialized.contains("bucket"));
assert!(!serialized.contains("endpoint"));
}
#[test]
fn test_key_salt_file_env_precedence() {
let _guard = config_path_lock();
let prev_salt = std::env::var_os("KACHE_KEY_SALT");
let restore_salt = |v: &Option<OsString>| unsafe {
match v {
Some(val) => std::env::set_var("KACHE_KEY_SALT", val),
None => std::env::remove_var("KACHE_KEY_SALT"),
}
};
restore_salt(&None);
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
std::fs::write(&cfg_path, "[cache]\nkey_salt = \"from-file\"\n").unwrap();
let _cfg_guard = set_kache_config_for_test(&cfg_path);
assert_eq!(
Config::load().unwrap().key_salt.as_deref(),
Some("from-file")
);
unsafe { std::env::set_var("KACHE_KEY_SALT", "from-env") };
assert_eq!(
Config::load().unwrap().key_salt.as_deref(),
Some("from-env")
);
unsafe { std::env::set_var("KACHE_KEY_SALT", " ") };
assert_eq!(Config::load().unwrap().key_salt, None);
restore_salt(&prev_salt);
}
#[test]
fn test_cc_extra_allowlist_flags_file_env_precedence() {
let _guard = config_path_lock();
let prev = std::env::var_os("KACHE_CC_EXTRA_ALLOWLIST_FLAGS");
let restore = |v: &Option<OsString>| unsafe {
match v {
Some(val) => std::env::set_var("KACHE_CC_EXTRA_ALLOWLIST_FLAGS", val),
None => std::env::remove_var("KACHE_CC_EXTRA_ALLOWLIST_FLAGS"),
}
};
restore(&None);
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
std::fs::write(
&cfg_path,
"[cc]\nextra_allowlist_flags = [\"-ffunction-sections\", \"-fdata-sections\"]\n",
)
.unwrap();
let _cfg_guard = set_kache_config_for_test(&cfg_path);
assert_eq!(
Config::load().unwrap().cc_extra_allowlist_flags,
vec![
"-ffunction-sections".to_string(),
"-fdata-sections".to_string()
]
);
unsafe {
std::env::set_var(
"KACHE_CC_EXTRA_ALLOWLIST_FLAGS",
" -fno-rtti -fno-rtti -fbravo ",
)
};
assert_eq!(
Config::load().unwrap().cc_extra_allowlist_flags,
vec!["-fno-rtti".to_string(), "-fbravo".to_string()]
);
unsafe { std::env::set_var("KACHE_CC_EXTRA_ALLOWLIST_FLAGS", " ") };
assert!(Config::load().unwrap().cc_extra_allowlist_flags.is_empty());
restore(&prev);
}
#[test]
fn test_key_env_vars_file_env_precedence() {
let _guard = config_path_lock();
let prev = std::env::var_os("KACHE_KEY_ENV_VARS");
let restore = |v: &Option<OsString>| unsafe {
match v {
Some(val) => std::env::set_var("KACHE_KEY_ENV_VARS", val),
None => std::env::remove_var("KACHE_KEY_ENV_VARS"),
}
};
restore(&None);
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
std::fs::write(
&cfg_path,
"[cache]\nkey_env_vars = [\"BOLTFFI_*\", \"APP_MODE\"]\n",
)
.unwrap();
let _cfg_guard = set_kache_config_for_test(&cfg_path);
assert_eq!(
Config::load().unwrap().key_env_vars,
vec!["APP_MODE".to_string(), "BOLTFFI_*".to_string()]
);
unsafe { std::env::set_var("KACHE_KEY_ENV_VARS", " ZULU, ALPHA ,ALPHA,, BRAVO ") };
assert_eq!(
Config::load().unwrap().key_env_vars,
vec!["ALPHA".to_string(), "BRAVO".to_string(), "ZULU".to_string()]
);
unsafe { std::env::set_var("KACHE_KEY_ENV_VARS", " ") };
assert!(Config::load().unwrap().key_env_vars.is_empty());
restore(&prev);
}
#[test]
fn test_key_env_vars_ignore_env_makes_file_win() {
let _guard = config_path_lock();
let prev = std::env::var_os("KACHE_KEY_ENV_VARS");
let restore = |v: &Option<OsString>| unsafe {
match v {
Some(val) => std::env::set_var("KACHE_KEY_ENV_VARS", val),
None => std::env::remove_var("KACHE_KEY_ENV_VARS"),
}
};
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
std::fs::write(
&cfg_path,
"[cache]\nignore_env = true\nkey_env_vars = [\"APP_MODE\"]\n",
)
.unwrap();
let _cfg_guard = set_kache_config_for_test(&cfg_path);
unsafe { std::env::set_var("KACHE_KEY_ENV_VARS", "SOMETHING_ELSE") };
assert_eq!(
Config::load().unwrap().key_env_vars,
vec!["APP_MODE".to_string()]
);
assert!(IGNORE_ENV_GATED_VARS.contains(&"KACHE_KEY_ENV_VARS"));
restore(&prev);
}
#[test]
fn test_normalize_key_env_vars_keeps_interior_star_pattern() {
assert_eq!(
normalize_key_env_vars(["A*B".to_string(), " ".to_string()], "test"),
vec!["A*B".to_string()]
);
}
#[test]
fn test_env_overrides_detect() {
let overrides = EnvOverrides::detect();
let _ = overrides.disabled;
let _ = overrides.cache_dir;
}
#[test]
fn test_config_store_dir() {
let config = Config {
fallback: None,
key_salt: None,
cc_extra_allowlist_flags: Vec::new(),
local_only: false,
remote_readonly: false,
modified_input_guard: false,
local_hit_daemon: false,
windows_hardlink: false,
auto_gc: true,
storage_layout_advice: true,
heartbeat_secs: 30,
explain_miss: false,
path_only_env_vars: Vec::new(),
key_env_vars: Vec::new(),
base_dirs: Vec::new(),
cache_dir: PathBuf::from("/tmp/kache"),
max_size: 1024,
remote: None,
remote_error: None,
disabled: false,
cache_executables: false,
clean_incremental: true,
event_log_max_size: 1024,
event_log_keep_lines: 100,
compression_level: 3,
s3_concurrency: 16,
prefetch_enabled: DEFAULT_PREFETCH_ENABLED,
remote_key_cache_refresh_secs: DEFAULT_REMOTE_KEY_CACHE_REFRESH_SECS,
prefetch_max_keys: DEFAULT_PREFETCH_MAX_KEYS,
prefetch_max_bytes: DEFAULT_PREFETCH_MAX_BYTES,
prefetch_deadline_secs: DEFAULT_PREFETCH_DEADLINE_SECS,
daemon_idle_timeout_secs: DEFAULT_DAEMON_IDLE_TIMEOUT_SECS,
s3_pool_idle_secs: DEFAULT_S3_POOL_IDLE_SECS,
};
assert_eq!(config.store_dir(), PathBuf::from("/tmp/kache/store"));
}
#[test]
fn test_config_index_db_path() {
let config = Config {
fallback: None,
key_salt: None,
cc_extra_allowlist_flags: Vec::new(),
local_only: false,
remote_readonly: false,
modified_input_guard: false,
local_hit_daemon: false,
windows_hardlink: false,
auto_gc: true,
storage_layout_advice: true,
heartbeat_secs: 30,
explain_miss: false,
path_only_env_vars: Vec::new(),
key_env_vars: Vec::new(),
base_dirs: Vec::new(),
cache_dir: PathBuf::from("/tmp/kache"),
max_size: 1024,
remote: None,
remote_error: None,
disabled: false,
cache_executables: false,
clean_incremental: true,
event_log_max_size: 1024,
event_log_keep_lines: 100,
compression_level: 3,
s3_concurrency: 16,
prefetch_enabled: DEFAULT_PREFETCH_ENABLED,
remote_key_cache_refresh_secs: DEFAULT_REMOTE_KEY_CACHE_REFRESH_SECS,
prefetch_max_keys: DEFAULT_PREFETCH_MAX_KEYS,
prefetch_max_bytes: DEFAULT_PREFETCH_MAX_BYTES,
prefetch_deadline_secs: DEFAULT_PREFETCH_DEADLINE_SECS,
daemon_idle_timeout_secs: DEFAULT_DAEMON_IDLE_TIMEOUT_SECS,
s3_pool_idle_secs: DEFAULT_S3_POOL_IDLE_SECS,
};
assert_eq!(config.index_db_path(), PathBuf::from("/tmp/kache/index.db"));
}
#[test]
fn test_config_event_log_path() {
let config = Config {
fallback: None,
key_salt: None,
cc_extra_allowlist_flags: Vec::new(),
local_only: false,
remote_readonly: false,
modified_input_guard: false,
local_hit_daemon: false,
windows_hardlink: false,
auto_gc: true,
storage_layout_advice: true,
heartbeat_secs: 30,
explain_miss: false,
path_only_env_vars: Vec::new(),
key_env_vars: Vec::new(),
base_dirs: Vec::new(),
cache_dir: PathBuf::from("/tmp/kache"),
max_size: 1024,
remote: None,
remote_error: None,
disabled: false,
cache_executables: false,
clean_incremental: true,
event_log_max_size: 1024,
event_log_keep_lines: 100,
compression_level: 3,
s3_concurrency: 16,
prefetch_enabled: DEFAULT_PREFETCH_ENABLED,
remote_key_cache_refresh_secs: DEFAULT_REMOTE_KEY_CACHE_REFRESH_SECS,
prefetch_max_keys: DEFAULT_PREFETCH_MAX_KEYS,
prefetch_max_bytes: DEFAULT_PREFETCH_MAX_BYTES,
prefetch_deadline_secs: DEFAULT_PREFETCH_DEADLINE_SECS,
daemon_idle_timeout_secs: DEFAULT_DAEMON_IDLE_TIMEOUT_SECS,
s3_pool_idle_secs: DEFAULT_S3_POOL_IDLE_SECS,
};
assert_eq!(
config.event_log_path(),
PathBuf::from("/tmp/kache/events.jsonl")
);
}
#[test]
fn test_config_socket_path() {
let config = Config {
fallback: None,
key_salt: None,
cc_extra_allowlist_flags: Vec::new(),
local_only: false,
remote_readonly: false,
modified_input_guard: false,
local_hit_daemon: false,
windows_hardlink: false,
auto_gc: true,
storage_layout_advice: true,
heartbeat_secs: 30,
explain_miss: false,
path_only_env_vars: Vec::new(),
key_env_vars: Vec::new(),
base_dirs: Vec::new(),
cache_dir: PathBuf::from("/tmp/kache"),
max_size: 1024,
remote: None,
remote_error: None,
disabled: false,
cache_executables: false,
clean_incremental: true,
event_log_max_size: 1024,
event_log_keep_lines: 100,
compression_level: 3,
s3_concurrency: 16,
prefetch_enabled: DEFAULT_PREFETCH_ENABLED,
remote_key_cache_refresh_secs: DEFAULT_REMOTE_KEY_CACHE_REFRESH_SECS,
prefetch_max_keys: DEFAULT_PREFETCH_MAX_KEYS,
prefetch_max_bytes: DEFAULT_PREFETCH_MAX_BYTES,
prefetch_deadline_secs: DEFAULT_PREFETCH_DEADLINE_SECS,
daemon_idle_timeout_secs: DEFAULT_DAEMON_IDLE_TIMEOUT_SECS,
s3_pool_idle_secs: DEFAULT_S3_POOL_IDLE_SECS,
};
assert_eq!(
config.socket_path(),
PathBuf::from("/tmp/kache/daemon.sock")
);
}
#[test]
fn test_source_excluded_matches_relative_pattern_against_root() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("crates/problem/src/lib.rs");
let patterns = vec!["crates/problem/**".to_string()];
assert!(source_excluded_by_patterns(
&patterns,
&source,
&[dir.path().to_path_buf()]
));
}
#[test]
fn test_source_excluded_matches_source_as_passed() {
let patterns = vec!["src/*.c".to_string()];
assert!(source_excluded_by_patterns(
&patterns,
Path::new("src/foo.c"),
&[]
));
assert!(!source_excluded_by_patterns(
&patterns,
Path::new("include/foo.h"),
&[]
));
}
#[test]
fn test_exclude_expands_cargo_home_default_when_unset() {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
let cargo_home = home.join(".cargo").to_string_lossy().into_owned();
let (expanded, _) = expand_env_vars_collecting("$CARGO_HOME/registry/src/**", |_| None);
assert_eq!(expanded, format!("{cargo_home}/registry/src/**"));
let (expanded_braced, _) =
expand_env_vars_collecting("${CARGO_HOME}/registry/src/**", |_| None);
assert_eq!(expanded_braced, format!("{cargo_home}/registry/src/**"));
}
#[test]
fn expand_collecting_reports_unset_vars_only_once() {
let (expanded, unset) =
expand_env_vars_collecting("$MISSING/$MISSING/${ALSO_MISSING}/x", |_| None);
assert_eq!(expanded, "$MISSING/$MISSING/${ALSO_MISSING}/x");
assert_eq!(
unset,
vec!["MISSING".to_string(), "ALSO_MISSING".to_string()]
);
}
#[test]
fn expand_collecting_no_unset_when_resolved_or_defaulted() {
let (expanded, unset) =
expand_env_vars_collecting("$FOO/x", |k| (k == "FOO").then(|| "bar".to_string()));
assert_eq!(expanded, "bar/x");
assert!(unset.is_empty());
let (_, unset_default) = expand_env_vars_collecting("$CARGO_HOME/x", |_| None);
assert!(unset_default.is_empty());
}
#[test]
fn test_load_config_reads_exclude_patterns() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
std::fs::write(
&config_path,
r#"
[cache]
exclude = ["src/generated/**", "vendor/problem/**"]
"#,
)
.unwrap();
assert!(Config::source_excluded(
Path::new("src/generated/lib.rs"),
&[]
));
assert!(Config::source_excluded(
Path::new("vendor/problem/foo.c"),
&[]
));
assert!(!Config::source_excluded(Path::new("src/main.rs"), &[]));
}
#[test]
fn test_config_file_path() {
let path = config_file_path();
assert!(path.to_string_lossy().contains("kache"));
assert!(path.to_string_lossy().ends_with("config.toml"));
}
#[test]
fn test_resolve_config_path_prefers_kache_config() {
let path = resolve_config_path_from(Some(PathBuf::from("/tmp/managed/config.toml")), None);
assert_eq!(path, PathBuf::from("/tmp/managed/config.toml"));
}
#[test]
fn test_load_and_save_raw_file_config_use_resolved_path() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("managed/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
let config = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
local_store: Some("/tmp/managed-cache".to_string()),
..Default::default()
}),
};
Config::save_file_config_to(&config, &resolve_config_path()).unwrap();
assert!(config_path.exists());
let (loaded, existed) = Config::load_raw_file_config();
assert!(existed);
assert_eq!(
loaded.cache.as_ref().and_then(|c| c.local_store.as_deref()),
Some("/tmp/managed-cache")
);
}
#[test]
fn test_shellexpand_no_tilde() {
let path = shellexpand("/absolute/path");
assert_eq!(path, PathBuf::from("/absolute/path"));
}
#[test]
fn test_shellexpand_relative() {
let path = shellexpand("relative/path");
assert_eq!(path, PathBuf::from("relative/path"));
}
#[test]
fn test_shellexpand_bare_tilde() {
let path = shellexpand("~");
if let Some(home) = dirs::home_dir() {
assert_eq!(path, home);
} else {
assert_eq!(path, PathBuf::from("~"));
}
}
fn remove_env_var_for_test(key: &'static str) -> GenericEnvGuard {
let previous = std::env::var_os(key);
unsafe {
std::env::remove_var(key);
}
GenericEnvGuard { key, previous }
}
const S3_ENV_VARS: [&str; 5] = [
"KACHE_S3_BUCKET",
"KACHE_S3_ENDPOINT",
"KACHE_S3_REGION",
"KACHE_S3_PREFIX",
"KACHE_S3_PROFILE",
];
fn isolate_s3_env() -> Vec<GenericEnvGuard> {
S3_ENV_VARS
.into_iter()
.map(remove_env_var_for_test)
.collect()
}
struct GenericEnvGuard {
key: &'static str,
previous: Option<OsString>,
}
impl Drop for GenericEnvGuard {
fn drop(&mut self) {
unsafe {
match self.previous.as_ref() {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
}
fn set_env_var_for_test(key: &'static str, value: &str) -> GenericEnvGuard {
let previous = std::env::var_os(key);
unsafe {
std::env::set_var(key, value);
}
GenericEnvGuard { key, previous }
}
#[test]
fn test_kache_cache_dir_env_expands_bare_tilde() {
let _guard = config_path_lock();
if let Some(home) = dirs::home_dir() {
let _env_guard = set_env_var_for_test("KACHE_CACHE_DIR", "~");
let config = Config::load().unwrap();
assert_eq!(config.cache_dir, home);
}
}
#[test]
fn test_kache_config_env_expands_bare_tilde() {
let _guard = config_path_lock();
if let Some(home) = dirs::home_dir() {
let _env_guard = set_env_var_for_test("KACHE_CONFIG", "~");
let resolved = resolve_config_path();
assert_eq!(resolved, home);
}
}
#[test]
fn test_parse_size_various() {
assert_eq!(parse_size("1KiB"), Some(1024));
assert_eq!(parse_size("10GiB"), Some(10 * 1024 * 1024 * 1024));
assert_eq!(parse_size("0B"), Some(0));
assert!(parse_size("").is_none());
assert!(parse_size("abc").is_none());
}
#[test]
fn test_save_and_load_file_config() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let config = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
local_only: None,
remote_readonly: None,
modified_input_guard: None,
local_hit_daemon: None,
windows_hardlink: None,
auto_gc: None,
storage_layout_advice: None,
heartbeat_secs: None,
explain_miss: None,
ignore_env: None,
fallback: None,
key_salt: None,
path_only_env_vars: None,
key_env_vars: None,
local_store: Some("/tmp/my-cache".to_string()),
local_max_size: Some("10GiB".to_string()),
planner: None,
cache_executables: Some(true),
clean_incremental: None,
exclude: None,
event_log_max_size: None,
event_log_keep_lines: None,
compression_level: Some(5),
s3_concurrency: None,
prefetch_enabled: None,
remote_key_cache_refresh_secs: None,
prefetch_max_keys: None,
prefetch_max_bytes: None,
prefetch_deadline_secs: None,
daemon_idle_timeout_secs: None,
s3_pool_idle_secs: None,
remote: None,
}),
};
Config::save_file_config_to(&config, &config_path).unwrap();
assert!(config_path.exists());
let (loaded, existed) = Config::load_raw_file_config_from(&config_path);
assert!(existed);
assert_eq!(
loaded.cache.as_ref().unwrap().local_store.as_deref(),
Some("/tmp/my-cache")
);
assert_eq!(loaded.cache.as_ref().unwrap().compression_level, Some(5));
}
#[test]
fn test_load_raw_file_config_nonexistent() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("nonexistent/config.toml");
let (config, existed) = Config::load_raw_file_config_from(&config_path);
assert!(!existed);
assert!(config.cache.is_none());
}
#[test]
fn local_only_via_file_suppresses_remote_and_planner() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
let file = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
local_only: Some(true),
remote: Some(RemoteFileConfig {
bucket: Some("hermetic-bucket".to_string()),
..Default::default()
}),
planner: Some(PlannerFileConfig {
endpoint: Some("https://planner.example.com".to_string()),
..Default::default()
}),
..Default::default()
}),
};
Config::save_file_config_to(&file, &config_path).unwrap();
let config = Config::load().unwrap();
assert!(config.local_only, "local_only must be on");
assert!(
config.remote.is_none(),
"remote must be suppressed under local-only, got {:?}",
config.remote
);
assert!(
Config::load_planner_config().is_none(),
"planner must be suppressed under local-only"
);
}
#[test]
fn local_only_env_wins_over_file() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
let file = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
local_only: Some(true),
..Default::default()
}),
};
Config::save_file_config_to(&file, &config_path).unwrap();
let prev = std::env::var_os("KACHE_LOCAL_ONLY");
unsafe { std::env::set_var("KACHE_LOCAL_ONLY", "0") };
let off = Config::load().unwrap().local_only;
unsafe { std::env::set_var("KACHE_LOCAL_ONLY", "1") };
let on = Config::load().unwrap().local_only;
unsafe {
match prev {
Some(v) => std::env::set_var("KACHE_LOCAL_ONLY", v),
None => std::env::remove_var("KACHE_LOCAL_ONLY"),
}
}
assert!(
!off,
"KACHE_LOCAL_ONLY=0 must force local-only OFF despite file=true"
);
assert!(on, "KACHE_LOCAL_ONLY=1 must force local-only ON");
}
#[test]
fn storage_layout_advice_defaults_on_and_file_false_disables() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
assert!(
Config::load().unwrap().storage_layout_advice,
"advice must default ON with no config file"
);
let file = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
storage_layout_advice: Some(false),
heartbeat_secs: None,
explain_miss: None,
..Default::default()
}),
};
Config::save_file_config_to(&file, &config_path).unwrap();
assert!(
!Config::load().unwrap().storage_layout_advice,
"[cache] storage_layout_advice = false must mute the advisories"
);
}
#[test]
fn storage_layout_advice_env_wins_over_file() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
let file = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
storage_layout_advice: Some(false),
heartbeat_secs: None,
explain_miss: None,
..Default::default()
}),
};
Config::save_file_config_to(&file, &config_path).unwrap();
let prev = std::env::var_os("KACHE_STORAGE_LAYOUT_ADVICE");
unsafe { std::env::set_var("KACHE_STORAGE_LAYOUT_ADVICE", "1") };
let on = Config::load().unwrap().storage_layout_advice;
unsafe { std::env::set_var("KACHE_STORAGE_LAYOUT_ADVICE", "0") };
let off = Config::load().unwrap().storage_layout_advice;
unsafe {
match prev {
Some(v) => std::env::set_var("KACHE_STORAGE_LAYOUT_ADVICE", v),
None => std::env::remove_var("KACHE_STORAGE_LAYOUT_ADVICE"),
}
}
assert!(
on,
"KACHE_STORAGE_LAYOUT_ADVICE=1 must re-enable despite file=false"
);
assert!(
!off,
"KACHE_STORAGE_LAYOUT_ADVICE=0 must mute the advisories"
);
}
#[test]
fn test_remote_file_config_with_profile() {
let config = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
planner: None,
remote: Some(RemoteFileConfig {
_type: Some("s3".to_string()),
bucket: Some("mybucket".to_string()),
region: Some("eu-west-1".to_string()),
profile: Some("ceph".to_string()),
..Default::default()
}),
..Default::default()
}),
};
let serialized = toml::to_string_pretty(&config).unwrap();
assert!(serialized.contains("profile = \"ceph\""));
let deserialized: FileConfig = toml::from_str(&serialized).unwrap();
assert_eq!(
deserialized
.cache
.unwrap()
.remote
.unwrap()
.profile
.as_deref(),
Some("ceph")
);
}
#[test]
fn test_load_remote_config_from_file_fields() {
let _guard = config_path_lock();
for v in [
"KACHE_S3_BUCKET",
"KACHE_S3_ENDPOINT",
"KACHE_S3_REGION",
"KACHE_S3_PREFIX",
"KACHE_S3_PROFILE",
] {
unsafe { std::env::remove_var(v) };
}
let file = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
planner: None,
remote: Some(RemoteFileConfig {
_type: Some("s3".to_string()),
bucket: Some("filebucket".to_string()),
endpoint: Some("https://s3.example.com".to_string()),
region: Some("eu-west-2".to_string()),
prefix: Some("myprefix".to_string()),
profile: Some(" ceph ".to_string()),
..Default::default()
}),
..Default::default()
}),
};
let remote = Config::load_remote_config(&Ok(file))
.expect("valid remote config")
.expect("remote from file");
assert_eq!(remote.prefix, "myprefix");
let RemoteBackendConfig::S3(s3) = remote.backend else {
panic!("expected S3 remote");
};
assert_eq!(s3.bucket, "filebucket");
assert_eq!(s3.endpoint.as_deref(), Some("https://s3.example.com"));
assert_eq!(s3.region, "eu-west-2");
assert_eq!(s3.profile.as_deref(), Some("ceph"));
let empty = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
planner: None,
remote: None,
..Default::default()
}),
};
assert!(
Config::load_remote_config(&Ok(empty))
.expect("empty config is valid")
.is_none()
);
}
#[test]
fn filesystem_remote_loads_without_a_bucket_and_defaults_atomic_dir() {
let _guard = config_path_lock();
let root = tempfile::tempdir().unwrap();
let file = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
_type: Some("filesystem".to_string()),
path: Some(root.path().to_string_lossy().into_owned()),
prefix: Some("shared".to_string()),
..Default::default()
}),
..Default::default()
}),
};
let remote = Config::load_remote_config(&Ok(file))
.expect("valid filesystem config")
.expect("filesystem remote");
assert_eq!(remote.prefix, "shared");
let RemoteBackendConfig::Filesystem(fs) = remote.backend else {
panic!("expected filesystem remote");
};
assert_eq!(fs.root, root.path());
assert_eq!(fs.atomic_write_dir, root.path().join(".kache-tmp"));
}
#[test]
fn filesystem_remote_ignores_legacy_s3_environment_overrides() {
let _guard = config_path_lock();
let _bucket = set_env_var_for_test("KACHE_S3_BUCKET", "ambient-bucket");
let _prefix = set_env_var_for_test("KACHE_S3_PREFIX", "ambient-prefix");
let root = tempfile::tempdir().unwrap();
let file = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
_type: Some("filesystem".to_string()),
path: Some(root.path().to_string_lossy().into_owned()),
prefix: Some("file-prefix".to_string()),
..Default::default()
}),
..Default::default()
}),
};
let remote = Config::load_remote_config(&Ok(file))
.expect("valid filesystem config")
.expect("filesystem remote");
assert_eq!(remote.prefix, "file-prefix");
assert!(matches!(
remote.backend,
RemoteBackendConfig::Filesystem(FilesystemRemoteConfig { root: loaded, .. })
if loaded == root.path()
));
}
#[test]
fn filesystem_remote_rejects_a_windows_drive_prefix() {
let root = tempfile::tempdir().unwrap();
let file = FileConfig {
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
_type: Some("filesystem".to_string()),
path: Some(root.path().to_string_lossy().into_owned()),
prefix: Some("C:/escape".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let error = Config::load_remote_config(&Ok(file))
.expect_err("filesystem drive prefix must be rejected")
.to_string();
assert!(error.contains("cannot contain ':'"), "{error}");
}
#[test]
fn legacy_remote_without_type_still_infers_s3() {
let _guard = config_path_lock();
let file = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
bucket: Some("legacy".to_string()),
..Default::default()
}),
..Default::default()
}),
};
let remote = Config::load_remote_config(&Ok(file))
.expect("legacy config is valid")
.expect("legacy S3 remote");
assert!(matches!(
remote.backend,
RemoteBackendConfig::S3(S3RemoteConfig { bucket, .. }) if bucket == "legacy"
));
}
#[test]
fn explicit_s3_rejects_an_empty_bucket() {
let _guard = config_path_lock();
let _bucket = set_env_var_for_test("KACHE_S3_BUCKET", "");
let file = FileConfig {
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
_type: Some("s3".to_string()),
bucket: Some(" ".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let error = Config::load_remote_config(&Ok(file))
.expect_err("empty S3 bucket must be rejected")
.to_string();
assert!(error.contains("non-empty bucket"), "{error}");
}
#[test]
fn legacy_noncanonical_prefixes_normalize_instead_of_failing() {
let _guard = config_path_lock();
let _isolated = isolate_s3_env();
let _bucket = set_env_var_for_test("KACHE_S3_BUCKET", "legacy-bucket");
for (configured, expected) in [("team/", "team"), ("/team", "team"), ("a//b", "a/b")] {
let file = FileConfig {
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
prefix: Some(configured.to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let remote = Config::load_remote_config(&Ok(file))
.unwrap_or_else(|e| panic!("{configured:?} must not fail: {e:#}"))
.expect("remote");
assert_eq!(remote.prefix, expected, "{configured:?}");
}
}
#[test]
fn legacy_empty_env_prefix_means_the_bucket_root() {
let _guard = config_path_lock();
let _isolated = isolate_s3_env();
let _bucket = set_env_var_for_test("KACHE_S3_BUCKET", "legacy-bucket");
let _prefix = set_env_var_for_test("KACHE_S3_PREFIX", "");
let remote = Config::load_remote_config(&Ok(FileConfig::default()))
.expect("empty prefix must be accepted")
.expect("remote");
assert_eq!(remote.prefix, "");
}
#[test]
fn empty_env_bucket_disables_the_remote_instead_of_falling_back() {
let _guard = config_path_lock();
let _isolated = isolate_s3_env();
let _bucket = set_env_var_for_test("KACHE_S3_BUCKET", "");
let file = FileConfig {
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
bucket: Some("production-cache".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
assert!(
Config::load_remote_config(&Ok(file))
.expect("empty override is not an error without an explicit type")
.is_none(),
"an empty KACHE_S3_BUCKET must not select the file-configured bucket"
);
}
#[test]
fn unusable_remote_config_degrades_to_local_only() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.toml");
let _g = set_kache_config_for_test(&cfg);
let _isolated = isolate_s3_env();
std::fs::write(
&cfg,
"[cache.remote]\ntype = \"s3\"\nbucket = \"b\"\nprefix = \"a/../b\"\n",
)
.unwrap();
let loaded = Config::load().expect("a bad remote must not fail Config::load");
assert!(loaded.remote.is_none(), "remote must be dropped");
let reason = loaded
.remote_error
.as_deref()
.expect("reason must be recorded");
assert!(reason.contains("path segments"), "{reason}");
let error = loaded
.require_remote()
.expect_err("require_remote must fail");
assert!(error.to_string().contains("unusable"), "{error}");
}
#[test]
fn staging_dir_inside_the_object_tree_is_rejected() {
let root = std::path::Path::new("/tmp/kache-remote");
let problem =
filesystem_staging_problem(root, &root.join("artifacts/v3/staging"), "artifacts")
.expect("staging inside the object tree must be rejected");
assert!(problem.contains("inside the object tree"), "{problem}");
assert!(filesystem_staging_problem(root, &root.join(".kache-tmp"), "artifacts").is_none());
assert!(
filesystem_staging_problem(root, &root.join(".kache-tmp"), "").is_none(),
"the default staging dir must be accepted with an empty prefix"
);
assert!(filesystem_staging_problem(root, &root.join("v3/staging"), "").is_some());
}
#[test]
fn filesystem_remote_with_an_empty_prefix_resolves() {
let _guard = config_path_lock();
let _isolated = isolate_s3_env();
let dir = tempfile::tempdir().unwrap();
let file = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
_type: Some("filesystem".to_string()),
path: Some(dir.path().to_string_lossy().to_string()),
prefix: Some(String::new()),
..Default::default()
}),
..Default::default()
}),
};
let remote = Config::load_remote_config(&Ok(file))
.expect("an empty prefix must be usable")
.expect("remote");
assert_eq!(remote.prefix, "");
}
#[test]
fn filesystem_remote_rejects_mixed_s3_fields() {
let file = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
_type: Some("filesystem".to_string()),
path: Some("/tmp/kache-remote".to_string()),
bucket: Some("wrong-backend".to_string()),
..Default::default()
}),
..Default::default()
}),
};
let error = Config::load_remote_config(&Ok(file))
.expect_err("mixed backend fields must be rejected")
.to_string();
assert!(error.contains("cannot include S3"), "{error}");
}
#[test]
fn test_load_planner_config_from_file() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
let config = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
planner: Some(PlannerFileConfig {
endpoint: Some("https://planner.example.com".to_string()),
timeout_ms: Some(1200),
token: Some("secret".to_string()),
}),
..Default::default()
}),
};
Config::save_file_config_to(&config, &config_path).unwrap();
let loaded = Config::load_planner_config().unwrap();
assert_eq!(loaded.endpoint, "https://planner.example.com");
assert_eq!(loaded.timeout_ms, 1200);
assert_eq!(loaded.token.as_deref(), Some("secret"));
}
#[test]
fn test_load_planner_config_env_overrides_file() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
let config = FileConfig {
cc: None,
paths: None,
cache: Some(CacheFileConfig {
planner: Some(PlannerFileConfig {
endpoint: Some("https://planner.example.com".to_string()),
timeout_ms: Some(1200),
token: Some("secret".to_string()),
}),
..Default::default()
}),
};
Config::save_file_config_to(&config, &config_path).unwrap();
struct ScopedVar {
key: &'static str,
previous: Option<OsString>,
}
impl ScopedVar {
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var_os(key);
unsafe {
std::env::set_var(key, value);
}
Self { key, previous }
}
}
impl Drop for ScopedVar {
fn drop(&mut self) {
match &self.previous {
Some(value) => unsafe {
std::env::set_var(self.key, value);
},
None => unsafe {
std::env::remove_var(self.key);
},
}
}
}
let _endpoint = ScopedVar::set("KACHE_PLANNER_ENDPOINT", "https://env.example.com");
let _timeout = ScopedVar::set("KACHE_PLANNER_TIMEOUT_MS", "400");
let _token = ScopedVar::set("KACHE_PLANNER_TOKEN", "env-token");
let loaded = Config::load_planner_config().unwrap();
assert_eq!(loaded.endpoint, "https://env.example.com");
assert_eq!(loaded.timeout_ms, 400);
assert_eq!(loaded.token.as_deref(), Some("env-token"));
}
#[test]
fn test_resolve_config_path_prefers_project_file() {
let dir = tempfile::tempdir().unwrap();
let project_root = dir.path().join("workspace");
let nested_dir = project_root.join("crate/src");
std::fs::create_dir_all(&nested_dir).unwrap();
let project_config = project_root.join(PROJECT_CONFIG_NAME);
std::fs::write(&project_config, "[cache]\n").unwrap();
let resolved = resolve_config_path_from(None, Some(nested_dir));
assert_eq!(resolved, project_config);
}
#[test]
fn test_resolve_config_path_env_overrides_project_file() {
let dir = tempfile::tempdir().unwrap();
let project_root = dir.path().join("workspace");
std::fs::create_dir_all(&project_root).unwrap();
let project_config = project_root.join(PROJECT_CONFIG_NAME);
let env_config = dir.path().join("explicit-kache.toml");
std::fs::write(&project_config, "[cache]\n").unwrap();
let resolved = resolve_config_path_from(Some(env_config.clone()), Some(project_root));
assert_eq!(resolved, env_config);
}
#[test]
fn test_resolve_config_path_falls_back_to_global_when_no_project_file() {
let dir = tempfile::tempdir().unwrap();
let nested_dir = dir.path().join("workspace/crate");
std::fs::create_dir_all(&nested_dir).unwrap();
let resolved = resolve_config_path_from(None, Some(nested_dir));
assert_eq!(resolved, config_file_path());
}
#[test]
fn test_normalize_cc_flags_trims_dedupes_and_drops_empty() {
let input = [
" -O2 ".to_string(),
"-O2".to_string(), String::new(), " ".to_string(), "-fPIC".to_string(),
" -fPIC".to_string(), ];
assert_eq!(
normalize_cc_flags(input),
vec!["-O2".to_string(), "-fPIC".to_string()]
);
assert!(normalize_cc_flags(Vec::<String>::new()).is_empty());
}
}