1use std::io::{self, Write};
28use std::path::Path;
29use std::time::{SystemTime, UNIX_EPOCH};
30
31use haz_cache::clean::{CleanOptions, CleanReport, EvictionMode};
32use haz_cache::info::CacheInfoReport;
33use haz_cache::manifest::HashFunctionLabel;
34use haz_domain::settings::cache_clean::CacheCleanSettings;
35use miette::{IntoDiagnostic, Result, WrapErr, miette};
36
37use crate::cli::CacheCleanArgs;
38use crate::context::Context;
39
40pub fn clear(ctx: &Context) -> Result<i32> {
51 let cache = ctx.cache_writer();
52 cache
53 .clear()
54 .into_diagnostic()
55 .wrap_err("cache clear failed")?;
56 let mut out = io::stdout().lock();
57 writeln!(out, "cache cleared.")
58 .into_diagnostic()
59 .wrap_err("failed to write confirmation")?;
60 Ok(0)
61}
62
63pub fn clean(ctx: &Context, args: &CacheCleanArgs) -> Result<i32> {
82 let opts = resolve_clean_options(
83 args,
84 &ctx.workspace().settings.cache.clean,
85 system_now_unix(),
86 )?;
87 let cache = ctx.cache_writer();
88 let outcome = cache
89 .clean(&opts)
90 .into_diagnostic()
91 .wrap_err("cache clean failed")?;
92 let mut out = io::stdout().lock();
93 print_clean_report(&opts, &outcome.report, &mut out)
94 .into_diagnostic()
95 .wrap_err("failed to write clean report")?;
96 drop(out);
97
98 if outcome.failures.is_empty() {
99 return Ok(0);
100 }
101
102 let mut err = io::stderr().lock();
106 for failure in &outcome.failures {
107 writeln!(err, "cache clean: {failure}")
108 .into_diagnostic()
109 .wrap_err("failed to write clean failure")?;
110 }
111 Ok(crate::EXIT_INTERNAL_OR_CONFIG_ERROR)
112}
113
114fn resolve_clean_options(
117 args: &CacheCleanArgs,
118 defaults: &CacheCleanSettings,
119 now_unix: u64,
120) -> Result<CleanOptions> {
121 let max_age = args.max_age.or(defaults.max_age);
122 let max_size = args.max_size.or(defaults.max_size);
123 if !args.soft && max_age.is_none() && max_size.is_none() {
124 return Err(miette!(
125 "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)"
126 ));
127 }
128 Ok(CleanOptions {
129 soft: args.soft,
130 max_age,
131 max_size,
132 dry_run: args.dry_run,
133 now_unix,
134 })
135}
136
137fn system_now_unix() -> u64 {
138 SystemTime::now()
139 .duration_since(UNIX_EPOCH)
140 .map_or(0, |d| d.as_secs())
141}
142
143fn print_clean_report(
151 opts: &CleanOptions,
152 report: &CleanReport,
153 out: &mut impl Write,
154) -> io::Result<()> {
155 if opts.dry_run {
156 writeln!(out, "dry-run: AUX-024 plan only, no entries removed.")?;
157 }
158 writeln!(out, "entries inspected: {}", report.inspected)?;
159 let (soft_label, age_label, size_label) = if opts.dry_run {
160 (
161 "would evict --soft",
162 "would evict --max-age",
163 "would evict --max-size",
164 )
165 } else {
166 ("evicted --soft", "evicted --max-age", "evicted --max-size")
167 };
168 writeln!(out, "{}: {}", soft_label, report.evicted_by_soft)?;
169 writeln!(out, "{}: {}", age_label, report.evicted_by_max_age)?;
170 writeln!(out, "{}: {}", size_label, report.evicted_by_max_size)?;
171 let tmp_verb = if opts.dry_run { "noted" } else { "removed" };
172 writeln!(
173 out,
174 "orphan tmp directories {tmp_verb}: {}",
175 report.removed_tmp_dirs,
176 )?;
177 writeln!(
178 out,
179 "orphan restore directories {tmp_verb}: {}",
180 report.removed_restore_dirs,
181 )?;
182 let bytes_label = if opts.dry_run {
183 "bytes projected"
184 } else {
185 "bytes reclaimed"
186 };
187 writeln!(out, "{bytes_label}: {}", report.bytes_reclaimed)?;
188 if opts.dry_run && !report.evicted_entries.is_empty() {
189 writeln!(out, "dry-run plan:")?;
190 for e in &report.evicted_entries {
191 writeln!(
192 out,
193 " {} created_at={} footprint={} mode={}",
194 e.key_hex_prefix,
195 e.created_at_unix,
196 e.footprint,
197 mode_label_name(e.matched_mode),
198 )?;
199 }
200 }
201 Ok(())
202}
203
204fn mode_label_name(m: EvictionMode) -> &'static str {
205 match m {
206 EvictionMode::Soft => "--soft",
207 EvictionMode::MaxAge => "--max-age",
208 EvictionMode::MaxSize => "--max-size",
209 }
210}
211
212pub fn info(ctx: &Context) -> Result<i32> {
223 let cache = ctx.cache_reader();
224 let report = cache
225 .info()
226 .into_diagnostic()
227 .wrap_err("cache info walk failed")?;
228 let mut out = io::stdout().lock();
229 print_info_report(cache.cache_root(), &report, &mut out)
230 .into_diagnostic()
231 .wrap_err("failed to write cache info report")?;
232 Ok(0)
233}
234
235fn print_info_report(
241 cache_root: &Path,
242 report: &CacheInfoReport,
243 out: &mut impl Write,
244) -> io::Result<()> {
245 writeln!(out, "cache root: {}", cache_root.display())?;
246 writeln!(out, "well-formed entries: {}", report.well_formed_entries)?;
247 writeln!(out, "corrupt entries: {}", report.corrupt_entries)?;
248 writeln!(out, "orphan tmp directories: {}", report.orphan_tmp_dirs)?;
249 writeln!(
250 out,
251 "orphan restore directories: {}",
252 report.orphan_restore_dirs,
253 )?;
254 writeln!(out, "total bytes: {}", report.total_bytes)?;
255 if !report.by_schema.is_empty() {
256 writeln!(out, "by schema:")?;
257 for ((rev, hash), count) in &report.by_schema {
258 writeln!(
259 out,
260 " (revision={rev}, hash={}): {count}",
261 hash_label_name(*hash),
262 )?;
263 }
264 }
265 Ok(())
266}
267
268fn hash_label_name(label: HashFunctionLabel) -> &'static str {
271 match label {
272 HashFunctionLabel::Blake3 => "blake3",
273 HashFunctionLabel::Sha256 => "sha256",
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::{
280 clean, clear, hash_label_name, info, mode_label_name, print_clean_report,
281 print_info_report, resolve_clean_options,
282 };
283 use crate::cli::CacheCleanArgs;
284 use crate::context::Context;
285 use haz_cache::clean::{CleanOptions, CleanReport, EvictedEntry, EvictionMode};
286 use haz_cache::info::CacheInfoReport;
287 use haz_cache::manifest::HashFunctionLabel;
288 use haz_domain::settings::cache_clean::CacheCleanSettings;
289 use haz_domain::settings::cache_clean::max_age::MaxAge;
290 use haz_domain::settings::cache_clean::max_size::MaxSize;
291 use miette::Result;
292 use std::collections::BTreeMap;
293 use std::fs;
294 use std::path::{Path, PathBuf};
295
296 fn run_clear(cwd: &Path) -> Result<i32> {
297 clear(&Context::open(cwd)?)
298 }
299
300 fn run_clean(cwd: &Path, args: &CacheCleanArgs) -> Result<i32> {
301 clean(&Context::open(cwd)?, args)
302 }
303
304 fn run_info(cwd: &Path) -> Result<i32> {
305 info(&Context::open(cwd)?)
306 }
307
308 fn write_file(path: &Path, contents: &str) {
309 if let Some(parent) = path.parent() {
310 fs::create_dir_all(parent).unwrap();
311 }
312 fs::write(path, contents).unwrap();
313 }
314
315 fn write_workspace(root: &Path) {
325 write_file(&root.join(".haz/workspace.yml"), "projects:\n - /lib\n");
326 write_file(
327 &root.join("lib/haz.yml"),
328 "name: lib\ntasks:\n default:\n command: [\"true\"]\n",
329 );
330 }
331
332 #[test]
333 fn clear_on_fresh_workspace_is_noop_and_exits_zero() {
334 let tempdir = tempfile::TempDir::new().unwrap();
335 let root = tempdir.path();
336 write_workspace(root);
337 let code = run_clear(root).unwrap();
338 assert_eq!(code, 0);
339 assert!(!root.join(".haz/cache").exists());
342 }
343
344 #[test]
345 fn clear_removes_an_existing_cache_root() {
346 let tempdir = tempfile::TempDir::new().unwrap();
347 let root = tempdir.path();
348 write_workspace(root);
349 let sentinel = root.join(".haz/cache/ab/sentinel");
352 write_file(&sentinel, "anything");
353 assert!(sentinel.exists());
354 let code = run_clear(root).unwrap();
355 assert_eq!(code, 0);
356 assert!(!root.join(".haz/cache").exists());
357 }
358
359 #[test]
360 fn clear_does_not_touch_files_outside_cache_root() {
361 let tempdir = tempfile::TempDir::new().unwrap();
362 let root = tempdir.path();
363 write_workspace(root);
364 write_file(&root.join("docs/keep.md"), "important");
365 write_file(&root.join(".haz/cache/ab/c"), "stale");
366 let code = run_clear(root).unwrap();
367 assert_eq!(code, 0);
368 assert!(!root.join(".haz/cache").exists());
369 assert_eq!(
370 fs::read_to_string(root.join("docs/keep.md")).unwrap(),
371 "important"
372 );
373 }
374
375 fn soft_only_args() -> CacheCleanArgs {
376 CacheCleanArgs {
377 soft: true,
378 ..CacheCleanArgs::default()
379 }
380 }
381
382 #[test]
383 fn clean_soft_on_fresh_workspace_reports_all_zero() {
384 let tempdir = tempfile::TempDir::new().unwrap();
385 let root = tempdir.path();
386 write_workspace(root);
387 let code = run_clean(root, &soft_only_args()).unwrap();
388 assert_eq!(code, 0);
389 }
390
391 #[test]
392 fn clean_soft_reclaims_a_restore_staging_directory() {
393 let tempdir = tempfile::TempDir::new().unwrap();
398 let root = tempdir.path();
399 write_workspace(root);
400 let staging = root.join(".haz/cache/.restore-abcdef-1234");
401 write_file(&staging.join("leftover.bin"), "scratch");
402 assert!(staging.exists());
403 let code = run_clean(root, &soft_only_args()).unwrap();
404 assert_eq!(code, 0);
405 assert!(!staging.exists());
406 }
407
408 #[test]
409 fn clean_rejects_invocation_with_no_effective_mode() {
410 let tempdir = tempfile::TempDir::new().unwrap();
414 let root = tempdir.path();
415 write_workspace(root);
416 let err = run_clean(root, &CacheCleanArgs::default()).unwrap_err();
417 let rendered = format!("{err}");
418 assert!(
419 rendered.contains("at least one mode"),
420 "AUX-022 rejection diagnostic must surface the requirement: {rendered}",
421 );
422 }
423
424 #[test]
425 fn clean_dry_run_under_soft_leaves_restore_staging_in_place() {
426 let tempdir = tempfile::TempDir::new().unwrap();
430 let root = tempdir.path();
431 write_workspace(root);
432 let staging = root.join(".haz/cache/.restore-abcdef-1234");
433 write_file(&staging.join("leftover.bin"), "scratch");
434
435 let args = CacheCleanArgs {
436 soft: true,
437 dry_run: true,
438 ..CacheCleanArgs::default()
439 };
440 let code = run_clean(root, &args).unwrap();
441 assert_eq!(code, 0);
442 assert!(
443 staging.exists(),
444 "dry-run must not remove the restore staging directory",
445 );
446 }
447
448 #[test]
449 fn clean_uses_workspace_default_max_age_when_cli_silent() {
450 let tempdir = tempfile::TempDir::new().unwrap();
453 let root = tempdir.path();
454 write_file(
455 &root.join(".haz/workspace.yml"),
456 "projects:\n - /lib\ncache:\n clean:\n max_age: 30d\n",
457 );
458 write_file(
459 &root.join("lib/haz.yml"),
460 "name: lib\ntasks:\n default:\n command: [\"true\"]\n",
461 );
462 let code = run_clean(root, &CacheCleanArgs::default()).unwrap();
464 assert_eq!(code, 0);
465 }
466
467 fn defaults_with(max_age: Option<&str>, max_size: Option<&str>) -> CacheCleanSettings {
470 CacheCleanSettings {
471 max_age: max_age.map(|s| MaxAge::parse(s).unwrap()),
472 max_size: max_size.map(|s| MaxSize::parse(s).unwrap()),
473 }
474 }
475
476 #[test]
477 fn resolve_clean_options_rejects_no_effective_mode() {
478 let err = resolve_clean_options(
479 &CacheCleanArgs::default(),
480 &CacheCleanSettings::default(),
481 0,
482 )
483 .unwrap_err();
484 let rendered = format!("{err}");
485 assert!(
486 rendered.contains("at least one mode"),
487 "AUX-022 rejection diagnostic must surface the requirement: {rendered}",
488 );
489 }
490
491 #[test]
492 fn resolve_clean_options_falls_back_to_workspace_when_cli_silent() {
493 let defaults = defaults_with(Some("30d"), Some("1GB"));
494 let opts = resolve_clean_options(&CacheCleanArgs::default(), &defaults, 12345).unwrap();
495 assert_eq!(opts.max_age, defaults.max_age);
496 assert_eq!(opts.max_size, defaults.max_size);
497 assert!(!opts.soft);
498 assert!(!opts.dry_run);
499 assert_eq!(opts.now_unix, 12345);
500 }
501
502 #[test]
503 fn resolve_clean_options_cli_overrides_workspace_defaults() {
504 let defaults = defaults_with(Some("30d"), Some("1GB"));
508 let cli_age = MaxAge::parse("7d").unwrap();
509 let args = CacheCleanArgs {
510 max_age: Some(cli_age),
511 ..CacheCleanArgs::default()
512 };
513 let opts = resolve_clean_options(&args, &defaults, 0).unwrap();
514 assert_eq!(opts.max_age, Some(cli_age));
515 assert_eq!(opts.max_size, defaults.max_size);
516 }
517
518 #[test]
519 fn resolve_clean_options_soft_alone_satisfies_aux_022() {
520 let opts =
521 resolve_clean_options(&soft_only_args(), &CacheCleanSettings::default(), 0).unwrap();
522 assert!(opts.soft);
523 assert!(opts.max_age.is_none());
524 assert!(opts.max_size.is_none());
525 }
526
527 fn sample_report() -> CleanReport {
530 CleanReport {
531 inspected: 7,
532 evicted_by_soft: 2,
533 evicted_by_max_age: 3,
534 evicted_by_max_size: 1,
535 removed_tmp_dirs: 1,
536 removed_restore_dirs: 2,
537 bytes_reclaimed: 4096,
538 evicted_entries: vec![
539 EvictedEntry {
540 key_hex_prefix: "deadbeef".to_owned(),
541 created_at_unix: 100,
542 footprint: 512,
543 matched_mode: EvictionMode::Soft,
544 },
545 EvictedEntry {
546 key_hex_prefix: "cafef00d".to_owned(),
547 created_at_unix: 200,
548 footprint: 1024,
549 matched_mode: EvictionMode::MaxAge,
550 },
551 ],
552 }
553 }
554
555 fn render(opts: &CleanOptions, report: &CleanReport) -> String {
556 let mut out = Vec::<u8>::new();
557 print_clean_report(opts, report, &mut out).unwrap();
558 String::from_utf8(out).unwrap()
559 }
560
561 #[test]
562 fn print_clean_report_renders_required_aux_024_categories() {
563 let opts = CleanOptions {
564 soft: true,
565 ..CleanOptions::default()
566 };
567 let rendered = render(&opts, &sample_report());
568 assert!(
569 rendered.contains("entries inspected: 7"),
570 "missing inspected line: {rendered}",
571 );
572 assert!(
573 rendered.contains("evicted --soft: 2"),
574 "missing soft line: {rendered}",
575 );
576 assert!(
577 rendered.contains("evicted --max-age: 3"),
578 "missing max-age line: {rendered}",
579 );
580 assert!(
581 rendered.contains("evicted --max-size: 1"),
582 "missing max-size line: {rendered}",
583 );
584 assert!(
585 rendered.contains("orphan tmp directories removed: 1"),
586 "missing tmp line: {rendered}",
587 );
588 assert!(
589 rendered.contains("orphan restore directories removed: 2"),
590 "missing restore line: {rendered}",
591 );
592 assert!(
593 rendered.contains("bytes reclaimed: 4096"),
594 "missing bytes line: {rendered}",
595 );
596 assert!(
597 !rendered.contains("dry-run"),
598 "non-dry-run output must not mention dry-run: {rendered}",
599 );
600 }
601
602 #[test]
603 fn print_clean_report_lists_per_entry_detail_under_dry_run() {
604 let opts = CleanOptions {
605 soft: true,
606 dry_run: true,
607 ..CleanOptions::default()
608 };
609 let rendered = render(&opts, &sample_report());
610 assert!(
611 rendered.contains("would evict --soft: 2"),
612 "dry-run soft label: {rendered}",
613 );
614 assert!(
615 rendered.contains("would evict --max-age: 3"),
616 "dry-run max-age label: {rendered}",
617 );
618 assert!(
619 rendered.contains("bytes projected: 4096"),
620 "dry-run bytes label: {rendered}",
621 );
622 assert!(
623 rendered.contains("dry-run plan:"),
624 "dry-run plan header: {rendered}",
625 );
626 assert!(
627 rendered.contains("deadbeef created_at=100 footprint=512 mode=--soft"),
628 "dry-run plan soft entry: {rendered}",
629 );
630 assert!(
631 rendered.contains("cafef00d created_at=200 footprint=1024 mode=--max-age"),
632 "dry-run plan max-age entry: {rendered}",
633 );
634 }
635
636 #[test]
637 fn print_clean_report_omits_dry_run_plan_when_evicted_entries_empty() {
638 let opts = CleanOptions {
641 soft: true,
642 dry_run: true,
643 ..CleanOptions::default()
644 };
645 let empty = CleanReport::default();
646 let rendered = render(&opts, &empty);
647 assert!(
648 !rendered.contains("dry-run plan:"),
649 "empty dry-run plan must omit the header: {rendered}",
650 );
651 }
652
653 #[test]
654 fn mode_label_name_maps_each_variant() {
655 assert_eq!(mode_label_name(EvictionMode::Soft), "--soft");
656 assert_eq!(mode_label_name(EvictionMode::MaxAge), "--max-age");
657 assert_eq!(mode_label_name(EvictionMode::MaxSize), "--max-size");
658 }
659
660 #[test]
663 fn aux_021_info_on_fresh_workspace_exits_zero() {
664 let tempdir = tempfile::TempDir::new().unwrap();
665 let root = tempdir.path();
666 write_workspace(root);
667 let code = run_info(root).unwrap();
668 assert_eq!(code, 0);
669 assert!(!root.join(".haz/cache").exists());
671 }
672
673 #[test]
674 fn aux_018_info_does_not_mutate_an_existing_cache_root() {
675 let tempdir = tempfile::TempDir::new().unwrap();
680 let root = tempdir.path();
681 write_workspace(root);
682 write_file(
683 &root.join(".haz/cache/.restore-abcdef-1234/leftover.bin"),
684 "scratch",
685 );
686 fs::create_dir_all(root.join(".haz/cache/ab/0000")).unwrap();
688 write_file(&root.join(".haz/cache/ab/0000/stdout"), "");
689 let before = snapshot_dir(&root.join(".haz/cache"));
690 let code = run_info(root).unwrap();
691 assert_eq!(code, 0);
692 let after = snapshot_dir(&root.join(".haz/cache"));
693 assert_eq!(
694 before, after,
695 "cache root state must not change under info()",
696 );
697 }
698
699 #[test]
700 fn aux_019_info_output_lists_every_required_category() {
701 let report = CacheInfoReport {
707 well_formed_entries: 5,
708 corrupt_entries: 2,
709 orphan_tmp_dirs: 1,
710 orphan_restore_dirs: 3,
711 total_bytes: 12345,
712 by_schema: {
713 let mut m: BTreeMap<(u8, HashFunctionLabel), u64> = BTreeMap::new();
714 m.insert((0, HashFunctionLabel::Blake3), 5);
715 m
716 },
717 };
718 let mut out = Vec::<u8>::new();
719 let cache_root = PathBuf::from("/some/workspace/.haz/cache");
720 print_info_report(&cache_root, &report, &mut out).unwrap();
721 let rendered = String::from_utf8(out).unwrap();
722 assert!(
723 rendered.contains("cache root: /some/workspace/.haz/cache"),
724 "missing cache root line: {rendered}",
725 );
726 assert!(
727 rendered.contains("well-formed entries: 5"),
728 "missing well-formed line: {rendered}",
729 );
730 assert!(
731 rendered.contains("corrupt entries: 2"),
732 "missing corrupt line: {rendered}",
733 );
734 assert!(
735 rendered.contains("orphan tmp directories: 1"),
736 "missing orphan tmp line: {rendered}",
737 );
738 assert!(
739 rendered.contains("orphan restore directories: 3"),
740 "missing orphan restore line: {rendered}",
741 );
742 assert!(
743 rendered.contains("total bytes: 12345"),
744 "missing total bytes line: {rendered}",
745 );
746 assert!(
747 rendered.contains("by schema:"),
748 "missing schema breakdown header: {rendered}",
749 );
750 assert!(
751 rendered.contains("(revision=0, hash=blake3): 5"),
752 "missing schema breakdown entry: {rendered}",
753 );
754 }
755
756 #[test]
757 fn aux_019_info_output_omits_schema_section_when_no_well_formed_entries() {
758 let report = CacheInfoReport {
762 well_formed_entries: 0,
763 corrupt_entries: 2,
764 orphan_tmp_dirs: 0,
765 orphan_restore_dirs: 0,
766 total_bytes: 0,
767 by_schema: BTreeMap::new(),
768 };
769 let mut out = Vec::<u8>::new();
770 print_info_report(Path::new("/ws/.haz/cache"), &report, &mut out).unwrap();
771 let rendered = String::from_utf8(out).unwrap();
772 assert!(
773 !rendered.contains("by schema:"),
774 "schema section should be omitted: {rendered}",
775 );
776 }
777
778 #[test]
779 fn hash_label_name_maps_spec_canonical_strings() {
780 assert_eq!(hash_label_name(HashFunctionLabel::Blake3), "blake3");
783 assert_eq!(hash_label_name(HashFunctionLabel::Sha256), "sha256");
784 }
785
786 fn snapshot_dir(root: &Path) -> BTreeMap<String, Vec<u8>> {
791 let mut out = BTreeMap::new();
792 snapshot_into(root, root, &mut out);
793 out
794 }
795
796 fn snapshot_into(base: &Path, dir: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
797 let Ok(entries) = fs::read_dir(dir) else {
798 return;
799 };
800 for entry in entries.flatten() {
801 let path = entry.path();
802 let Ok(ft) = entry.file_type() else { continue };
803 if ft.is_file() {
804 let rel = path
805 .strip_prefix(base)
806 .map_or_else(|_| path.display().to_string(), |p| p.display().to_string());
807 let bytes = fs::read(&path).unwrap_or_default();
808 out.insert(rel, bytes);
809 } else if ft.is_dir() {
810 snapshot_into(base, &path, out);
811 }
812 }
813 }
814}