1use std::io::{self, Write};
28use std::path::Path;
29use std::time::{SystemTime, UNIX_EPOCH};
30
31use haz_cache::cache::Cache;
32use haz_cache::clean::{CleanOptions, CleanReport, EvictionMode};
33use haz_cache::info::CacheInfoReport;
34use haz_cache::manifest::HashFunctionLabel;
35use haz_domain::settings::cache::HashAlgo;
36use haz_domain::settings::cache_clean::CacheCleanSettings;
37use haz_vfs::StdFilesystem;
38use miette::{IntoDiagnostic, Result, WrapErr, miette};
39
40use crate::cli::CacheCleanArgs;
41use crate::workspace_load::load_workspace;
42
43const HASH_ALGO: HashAlgo = HashAlgo::Blake3;
49
50pub fn clear(cwd: &Path) -> Result<i32> {
61 let (workspace, _graph) = load_workspace(cwd)?;
62 let cache = build_cache(workspace.root.as_path());
63 cache
64 .clear()
65 .into_diagnostic()
66 .wrap_err("cache clear failed")?;
67 let mut out = io::stdout().lock();
68 writeln!(out, "cache cleared.")
69 .into_diagnostic()
70 .wrap_err("failed to write confirmation")?;
71 Ok(0)
72}
73
74pub fn clean(cwd: &Path, args: &CacheCleanArgs) -> Result<i32> {
89 let (workspace, _graph) = load_workspace(cwd)?;
90 let opts = resolve_clean_options(args, &workspace.settings.cache.clean, system_now_unix())?;
91 let cache = build_cache(workspace.root.as_path());
92 let report = cache
93 .clean(&opts)
94 .into_diagnostic()
95 .wrap_err("cache clean failed")?;
96 let mut out = io::stdout().lock();
97 print_clean_report(&opts, &report, &mut out)
98 .into_diagnostic()
99 .wrap_err("failed to write clean report")?;
100 Ok(0)
101}
102
103fn resolve_clean_options(
106 args: &CacheCleanArgs,
107 defaults: &CacheCleanSettings,
108 now_unix: u64,
109) -> Result<CleanOptions> {
110 let max_age = args.max_age.or(defaults.max_age);
111 let max_size = args.max_size.or(defaults.max_size);
112 if !args.soft && max_age.is_none() && max_size.is_none() {
113 return Err(miette!(
114 "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)"
115 ));
116 }
117 Ok(CleanOptions {
118 soft: args.soft,
119 max_age,
120 max_size,
121 dry_run: args.dry_run,
122 now_unix,
123 })
124}
125
126fn system_now_unix() -> u64 {
127 SystemTime::now()
128 .duration_since(UNIX_EPOCH)
129 .map_or(0, |d| d.as_secs())
130}
131
132fn print_clean_report(
140 opts: &CleanOptions,
141 report: &CleanReport,
142 out: &mut impl Write,
143) -> io::Result<()> {
144 if opts.dry_run {
145 writeln!(out, "dry-run: AUX-024 plan only, no entries removed.")?;
146 }
147 writeln!(out, "entries inspected: {}", report.inspected)?;
148 let (soft_label, age_label, size_label) = if opts.dry_run {
149 (
150 "would evict --soft",
151 "would evict --max-age",
152 "would evict --max-size",
153 )
154 } else {
155 ("evicted --soft", "evicted --max-age", "evicted --max-size")
156 };
157 writeln!(out, "{}: {}", soft_label, report.evicted_by_soft)?;
158 writeln!(out, "{}: {}", age_label, report.evicted_by_max_age)?;
159 writeln!(out, "{}: {}", size_label, report.evicted_by_max_size)?;
160 let tmp_verb = if opts.dry_run { "noted" } else { "removed" };
161 writeln!(
162 out,
163 "orphan tmp directories {tmp_verb}: {}",
164 report.removed_tmp_dirs,
165 )?;
166 writeln!(
167 out,
168 "orphan restore directories {tmp_verb}: {}",
169 report.removed_restore_dirs,
170 )?;
171 let bytes_label = if opts.dry_run {
172 "bytes projected"
173 } else {
174 "bytes reclaimed"
175 };
176 writeln!(out, "{bytes_label}: {}", report.bytes_reclaimed)?;
177 if opts.dry_run && !report.evicted_entries.is_empty() {
178 writeln!(out, "dry-run plan:")?;
179 for e in &report.evicted_entries {
180 writeln!(
181 out,
182 " {} created_at={} footprint={} mode={}",
183 e.key_hex_prefix,
184 e.created_at_unix,
185 e.footprint,
186 mode_label_name(e.matched_mode),
187 )?;
188 }
189 }
190 Ok(())
191}
192
193fn mode_label_name(m: EvictionMode) -> &'static str {
194 match m {
195 EvictionMode::Soft => "--soft",
196 EvictionMode::MaxAge => "--max-age",
197 EvictionMode::MaxSize => "--max-size",
198 }
199}
200
201pub fn info(cwd: &Path) -> Result<i32> {
212 let (workspace, _graph) = load_workspace(cwd)?;
213 let cache = build_cache(workspace.root.as_path());
214 let report = cache
215 .info()
216 .into_diagnostic()
217 .wrap_err("cache info walk failed")?;
218 let mut out = io::stdout().lock();
219 print_info_report(cache.cache_root(), &report, &mut out)
220 .into_diagnostic()
221 .wrap_err("failed to write cache info report")?;
222 Ok(0)
223}
224
225fn print_info_report(
231 cache_root: &Path,
232 report: &CacheInfoReport,
233 out: &mut impl Write,
234) -> io::Result<()> {
235 writeln!(out, "cache root: {}", cache_root.display())?;
236 writeln!(out, "well-formed entries: {}", report.well_formed_entries)?;
237 writeln!(out, "corrupt entries: {}", report.corrupt_entries)?;
238 writeln!(out, "orphan tmp directories: {}", report.orphan_tmp_dirs)?;
239 writeln!(
240 out,
241 "orphan restore directories: {}",
242 report.orphan_restore_dirs,
243 )?;
244 writeln!(out, "total bytes: {}", report.total_bytes)?;
245 if !report.by_schema.is_empty() {
246 writeln!(out, "by schema:")?;
247 for ((rev, hash), count) in &report.by_schema {
248 writeln!(
249 out,
250 " (revision={rev}, hash={}): {count}",
251 hash_label_name(*hash),
252 )?;
253 }
254 }
255 Ok(())
256}
257
258fn hash_label_name(label: HashFunctionLabel) -> &'static str {
261 match label {
262 HashFunctionLabel::Blake3 => "blake3",
263 HashFunctionLabel::Sha256 => "sha256",
264 }
265}
266
267fn build_cache(workspace_root: &Path) -> Cache<StdFilesystem> {
268 Cache::new(StdFilesystem::new(), workspace_root, HASH_ALGO)
269}
270
271#[cfg(test)]
272mod tests {
273 use super::{
274 clean, clear, hash_label_name, info, mode_label_name, print_clean_report,
275 print_info_report, resolve_clean_options,
276 };
277 use crate::cli::CacheCleanArgs;
278 use haz_cache::clean::{CleanOptions, CleanReport, EvictedEntry, EvictionMode};
279 use haz_cache::info::CacheInfoReport;
280 use haz_cache::manifest::HashFunctionLabel;
281 use haz_domain::settings::cache_clean::CacheCleanSettings;
282 use haz_domain::settings::cache_clean::max_age::MaxAge;
283 use haz_domain::settings::cache_clean::max_size::MaxSize;
284 use std::collections::BTreeMap;
285 use std::fs;
286 use std::path::{Path, PathBuf};
287
288 fn write_file(path: &Path, contents: &str) {
289 if let Some(parent) = path.parent() {
290 fs::create_dir_all(parent).unwrap();
291 }
292 fs::write(path, contents).unwrap();
293 }
294
295 fn write_workspace(root: &Path) {
305 write_file(&root.join(".haz/workspace.yml"), "projects:\n - /lib\n");
306 write_file(
307 &root.join("lib/haz.yml"),
308 "name: lib\ntasks:\n default:\n command: [\"true\"]\n",
309 );
310 }
311
312 #[test]
313 fn clear_on_fresh_workspace_is_noop_and_exits_zero() {
314 let tempdir = tempfile::TempDir::new().unwrap();
315 let root = tempdir.path();
316 write_workspace(root);
317 let code = clear(root).unwrap();
318 assert_eq!(code, 0);
319 assert!(!root.join(".haz/cache").exists());
322 }
323
324 #[test]
325 fn clear_removes_an_existing_cache_root() {
326 let tempdir = tempfile::TempDir::new().unwrap();
327 let root = tempdir.path();
328 write_workspace(root);
329 let sentinel = root.join(".haz/cache/ab/sentinel");
332 write_file(&sentinel, "anything");
333 assert!(sentinel.exists());
334 let code = clear(root).unwrap();
335 assert_eq!(code, 0);
336 assert!(!root.join(".haz/cache").exists());
337 }
338
339 #[test]
340 fn clear_does_not_touch_files_outside_cache_root() {
341 let tempdir = tempfile::TempDir::new().unwrap();
342 let root = tempdir.path();
343 write_workspace(root);
344 write_file(&root.join("docs/keep.md"), "important");
345 write_file(&root.join(".haz/cache/ab/c"), "stale");
346 let code = clear(root).unwrap();
347 assert_eq!(code, 0);
348 assert!(!root.join(".haz/cache").exists());
349 assert_eq!(
350 fs::read_to_string(root.join("docs/keep.md")).unwrap(),
351 "important"
352 );
353 }
354
355 fn soft_only_args() -> CacheCleanArgs {
356 CacheCleanArgs {
357 soft: true,
358 ..CacheCleanArgs::default()
359 }
360 }
361
362 #[test]
363 fn clean_soft_on_fresh_workspace_reports_all_zero() {
364 let tempdir = tempfile::TempDir::new().unwrap();
365 let root = tempdir.path();
366 write_workspace(root);
367 let code = clean(root, &soft_only_args()).unwrap();
368 assert_eq!(code, 0);
369 }
370
371 #[test]
372 fn clean_soft_reclaims_a_restore_staging_directory() {
373 let tempdir = tempfile::TempDir::new().unwrap();
378 let root = tempdir.path();
379 write_workspace(root);
380 let staging = root.join(".haz/cache/.restore-abcdef-1234");
381 write_file(&staging.join("leftover.bin"), "scratch");
382 assert!(staging.exists());
383 let code = clean(root, &soft_only_args()).unwrap();
384 assert_eq!(code, 0);
385 assert!(!staging.exists());
386 }
387
388 #[test]
389 fn clean_rejects_invocation_with_no_effective_mode() {
390 let tempdir = tempfile::TempDir::new().unwrap();
394 let root = tempdir.path();
395 write_workspace(root);
396 let err = clean(root, &CacheCleanArgs::default()).unwrap_err();
397 let rendered = format!("{err}");
398 assert!(
399 rendered.contains("at least one mode"),
400 "AUX-022 rejection diagnostic must surface the requirement: {rendered}",
401 );
402 }
403
404 #[test]
405 fn clean_dry_run_under_soft_leaves_restore_staging_in_place() {
406 let tempdir = tempfile::TempDir::new().unwrap();
410 let root = tempdir.path();
411 write_workspace(root);
412 let staging = root.join(".haz/cache/.restore-abcdef-1234");
413 write_file(&staging.join("leftover.bin"), "scratch");
414
415 let args = CacheCleanArgs {
416 soft: true,
417 dry_run: true,
418 ..CacheCleanArgs::default()
419 };
420 let code = clean(root, &args).unwrap();
421 assert_eq!(code, 0);
422 assert!(
423 staging.exists(),
424 "dry-run must not remove the restore staging directory",
425 );
426 }
427
428 #[test]
429 fn clean_uses_workspace_default_max_age_when_cli_silent() {
430 let tempdir = tempfile::TempDir::new().unwrap();
433 let root = tempdir.path();
434 write_file(
435 &root.join(".haz/workspace.yml"),
436 "projects:\n - /lib\ncache:\n clean:\n max_age: 30d\n",
437 );
438 write_file(
439 &root.join("lib/haz.yml"),
440 "name: lib\ntasks:\n default:\n command: [\"true\"]\n",
441 );
442 let code = clean(root, &CacheCleanArgs::default()).unwrap();
444 assert_eq!(code, 0);
445 }
446
447 fn defaults_with(max_age: Option<&str>, max_size: Option<&str>) -> CacheCleanSettings {
450 CacheCleanSettings {
451 max_age: max_age.map(|s| MaxAge::parse(s).unwrap()),
452 max_size: max_size.map(|s| MaxSize::parse(s).unwrap()),
453 }
454 }
455
456 #[test]
457 fn resolve_clean_options_rejects_no_effective_mode() {
458 let err = resolve_clean_options(
459 &CacheCleanArgs::default(),
460 &CacheCleanSettings::default(),
461 0,
462 )
463 .unwrap_err();
464 let rendered = format!("{err}");
465 assert!(
466 rendered.contains("at least one mode"),
467 "AUX-022 rejection diagnostic must surface the requirement: {rendered}",
468 );
469 }
470
471 #[test]
472 fn resolve_clean_options_falls_back_to_workspace_when_cli_silent() {
473 let defaults = defaults_with(Some("30d"), Some("1GB"));
474 let opts = resolve_clean_options(&CacheCleanArgs::default(), &defaults, 12345).unwrap();
475 assert_eq!(opts.max_age, defaults.max_age);
476 assert_eq!(opts.max_size, defaults.max_size);
477 assert!(!opts.soft);
478 assert!(!opts.dry_run);
479 assert_eq!(opts.now_unix, 12345);
480 }
481
482 #[test]
483 fn resolve_clean_options_cli_overrides_workspace_defaults() {
484 let defaults = defaults_with(Some("30d"), Some("1GB"));
488 let cli_age = MaxAge::parse("7d").unwrap();
489 let args = CacheCleanArgs {
490 max_age: Some(cli_age),
491 ..CacheCleanArgs::default()
492 };
493 let opts = resolve_clean_options(&args, &defaults, 0).unwrap();
494 assert_eq!(opts.max_age, Some(cli_age));
495 assert_eq!(opts.max_size, defaults.max_size);
496 }
497
498 #[test]
499 fn resolve_clean_options_soft_alone_satisfies_aux_022() {
500 let opts =
501 resolve_clean_options(&soft_only_args(), &CacheCleanSettings::default(), 0).unwrap();
502 assert!(opts.soft);
503 assert!(opts.max_age.is_none());
504 assert!(opts.max_size.is_none());
505 }
506
507 fn sample_report() -> CleanReport {
510 CleanReport {
511 inspected: 7,
512 evicted_by_soft: 2,
513 evicted_by_max_age: 3,
514 evicted_by_max_size: 1,
515 removed_tmp_dirs: 1,
516 removed_restore_dirs: 2,
517 bytes_reclaimed: 4096,
518 evicted_entries: vec![
519 EvictedEntry {
520 key_hex_prefix: "deadbeef".to_owned(),
521 created_at_unix: 100,
522 footprint: 512,
523 matched_mode: EvictionMode::Soft,
524 },
525 EvictedEntry {
526 key_hex_prefix: "cafef00d".to_owned(),
527 created_at_unix: 200,
528 footprint: 1024,
529 matched_mode: EvictionMode::MaxAge,
530 },
531 ],
532 }
533 }
534
535 fn render(opts: &CleanOptions, report: &CleanReport) -> String {
536 let mut out = Vec::<u8>::new();
537 print_clean_report(opts, report, &mut out).unwrap();
538 String::from_utf8(out).unwrap()
539 }
540
541 #[test]
542 fn print_clean_report_renders_required_aux_024_categories() {
543 let opts = CleanOptions {
544 soft: true,
545 ..CleanOptions::default()
546 };
547 let rendered = render(&opts, &sample_report());
548 assert!(
549 rendered.contains("entries inspected: 7"),
550 "missing inspected line: {rendered}",
551 );
552 assert!(
553 rendered.contains("evicted --soft: 2"),
554 "missing soft line: {rendered}",
555 );
556 assert!(
557 rendered.contains("evicted --max-age: 3"),
558 "missing max-age line: {rendered}",
559 );
560 assert!(
561 rendered.contains("evicted --max-size: 1"),
562 "missing max-size line: {rendered}",
563 );
564 assert!(
565 rendered.contains("orphan tmp directories removed: 1"),
566 "missing tmp line: {rendered}",
567 );
568 assert!(
569 rendered.contains("orphan restore directories removed: 2"),
570 "missing restore line: {rendered}",
571 );
572 assert!(
573 rendered.contains("bytes reclaimed: 4096"),
574 "missing bytes line: {rendered}",
575 );
576 assert!(
577 !rendered.contains("dry-run"),
578 "non-dry-run output must not mention dry-run: {rendered}",
579 );
580 }
581
582 #[test]
583 fn print_clean_report_lists_per_entry_detail_under_dry_run() {
584 let opts = CleanOptions {
585 soft: true,
586 dry_run: true,
587 ..CleanOptions::default()
588 };
589 let rendered = render(&opts, &sample_report());
590 assert!(
591 rendered.contains("would evict --soft: 2"),
592 "dry-run soft label: {rendered}",
593 );
594 assert!(
595 rendered.contains("would evict --max-age: 3"),
596 "dry-run max-age label: {rendered}",
597 );
598 assert!(
599 rendered.contains("bytes projected: 4096"),
600 "dry-run bytes label: {rendered}",
601 );
602 assert!(
603 rendered.contains("dry-run plan:"),
604 "dry-run plan header: {rendered}",
605 );
606 assert!(
607 rendered.contains("deadbeef created_at=100 footprint=512 mode=--soft"),
608 "dry-run plan soft entry: {rendered}",
609 );
610 assert!(
611 rendered.contains("cafef00d created_at=200 footprint=1024 mode=--max-age"),
612 "dry-run plan max-age entry: {rendered}",
613 );
614 }
615
616 #[test]
617 fn print_clean_report_omits_dry_run_plan_when_evicted_entries_empty() {
618 let opts = CleanOptions {
621 soft: true,
622 dry_run: true,
623 ..CleanOptions::default()
624 };
625 let empty = CleanReport::default();
626 let rendered = render(&opts, &empty);
627 assert!(
628 !rendered.contains("dry-run plan:"),
629 "empty dry-run plan must omit the header: {rendered}",
630 );
631 }
632
633 #[test]
634 fn mode_label_name_maps_each_variant() {
635 assert_eq!(mode_label_name(EvictionMode::Soft), "--soft");
636 assert_eq!(mode_label_name(EvictionMode::MaxAge), "--max-age");
637 assert_eq!(mode_label_name(EvictionMode::MaxSize), "--max-size");
638 }
639
640 #[test]
643 fn aux_021_info_on_fresh_workspace_exits_zero() {
644 let tempdir = tempfile::TempDir::new().unwrap();
645 let root = tempdir.path();
646 write_workspace(root);
647 let code = info(root).unwrap();
648 assert_eq!(code, 0);
649 assert!(!root.join(".haz/cache").exists());
651 }
652
653 #[test]
654 fn aux_018_info_does_not_mutate_an_existing_cache_root() {
655 let tempdir = tempfile::TempDir::new().unwrap();
660 let root = tempdir.path();
661 write_workspace(root);
662 write_file(
663 &root.join(".haz/cache/.restore-abcdef-1234/leftover.bin"),
664 "scratch",
665 );
666 fs::create_dir_all(root.join(".haz/cache/ab/0000")).unwrap();
668 write_file(&root.join(".haz/cache/ab/0000/stdout"), "");
669 let before = snapshot_dir(&root.join(".haz/cache"));
670 let code = info(root).unwrap();
671 assert_eq!(code, 0);
672 let after = snapshot_dir(&root.join(".haz/cache"));
673 assert_eq!(
674 before, after,
675 "cache root state must not change under info()",
676 );
677 }
678
679 #[test]
680 fn aux_019_info_output_lists_every_required_category() {
681 let report = CacheInfoReport {
687 well_formed_entries: 5,
688 corrupt_entries: 2,
689 orphan_tmp_dirs: 1,
690 orphan_restore_dirs: 3,
691 total_bytes: 12345,
692 by_schema: {
693 let mut m: BTreeMap<(u8, HashFunctionLabel), u64> = BTreeMap::new();
694 m.insert((0, HashFunctionLabel::Blake3), 5);
695 m
696 },
697 };
698 let mut out = Vec::<u8>::new();
699 let cache_root = PathBuf::from("/some/workspace/.haz/cache");
700 print_info_report(&cache_root, &report, &mut out).unwrap();
701 let rendered = String::from_utf8(out).unwrap();
702 assert!(
703 rendered.contains("cache root: /some/workspace/.haz/cache"),
704 "missing cache root line: {rendered}",
705 );
706 assert!(
707 rendered.contains("well-formed entries: 5"),
708 "missing well-formed line: {rendered}",
709 );
710 assert!(
711 rendered.contains("corrupt entries: 2"),
712 "missing corrupt line: {rendered}",
713 );
714 assert!(
715 rendered.contains("orphan tmp directories: 1"),
716 "missing orphan tmp line: {rendered}",
717 );
718 assert!(
719 rendered.contains("orphan restore directories: 3"),
720 "missing orphan restore line: {rendered}",
721 );
722 assert!(
723 rendered.contains("total bytes: 12345"),
724 "missing total bytes line: {rendered}",
725 );
726 assert!(
727 rendered.contains("by schema:"),
728 "missing schema breakdown header: {rendered}",
729 );
730 assert!(
731 rendered.contains("(revision=0, hash=blake3): 5"),
732 "missing schema breakdown entry: {rendered}",
733 );
734 }
735
736 #[test]
737 fn aux_019_info_output_omits_schema_section_when_no_well_formed_entries() {
738 let report = CacheInfoReport {
742 well_formed_entries: 0,
743 corrupt_entries: 2,
744 orphan_tmp_dirs: 0,
745 orphan_restore_dirs: 0,
746 total_bytes: 0,
747 by_schema: BTreeMap::new(),
748 };
749 let mut out = Vec::<u8>::new();
750 print_info_report(Path::new("/ws/.haz/cache"), &report, &mut out).unwrap();
751 let rendered = String::from_utf8(out).unwrap();
752 assert!(
753 !rendered.contains("by schema:"),
754 "schema section should be omitted: {rendered}",
755 );
756 }
757
758 #[test]
759 fn hash_label_name_maps_spec_canonical_strings() {
760 assert_eq!(hash_label_name(HashFunctionLabel::Blake3), "blake3");
763 assert_eq!(hash_label_name(HashFunctionLabel::Sha256), "sha256");
764 }
765
766 fn snapshot_dir(root: &Path) -> BTreeMap<String, Vec<u8>> {
771 let mut out = BTreeMap::new();
772 snapshot_into(root, root, &mut out);
773 out
774 }
775
776 fn snapshot_into(base: &Path, dir: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
777 let Ok(entries) = fs::read_dir(dir) else {
778 return;
779 };
780 for entry in entries.flatten() {
781 let path = entry.path();
782 let Ok(ft) = entry.file_type() else { continue };
783 if ft.is_file() {
784 let rel = path
785 .strip_prefix(base)
786 .map_or_else(|_| path.display().to_string(), |p| p.display().to_string());
787 let bytes = fs::read(&path).unwrap_or_default();
788 out.insert(rel, bytes);
789 } else if ft.is_dir() {
790 snapshot_into(base, &path, out);
791 }
792 }
793 }
794}