use assert_cmd::Command;
use std::path::Path;
use tempfile::TempDir;
const KACHE_BIN: &str = env!("CARGO_BIN_EXE_kache");
fn kache(home: &Path, cache_dir: &Path) -> Command {
let mut cmd = Command::new(KACHE_BIN);
cmd.env("KACHE_CACHE_DIR", cache_dir)
.env("KACHE_CONFIG", cache_dir.join("config.toml"))
.env("KACHE_LOG", "off")
.env("HOME", home)
.env("CARGO_HOME", home.join(".cargo"))
.env("KACHE_DAEMON_IDLE_TIMEOUT", "3")
.env_remove("RUSTC_WRAPPER")
.env_remove("CARGO_BUILD_RUSTC_WRAPPER");
cmd
}
struct Env {
_home: TempDir,
_cache: TempDir,
home: std::path::PathBuf,
cache: std::path::PathBuf,
}
fn env() -> Env {
let home = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
Env {
home: home.path().to_path_buf(),
cache: cache.path().to_path_buf(),
_home: home,
_cache: cache,
}
}
impl Env {
fn cmd(&self) -> Command {
kache(&self.home, &self.cache)
}
fn wrapper_build(&self, project: &Path, target_dir: &Path) -> std::process::Output {
std::process::Command::new(env!("CARGO"))
.args(["build", "--lib"])
.current_dir(project)
.env("RUSTC_WRAPPER", KACHE_BIN)
.env("KACHE_CACHE_DIR", &self.cache)
.env("KACHE_CONFIG", self.cache.join("config.toml"))
.env("KACHE_LOG", "off")
.env("HOME", &self.home)
.env("KACHE_DAEMON_IDLE_TIMEOUT", "3")
.env("CARGO_TARGET_DIR", target_dir)
.env("CARGO_INCREMENTAL", "0")
.output()
.expect("run cargo build with kache wrapper")
}
}
fn scaffold_lib(name: &str, body: &str) -> TempDir {
let project = TempDir::new().unwrap();
std::fs::create_dir_all(project.path().join("src")).unwrap();
std::fs::write(
project.path().join("Cargo.toml"),
format!(
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
[lib]\npath = \"src/lib.rs\"\n\n[workspace]\n"
),
)
.unwrap();
std::fs::write(project.path().join("src/lib.rs"), body).unwrap();
project
}
fn cc_available() -> bool {
std::process::Command::new("cc")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[test]
fn version_prints_package_version() {
let e = env();
e.cmd()
.arg("--version")
.assert()
.success()
.stdout(predicates::str::contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn help_lists_subcommands() {
let e = env();
e.cmd()
.arg("--help")
.assert()
.success()
.stdout(predicates::str::contains("list"))
.stdout(predicates::str::contains("report"))
.stdout(predicates::str::contains("doctor"))
.stdout(predicates::str::contains("completions"));
}
#[test]
fn unknown_subcommand_is_a_usage_error() {
let e = env();
e.cmd()
.arg("definitely-not-a-command")
.assert()
.failure()
.code(2);
}
#[test]
fn subcommand_help_is_available() {
for sub in [
"list",
"report",
"gc",
"purge",
"doctor",
"stats",
"sync",
"completions",
] {
let e = env();
e.cmd()
.args([sub, "--help"])
.assert()
.success()
.stdout(predicates::str::contains("Usage"));
}
}
#[test]
fn completions_zsh_emits_script() {
let e = env();
e.cmd()
.args(["completions", "zsh"])
.assert()
.success()
.stdout(predicates::str::contains("#compdef"))
.stdout(predicates::str::contains("kache"));
}
#[test]
fn completions_bash_emits_script() {
let e = env();
e.cmd()
.args(["completions", "bash"])
.assert()
.success()
.stdout(predicates::str::contains("complete"))
.stdout(predicates::str::contains("kache"));
}
#[test]
fn completions_fish_emits_script() {
let e = env();
e.cmd()
.args(["completions", "fish"])
.assert()
.success()
.stdout(predicates::str::contains("complete"))
.stdout(predicates::str::contains("kache"));
}
#[test]
fn completions_invalid_shell_is_a_usage_error() {
let e = env();
e.cmd()
.args(["completions", "not-a-shell"])
.assert()
.failure()
.code(2);
}
#[test]
fn completions_work_with_broken_config() {
let e = env();
std::fs::write(e.cache.join("config.toml"), "{{{{ not toml").unwrap();
e.cmd()
.args(["completions", "zsh"])
.assert()
.success()
.stdout(predicates::str::contains("#compdef"));
}
#[test]
fn list_on_empty_cache_reports_no_entries() {
let e = env();
e.cmd()
.arg("list")
.assert()
.success()
.stdout(predicates::str::contains("No cached entries"));
}
#[test]
fn list_accepts_all_sort_modes() {
for sort in ["name", "size", "hits", "age"] {
let e = env();
e.cmd().args(["list", "--sort", sort]).assert().success();
}
}
#[test]
fn list_for_specific_missing_crate_succeeds() {
let e = env();
e.cmd().args(["list", "serde"]).assert().success();
}
#[test]
fn stats_on_empty_cache_succeeds() {
let e = env();
e.cmd().arg("stats").assert().success();
let e = env();
e.cmd().args(["stats", "--since", "1h"]).assert().success();
}
#[test]
fn report_emits_each_format() {
for format in ["text", "json", "markdown", "github"] {
let e = env();
e.cmd()
.args(["report", "--format", format])
.assert()
.success();
}
}
#[test]
fn report_json_on_empty_cache_is_valid_json() {
let e = env();
let out = e
.cmd()
.args(["report", "--format", "json", "--since", "24h"])
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).expect("valid json");
assert_eq!(v["summary"]["misses"].as_u64(), Some(0));
assert_eq!(v["summary"]["local_hits"].as_u64(), Some(0));
}
#[test]
fn report_writes_to_output_file() {
let e = env();
let out = e.cache.join("report.json");
e.cmd()
.args(["report", "--format", "json", "--top", "3", "-o"])
.arg(&out)
.assert()
.success();
let body = std::fs::read_to_string(&out).expect("report file written");
let _: serde_json::Value = serde_json::from_str(&body).expect("valid json on disk");
}
#[test]
fn gc_on_empty_cache_succeeds() {
let e = env();
e.cmd().arg("gc").assert().success();
let e = env();
e.cmd().args(["gc", "--max-age", "7d"]).assert().success();
}
#[test]
fn purge_on_empty_cache_succeeds() {
let e = env();
e.cmd().arg("purge").assert().success();
let e = env();
e.cmd()
.args(["purge", "--crate-name", "serde"])
.assert()
.success();
}
#[test]
fn why_miss_on_unknown_crate_succeeds() {
let e = env();
e.cmd().args(["why-miss", "serde"]).assert().success();
}
#[test]
fn clean_dry_run_reports_no_targets_in_empty_dir() {
let e = env();
let workdir = TempDir::new().unwrap();
e.cmd()
.current_dir(workdir.path())
.args(["clean", "--dry-run"])
.assert()
.success()
.stdout(predicates::str::contains("No target/ directories found"));
}
#[cfg(not(target_os = "macos"))]
#[test]
fn clean_dry_run_reports_a_populated_target_dir() {
let e = env();
let project = TempDir::new().unwrap();
let root = project.path();
std::fs::write(root.join("Cargo.toml"), "[package]\nname=\"p\"\n").unwrap();
for profile in ["debug", "release"] {
let deps = root.join("target").join(profile).join("deps");
std::fs::create_dir_all(&deps).unwrap();
std::fs::write(deps.join("libfoo.rlib"), vec![0u8; 4096]).unwrap();
std::fs::write(deps.join("libfoo.rmeta"), vec![0u8; 1024]).unwrap();
std::fs::write(deps.join("foo.d"), b"foo.o: foo.rs\n").unwrap();
}
e.cmd()
.current_dir(root)
.args(["clean", "--dry-run"])
.assert()
.success()
.stdout(predicates::str::contains("Found 1 target"))
.stdout(predicates::str::contains("Dry run: would free"));
}
#[test]
fn doctor_runs_readonly_diagnostics() {
let e = env();
e.cmd().arg("doctor").assert().success();
}
#[test]
fn doctor_reports_wrapper_configured_via_env() {
let e = env();
e.cmd()
.env("RUSTC_WRAPPER", "kache")
.arg("doctor")
.assert()
.success()
.stdout(predicates::str::contains("RUSTC_WRAPPER"));
}
#[test]
fn doctor_reports_wrapper_configured_via_cargo_config() {
let e = env();
let cargo_dir = e.home.join(".cargo");
std::fs::create_dir_all(&cargo_dir).unwrap();
std::fs::write(
cargo_dir.join("config.toml"),
"[build]\nrustc-wrapper = \"kache\"\n",
)
.unwrap();
e.cmd()
.current_dir(&e.home) .arg("doctor")
.assert()
.success()
.stdout(predicates::str::contains("RUSTC_WRAPPER"));
}
#[test]
fn doctor_reports_local_only_mode() {
let e = env();
e.cmd()
.env("KACHE_LOCAL_ONLY", "1")
.arg("doctor")
.assert()
.success()
.stdout(predicates::str::contains("local-only"));
}
#[test]
fn doctor_reports_configured_remote() {
let e = env();
e.cmd()
.env("KACHE_S3_BUCKET", "my-remote-bucket")
.arg("doctor")
.assert()
.success()
.stdout(predicates::str::contains("my-remote-bucket"));
}
#[test]
fn doctor_reports_reachable_daemon_version() {
let e = env();
let project = scaffold_lib("kachedoc", "pub fn d() -> u8 { 1 }\n");
let target_dir = project.path().join("target");
let build = std::process::Command::new(env!("CARGO"))
.args(["build", "--lib"])
.current_dir(project.path())
.env("RUSTC_WRAPPER", KACHE_BIN)
.env("KACHE_CACHE_DIR", &e.cache)
.env("KACHE_CONFIG", e.cache.join("config.toml"))
.env("KACHE_LOG", "off")
.env("HOME", &e.home)
.env("KACHE_DAEMON_IDLE_TIMEOUT", "30") .env("CARGO_TARGET_DIR", &target_dir)
.env("CARGO_INCREMENTAL", "0")
.output()
.expect("run build");
assert!(build.status.success(), "build failed");
e.cmd()
.arg("doctor")
.assert()
.success()
.stdout(predicates::str::contains("Daemon version"));
}
#[test]
fn doctor_verify_on_empty_cache_succeeds() {
let e = env();
e.cmd().args(["doctor", "--verify"]).assert().success();
let e = env();
e.cmd()
.args(["doctor", "--verify", "--checksums"])
.assert()
.success();
}
#[test]
fn doctor_fix_in_isolated_home_succeeds() {
let e = env();
std::fs::create_dir_all(e.home.join(".cargo")).unwrap();
e.cmd().args(["doctor", "--fix"]).assert().success();
}
#[cfg(unix)]
#[test]
fn doctor_fix_migrates_sccache_cargo_config_to_kache() {
let e = env();
let cargo_dir = e.home.join(".cargo");
std::fs::create_dir_all(&cargo_dir).unwrap();
std::fs::write(
cargo_dir.join("config.toml"),
"[build]\nrustc-wrapper = \"sccache\"\n",
)
.unwrap();
std::fs::write(
e.home.join(".zshrc"),
"export RUSTC_WRAPPER=sccache\n# sccache comment line\n",
)
.unwrap();
e.cmd().args(["doctor", "--fix"]).assert().success();
let rewritten = std::fs::read_to_string(cargo_dir.join("config.toml")).unwrap();
assert!(
rewritten.contains("kache") && !rewritten.contains("sccache"),
"migrate should replace sccache with kache: {rewritten}"
);
}
#[test]
fn doctor_fix_migrates_sccache_config_in_custom_cargo_home() {
let e = env();
let cargo_home = e.home.join("custom-cargo-home");
std::fs::create_dir_all(&cargo_home).unwrap();
std::fs::write(
cargo_home.join("config.toml"),
"[build]\nrustc-wrapper = \"sccache\"\n",
)
.unwrap();
e.cmd()
.env("CARGO_HOME", &cargo_home)
.args(["doctor", "--fix"])
.assert()
.success();
let rewritten = std::fs::read_to_string(cargo_home.join("config.toml")).unwrap();
assert!(
rewritten.contains("kache") && !rewritten.contains("sccache"),
"migrate should rewrite the $CARGO_HOME config: {rewritten}"
);
assert!(
!e.home.join(".cargo").join("config.toml").exists(),
"migrate must not touch ~/.cargo when CARGO_HOME points elsewhere"
);
}
#[test]
fn init_check_is_a_dry_run() {
let e = env();
std::fs::create_dir_all(e.home.join(".cargo")).unwrap();
e.cmd().args(["init", "--check"]).assert().success();
}
#[test]
fn init_noninteractive_writes_isolated_cargo_config() {
let e = env();
let cargo_home = e.home.join(".cargo");
std::fs::create_dir_all(&cargo_home).unwrap();
e.cmd()
.args(["init", "--no-service"])
.write_stdin("y\nn\n")
.assert()
.success();
assert!(
cargo_home.join("config.toml").exists(),
"init should have written an isolated cargo config"
);
}
#[test]
fn init_writes_config_to_custom_cargo_home() {
let e = env();
let cargo_home = e.home.join("custom-cargo-home");
e.cmd()
.env("CARGO_HOME", &cargo_home)
.args(["init", "--no-service"])
.write_stdin("y\nn\n")
.assert()
.success();
assert!(
cargo_home.join("config.toml").exists(),
"init should write the wrapper config under $CARGO_HOME"
);
assert!(
!e.home.join(".cargo").join("config.toml").exists(),
"init must not touch ~/.cargo when CARGO_HOME points elsewhere"
);
}
#[test]
fn daemon_without_subcommand_succeeds() {
let e = env();
e.cmd().arg("daemon").assert().success();
}
#[test]
fn commands_operate_on_a_populated_cache() {
let e = env();
let project = scaffold_lib("kachetestlib", "pub fn answer() -> u8 { 42 }\n");
let target_dir = project.path().join("target");
let cold = e.wrapper_build(project.path(), &target_dir);
assert!(
cold.status.success(),
"cold build failed: {}",
String::from_utf8_lossy(&cold.stderr)
);
std::fs::remove_dir_all(&target_dir).ok();
let warm = e.wrapper_build(project.path(), &target_dir);
assert!(
warm.status.success(),
"warm build failed: {}",
String::from_utf8_lossy(&warm.stderr)
);
e.cmd()
.arg("list")
.assert()
.success()
.stdout(predicates::str::contains("kachetestlib"));
let out = e
.cmd()
.args(["report", "--format", "json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).expect("valid json");
assert_eq!(v["summary"]["local_hits"].as_u64(), Some(1), "report: {v}");
assert_eq!(v["summary"]["misses"].as_u64(), Some(1), "report: {v}");
assert!(
v["top_hits"]
.as_array()
.map(|a| !a.is_empty())
.unwrap_or(false),
"expected a non-empty top_hits list: {v}"
);
assert!(
v["storage"]["store_blobs"].as_u64().unwrap_or(0) >= 1,
"expected stored blobs: {v}"
);
e.cmd()
.arg("stats")
.assert()
.success()
.stdout(predicates::str::contains("Store:"))
.stdout(predicates::str::contains("Hit rate:"))
.stdout(predicates::str::contains("Dedup:"))
.stdout(predicates::str::contains("Time saved:"));
e.cmd()
.args(["doctor", "--verify", "--checksums"])
.assert()
.success();
e.cmd()
.args(["why-miss", "kachetestlib"])
.assert()
.success()
.stdout(predicates::str::contains("kachetestlib"));
e.cmd().args(["list", "kachetestlib"]).assert().success();
e.cmd()
.args(["purge", "--crate-name", "kachetestlib"])
.assert()
.success();
}
#[test]
fn why_miss_reports_key_mismatch_after_source_edit() {
let e = env();
let project = scaffold_lib("kachediff", "pub fn v() -> u8 { 1 }\n");
let target_dir = project.path().join("target");
assert!(
e.wrapper_build(project.path(), &target_dir)
.status
.success(),
"first build failed"
);
std::fs::remove_dir_all(&target_dir).ok();
std::fs::write(
project.path().join("src/lib.rs"),
"pub fn v() -> u32 { 123456 }\n",
)
.unwrap();
assert!(
e.wrapper_build(project.path(), &target_dir)
.status
.success(),
"second build failed"
);
e.cmd()
.args(["why-miss", "kachediff"])
.assert()
.success()
.stdout(predicates::str::contains("Why `kachediff` missed"))
.stdout(predicates::str::contains("Diagnosis"));
e.cmd()
.args(["list", "kachediff"])
.assert()
.success()
.stdout(predicates::str::contains("kachediff"));
}
#[test]
fn cc_link_invocation_passes_through() {
if !cc_available() {
eprintln!("skipping cc_link_invocation_passes_through: no cc on PATH");
return;
}
let e = env();
let project = TempDir::new().unwrap();
std::fs::write(project.path().join("main.c"), "int main(void){return 0;}\n").unwrap();
e.cmd()
.current_dir(project.path())
.args(["cc", "main.c", "-o", "app"])
.assert()
.success();
assert!(
project.path().join("app").exists() || project.path().join("app.exe").exists(),
"passthrough should have produced the linked binary"
);
let out = e
.cmd()
.args(["report", "--format", "json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).expect("valid json");
assert!(
v["summary"]["passthroughs"].as_u64().unwrap_or(0) >= 1,
"report should record a passthrough: {v}"
);
}
#[test]
fn cc_compile_roundtrips_through_cache() {
if !cc_available() {
eprintln!("skipping cc_compile_roundtrips_through_cache: no cc on PATH");
return;
}
let e = env();
let project = TempDir::new().unwrap();
std::fs::write(
project.path().join("add.c"),
"int add(int a, int b){return a + b;}\n",
)
.unwrap();
e.cmd()
.current_dir(project.path())
.args(["cc", "-c", "add.c", "-o", "add.o"])
.assert()
.success();
assert!(
project.path().join("add.o").exists(),
"first compile output"
);
std::fs::remove_file(project.path().join("add.o")).unwrap();
e.cmd()
.current_dir(project.path())
.args(["cc", "-c", "add.c", "-o", "add.o"])
.assert()
.success();
assert!(
project.path().join("add.o").exists(),
"second compile should restore the object from cache"
);
let out = e
.cmd()
.args(["report", "--format", "json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).expect("valid json");
assert!(
v["summary"]["local_hits"].as_u64().unwrap_or(0) >= 1,
"report should record a local hit after the second compile: {v}"
);
}
#[test]
fn cc_compile_with_depinfo_roundtrips() {
if !cc_available() {
eprintln!("skipping cc_compile_with_depinfo_roundtrips: no cc on PATH");
return;
}
let e = env();
let project = TempDir::new().unwrap();
std::fs::write(project.path().join("dep.c"), "int dep(void){return 7;}\n").unwrap();
let args = ["cc", "-c", "dep.c", "-MMD", "-MF", "dep.d", "-o", "dep.o"];
e.cmd()
.current_dir(project.path())
.args(args)
.assert()
.success();
assert!(project.path().join("dep.o").exists(), "object produced");
assert!(project.path().join("dep.d").exists(), "depfile produced");
std::fs::remove_file(project.path().join("dep.o")).unwrap();
std::fs::remove_file(project.path().join("dep.d")).unwrap();
e.cmd()
.current_dir(project.path())
.args(args)
.assert()
.success();
assert!(
project.path().join("dep.o").exists(),
"object restored from cache"
);
assert!(
project.path().join("dep.d").exists(),
"depfile restored from cache"
);
}
#[test]
fn cc_entry_lacking_depfile_is_evicted_then_recached() {
if !cc_available() {
eprintln!("skipping cc_entry_lacking_depfile_is_evicted_then_recached: no cc on PATH");
return;
}
let e = env();
let project = TempDir::new().unwrap();
std::fs::write(project.path().join("ev.c"), "int ev(void){return 9;}\n").unwrap();
e.cmd()
.current_dir(project.path())
.args(["cc", "-c", "ev.c", "-o", "ev.o"])
.assert()
.success();
e.cmd()
.current_dir(project.path())
.args(["cc", "-c", "ev.c", "-MMD", "-MF", "ev.d", "-o", "ev.o"])
.assert()
.success();
assert!(
project.path().join("ev.d").exists(),
"recompile after eviction should produce the depfile"
);
std::fs::remove_file(project.path().join("ev.o")).unwrap();
std::fs::remove_file(project.path().join("ev.d")).unwrap();
e.cmd()
.current_dir(project.path())
.args(["cc", "-c", "ev.c", "-MMD", "-MF", "ev.d", "-o", "ev.o"])
.assert()
.success();
assert!(
project.path().join("ev.d").exists() && project.path().join("ev.o").exists(),
"re-cached entry should satisfy the depfile invocation on hit"
);
}
fn first_file_under(dir: &Path) -> Option<std::path::PathBuf> {
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
for entry in std::fs::read_dir(&d).ok()?.flatten() {
let p = entry.path();
if p.is_dir() {
stack.push(p);
} else if p.is_file() {
return Some(p);
}
}
}
None
}
#[test]
fn doctor_repair_removes_corrupted_entries() {
let e = env();
let project = scaffold_lib("repairlib", "pub fn r() -> u8 { 5 }\n");
let target_dir = project.path().join("target");
assert!(
e.wrapper_build(project.path(), &target_dir)
.status
.success(),
"build failed"
);
let blobs = e.cache.join("store").join("blobs");
let blob = first_file_under(&blobs).expect("a stored blob should exist");
let mut perms = std::fs::metadata(&blob).unwrap().permissions();
perms.set_readonly(false);
std::fs::set_permissions(&blob, perms).unwrap();
std::fs::write(&blob, b"this is not the original blob content").unwrap();
e.cmd()
.args(["doctor", "--checksums", "--repair"])
.assert()
.success()
.stdout(predicates::str::contains("corrupted"))
.stdout(predicates::str::contains("Repairing: removing"));
e.cmd()
.arg("list")
.assert()
.success()
.stdout(predicates::str::contains("No cached entries"));
}
#[test]
fn gc_max_age_runs_eviction_sweep_on_populated_cache() {
let e = env();
let project = scaffold_lib("kachegc", "pub fn g() -> u8 { 7 }\n");
let target_dir = project.path().join("target");
assert!(
e.wrapper_build(project.path(), &target_dir)
.status
.success(),
"build failed"
);
e.cmd()
.arg("list")
.assert()
.success()
.stdout(predicates::str::contains("kachegc"));
e.cmd()
.args(["gc", "--max-age", "0h"])
.assert()
.success()
.stdout(predicates::str::contains("Store:"));
}