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::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
40/// Execute `haz cache clear` against `cwd`.
41///
42/// Returns 0 on success. Errors propagate as miette diagnostics
43/// (workspace-load failure, filesystem failure during the
44/// recursive removal).
45///
46/// # Errors
47///
48/// Returns a [`miette::Report`] for workspace-load or
49/// invalidation failures.
50pub 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
63/// Execute `haz cache clean` against `cwd` with the supplied CLI
64/// args.
65///
66/// Resolves the effective mode set by combining `args` with
67/// workspace `cache.clean.*` defaults per `AUX-025` (CLI overrides
68/// workspace), enforces the `AUX-022` "at least one mode" rule,
69/// runs the unified `CacheWriter::clean`, and emits the `AUX-024`
70/// report.
71///
72/// # Errors
73///
74/// Returns a [`miette::Report`] on workspace-load failure, on a
75/// no-effective-mode invocation, or when the cache root itself
76/// cannot be read (the only fatal `clean` filesystem error per
77/// `AUX-028`). Best-effort per-item failures are NOT returned as a
78/// report: they are printed to stderr and yield the
79/// [`EXIT_INTERNAL_OR_CONFIG_ERROR`](crate::EXIT_INTERNAL_OR_CONFIG_ERROR)
80/// exit class per `AUX-027`, after the `AUX-024` report is emitted.
81pub 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    // `AUX-028`: the report above already described what was
103    // reclaimed; the per-item failures go to stderr and the run
104    // exits in the `EXEC-021` config-error class per `AUX-027`.
105    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
114/// Merge CLI flags with workspace defaults per `AUX-025` and
115/// enforce the `AUX-022` "at least one mode" rule.
116fn 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
143/// Render an `AUX-024` clean report to `out`.
144///
145/// The format is implementation-defined within the `AUX-024`
146/// requirements: every required category appears as its own
147/// labelled line, and the per-entry dry-run plan is emitted only
148/// when [`CleanOptions::dry_run`] is set and the eviction set is
149/// non-empty (so the section header never dangles).
150fn 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
212/// Execute `haz cache info` against `cwd`.
213///
214/// Loads the workspace to locate the cache root, walks the cache
215/// in read-only mode via `CacheReader::info`, and prints the
216/// classification per `AUX-019`. Returns 0 on success.
217///
218/// # Errors
219///
220/// Returns a [`miette::Report`] for workspace-load or filesystem
221/// read failures during the cache walk.
222pub 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
235/// Render an [`AUX-019`] info report to `out`.
236///
237/// The format is implementation-defined; this v1 lists each
238/// category on its own line. The `by schema` section is omitted
239/// entirely when there are no well-formed entries to break down.
240fn 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
268/// Map a [`HashFunctionLabel`] to the spec's canonical lowercase
269/// string (`CACHE-002` registry).
270fn 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    /// Build an explicit-mode workspace `load_workspace` accepts.
316    ///
317    /// Explicit mode (`.haz/workspace.yml`) rather than implicit
318    /// mode (`.git` marker), because cache tests pre-populate
319    /// `.haz/cache/...`; with implicit mode, discovery sees the
320    /// `.haz/` directory and tries to read a `workspace.yml` that
321    /// isn't there. Explicit mode keeps `.haz/` legitimately used
322    /// by the workspace, so a sibling `.haz/cache/` subtree does
323    /// not confuse discovery.
324    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        // Cache root was never created; idempotent no-op behaviour
340        // of `Cache::clear` is what makes this OK.
341        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        // Pre-populate the cache root with a sentinel file; clear
350        // MUST take the whole subtree out.
351        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        // A `.restore-<key>-<rand>` directory under the cache root
394        // is the simplest "objectively stale" artefact for a
395        // black-box test: it doesn't require constructing a valid
396        // manifest, only creating the staging directory shape.
397        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        // AUX-022: a bare `haz cache clean` against a workspace
411        // with no `cache.clean.*` defaults MUST be rejected with
412        // a typed CLI error.
413        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        // AUX-024: `--dry-run` MUST compute the eviction set but
427        // make no on-disk changes. Soft pass would normally
428        // reclaim the staging directory; under dry-run it stays.
429        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        // AUX-025: workspace `cache.clean.max_age` MAY supply the
451        // mode that satisfies AUX-022 when the CLI is silent.
452        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        // No CLI mode flags set; the workspace default supplies it.
463        let code = run_clean(root, &CacheCleanArgs::default()).unwrap();
464        assert_eq!(code, 0);
465    }
466
467    // ---- resolve_clean_options ----
468
469    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        // AUX-025: an explicit CLI flag overrides the workspace
505        // default for that mode (the OTHER mode still inherits
506        // its default when CLI is silent for it).
507        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    // ---- print_clean_report ----
528
529    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        // The "dry-run plan:" header MUST NOT appear when there
639        // is nothing to list; otherwise it would dangle.
640        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    // ---- info ----
661
662    #[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        // No cache root was created during info; AUX-018 read-only.
670        assert!(!root.join(".haz/cache").exists());
671    }
672
673    #[test]
674    fn aux_018_info_does_not_mutate_an_existing_cache_root() {
675        // Pre-populate a mix of cache shapes (an orphan restore
676        // dir + a corrupt entry without a manifest). Snapshot the
677        // (path, bytes) state of `.haz/cache/` before and after
678        // info; the two snapshots MUST be byte-identical.
679        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        // Corrupt entry: shard dir + entry dir, no manifest.
687        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        // The format is implementation-defined; this test pins it
702        // by checking that every AUX-019 category appears as its
703        // own labelled line. Constructing a CacheInfoReport
704        // directly bypasses the FS walk so the test is purely a
705        // format check.
706        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        // The `by schema:` section MUST NOT appear when there are
759        // no well-formed entries to enumerate; otherwise the
760        // output would carry a dangling header.
761        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        // CACHE-002 registry: the canonical labels are `blake3`
781        // and `sha256` (lowercase).
782        assert_eq!(hash_label_name(HashFunctionLabel::Blake3), "blake3");
783        assert_eq!(hash_label_name(HashFunctionLabel::Sha256), "sha256");
784    }
785
786    /// Recursively snapshot every regular file under `root`,
787    /// returning a `BTreeMap<relative_path, bytes>`. Symlinks and
788    /// other non-file kinds are skipped; nested directories
789    /// recurse.
790    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}