use std::io::{self, Write};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use haz_cache::clean::{CleanOptions, CleanReport, EvictionMode};
use haz_cache::info::CacheInfoReport;
use haz_cache::manifest::HashFunctionLabel;
use haz_domain::settings::cache_clean::CacheCleanSettings;
use miette::{IntoDiagnostic, Result, WrapErr, miette};
use crate::cli::CacheCleanArgs;
use crate::context::Context;
pub fn clear(ctx: &Context) -> Result<i32> {
let cache = ctx.cache_writer();
cache
.clear()
.into_diagnostic()
.wrap_err("cache clear failed")?;
let mut out = io::stdout().lock();
writeln!(out, "cache cleared.")
.into_diagnostic()
.wrap_err("failed to write confirmation")?;
Ok(0)
}
pub fn clean(ctx: &Context, args: &CacheCleanArgs) -> Result<i32> {
let opts = resolve_clean_options(
args,
&ctx.workspace().settings.cache.clean,
system_now_unix(),
)?;
let cache = ctx.cache_writer();
let outcome = cache
.clean(&opts)
.into_diagnostic()
.wrap_err("cache clean failed")?;
let mut out = io::stdout().lock();
print_clean_report(&opts, &outcome.report, &mut out)
.into_diagnostic()
.wrap_err("failed to write clean report")?;
drop(out);
if outcome.failures.is_empty() {
return Ok(0);
}
let mut err = io::stderr().lock();
for failure in &outcome.failures {
writeln!(err, "cache clean: {failure}")
.into_diagnostic()
.wrap_err("failed to write clean failure")?;
}
Ok(crate::EXIT_INTERNAL_OR_CONFIG_ERROR)
}
fn resolve_clean_options(
args: &CacheCleanArgs,
defaults: &CacheCleanSettings,
now_unix: u64,
) -> Result<CleanOptions> {
let max_age = args.max_age.or(defaults.max_age);
let max_size = args.max_size.or(defaults.max_size);
if !args.soft && max_age.is_none() && max_size.is_none() {
return Err(miette!(
"haz cache clean requires at least one mode: --soft, --max-age <DURATION>, or --max-size <BYTES> (AUX-022; workspace cache.clean.max_age / max_size MAY supply a default per AUX-025)"
));
}
Ok(CleanOptions {
soft: args.soft,
max_age,
max_size,
dry_run: args.dry_run,
now_unix,
})
}
fn system_now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn print_clean_report(
opts: &CleanOptions,
report: &CleanReport,
out: &mut impl Write,
) -> io::Result<()> {
if opts.dry_run {
writeln!(out, "dry-run: AUX-024 plan only, no entries removed.")?;
}
writeln!(out, "entries inspected: {}", report.inspected)?;
let (soft_label, age_label, size_label) = if opts.dry_run {
(
"would evict --soft",
"would evict --max-age",
"would evict --max-size",
)
} else {
("evicted --soft", "evicted --max-age", "evicted --max-size")
};
writeln!(out, "{}: {}", soft_label, report.evicted_by_soft)?;
writeln!(out, "{}: {}", age_label, report.evicted_by_max_age)?;
writeln!(out, "{}: {}", size_label, report.evicted_by_max_size)?;
let tmp_verb = if opts.dry_run { "noted" } else { "removed" };
writeln!(
out,
"orphan tmp directories {tmp_verb}: {}",
report.removed_tmp_dirs,
)?;
writeln!(
out,
"orphan restore directories {tmp_verb}: {}",
report.removed_restore_dirs,
)?;
let bytes_label = if opts.dry_run {
"bytes projected"
} else {
"bytes reclaimed"
};
writeln!(out, "{bytes_label}: {}", report.bytes_reclaimed)?;
if opts.dry_run && !report.evicted_entries.is_empty() {
writeln!(out, "dry-run plan:")?;
for e in &report.evicted_entries {
writeln!(
out,
" {} created_at={} footprint={} mode={}",
e.key_hex_prefix,
e.created_at_unix,
e.footprint,
mode_label_name(e.matched_mode),
)?;
}
}
Ok(())
}
fn mode_label_name(m: EvictionMode) -> &'static str {
match m {
EvictionMode::Soft => "--soft",
EvictionMode::MaxAge => "--max-age",
EvictionMode::MaxSize => "--max-size",
}
}
pub fn info(ctx: &Context) -> Result<i32> {
let cache = ctx.cache_reader();
let report = cache
.info()
.into_diagnostic()
.wrap_err("cache info walk failed")?;
let mut out = io::stdout().lock();
print_info_report(cache.cache_root(), &report, &mut out)
.into_diagnostic()
.wrap_err("failed to write cache info report")?;
Ok(0)
}
fn print_info_report(
cache_root: &Path,
report: &CacheInfoReport,
out: &mut impl Write,
) -> io::Result<()> {
writeln!(out, "cache root: {}", cache_root.display())?;
writeln!(out, "well-formed entries: {}", report.well_formed_entries)?;
writeln!(out, "corrupt entries: {}", report.corrupt_entries)?;
writeln!(out, "orphan tmp directories: {}", report.orphan_tmp_dirs)?;
writeln!(
out,
"orphan restore directories: {}",
report.orphan_restore_dirs,
)?;
writeln!(out, "total bytes: {}", report.total_bytes)?;
if !report.by_schema.is_empty() {
writeln!(out, "by schema:")?;
for ((rev, hash), count) in &report.by_schema {
writeln!(
out,
" (revision={rev}, hash={}): {count}",
hash_label_name(*hash),
)?;
}
}
Ok(())
}
fn hash_label_name(label: HashFunctionLabel) -> &'static str {
match label {
HashFunctionLabel::Blake3 => "blake3",
HashFunctionLabel::Sha256 => "sha256",
}
}
#[cfg(test)]
mod tests {
use super::{
clean, clear, hash_label_name, info, mode_label_name, print_clean_report,
print_info_report, resolve_clean_options,
};
use crate::cli::CacheCleanArgs;
use crate::context::Context;
use haz_cache::clean::{CleanOptions, CleanReport, EvictedEntry, EvictionMode};
use haz_cache::info::CacheInfoReport;
use haz_cache::manifest::HashFunctionLabel;
use haz_domain::settings::cache_clean::CacheCleanSettings;
use haz_domain::settings::cache_clean::max_age::MaxAge;
use haz_domain::settings::cache_clean::max_size::MaxSize;
use miette::Result;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
fn run_clear(cwd: &Path) -> Result<i32> {
clear(&Context::open(cwd)?)
}
fn run_clean(cwd: &Path, args: &CacheCleanArgs) -> Result<i32> {
clean(&Context::open(cwd)?, args)
}
fn run_info(cwd: &Path) -> Result<i32> {
info(&Context::open(cwd)?)
}
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap();
}
fn write_workspace(root: &Path) {
write_file(&root.join(".haz/workspace.yml"), "projects:\n - /lib\n");
write_file(
&root.join("lib/haz.yml"),
"name: lib\ntasks:\n default:\n command: [\"true\"]\n",
);
}
#[test]
fn clear_on_fresh_workspace_is_noop_and_exits_zero() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_workspace(root);
let code = run_clear(root).unwrap();
assert_eq!(code, 0);
assert!(!root.join(".haz/cache").exists());
}
#[test]
fn clear_removes_an_existing_cache_root() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_workspace(root);
let sentinel = root.join(".haz/cache/ab/sentinel");
write_file(&sentinel, "anything");
assert!(sentinel.exists());
let code = run_clear(root).unwrap();
assert_eq!(code, 0);
assert!(!root.join(".haz/cache").exists());
}
#[test]
fn clear_does_not_touch_files_outside_cache_root() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_workspace(root);
write_file(&root.join("docs/keep.md"), "important");
write_file(&root.join(".haz/cache/ab/c"), "stale");
let code = run_clear(root).unwrap();
assert_eq!(code, 0);
assert!(!root.join(".haz/cache").exists());
assert_eq!(
fs::read_to_string(root.join("docs/keep.md")).unwrap(),
"important"
);
}
fn soft_only_args() -> CacheCleanArgs {
CacheCleanArgs {
soft: true,
..CacheCleanArgs::default()
}
}
#[test]
fn clean_soft_on_fresh_workspace_reports_all_zero() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_workspace(root);
let code = run_clean(root, &soft_only_args()).unwrap();
assert_eq!(code, 0);
}
#[test]
fn clean_soft_reclaims_a_restore_staging_directory() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_workspace(root);
let staging = root.join(".haz/cache/.restore-abcdef-1234");
write_file(&staging.join("leftover.bin"), "scratch");
assert!(staging.exists());
let code = run_clean(root, &soft_only_args()).unwrap();
assert_eq!(code, 0);
assert!(!staging.exists());
}
#[test]
fn clean_rejects_invocation_with_no_effective_mode() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_workspace(root);
let err = run_clean(root, &CacheCleanArgs::default()).unwrap_err();
let rendered = format!("{err}");
assert!(
rendered.contains("at least one mode"),
"AUX-022 rejection diagnostic must surface the requirement: {rendered}",
);
}
#[test]
fn clean_dry_run_under_soft_leaves_restore_staging_in_place() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_workspace(root);
let staging = root.join(".haz/cache/.restore-abcdef-1234");
write_file(&staging.join("leftover.bin"), "scratch");
let args = CacheCleanArgs {
soft: true,
dry_run: true,
..CacheCleanArgs::default()
};
let code = run_clean(root, &args).unwrap();
assert_eq!(code, 0);
assert!(
staging.exists(),
"dry-run must not remove the restore staging directory",
);
}
#[test]
fn clean_uses_workspace_default_max_age_when_cli_silent() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_file(
&root.join(".haz/workspace.yml"),
"projects:\n - /lib\ncache:\n clean:\n max_age: 30d\n",
);
write_file(
&root.join("lib/haz.yml"),
"name: lib\ntasks:\n default:\n command: [\"true\"]\n",
);
let code = run_clean(root, &CacheCleanArgs::default()).unwrap();
assert_eq!(code, 0);
}
fn defaults_with(max_age: Option<&str>, max_size: Option<&str>) -> CacheCleanSettings {
CacheCleanSettings {
max_age: max_age.map(|s| MaxAge::parse(s).unwrap()),
max_size: max_size.map(|s| MaxSize::parse(s).unwrap()),
}
}
#[test]
fn resolve_clean_options_rejects_no_effective_mode() {
let err = resolve_clean_options(
&CacheCleanArgs::default(),
&CacheCleanSettings::default(),
0,
)
.unwrap_err();
let rendered = format!("{err}");
assert!(
rendered.contains("at least one mode"),
"AUX-022 rejection diagnostic must surface the requirement: {rendered}",
);
}
#[test]
fn resolve_clean_options_falls_back_to_workspace_when_cli_silent() {
let defaults = defaults_with(Some("30d"), Some("1GB"));
let opts = resolve_clean_options(&CacheCleanArgs::default(), &defaults, 12345).unwrap();
assert_eq!(opts.max_age, defaults.max_age);
assert_eq!(opts.max_size, defaults.max_size);
assert!(!opts.soft);
assert!(!opts.dry_run);
assert_eq!(opts.now_unix, 12345);
}
#[test]
fn resolve_clean_options_cli_overrides_workspace_defaults() {
let defaults = defaults_with(Some("30d"), Some("1GB"));
let cli_age = MaxAge::parse("7d").unwrap();
let args = CacheCleanArgs {
max_age: Some(cli_age),
..CacheCleanArgs::default()
};
let opts = resolve_clean_options(&args, &defaults, 0).unwrap();
assert_eq!(opts.max_age, Some(cli_age));
assert_eq!(opts.max_size, defaults.max_size);
}
#[test]
fn resolve_clean_options_soft_alone_satisfies_aux_022() {
let opts =
resolve_clean_options(&soft_only_args(), &CacheCleanSettings::default(), 0).unwrap();
assert!(opts.soft);
assert!(opts.max_age.is_none());
assert!(opts.max_size.is_none());
}
fn sample_report() -> CleanReport {
CleanReport {
inspected: 7,
evicted_by_soft: 2,
evicted_by_max_age: 3,
evicted_by_max_size: 1,
removed_tmp_dirs: 1,
removed_restore_dirs: 2,
bytes_reclaimed: 4096,
evicted_entries: vec![
EvictedEntry {
key_hex_prefix: "deadbeef".to_owned(),
created_at_unix: 100,
footprint: 512,
matched_mode: EvictionMode::Soft,
},
EvictedEntry {
key_hex_prefix: "cafef00d".to_owned(),
created_at_unix: 200,
footprint: 1024,
matched_mode: EvictionMode::MaxAge,
},
],
}
}
fn render(opts: &CleanOptions, report: &CleanReport) -> String {
let mut out = Vec::<u8>::new();
print_clean_report(opts, report, &mut out).unwrap();
String::from_utf8(out).unwrap()
}
#[test]
fn print_clean_report_renders_required_aux_024_categories() {
let opts = CleanOptions {
soft: true,
..CleanOptions::default()
};
let rendered = render(&opts, &sample_report());
assert!(
rendered.contains("entries inspected: 7"),
"missing inspected line: {rendered}",
);
assert!(
rendered.contains("evicted --soft: 2"),
"missing soft line: {rendered}",
);
assert!(
rendered.contains("evicted --max-age: 3"),
"missing max-age line: {rendered}",
);
assert!(
rendered.contains("evicted --max-size: 1"),
"missing max-size line: {rendered}",
);
assert!(
rendered.contains("orphan tmp directories removed: 1"),
"missing tmp line: {rendered}",
);
assert!(
rendered.contains("orphan restore directories removed: 2"),
"missing restore line: {rendered}",
);
assert!(
rendered.contains("bytes reclaimed: 4096"),
"missing bytes line: {rendered}",
);
assert!(
!rendered.contains("dry-run"),
"non-dry-run output must not mention dry-run: {rendered}",
);
}
#[test]
fn print_clean_report_lists_per_entry_detail_under_dry_run() {
let opts = CleanOptions {
soft: true,
dry_run: true,
..CleanOptions::default()
};
let rendered = render(&opts, &sample_report());
assert!(
rendered.contains("would evict --soft: 2"),
"dry-run soft label: {rendered}",
);
assert!(
rendered.contains("would evict --max-age: 3"),
"dry-run max-age label: {rendered}",
);
assert!(
rendered.contains("bytes projected: 4096"),
"dry-run bytes label: {rendered}",
);
assert!(
rendered.contains("dry-run plan:"),
"dry-run plan header: {rendered}",
);
assert!(
rendered.contains("deadbeef created_at=100 footprint=512 mode=--soft"),
"dry-run plan soft entry: {rendered}",
);
assert!(
rendered.contains("cafef00d created_at=200 footprint=1024 mode=--max-age"),
"dry-run plan max-age entry: {rendered}",
);
}
#[test]
fn print_clean_report_omits_dry_run_plan_when_evicted_entries_empty() {
let opts = CleanOptions {
soft: true,
dry_run: true,
..CleanOptions::default()
};
let empty = CleanReport::default();
let rendered = render(&opts, &empty);
assert!(
!rendered.contains("dry-run plan:"),
"empty dry-run plan must omit the header: {rendered}",
);
}
#[test]
fn mode_label_name_maps_each_variant() {
assert_eq!(mode_label_name(EvictionMode::Soft), "--soft");
assert_eq!(mode_label_name(EvictionMode::MaxAge), "--max-age");
assert_eq!(mode_label_name(EvictionMode::MaxSize), "--max-size");
}
#[test]
fn aux_021_info_on_fresh_workspace_exits_zero() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_workspace(root);
let code = run_info(root).unwrap();
assert_eq!(code, 0);
assert!(!root.join(".haz/cache").exists());
}
#[test]
fn aux_018_info_does_not_mutate_an_existing_cache_root() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_workspace(root);
write_file(
&root.join(".haz/cache/.restore-abcdef-1234/leftover.bin"),
"scratch",
);
fs::create_dir_all(root.join(".haz/cache/ab/0000")).unwrap();
write_file(&root.join(".haz/cache/ab/0000/stdout"), "");
let before = snapshot_dir(&root.join(".haz/cache"));
let code = run_info(root).unwrap();
assert_eq!(code, 0);
let after = snapshot_dir(&root.join(".haz/cache"));
assert_eq!(
before, after,
"cache root state must not change under info()",
);
}
#[test]
fn aux_019_info_output_lists_every_required_category() {
let report = CacheInfoReport {
well_formed_entries: 5,
corrupt_entries: 2,
orphan_tmp_dirs: 1,
orphan_restore_dirs: 3,
total_bytes: 12345,
by_schema: {
let mut m: BTreeMap<(u8, HashFunctionLabel), u64> = BTreeMap::new();
m.insert((0, HashFunctionLabel::Blake3), 5);
m
},
};
let mut out = Vec::<u8>::new();
let cache_root = PathBuf::from("/some/workspace/.haz/cache");
print_info_report(&cache_root, &report, &mut out).unwrap();
let rendered = String::from_utf8(out).unwrap();
assert!(
rendered.contains("cache root: /some/workspace/.haz/cache"),
"missing cache root line: {rendered}",
);
assert!(
rendered.contains("well-formed entries: 5"),
"missing well-formed line: {rendered}",
);
assert!(
rendered.contains("corrupt entries: 2"),
"missing corrupt line: {rendered}",
);
assert!(
rendered.contains("orphan tmp directories: 1"),
"missing orphan tmp line: {rendered}",
);
assert!(
rendered.contains("orphan restore directories: 3"),
"missing orphan restore line: {rendered}",
);
assert!(
rendered.contains("total bytes: 12345"),
"missing total bytes line: {rendered}",
);
assert!(
rendered.contains("by schema:"),
"missing schema breakdown header: {rendered}",
);
assert!(
rendered.contains("(revision=0, hash=blake3): 5"),
"missing schema breakdown entry: {rendered}",
);
}
#[test]
fn aux_019_info_output_omits_schema_section_when_no_well_formed_entries() {
let report = CacheInfoReport {
well_formed_entries: 0,
corrupt_entries: 2,
orphan_tmp_dirs: 0,
orphan_restore_dirs: 0,
total_bytes: 0,
by_schema: BTreeMap::new(),
};
let mut out = Vec::<u8>::new();
print_info_report(Path::new("/ws/.haz/cache"), &report, &mut out).unwrap();
let rendered = String::from_utf8(out).unwrap();
assert!(
!rendered.contains("by schema:"),
"schema section should be omitted: {rendered}",
);
}
#[test]
fn hash_label_name_maps_spec_canonical_strings() {
assert_eq!(hash_label_name(HashFunctionLabel::Blake3), "blake3");
assert_eq!(hash_label_name(HashFunctionLabel::Sha256), "sha256");
}
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);
}
}
}
}