use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::process::Command;
use haz_cache::CacheWriter;
use haz_cache::key::CacheKey;
use haz_cache::key::prefix::CHAPTER_REVISION;
use haz_cache::layout;
use haz_cache::manifest::{HashFunctionLabel, Manifest};
use haz_cache::store::{StoreInputs, StoredOutput};
use haz_cli::EXIT_INTERNAL_OR_CONFIG_ERROR;
use haz_domain::settings::cache::HashAlgo;
use haz_vfs::StdFilesystem;
use tempfile::TempDir;
const HAZ_BIN: &str = env!("CARGO_BIN_EXE_haz");
const HASH_ALGO: HashAlgo = HashAlgo::Blake3;
fn fixture_tempdir(name: &str) -> TempDir {
let src = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/cache_clean")
.join(name);
let dst = TempDir::new().expect("tempdir creation failed");
copy_dir_recursive(&src, dst.path());
dst
}
fn copy_dir_recursive(src: &Path, dst: &Path) {
fs::create_dir_all(dst).unwrap();
for entry in fs::read_dir(src).unwrap() {
let entry = entry.unwrap();
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if entry.file_type().unwrap().is_dir() {
copy_dir_recursive(&src_path, &dst_path);
} else {
fs::copy(&src_path, &dst_path).unwrap();
}
}
}
fn haz_cache_clean(workspace_root: &Path, args: &[&str]) -> (String, String, Option<i32>) {
let mut cmd = Command::new(HAZ_BIN);
cmd.args(["cache", "clean"]);
cmd.args(args);
cmd.current_dir(workspace_root);
let output = cmd.output().expect("failed to spawn haz");
(
String::from_utf8(output.stdout).expect("haz stdout was not utf-8"),
String::from_utf8(output.stderr).expect("haz stderr was not utf-8"),
output.status.code(),
)
}
fn build_cache(workspace_root: &Path) -> CacheWriter<StdFilesystem> {
CacheWriter::new(StdFilesystem::new(), workspace_root, HASH_ALGO)
}
fn key_with_first_byte(first: u8) -> CacheKey {
let mut bytes = [0u8; 32];
bytes[0] = first;
CacheKey::from_bytes(bytes)
}
fn store_valid_entry_at(
cache: &CacheWriter<StdFilesystem>,
key: &CacheKey,
rel: &str,
bytes: &[u8],
created_at_unix: u64,
) {
let workspace_root = cache.workspace_root().to_path_buf();
let target = workspace_root.join(rel);
let anchored = format!("/{rel}");
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(&target, bytes).unwrap();
let outs = [StoredOutput {
workspace_absolute_path: &anchored,
on_disk_path: &target,
mode: 0o644,
}];
cache
.store(
key,
&StoreInputs {
outputs: &outs,
stdout: b"",
stderr: b"",
created_at_unix,
},
)
.expect("store should succeed for a fresh key");
}
fn write_schema_stale_entry(cache: &CacheWriter<StdFilesystem>, key: &CacheKey) {
let entry_dir = layout::entry_dir(cache.cache_root(), key);
fs::create_dir_all(&entry_dir).unwrap();
let manifest = Manifest {
chapter_revision: CHAPTER_REVISION.saturating_add(1),
hash_function: HashFunctionLabel::Blake3,
key: *key,
outputs: vec![],
stdout_len: 0,
stderr_len: 0,
stdout_hash: [0u8; 32],
stderr_hash: [0u8; 32],
exit_status: 0,
created_at_unix: 0,
};
fs::write(
layout::manifest_path(cache.cache_root(), key),
manifest.to_json_bytes(),
)
.unwrap();
}
fn write_orphan_tmp_dir(cache: &CacheWriter<StdFilesystem>, key: &CacheKey, random: &str) {
let tmp = layout::tmp_entry_dir(cache.cache_root(), key, random);
fs::create_dir_all(&tmp).unwrap();
fs::write(tmp.join("partial.bin"), b"scratch").unwrap();
}
fn write_orphan_restore_dir(cache: &CacheWriter<StdFilesystem>, key: &CacheKey, random: &str) {
let staging = layout::restore_staging_dir(cache.cache_root(), key, random);
fs::create_dir_all(&staging).unwrap();
fs::write(staging.join("leftover.bin"), b"scratch").unwrap();
}
fn populate_mixed_cache(workspace_root: &Path) {
let cache = build_cache(workspace_root);
let key_a = key_with_first_byte(0xAA);
store_valid_entry_at(&cache, &key_a, "lib/out_a", b"hello", 0);
let key_b = key_with_first_byte(0xBB);
store_valid_entry_at(&cache, &key_b, "lib/out_b", b"world!", 0);
let key_c = key_with_first_byte(0xCC);
write_schema_stale_entry(&cache, &key_c);
let key_d = key_with_first_byte(0xDD);
write_orphan_tmp_dir(&cache, &key_d, "tmpsuffix");
let key_e = key_with_first_byte(0xEE);
write_orphan_restore_dir(&cache, &key_e, "rsfxsuffix");
}
fn snapshot_dir(root: &Path) -> BTreeMap<String, Vec<u8>> {
let mut out = BTreeMap::new();
snapshot_into(root, root, &mut out);
out
}
fn snapshot_into(base: &Path, dir: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(ft) = entry.file_type() else { continue };
if ft.is_file() {
let rel = path
.strip_prefix(base)
.map_or_else(|_| path.display().to_string(), |p| p.display().to_string());
let bytes = fs::read(&path).unwrap_or_default();
out.insert(rel, bytes);
} else if ft.is_dir() {
snapshot_into(base, &path, out);
}
}
}
#[test]
fn aux_022_no_mode_rejects_with_diagnostic() {
let tmp = fixture_tempdir("fresh_workspace");
let (_, stderr, code) = haz_cache_clean(tmp.path(), &[]);
assert_ne!(
code,
Some(0),
"no-mode invocation must exit non-zero; stderr: {stderr}",
);
assert!(
stderr.contains("at least one mode"),
"AUX-022 diagnostic must surface the requirement: {stderr}",
);
}
#[test]
fn clean_soft_reports_aux_024_categories_on_populated_cache() {
let tmp = fixture_tempdir("populated_with_mixed_entries");
populate_mixed_cache(tmp.path());
let (stdout, stderr, code) = haz_cache_clean(tmp.path(), &["--soft"]);
assert_eq!(code, Some(0), "expected exit 0; stderr: {stderr}");
assert!(
stdout.contains("entries inspected: 3"),
"expected inspected=3 (2 well-formed + 1 corrupt): {stdout}",
);
assert!(
stdout.contains("evicted --soft: 1"),
"expected one soft-evicted entry (the schema-stale one): {stdout}",
);
assert!(
stdout.contains("evicted --max-age: 0"),
"max-age inactive must read zero: {stdout}",
);
assert!(
stdout.contains("evicted --max-size: 0"),
"max-size inactive must read zero: {stdout}",
);
assert!(
stdout.contains("orphan tmp directories removed: 1"),
"expected one orphan tmp removed: {stdout}",
);
assert!(
stdout.contains("orphan restore directories removed: 1"),
"expected one orphan restore removed: {stdout}",
);
let cache_root = tmp.path().join(".haz/cache");
let key_a = key_with_first_byte(0xAA);
assert!(layout::entry_dir(&cache_root, &key_a).exists());
}
#[test]
fn clean_max_age_evicts_well_formed_entries_strictly_older_than_cutoff() {
let tmp = fixture_tempdir("populated_with_mixed_entries");
populate_mixed_cache(tmp.path());
let (stdout, stderr, code) = haz_cache_clean(tmp.path(), &["--max-age", "1s"]);
assert_eq!(code, Some(0), "expected exit 0; stderr: {stderr}");
assert!(
stdout.contains("evicted --max-age: 2"),
"expected both well-formed entries evicted by max-age: {stdout}",
);
let cache_root = tmp.path().join(".haz/cache");
let key_c = key_with_first_byte(0xCC);
assert!(
layout::entry_dir(&cache_root, &key_c).exists(),
"schema-stale entry must NOT be evicted by --max-age alone",
);
let key_d = key_with_first_byte(0xDD);
let key_e = key_with_first_byte(0xEE);
assert!(layout::tmp_entry_dir(&cache_root, &key_d, "tmpsuffix").exists());
assert!(layout::restore_staging_dir(&cache_root, &key_e, "rsfxsuffix").exists());
}
#[test]
fn aux_024_dry_run_does_not_modify_disk_and_emits_plan() {
let tmp = fixture_tempdir("populated_with_mixed_entries");
populate_mixed_cache(tmp.path());
let cache_root = tmp.path().join(".haz/cache");
let before = snapshot_dir(&cache_root);
let (stdout, stderr, code) =
haz_cache_clean(tmp.path(), &["--soft", "--max-age", "1s", "--dry-run"]);
assert_eq!(code, Some(0), "expected exit 0; stderr: {stderr}");
assert!(
stdout.contains("dry-run: AUX-024 plan only"),
"dry-run banner missing: {stdout}",
);
assert!(
stdout.contains("would evict --soft:"),
"dry-run mode labels: {stdout}",
);
assert!(
stdout.contains("bytes projected:"),
"dry-run bytes label: {stdout}",
);
assert!(
stdout.contains("dry-run plan:"),
"per-entry plan header: {stdout}",
);
assert!(
stdout.contains("mode=--soft"),
"dry-run plan must include soft mode entries: {stdout}",
);
assert!(
stdout.contains("mode=--max-age"),
"dry-run plan must include max-age mode entries: {stdout}",
);
let after = snapshot_dir(&cache_root);
assert_eq!(
before, after,
"dry-run must leave the cache root byte-identical",
);
}
#[test]
fn aux_025_workspace_default_max_age_satisfies_aux_022() {
let tmp = fixture_tempdir("with_workspace_max_age_default");
let cache = build_cache(tmp.path());
let key = key_with_first_byte(0xAA);
store_valid_entry_at(&cache, &key, "lib/out", b"x", 0);
let (stdout, stderr, code) = haz_cache_clean(tmp.path(), &[]);
assert_eq!(code, Some(0), "expected exit 0; stderr: {stderr}");
assert!(
stdout.contains("evicted --max-age: 1"),
"expected the entry to be evicted under the workspace default: {stdout}",
);
let cache_root = tmp.path().join(".haz/cache");
assert!(!layout::entry_dir(&cache_root, &key).exists());
}
#[test]
fn cfg_034_bare_integer_max_age_rejected_at_cli_parse_time() {
let tmp = fixture_tempdir("fresh_workspace");
let (_, stderr, code) = haz_cache_clean(tmp.path(), &["--max-age", "90"]);
assert_eq!(
code,
Some(EXIT_INTERNAL_OR_CONFIG_ERROR),
"malformed --max-age must use the config-error class; stderr: {stderr}",
);
assert!(
!stderr.is_empty(),
"missing diagnostic for malformed duration: {stderr}",
);
}
#[test]
fn aux_027_success_exits_zero_and_does_not_use_task_failure_class() {
const EXIT_TASK_FAILURE: i32 = 1;
let tmp = fixture_tempdir("fresh_workspace");
let (_, stderr, code) = haz_cache_clean(tmp.path(), &["--soft"]);
assert_eq!(
code,
Some(0),
"AUX-027: --soft on a fresh workspace exits 0; stderr: {stderr}",
);
assert_ne!(code, Some(EXIT_TASK_FAILURE));
}
#[test]
fn aux_027_no_mode_rejection_uses_config_error_class() {
let tmp = fixture_tempdir("fresh_workspace");
let (_, stderr, code) = haz_cache_clean(tmp.path(), &[]);
assert_eq!(
code,
Some(EXIT_INTERNAL_OR_CONFIG_ERROR),
"AUX-027: no-mode rejection MUST use the config-error class; stderr: {stderr}",
);
}
#[test]
fn aux_027_malformed_duration_uses_config_error_class() {
let tmp = fixture_tempdir("fresh_workspace");
let (_, stderr, code) = haz_cache_clean(tmp.path(), &["--max-age", "90"]);
assert_eq!(
code,
Some(EXIT_INTERNAL_OR_CONFIG_ERROR),
"AUX-027: malformed duration MUST use the config-error class; stderr: {stderr}",
);
}
#[cfg(unix)]
fn read_only_dirs_block_writes(base: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
let probe = base.join(".perm-probe");
if fs::create_dir(&probe).is_err() {
return false;
}
fs::set_permissions(&probe, fs::Permissions::from_mode(0o555)).unwrap();
let blocked = fs::write(probe.join("x"), b"x").is_err();
fs::set_permissions(&probe, fs::Permissions::from_mode(0o755)).unwrap();
fs::remove_dir_all(&probe).unwrap();
blocked
}
#[cfg(unix)]
#[test]
fn aux_028_partial_removal_failure_reports_and_exits_config_error() {
use std::os::unix::fs::PermissionsExt;
let tmp = fixture_tempdir("fresh_workspace");
if !read_only_dirs_block_writes(tmp.path()) {
return;
}
let cache = build_cache(tmp.path());
let key_locked = key_with_first_byte(0xAA);
let key_ok = key_with_first_byte(0xCC);
write_schema_stale_entry(&cache, &key_locked);
write_schema_stale_entry(&cache, &key_ok);
let locked_shard = layout::shard_dir(cache.cache_root(), &key_locked);
let original = fs::metadata(&locked_shard).unwrap().permissions();
fs::set_permissions(&locked_shard, fs::Permissions::from_mode(0o555)).unwrap();
let (stdout, stderr, code) = haz_cache_clean(tmp.path(), &["--soft"]);
fs::set_permissions(&locked_shard, original).unwrap();
assert_eq!(
code,
Some(EXIT_INTERNAL_OR_CONFIG_ERROR),
"a partial removal failure exits in the config-error class (AUX-027/AUX-028); stderr: {stderr}",
);
assert!(
stdout.contains("entries inspected"),
"the AUX-024 report is still emitted on stdout: {stdout}",
);
assert!(
stdout.contains("evicted --soft: 1"),
"the report counts the entry actually removed, not the plan: {stdout}",
);
assert!(
stderr.contains("failed to remove cache entry"),
"the per-item failure is surfaced on stderr (AUX-028): {stderr}",
);
assert!(
layout::entry_dir(cache.cache_root(), &key_locked).exists(),
"the un-removable entry remains on disk",
);
assert!(
!layout::entry_dir(cache.cache_root(), &key_ok).exists(),
"the removable entry was evicted despite the other failing",
);
}