Skip to main content

haz_cli/cli/
cache.rs

1//! Parser for the `haz cache` subcommand family.
2//!
3//! Owns [`CacheCleanArgs`], the [`cache_command`] dispatcher over
4//! the three `clear` / `clean` / `info` branches, and the
5//! [`clean_command`] mode-flag parser. The `clear` and `info`
6//! branches carry no arguments and map straight to their [`Cmd`]
7//! variants; `clean` collects the composable mode flags.
8
9use bpaf::{Parser, construct, long, pure};
10use haz_domain::settings::cache_clean::max_age::MaxAge;
11use haz_domain::settings::cache_clean::max_size::MaxSize;
12
13use crate::cli::Cmd;
14
15/// Arguments parsed for the `haz cache clean` subcommand.
16///
17/// Three composable mode flags (`--soft`, `--max-age`,
18/// `--max-size`) plus the `--dry-run` modifier per
19/// `AUX-022`..`AUX-024`. The CLI parser eagerly validates
20/// `--max-age` / `--max-size` value strings against the
21/// `CFG-034` / `CFG-035` grammars so a syntactically bad value
22/// is rejected before workspace load. The `AUX-022` "at least
23/// one mode" requirement is enforced at the executor layer
24/// because workspace `cache.clean.*` defaults (`AUX-025`) MAY
25/// supply a mode when the CLI is silent.
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
27pub struct CacheCleanArgs {
28    /// `--soft`: reclaim objectively-stale state (`CACHE-022`).
29    pub soft: bool,
30    /// `--max-age <DURATION>`: evict entries strictly older than
31    /// `now - DURATION` (`AUX-022`).
32    pub max_age: Option<MaxAge>,
33    /// `--max-size <BYTES>`: evict oldest-first until the
34    /// well-formed footprint is at or below `BYTES`
35    /// (`AUX-022`).
36    pub max_size: Option<MaxSize>,
37    /// `--dry-run`: compute the eviction set per `AUX-024`
38    /// without removing anything.
39    pub dry_run: bool,
40}
41
42pub(super) fn cache_command() -> impl Parser<Cmd> {
43    // Three branches under `haz cache`: `clear` (remove every
44    // entry, `CACHE-021`), `clean --soft` (remove only objectively
45    // stale entries, `CACHE-022`), and `info` (read-only report,
46    // `AUX-017`..`AUX-021`). Bindings named by their semantic
47    // role rather than `cache_<verb>_subcommand` to keep clippy's
48    // `similar_names` lint at bay (the spec verbs `clear` and
49    // `clean` are too close on their own).
50    let remove_all = pure(Cmd::CacheClear)
51        .to_options()
52        .command("clear")
53        .help("Remove every cache entry (CACHE-021)");
54    let prune_stale = clean_command()
55        .to_options()
56        .command("clean")
57        .help("Selectively reclaim cache entries (CACHE-022, AUX-022..AUX-027)");
58    let report_info = pure(Cmd::CacheInfo)
59        .to_options()
60        .command("info")
61        .help("Report cache-state statistics (AUX-017..AUX-021)");
62    construct!([remove_all, prune_stale, report_info])
63}
64
65fn clean_command() -> impl Parser<Cmd> {
66    // Three composable mode flags + `--dry-run`. The `AUX-022`
67    // "at least one mode" rule MAY be satisfied by workspace
68    // `cache.clean.*` defaults (`AUX-025`), so enforcement
69    // lives at the executor layer after the workspace loads.
70    let soft = long("soft")
71        .help("Reclaim objectively-stale state (CACHE-022)")
72        .switch();
73    let max_age = long("max-age")
74        .help("Evict entries strictly older than DURATION (AUX-022)")
75        .argument::<String>("DURATION")
76        .parse(|s| MaxAge::parse(&s).map_err(|e| e.to_string()))
77        .optional();
78    let max_size = long("max-size")
79        .help("Cap on-disk footprint at BYTES, evicting oldest first (AUX-022)")
80        .argument::<String>("BYTES")
81        .parse(|s| MaxSize::parse(&s).map_err(|e| e.to_string()))
82        .optional();
83    let dry_run = long("dry-run")
84        .help("Compute the eviction set without removing anything (AUX-024)")
85        .switch();
86    construct!(CacheCleanArgs {
87        soft,
88        max_age,
89        max_size,
90        dry_run,
91    })
92    .map(Cmd::CacheClean)
93}
94
95#[cfg(test)]
96mod tests {
97    use super::{CacheCleanArgs, MaxAge, MaxSize};
98    use crate::cli::{Cmd, parse};
99
100    #[test]
101    fn cache_clear_parses() {
102        assert_eq!(parse(&["cache", "clear"]).unwrap(), Cmd::CacheClear);
103    }
104
105    #[test]
106    fn cache_info_parses() {
107        assert_eq!(parse(&["cache", "info"]).unwrap(), Cmd::CacheInfo);
108    }
109
110    #[test]
111    fn aux_017_cache_info_rejects_positional() {
112        // AUX-017 form contract: bare `cache info` only; any extra
113        // positional is a CLI error.
114        let err = parse(&["cache", "info", "extra"]).unwrap_err();
115        assert!(
116            !err.is_empty(),
117            "extra positional must produce a diagnostic: {err}",
118        );
119    }
120
121    #[test]
122    fn aux_017_cache_info_rejects_unknown_flag() {
123        // AUX-017 form contract: no flags defined for v1.
124        let err = parse(&["cache", "info", "--soft"]).unwrap_err();
125        assert!(
126            !err.is_empty(),
127            "unknown flag must produce a diagnostic: {err}",
128        );
129    }
130
131    #[test]
132    fn cache_clean_soft_parses() {
133        assert_eq!(
134            parse(&["cache", "clean", "--soft"]).unwrap(),
135            Cmd::CacheClean(CacheCleanArgs {
136                soft: true,
137                ..CacheCleanArgs::default()
138            }),
139        );
140    }
141
142    #[test]
143    fn cache_clean_bare_parses_with_no_modes() {
144        // AUX-025 lets workspace `cache.clean.*` defaults supply
145        // a mode, so the parser MUST accept a bare invocation.
146        // The "at least one mode" rule (AUX-022) is enforced at
147        // the executor layer after workspace load.
148        assert_eq!(
149            parse(&["cache", "clean"]).unwrap(),
150            Cmd::CacheClean(CacheCleanArgs::default()),
151        );
152    }
153
154    #[test]
155    fn cache_clean_max_age_parses_valid_duration() {
156        let want = MaxAge::parse("90d").unwrap();
157        assert_eq!(
158            parse(&["cache", "clean", "--max-age", "90d"]).unwrap(),
159            Cmd::CacheClean(CacheCleanArgs {
160                max_age: Some(want),
161                ..CacheCleanArgs::default()
162            }),
163        );
164    }
165
166    #[test]
167    fn cache_clean_max_age_rejects_bare_integer() {
168        // CFG-034 / AUX-022: the bare-integer form is ambiguous
169        // and MUST be rejected by the duration grammar.
170        let err = parse(&["cache", "clean", "--max-age", "90"]).unwrap_err();
171        assert!(
172            !err.is_empty(),
173            "malformed --max-age must produce a diagnostic: {err}",
174        );
175    }
176
177    #[test]
178    fn cache_clean_max_size_parses_si_suffix() {
179        let want = MaxSize::parse("1GB").unwrap();
180        assert_eq!(
181            parse(&["cache", "clean", "--max-size", "1GB"]).unwrap(),
182            Cmd::CacheClean(CacheCleanArgs {
183                max_size: Some(want),
184                ..CacheCleanArgs::default()
185            }),
186        );
187    }
188
189    #[test]
190    fn cache_clean_max_size_rejects_unknown_suffix() {
191        let err = parse(&["cache", "clean", "--max-size", "1ZB"]).unwrap_err();
192        assert!(
193            !err.is_empty(),
194            "malformed --max-size must produce a diagnostic: {err}",
195        );
196    }
197
198    #[test]
199    fn cache_clean_dry_run_composes_with_other_flags() {
200        let want_age = MaxAge::parse("30d").unwrap();
201        assert_eq!(
202            parse(&["cache", "clean", "--soft", "--max-age", "30d", "--dry-run"]).unwrap(),
203            Cmd::CacheClean(CacheCleanArgs {
204                soft: true,
205                max_age: Some(want_age),
206                max_size: None,
207                dry_run: true,
208            }),
209        );
210    }
211}