haz_cli/cli/mod.rs
1//! Command-line argument parser for the `haz` binary.
2//!
3//! Built on `bpaf` 0.9 using the combinator API; the derive
4//! macro is deliberately not used so the parser type tree stays
5//! decoupled from bpaf's macros. The parser produces a [`Cmd`]
6//! sum: one variant per command shape that the spec names
7//! normatively (`haz run`, `haz check`, `haz cache clear`,
8//! `haz cache clean --soft`).
9//!
10//! `--help` and `--version` are provided by bpaf automatically;
11//! the version string is sourced from `CARGO_PKG_VERSION` at
12//! compile time.
13
14use bpaf::{OptionParser, Parser, construct};
15pub use haz_domain::scope::Scope;
16pub use haz_domain::settings::output::OutputMode;
17pub use haz_domain::task_ref::TaskRef;
18pub use haz_vcs::GitFilter;
19
20pub(super) mod cache;
21pub(super) mod check;
22pub(super) mod init;
23pub(super) mod query;
24pub(super) mod run;
25pub(super) mod why;
26
27use crate::cli::cache::cache_command;
28use crate::cli::check::check_command;
29use crate::cli::init::init_command;
30use crate::cli::query::query_command;
31use crate::cli::run::run_command;
32use crate::cli::why::why_command;
33
34pub use crate::cli::cache::CacheCleanArgs;
35pub use crate::cli::check::CheckArgs;
36pub use crate::cli::init::InitArgs;
37pub use crate::cli::query::{FieldPresence, OutputFormat, QueryArgs};
38pub use crate::cli::run::RunArgs;
39pub use crate::cli::why::WhyArgs;
40
41/// Top-level command parsed from `argv`.
42///
43/// Each variant carries the per-command arguments needed to
44/// dispatch. The parser layer does not load the workspace, run
45/// the scheduler, or touch the filesystem; downstream modules
46/// take a [`Cmd`] value and act on it.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Cmd {
49 /// `haz run [--all | --workspace] [--dry-run] [--output-mode <mode>]
50 /// [<TASK_REF>...]` -- run (or, with `--dry-run`, enumerate)
51 /// the selected tasks plus their transitive hard predecessors
52 /// in the discovered workspace.
53 Run(RunArgs),
54 /// `haz check [--all | --workspace]` -- validate the discovered
55 /// workspace without running any task (`AUX-009`..`AUX-012`).
56 Check(CheckArgs),
57 /// `haz why <TASK>` -- read-only introspection of one task per
58 /// `AUX-013`..`AUX-016`.
59 Why(WhyArgs),
60 /// `haz cache clear` -- remove every cache entry (`CACHE-021`).
61 CacheClear,
62 /// `haz cache clean [--soft] [--max-age <DURATION>]
63 /// [--max-size <BYTES>] [--dry-run]` -- selective cache
64 /// reclamation (`CACHE-022`, `AUX-022`..`AUX-027`). The
65 /// executor enforces the "at least one mode" rule
66 /// (`AUX-022`) after merging CLI flags with workspace
67 /// `cache.clean.*` defaults (`AUX-025`).
68 CacheClean(CacheCleanArgs),
69 /// `haz cache info` -- read-only cache-state report
70 /// (`AUX-017`..`AUX-021`).
71 CacheInfo,
72 /// `haz query [filter flags]... [--format <fmt>]` -- enumerate
73 /// the tasks in the workspace matching every supplied filter
74 /// (`QRY-001`..`QRY-009`).
75 Query(QueryArgs),
76 /// `haz init [--yes]` -- scaffold a workspace
77 /// (`AUX-001`..`AUX-008`). The bare form runs the interactive
78 /// flow (`AUX-003`..`AUX-005`); `--yes` writes the minimal
79 /// `projects: []` workspace per `AUX-007`.
80 Init(InitArgs),
81}
82
83/// Build the top-level argument parser.
84///
85/// Returns an [`OptionParser`] that callers consume via
86/// `.run()` (for `argv` parsing) or `.run_inner(...)` (for
87/// programmatic / test parsing).
88pub fn cli() -> OptionParser<Cmd> {
89 let run = run_command().map(Cmd::Run).to_options();
90 let check = check_command().map(Cmd::Check).to_options();
91 let why = why_command().map(Cmd::Why).to_options();
92 let cache = cache_command().to_options();
93 let query = query_command().map(Cmd::Query).to_options();
94 let init = init_command().map(Cmd::Init).to_options();
95
96 let run_subcommand = run.command("run").help("Run one or more tasks");
97 let check_subcommand = check
98 .command("check")
99 .help("Validate the workspace without running (AUX-009..AUX-012)");
100 let why_subcommand = why
101 .command("why")
102 .help("Introspect a single task (AUX-013..AUX-016)");
103 let cache_subcommand = cache
104 .command("cache")
105 .help("Cache management commands (CACHE-021, CACHE-022)");
106 let query_subcommand = query
107 .command("query")
108 .help("Enumerate tasks under a filter expression (QRY-001..QRY-009)");
109 let init_subcommand = init
110 .command("init")
111 .help("Scaffold a workspace (AUX-001..AUX-008)");
112
113 construct!([
114 run_subcommand,
115 check_subcommand,
116 why_subcommand,
117 cache_subcommand,
118 query_subcommand,
119 init_subcommand,
120 ])
121 .to_options()
122 .version(env!("CARGO_PKG_VERSION"))
123 .descr("haz: task dependency graph runner")
124}
125
126/// Shared test parser harness: run the full `cli()` parser
127/// against `args` and surface the bpaf stderr string on error.
128/// Lives at module scope so the per-subcommand child test modules
129/// can reach it as `crate::cli::parse`.
130#[cfg(test)]
131fn parse(args: &[&str]) -> Result<Cmd, String> {
132 cli().run_inner(args).map_err(|err| err.unwrap_stderr())
133}
134
135#[cfg(test)]
136mod tests {
137 use super::parse;
138
139 #[test]
140 fn no_subcommand_rejects() {
141 let err = parse(&[]).unwrap_err();
142 assert!(
143 !err.is_empty(),
144 "missing subcommand must produce a diagnostic",
145 );
146 }
147}