Skip to main content

haz_cli/commands/
cache.rs

1//! `haz cache` subcommands per `CACHE-021` / `CACHE-022` /
2//! `AUX-017`..`AUX-027`.
3//!
4//! - [`clear`] backs `haz cache clear`: an unconditional reset of
5//!   `<workspace_root>/.haz/cache`. Subsequent lookups against
6//!   the same workspace are misses until new entries are stored.
7//! - [`clean`] backs `haz cache clean [--soft] [--max-age <D>]
8//!   [--max-size <B>] [--dry-run]`: composable reclamation of
9//!   objectively-stale entries plus age- / size-bounded
10//!   eviction (`CACHE-022`, `AUX-022`..`AUX-027`). The executor
11//!   merges CLI flags with workspace `cache.clean.*` defaults
12//!   (`AUX-025`) and enforces the "at least one mode" rule
13//!   (`AUX-022`).
14//! - [`info`] backs `haz cache info`: a read-only walk that
15//!   classifies entries and reports counts, total bytes, and
16//!   schema-prefix breakdown without mutating the cache root.
17//!
18//! All three subcommands first load the workspace via
19//! [`crate::workspace_load::load_workspace`] to locate the
20//! workspace root. The cache subcommand surface is independent of
21//! the bearing-project concept (`EXEC-022`) and ignores cwd
22//! beyond using it as the discovery starting point. A future
23//! revision MAY expose a discovery-only fast path so the cache
24//! commands succeed against a workspace with broken `haz.yml`
25//! files; for v1, a parseable workspace is required.
26
27use 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
43/// The hash algorithm haz-cli uses when probing manifests. MUST
44/// match the algorithm used by `commands::run::execute` so the
45/// schema-mismatch arm of `CACHE-022` only fires for genuinely
46/// out-of-band manifests, never for a cache the same `haz`
47/// invocation could itself produce.
48const HASH_ALGO: HashAlgo = HashAlgo::Blake3;
49
50/// Execute `haz cache clear` against `cwd`.
51///
52/// Returns 0 on success. Errors propagate as miette diagnostics
53/// (workspace-load failure, filesystem failure during the
54/// recursive removal).
55///
56/// # Errors
57///
58/// Returns a [`miette::Report`] for workspace-load or
59/// invalidation failures.
60pub 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
74/// Execute `haz cache clean` against `cwd` with the supplied CLI
75/// args.
76///
77/// Resolves the effective mode set by combining `args` with
78/// workspace `cache.clean.*` defaults per `AUX-025` (CLI overrides
79/// workspace), enforces the `AUX-022` "at least one mode" rule,
80/// runs the unified [`Cache::clean`], and emits the `AUX-024`
81/// report.
82///
83/// # Errors
84///
85/// Returns a [`miette::Report`] on workspace-load failure, on a
86/// no-effective-mode invocation, or on a filesystem failure
87/// during the eviction walk.
88pub 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
103/// Merge CLI flags with workspace defaults per `AUX-025` and
104/// enforce the `AUX-022` "at least one mode" rule.
105fn 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
132/// Render an `AUX-024` clean report to `out`.
133///
134/// The format is implementation-defined within the `AUX-024`
135/// requirements: every required category appears as its own
136/// labelled line, and the per-entry dry-run plan is emitted only
137/// when [`CleanOptions::dry_run`] is set and the eviction set is
138/// non-empty (so the section header never dangles).
139fn 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
201/// Execute `haz cache info` against `cwd`.
202///
203/// Loads the workspace to locate the cache root, walks the cache
204/// in read-only mode via [`Cache::info`], and prints the
205/// classification per `AUX-019`. Returns 0 on success.
206///
207/// # Errors
208///
209/// Returns a [`miette::Report`] for workspace-load or filesystem
210/// read failures during the cache walk.
211pub 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
225/// Render an [`AUX-019`] info report to `out`.
226///
227/// The format is implementation-defined; this v1 lists each
228/// category on its own line. The `by schema` section is omitted
229/// entirely when there are no well-formed entries to break down.
230fn 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
258/// Map a [`HashFunctionLabel`] to the spec's canonical lowercase
259/// string (`CACHE-002` registry).
260fn 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    /// Build an explicit-mode workspace `load_workspace` accepts.
296    ///
297    /// Explicit mode (`.haz/workspace.yml`) rather than implicit
298    /// mode (`.git` marker), because cache tests pre-populate
299    /// `.haz/cache/...`; with implicit mode, discovery sees the
300    /// `.haz/` directory and tries to read a `workspace.yml` that
301    /// isn't there. Explicit mode keeps `.haz/` legitimately used
302    /// by the workspace, so a sibling `.haz/cache/` subtree does
303    /// not confuse discovery.
304    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        // Cache root was never created; idempotent no-op behaviour
320        // of `Cache::clear` is what makes this OK.
321        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        // Pre-populate the cache root with a sentinel file; clear
330        // MUST take the whole subtree out.
331        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        // A `.restore-<key>-<rand>` directory under the cache root
374        // is the simplest "objectively stale" artefact for a
375        // black-box test: it doesn't require constructing a valid
376        // manifest, only creating the staging directory shape.
377        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        // AUX-022: a bare `haz cache clean` against a workspace
391        // with no `cache.clean.*` defaults MUST be rejected with
392        // a typed CLI error.
393        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        // AUX-024: `--dry-run` MUST compute the eviction set but
407        // make no on-disk changes. Soft pass would normally
408        // reclaim the staging directory; under dry-run it stays.
409        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        // AUX-025: workspace `cache.clean.max_age` MAY supply the
431        // mode that satisfies AUX-022 when the CLI is silent.
432        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        // No CLI mode flags set; the workspace default supplies it.
443        let code = clean(root, &CacheCleanArgs::default()).unwrap();
444        assert_eq!(code, 0);
445    }
446
447    // ---- resolve_clean_options ----
448
449    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        // AUX-025: an explicit CLI flag overrides the workspace
485        // default for that mode (the OTHER mode still inherits
486        // its default when CLI is silent for it).
487        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    // ---- print_clean_report ----
508
509    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        // The "dry-run plan:" header MUST NOT appear when there
619        // is nothing to list; otherwise it would dangle.
620        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    // ---- info ----
641
642    #[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        // No cache root was created during info; AUX-018 read-only.
650        assert!(!root.join(".haz/cache").exists());
651    }
652
653    #[test]
654    fn aux_018_info_does_not_mutate_an_existing_cache_root() {
655        // Pre-populate a mix of cache shapes (an orphan restore
656        // dir + a corrupt entry without a manifest). Snapshot the
657        // (path, bytes) state of `.haz/cache/` before and after
658        // info; the two snapshots MUST be byte-identical.
659        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        // Corrupt entry: shard dir + entry dir, no manifest.
667        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        // The format is implementation-defined; this test pins it
682        // by checking that every AUX-019 category appears as its
683        // own labelled line. Constructing a CacheInfoReport
684        // directly bypasses the FS walk so the test is purely a
685        // format check.
686        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        // The `by schema:` section MUST NOT appear when there are
739        // no well-formed entries to enumerate; otherwise the
740        // output would carry a dangling header.
741        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        // CACHE-002 registry: the canonical labels are `blake3`
761        // and `sha256` (lowercase).
762        assert_eq!(hash_label_name(HashFunctionLabel::Blake3), "blake3");
763        assert_eq!(hash_label_name(HashFunctionLabel::Sha256), "sha256");
764    }
765
766    /// Recursively snapshot every regular file under `root`,
767    /// returning a `BTreeMap<relative_path, bytes>`. Symlinks and
768    /// other non-file kinds are skipped; nested directories
769    /// recurse.
770    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}