Skip to main content

haz_cli/
cli.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, long, positional, pure};
15use haz_domain::settings::cache_clean::max_age::MaxAge;
16use haz_domain::settings::cache_clean::max_size::MaxSize;
17pub use haz_domain::settings::output::OutputMode;
18pub use haz_vcs::GitFilter;
19
20/// Top-level command parsed from `argv`.
21///
22/// Each variant carries the per-command arguments needed to
23/// dispatch. The parser layer does not load the workspace, run
24/// the scheduler, or touch the filesystem; downstream modules
25/// take a [`Cmd`] value and act on it.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum Cmd {
28    /// `haz run [--all | --workspace] [--dry-run] [--output-mode <mode>]
29    /// [<TASK_REF>...]` -- run (or, with `--dry-run`, enumerate)
30    /// the selected tasks plus their transitive hard predecessors
31    /// in the discovered workspace.
32    Run(RunArgs),
33    /// `haz check [--all | --workspace]` -- validate the discovered
34    /// workspace without running any task (`AUX-009`..`AUX-012`).
35    Check(CheckArgs),
36    /// `haz why <TASK>` -- read-only introspection of one task per
37    /// `AUX-013`..`AUX-016`.
38    Why(WhyArgs),
39    /// `haz cache clear` -- remove every cache entry (`CACHE-021`).
40    CacheClear,
41    /// `haz cache clean [--soft] [--max-age <DURATION>]
42    /// [--max-size <BYTES>] [--dry-run]` -- selective cache
43    /// reclamation (`CACHE-022`, `AUX-022`..`AUX-027`). The
44    /// executor enforces the "at least one mode" rule
45    /// (`AUX-022`) after merging CLI flags with workspace
46    /// `cache.clean.*` defaults (`AUX-025`).
47    CacheClean(CacheCleanArgs),
48    /// `haz cache info` -- read-only cache-state report
49    /// (`AUX-017`..`AUX-021`).
50    CacheInfo,
51    /// `haz query [filter flags]... [--format <fmt>]` -- enumerate
52    /// the tasks in the workspace matching every supplied filter
53    /// (`QRY-001`..`QRY-009`).
54    Query(QueryArgs),
55    /// `haz init [--yes]` -- scaffold a workspace
56    /// (`AUX-001`..`AUX-008`). The bare form runs the interactive
57    /// flow (`AUX-003`..`AUX-005`); `--yes` writes the minimal
58    /// `projects: []` workspace per `AUX-007`.
59    Init(InitArgs),
60}
61
62/// Arguments parsed for the `haz run` subcommand.
63///
64/// The three scope-mutex rules (`--all` vs `--workspace`,
65/// `--all` vs positional refs, `--workspace` vs positional refs)
66/// are enforced by the parser itself, so any [`RunArgs`] value
67/// produced by [`cli`] is already known to satisfy them.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct RunArgs {
70    /// Positional task references the user named on the command
71    /// line. Each entry is a raw string; reference resolution
72    /// (`REF-001`..`REF-008`) happens after the workspace loads.
73    pub targets: Vec<String>,
74    /// `--all`: widen the target set to every task in the current
75    /// bearing scope (`EXEC-024`). Mutex with [`Self::workspace`]
76    /// and with non-empty [`Self::targets`]; enforced at parse
77    /// time.
78    pub all: bool,
79    /// `--workspace`: widen the target set to every task in every
80    /// project, regardless of cwd (`EXEC-025`). Mutex with
81    /// [`Self::all`] and with non-empty [`Self::targets`];
82    /// enforced at parse time.
83    pub workspace: bool,
84    /// `--git-changed` / `--git-staged` / `--git-changed-since
85    /// <REV>`: which git changeset to restrict the candidate set
86    /// to (`EXEC-026`..`EXEC-029`). At most one filter; intra-
87    /// family mutex enforced at parse time. Mutex with non-empty
88    /// [`Self::targets`] also enforced at parse time. MAY combine
89    /// with [`Self::all`] or [`Self::workspace`].
90    pub git_filter: Option<GitFilter>,
91    /// `--dry-run`: enumerate the target set without spawning any
92    /// task (`EXEC-030`). Composable with every other flag.
93    pub dry_run: bool,
94    /// `--output-mode` flag value, if the user provided one.
95    /// `None` means "fall back to the workspace's `output.mode`
96    /// setting" per `EXEC-016`.
97    pub output_mode: Option<OutputMode>,
98}
99
100/// Arguments parsed for the `haz check` subcommand.
101///
102/// `haz check` accepts only the scope flags (`--all`,
103/// `--workspace`) per `AUX-009`. Like `haz run`, the two scope
104/// flags are mutually exclusive; the parser enforces the mutex
105/// so any [`CheckArgs`] value reaching the executor is in a
106/// well-formed shape.
107#[derive(Debug, Clone, PartialEq, Eq, Default)]
108pub struct CheckArgs {
109    /// `--all`: widen the validated set to every task in the
110    /// current bearing scope (`AUX-009`, mirroring `EXEC-024`).
111    /// Mutex with [`Self::workspace`]; enforced at parse time.
112    pub all: bool,
113    /// `--workspace`: widen the validated set to every task in
114    /// every project, regardless of cwd (`AUX-009`, mirroring
115    /// `EXEC-025`). Mutex with [`Self::all`]; enforced at parse
116    /// time.
117    pub workspace: bool,
118}
119
120/// Arguments parsed for the `haz why` subcommand.
121///
122/// `haz why` takes exactly one positional task reference per
123/// `AUX-013` and no flags for v1. Reference resolution
124/// (`REF-001`..`REF-008`) and the "exactly one node" check
125/// happen at the executor, after workspace load.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct WhyArgs {
128    /// The task reference the user named on the command line.
129    /// Stored as the raw string; resolution happens after the
130    /// workspace and graph have loaded.
131    pub target: String,
132}
133
134/// Arguments parsed for the `haz cache clean` subcommand.
135///
136/// Three composable mode flags (`--soft`, `--max-age`,
137/// `--max-size`) plus the `--dry-run` modifier per
138/// `AUX-022`..`AUX-024`. The CLI parser eagerly validates
139/// `--max-age` / `--max-size` value strings against the
140/// `CFG-034` / `CFG-035` grammars so a syntactically bad value
141/// is rejected before workspace load. The `AUX-022` "at least
142/// one mode" requirement is enforced at the executor layer
143/// because workspace `cache.clean.*` defaults (`AUX-025`) MAY
144/// supply a mode when the CLI is silent.
145#[derive(Debug, Clone, PartialEq, Eq, Default)]
146pub struct CacheCleanArgs {
147    /// `--soft`: reclaim objectively-stale state (`CACHE-022`).
148    pub soft: bool,
149    /// `--max-age <DURATION>`: evict entries strictly older than
150    /// `now - DURATION` (`AUX-022`).
151    pub max_age: Option<MaxAge>,
152    /// `--max-size <BYTES>`: evict oldest-first until the
153    /// well-formed footprint is at or below `BYTES`
154    /// (`AUX-022`).
155    pub max_size: Option<MaxSize>,
156    /// `--dry-run`: compute the eviction set per `AUX-024`
157    /// without removing anything.
158    pub dry_run: bool,
159}
160
161/// Arguments parsed for the `haz query` subcommand.
162///
163/// Each filter slot stores the user's raw expression string; the
164/// executor parses it through `haz_query_lang::parser` and lifts
165/// each atom to its typed form via the appropriate
166/// `haz_query::expr::*` helper. Storing strings here keeps the CLI
167/// layer agnostic to the query language's typed-atom surface and
168/// matches the convention already used for `targets` on
169/// [`RunArgs`].
170///
171/// The boolean shortcuts each carry their own field; the parser
172/// enforces the intra-pair mutex (`--has-X` vs `--no-X`) at parse
173/// time so the executor sees at most one of each pair set.
174#[derive(Debug, Clone, PartialEq, Eq, Default)]
175pub struct QueryArgs {
176    /// `--tags <EXPR>` per `QRY-003`.
177    pub tags: Option<String>,
178    /// `--projects <EXPR>` per `QRY-003`.
179    pub projects: Option<String>,
180    /// `--tasks <EXPR>` per `QRY-003`.
181    pub tasks: Option<String>,
182    /// `--inputs <EXPR>` per `QRY-003`.
183    pub inputs: Option<String>,
184    /// `--outputs <EXPR>` per `QRY-003`.
185    pub outputs: Option<String>,
186    /// `--child-of <EXPR>` per `QRY-004`.
187    pub child_of: Option<String>,
188    /// `--parent-of <EXPR>` per `QRY-004`.
189    pub parent_of: Option<String>,
190    /// `--depends-on <EXPR>` per `QRY-004`.
191    pub depends_on: Option<String>,
192    /// `--ancestor-of <EXPR>` per `QRY-004`.
193    pub ancestor_of: Option<String>,
194    /// `--has-deps` / `--no-deps` per `QRY-005`.
195    pub deps_presence: FieldPresence,
196    /// `--has-inputs` / `--no-inputs` per `QRY-005`.
197    pub inputs_presence: FieldPresence,
198    /// `--has-outputs` / `--no-outputs` per `QRY-005`.
199    pub outputs_presence: FieldPresence,
200    /// `--mutex` per `QRY-005`.
201    pub mutex: bool,
202    /// `--format <FMT>` per `QRY-008`; defaults to `plain`.
203    pub format: OutputFormat,
204}
205
206/// Arguments parsed for the `haz init` subcommand.
207///
208/// `AUX-001` admits exactly two invocation shapes: the bare form
209/// (which would run the interactive scaffold flow,
210/// `AUX-003`..`AUX-005`) and `--yes` (which writes the minimal
211/// `projects: []` workspace per `AUX-007`). Both forms parse;
212/// the executor selects the branch.
213#[derive(Debug, Clone, PartialEq, Eq, Default)]
214pub struct InitArgs {
215    /// `--yes`: skip the interactive flow per `AUX-007`.
216    pub yes: bool,
217}
218
219/// Per-field presence selector for the `--has-X` / `--no-X` flag
220/// pairs of `QRY-005`. Encoding the pair as a single tri-state
221/// value lifts the spec's intra-pair mutex (within a pair, the
222/// two flags are mutually exclusive) into the type system, so the
223/// invariant cannot be violated at any layer.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
225pub enum FieldPresence {
226    /// Neither `--has-X` nor `--no-X` was supplied; the field
227    /// contributes no constraint.
228    #[default]
229    Any,
230    /// `--has-X`: the field MUST be non-empty.
231    NonEmpty,
232    /// `--no-X`: the field MUST be empty.
233    Empty,
234}
235
236/// Output presentation for `haz query` per `QRY-008`.
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
238pub enum OutputFormat {
239    /// One line per task, formatted as `<project>:<task>`. The
240    /// default per `QRY-001`.
241    #[default]
242    Plain,
243    /// A single JSON array of objects with `project`, `task`, and
244    /// `tags` fields per `QRY-008`.
245    Json,
246}
247
248/// Build the top-level argument parser.
249///
250/// Returns an [`OptionParser`] that callers consume via
251/// `.run()` (for `argv` parsing) or `.run_inner(...)` (for
252/// programmatic / test parsing).
253pub fn cli() -> OptionParser<Cmd> {
254    let run = run_command().map(Cmd::Run).to_options();
255    let check = check_command().map(Cmd::Check).to_options();
256    let why = why_command().map(Cmd::Why).to_options();
257    let cache = cache_command().to_options();
258    let query = query_command().map(Cmd::Query).to_options();
259    let init = init_command().map(Cmd::Init).to_options();
260
261    let run_subcommand = run.command("run").help("Run one or more tasks");
262    let check_subcommand = check
263        .command("check")
264        .help("Validate the workspace without running (AUX-009..AUX-012)");
265    let why_subcommand = why
266        .command("why")
267        .help("Introspect a single task (AUX-013..AUX-016)");
268    let cache_subcommand = cache
269        .command("cache")
270        .help("Cache management commands (CACHE-021, CACHE-022)");
271    let query_subcommand = query
272        .command("query")
273        .help("Enumerate tasks under a filter expression (QRY-001..QRY-009)");
274    let init_subcommand = init
275        .command("init")
276        .help("Scaffold a workspace (AUX-001..AUX-008)");
277
278    construct!([
279        run_subcommand,
280        check_subcommand,
281        why_subcommand,
282        cache_subcommand,
283        query_subcommand,
284        init_subcommand,
285    ])
286    .to_options()
287    .version(env!("CARGO_PKG_VERSION"))
288    .descr("haz: task dependency graph runner")
289}
290
291fn run_command() -> impl Parser<RunArgs> {
292    let all = long("all")
293        .help("Run every task in the bearing scope (EXEC-024)")
294        .switch();
295    let workspace = long("workspace")
296        .help("Run every task in every project, regardless of cwd (EXEC-025)")
297        .switch();
298    let dry_run = long("dry-run")
299        .help("Enumerate the target set without spawning any task (EXEC-030)")
300        .switch();
301    let git_filter = git_filter_parser();
302    let output_mode = long("output-mode")
303        .help("Output presentation mode (live | buffered)")
304        .argument::<String>("MODE")
305        .parse(|s| match s.as_ref() {
306            "live" => Ok(OutputMode::Live),
307            "buffered" => Ok(OutputMode::Buffered),
308            other => Err(format!(
309                "unknown output mode `{other}` (expected `live` or `buffered`)"
310            )),
311        })
312        .optional();
313    let targets = positional::<String>("TASK")
314        .help("Task reference (e.g. `proj:task`, `^:task`)")
315        .many();
316    // Named options before positionals: bpaf's parser-combinator
317    // matching follows construct! order. Putting the named flags
318    // first lets `--output-mode <value>` consume its argument
319    // before `targets.many()` becomes greedy on the same value.
320    construct!(RunArgs {
321        all,
322        workspace,
323        git_filter,
324        dry_run,
325        output_mode,
326        targets,
327    })
328    // EXEC-024 / EXEC-025 scope mutexes and the EXEC-026 git-
329    // filter / positional mutex. Caught at parse time so a
330    // downstream resolver can assume every received RunArgs is
331    // already in a well-formed shape (at most one scope flag, and
332    // either a positional list OR a git filter OR neither, but
333    // never both).
334    .parse(|args: RunArgs| {
335        let has_positionals = !args.targets.is_empty();
336        let has_git_filter = args.git_filter.is_some();
337        match (args.all, args.workspace, has_git_filter, has_positionals) {
338            (true, true, _, _) => {
339                Err("`--all` and `--workspace` are mutually exclusive".to_owned())
340            }
341            (true, _, _, true) => {
342                Err("`--all` cannot be combined with positional task references".to_owned())
343            }
344            (_, true, _, true) => {
345                Err("`--workspace` cannot be combined with positional task references".to_owned())
346            }
347            (_, _, true, true) => Err(
348                "git-changeset filters cannot be combined with positional task references"
349                    .to_owned(),
350            ),
351            _ => Ok(args),
352        }
353    })
354}
355
356/// Build the optional git-changeset filter parser.
357///
358/// Three independent sub-flags are gathered (`--git-changed`,
359/// `--git-staged`, `--git-changed-since <REV>`) and reduced to an
360/// `Option<GitFilter>`. The reduction is the EXEC-026 intra-family
361/// mutex: two or more flags are rejected at parse time, so callers
362/// never see an ambiguous filter value.
363fn git_filter_parser() -> impl Parser<Option<GitFilter>> {
364    let changed = long("git-changed")
365        .help("Restrict targets to tasks affected by working-tree changes vs HEAD (EXEC-027)")
366        .switch();
367    let staged = long("git-staged")
368        .help("Restrict targets to tasks affected by staged changes vs HEAD (EXEC-028)")
369        .switch();
370    let changed_since = long("git-changed-since")
371        .help("Restrict targets to tasks affected by changes since <REV> (EXEC-029)")
372        .argument::<String>("REV")
373        .optional();
374    construct!(changed, staged, changed_since).parse(|(changed, staged, changed_since)| {
375        match (changed, staged, changed_since) {
376            (false, false, None) => Ok(None),
377            (true, false, None) => Ok(Some(GitFilter::Changed)),
378            (false, true, None) => Ok(Some(GitFilter::Staged)),
379            (false, false, Some(rev)) => Ok(Some(GitFilter::ChangedSince(rev))),
380            _ => Err("git-changeset filters are mutually exclusive".to_owned()),
381        }
382    })
383}
384
385fn check_command() -> impl Parser<CheckArgs> {
386    let all = long("all")
387        .help("Validate every task in the bearing scope (AUX-009, mirrors EXEC-024)")
388        .switch();
389    let workspace = long("workspace")
390        .help("Validate every task in every project, regardless of cwd (AUX-009, mirrors EXEC-025)")
391        .switch();
392    construct!(CheckArgs { all, workspace })
393        // AUX-009 scope mutex. Caught at parse time so the
394        // executor never sees both flags set.
395        .parse(|args: CheckArgs| {
396            if args.all && args.workspace {
397                Err("`--all` and `--workspace` are mutually exclusive".to_owned())
398            } else {
399                Ok(args)
400            }
401        })
402}
403
404fn why_command() -> impl Parser<WhyArgs> {
405    // AUX-013 form contract: exactly one positional task
406    // reference and no flags defined for v1. Reference resolution
407    // and the "exactly one node" check happen at the executor.
408    let target = positional::<String>("TASK").help("Task reference (REF-001..REF-008)");
409    construct!(WhyArgs { target })
410}
411
412fn cache_command() -> impl Parser<Cmd> {
413    // Three branches under `haz cache`: `clear` (remove every
414    // entry, `CACHE-021`), `clean --soft` (remove only objectively
415    // stale entries, `CACHE-022`), and `info` (read-only report,
416    // `AUX-017`..`AUX-021`). Bindings named by their semantic
417    // role rather than `cache_<verb>_subcommand` to keep clippy's
418    // `similar_names` lint at bay (the spec verbs `clear` and
419    // `clean` are too close on their own).
420    let remove_all = pure(Cmd::CacheClear)
421        .to_options()
422        .command("clear")
423        .help("Remove every cache entry (CACHE-021)");
424    let prune_stale = clean_command()
425        .to_options()
426        .command("clean")
427        .help("Selectively reclaim cache entries (CACHE-022, AUX-022..AUX-027)");
428    let report_info = pure(Cmd::CacheInfo)
429        .to_options()
430        .command("info")
431        .help("Report cache-state statistics (AUX-017..AUX-021)");
432    construct!([remove_all, prune_stale, report_info])
433}
434
435fn clean_command() -> impl Parser<Cmd> {
436    // Three composable mode flags + `--dry-run`. The `AUX-022`
437    // "at least one mode" rule MAY be satisfied by workspace
438    // `cache.clean.*` defaults (`AUX-025`), so enforcement
439    // lives at the executor layer after the workspace loads.
440    let soft = long("soft")
441        .help("Reclaim objectively-stale state (CACHE-022)")
442        .switch();
443    let max_age = long("max-age")
444        .help("Evict entries strictly older than DURATION (AUX-022)")
445        .argument::<String>("DURATION")
446        .parse(|s| MaxAge::parse(&s).map_err(|e| e.to_string()))
447        .optional();
448    let max_size = long("max-size")
449        .help("Cap on-disk footprint at BYTES, evicting oldest first (AUX-022)")
450        .argument::<String>("BYTES")
451        .parse(|s| MaxSize::parse(&s).map_err(|e| e.to_string()))
452        .optional();
453    let dry_run = long("dry-run")
454        .help("Compute the eviction set without removing anything (AUX-024)")
455        .switch();
456    construct!(CacheCleanArgs {
457        soft,
458        max_age,
459        max_size,
460        dry_run,
461    })
462    .map(Cmd::CacheClean)
463}
464
465fn query_command() -> impl Parser<QueryArgs> {
466    let tags = long("tags")
467        .help("Filter by owning-project tag expression (QRY-003)")
468        .argument::<String>("EXPR")
469        .optional();
470    let projects = long("projects")
471        .help("Filter by project-name expression (QRY-003)")
472        .argument::<String>("EXPR")
473        .optional();
474    let tasks = long("tasks")
475        .help("Filter by task-name expression (QRY-003)")
476        .argument::<String>("EXPR")
477        .optional();
478    let inputs = long("inputs")
479        .help("Filter by inputs path-pattern expression (QRY-003)")
480        .argument::<String>("EXPR")
481        .optional();
482    let outputs = long("outputs")
483        .help("Filter by outputs path-pattern expression (QRY-003)")
484        .argument::<String>("EXPR")
485        .optional();
486    let child_of = long("child-of")
487        .help("Direct hard-edge predecessor must satisfy EXPR (QRY-004)")
488        .argument::<String>("EXPR")
489        .optional();
490    let parent_of = long("parent-of")
491        .help("Direct hard-edge successor must satisfy EXPR (QRY-004)")
492        .argument::<String>("EXPR")
493        .optional();
494    let depends_on = long("depends-on")
495        .help("Transitive hard-edge predecessor must satisfy EXPR (QRY-004)")
496        .argument::<String>("EXPR")
497        .optional();
498    let ancestor_of = long("ancestor-of")
499        .help("Transitive hard-edge successor must satisfy EXPR (QRY-004)")
500        .argument::<String>("EXPR")
501        .optional();
502
503    let deps_presence = presence_parser(
504        "has-deps",
505        "no-deps",
506        "Select tasks whose `deps:` is non-empty (QRY-005)",
507        "Select tasks whose `deps:` is empty (QRY-005)",
508    );
509    let inputs_presence = presence_parser(
510        "has-inputs",
511        "no-inputs",
512        "Select tasks whose `inputs:` is non-empty (QRY-005)",
513        "Select tasks whose `inputs:` is empty (QRY-005)",
514    );
515    let outputs_presence = presence_parser(
516        "has-outputs",
517        "no-outputs",
518        "Select tasks whose `outputs:` is non-empty (QRY-005)",
519        "Select tasks whose `outputs:` is empty (QRY-005)",
520    );
521    let mutex = long("mutex")
522        .help("Select tasks that declare a `mutex:` field (QRY-005)")
523        .switch();
524
525    let format = long("format")
526        .help("Output format (plain | json) per QRY-008")
527        .argument::<String>("FMT")
528        .parse(|s| match s.as_ref() {
529            "plain" => Ok(OutputFormat::Plain),
530            "json" => Ok(OutputFormat::Json),
531            other => Err(format!(
532                "unknown output format `{other}` (expected `plain` or `json`)"
533            )),
534        })
535        .fallback(OutputFormat::Plain);
536
537    construct!(QueryArgs {
538        tags,
539        projects,
540        tasks,
541        inputs,
542        outputs,
543        child_of,
544        parent_of,
545        depends_on,
546        ancestor_of,
547        deps_presence,
548        inputs_presence,
549        outputs_presence,
550        mutex,
551        format,
552    })
553}
554
555fn init_command() -> impl Parser<InitArgs> {
556    let yes = long("yes")
557        .help("Skip the interactive flow and write a minimal workspace (AUX-007)")
558        .switch();
559    construct!(InitArgs { yes })
560}
561
562/// Build the `--<has_flag>` / `--<no_flag>` pair parser. The
563/// reduction to [`FieldPresence`] is the `QRY-005` intra-pair
564/// mutex: two switches yield three valid states; the impossible
565/// "both" state is rejected at parse time.
566fn presence_parser(
567    has_flag: &'static str,
568    no_flag: &'static str,
569    has_help: &'static str,
570    no_help: &'static str,
571) -> impl Parser<FieldPresence> {
572    let has = long(has_flag).help(has_help).switch();
573    let no = long(no_flag).help(no_help).switch();
574    construct!(has, no).parse(move |(has, no)| match (has, no) {
575        (false, false) => Ok(FieldPresence::Any),
576        (true, false) => Ok(FieldPresence::NonEmpty),
577        (false, true) => Ok(FieldPresence::Empty),
578        (true, true) => Err(format!(
579            "`--{has_flag}` and `--{no_flag}` are mutually exclusive"
580        )),
581    })
582}
583
584#[cfg(test)]
585mod tests {
586    use super::{
587        CacheCleanArgs, CheckArgs, Cmd, FieldPresence, GitFilter, InitArgs, MaxAge, MaxSize,
588        OutputFormat, OutputMode, QueryArgs, RunArgs, WhyArgs, cli,
589    };
590
591    fn parse(args: &[&str]) -> Result<Cmd, String> {
592        cli().run_inner(args).map_err(|err| err.unwrap_stderr())
593    }
594
595    fn bare_run_args() -> RunArgs {
596        RunArgs {
597            targets: Vec::new(),
598            all: false,
599            workspace: false,
600            git_filter: None,
601            dry_run: false,
602            output_mode: None,
603        }
604    }
605
606    #[test]
607    fn run_with_no_targets_parses() {
608        let parsed = parse(&["run"]).unwrap();
609        assert_eq!(parsed, Cmd::Run(bare_run_args()));
610    }
611
612    #[test]
613    fn run_with_one_target_parses() {
614        let parsed = parse(&["run", "lib:build"]).unwrap();
615        assert_eq!(
616            parsed,
617            Cmd::Run(RunArgs {
618                targets: vec!["lib:build".to_owned()],
619                ..bare_run_args()
620            }),
621        );
622    }
623
624    #[test]
625    fn run_with_multiple_targets_parses_in_order() {
626        let parsed = parse(&["run", "lib:build", "app:test", "^:lint"]).unwrap();
627        assert_eq!(
628            parsed,
629            Cmd::Run(RunArgs {
630                targets: vec![
631                    "lib:build".to_owned(),
632                    "app:test".to_owned(),
633                    "^:lint".to_owned(),
634                ],
635                ..bare_run_args()
636            }),
637        );
638    }
639
640    #[test]
641    fn run_with_output_mode_live() {
642        let parsed = parse(&["run", "--output-mode", "live", "lib:build"]).unwrap();
643        assert_eq!(
644            parsed,
645            Cmd::Run(RunArgs {
646                targets: vec!["lib:build".to_owned()],
647                output_mode: Some(OutputMode::Live),
648                ..bare_run_args()
649            }),
650        );
651    }
652
653    #[test]
654    fn run_with_output_mode_buffered() {
655        let parsed = parse(&["run", "--output-mode", "buffered", "lib:build"]).unwrap();
656        assert_eq!(
657            parsed,
658            Cmd::Run(RunArgs {
659                targets: vec!["lib:build".to_owned()],
660                output_mode: Some(OutputMode::Buffered),
661                ..bare_run_args()
662            }),
663        );
664    }
665
666    #[test]
667    fn run_with_all_flag_parses() {
668        let parsed = parse(&["run", "--all"]).unwrap();
669        assert_eq!(
670            parsed,
671            Cmd::Run(RunArgs {
672                all: true,
673                ..bare_run_args()
674            }),
675        );
676    }
677
678    #[test]
679    fn run_with_workspace_flag_parses() {
680        let parsed = parse(&["run", "--workspace"]).unwrap();
681        assert_eq!(
682            parsed,
683            Cmd::Run(RunArgs {
684                workspace: true,
685                ..bare_run_args()
686            }),
687        );
688    }
689
690    #[test]
691    fn run_with_dry_run_flag_parses() {
692        let parsed = parse(&["run", "--dry-run"]).unwrap();
693        assert_eq!(
694            parsed,
695            Cmd::Run(RunArgs {
696                dry_run: true,
697                ..bare_run_args()
698            }),
699        );
700    }
701
702    #[test]
703    fn run_with_dry_run_composes_with_positional() {
704        let parsed = parse(&["run", "--dry-run", "lib:build"]).unwrap();
705        assert_eq!(
706            parsed,
707            Cmd::Run(RunArgs {
708                targets: vec!["lib:build".to_owned()],
709                dry_run: true,
710                ..bare_run_args()
711            }),
712        );
713    }
714
715    #[test]
716    fn run_with_dry_run_composes_with_all() {
717        let parsed = parse(&["run", "--dry-run", "--all"]).unwrap();
718        assert_eq!(
719            parsed,
720            Cmd::Run(RunArgs {
721                all: true,
722                dry_run: true,
723                ..bare_run_args()
724            }),
725        );
726    }
727
728    #[test]
729    fn run_with_dry_run_composes_with_workspace() {
730        let parsed = parse(&["run", "--dry-run", "--workspace"]).unwrap();
731        assert_eq!(
732            parsed,
733            Cmd::Run(RunArgs {
734                workspace: true,
735                dry_run: true,
736                ..bare_run_args()
737            }),
738        );
739    }
740
741    #[test]
742    fn run_all_plus_workspace_rejects() {
743        // EXEC-024 / EXEC-025 mutex.
744        let err = parse(&["run", "--all", "--workspace"]).unwrap_err();
745        assert!(
746            err.contains("mutually exclusive"),
747            "diagnostic should name the mutex: {err}",
748        );
749    }
750
751    #[test]
752    fn run_all_plus_positional_rejects() {
753        // EXEC-024 mutex with positional targets.
754        let err = parse(&["run", "--all", "lib:build"]).unwrap_err();
755        assert!(
756            err.contains("`--all`") && err.contains("positional"),
757            "diagnostic should mention --all and positional refs: {err}",
758        );
759    }
760
761    #[test]
762    fn run_workspace_plus_positional_rejects() {
763        // EXEC-025 mutex with positional targets.
764        let err = parse(&["run", "--workspace", "lib:build"]).unwrap_err();
765        assert!(
766            err.contains("`--workspace`") && err.contains("positional"),
767            "diagnostic should mention --workspace and positional refs: {err}",
768        );
769    }
770
771    #[test]
772    fn run_with_git_changed_parses() {
773        let parsed = parse(&["run", "--git-changed"]).unwrap();
774        assert_eq!(
775            parsed,
776            Cmd::Run(RunArgs {
777                git_filter: Some(GitFilter::Changed),
778                ..bare_run_args()
779            }),
780        );
781    }
782
783    #[test]
784    fn run_with_git_staged_parses() {
785        let parsed = parse(&["run", "--git-staged"]).unwrap();
786        assert_eq!(
787            parsed,
788            Cmd::Run(RunArgs {
789                git_filter: Some(GitFilter::Staged),
790                ..bare_run_args()
791            }),
792        );
793    }
794
795    #[test]
796    fn run_with_git_changed_since_parses() {
797        let parsed = parse(&["run", "--git-changed-since", "main"]).unwrap();
798        assert_eq!(
799            parsed,
800            Cmd::Run(RunArgs {
801                git_filter: Some(GitFilter::ChangedSince("main".to_owned())),
802                ..bare_run_args()
803            }),
804        );
805    }
806
807    #[test]
808    fn run_git_changed_plus_all_composes() {
809        // EXEC-026 explicitly permits filter + single scope flag.
810        let parsed = parse(&["run", "--git-changed", "--all"]).unwrap();
811        assert_eq!(
812            parsed,
813            Cmd::Run(RunArgs {
814                all: true,
815                git_filter: Some(GitFilter::Changed),
816                ..bare_run_args()
817            }),
818        );
819    }
820
821    #[test]
822    fn run_git_changed_plus_workspace_composes() {
823        let parsed = parse(&["run", "--git-changed", "--workspace"]).unwrap();
824        assert_eq!(
825            parsed,
826            Cmd::Run(RunArgs {
827                workspace: true,
828                git_filter: Some(GitFilter::Changed),
829                ..bare_run_args()
830            }),
831        );
832    }
833
834    #[test]
835    fn run_git_changed_plus_staged_rejects() {
836        // EXEC-026 intra-family mutex.
837        let err = parse(&["run", "--git-changed", "--git-staged"]).unwrap_err();
838        assert!(
839            err.contains("mutually exclusive"),
840            "diagnostic should name the intra-family mutex: {err}",
841        );
842    }
843
844    #[test]
845    fn run_git_changed_plus_changed_since_rejects() {
846        let err = parse(&["run", "--git-changed", "--git-changed-since", "main"]).unwrap_err();
847        assert!(
848            err.contains("mutually exclusive"),
849            "diagnostic should name the intra-family mutex: {err}",
850        );
851    }
852
853    #[test]
854    fn run_git_staged_plus_changed_since_rejects() {
855        let err = parse(&["run", "--git-staged", "--git-changed-since", "main"]).unwrap_err();
856        assert!(
857            err.contains("mutually exclusive"),
858            "diagnostic should name the intra-family mutex: {err}",
859        );
860    }
861
862    #[test]
863    fn run_git_changed_plus_positional_rejects() {
864        // EXEC-026 filter-vs-positional mutex.
865        let err = parse(&["run", "--git-changed", "lib:build"]).unwrap_err();
866        assert!(
867            err.contains("git-changeset") && err.contains("positional"),
868            "diagnostic should mention the filter-vs-positional mutex: {err}",
869        );
870    }
871
872    #[test]
873    fn run_git_staged_plus_positional_rejects() {
874        let err = parse(&["run", "--git-staged", "lib:build"]).unwrap_err();
875        assert!(
876            err.contains("git-changeset") && err.contains("positional"),
877            "diagnostic should mention the filter-vs-positional mutex: {err}",
878        );
879    }
880
881    #[test]
882    fn run_git_changed_since_plus_positional_rejects() {
883        let err = parse(&["run", "--git-changed-since", "main", "lib:build"]).unwrap_err();
884        assert!(
885            err.contains("git-changeset") && err.contains("positional"),
886            "diagnostic should mention the filter-vs-positional mutex: {err}",
887        );
888    }
889
890    #[test]
891    fn run_with_unknown_output_mode_rejects() {
892        let err = parse(&["run", "--output-mode", "noisy", "lib:build"]).unwrap_err();
893        assert!(
894            err.contains("unknown output mode"),
895            "diagnostic should name the offending value: {err}",
896        );
897    }
898
899    #[test]
900    fn aux_009_check_with_no_args_parses() {
901        assert_eq!(parse(&["check"]).unwrap(), Cmd::Check(CheckArgs::default()),);
902    }
903
904    #[test]
905    fn aux_009_check_with_all_flag_parses() {
906        assert_eq!(
907            parse(&["check", "--all"]).unwrap(),
908            Cmd::Check(CheckArgs {
909                all: true,
910                workspace: false,
911            }),
912        );
913    }
914
915    #[test]
916    fn aux_009_check_with_workspace_flag_parses() {
917        assert_eq!(
918            parse(&["check", "--workspace"]).unwrap(),
919            Cmd::Check(CheckArgs {
920                all: false,
921                workspace: true,
922            }),
923        );
924    }
925
926    #[test]
927    fn aux_009_check_all_plus_workspace_rejects() {
928        // AUX-009 scope mutex (mirrors EXEC-024 / EXEC-025).
929        let err = parse(&["check", "--all", "--workspace"]).unwrap_err();
930        assert!(
931            err.contains("mutually exclusive"),
932            "diagnostic should name the mutex: {err}",
933        );
934    }
935
936    #[test]
937    fn aux_009_check_rejects_unknown_flag() {
938        // AUX-009 form contract: no positional or extra flags
939        // beyond --all / --workspace.
940        let err = parse(&["check", "--dry-run"]).unwrap_err();
941        assert!(
942            !err.is_empty(),
943            "unknown flag must produce a diagnostic: {err}",
944        );
945    }
946
947    #[test]
948    fn aux_009_check_rejects_positional() {
949        // AUX-009 form contract: haz check takes no positional
950        // arguments.
951        let err = parse(&["check", "lib:build"]).unwrap_err();
952        assert!(
953            !err.is_empty(),
954            "positional argument must produce a diagnostic: {err}",
955        );
956    }
957
958    // ---- why ----
959
960    #[test]
961    fn aux_013_why_parses_a_single_positional_target() {
962        assert_eq!(
963            parse(&["why", "lib:build"]).unwrap(),
964            Cmd::Why(WhyArgs {
965                target: "lib:build".to_owned(),
966            }),
967        );
968    }
969
970    #[test]
971    fn aux_013_why_accepts_fan_out_reference_form_at_parse_time() {
972        // REF-001..REF-008 admits forms like `^:test` (fan-out by
973        // task name across projects). The parser accepts any
974        // non-empty string verbatim; reference parsing and
975        // resolution happen at the executor per AUX-013.
976        assert_eq!(
977            parse(&["why", "^:test"]).unwrap(),
978            Cmd::Why(WhyArgs {
979                target: "^:test".to_owned(),
980            }),
981        );
982    }
983
984    #[test]
985    fn aux_013_why_rejects_missing_positional() {
986        // AUX-013 form contract: exactly one positional argument.
987        let err = parse(&["why"]).unwrap_err();
988        assert!(
989            !err.is_empty(),
990            "missing positional must produce a diagnostic: {err}",
991        );
992    }
993
994    #[test]
995    fn aux_013_why_rejects_extra_positional() {
996        // AUX-013 form contract: at most one positional argument.
997        let err = parse(&["why", "lib:build", "other:task"]).unwrap_err();
998        assert!(
999            !err.is_empty(),
1000            "extra positional must produce a diagnostic: {err}",
1001        );
1002    }
1003
1004    #[test]
1005    fn aux_013_why_rejects_unknown_flag() {
1006        // AUX-013 form contract: no flags defined for v1.
1007        let err = parse(&["why", "--all", "lib:build"]).unwrap_err();
1008        assert!(
1009            !err.is_empty(),
1010            "unknown flag must produce a diagnostic: {err}",
1011        );
1012    }
1013
1014    // ---- cache ----
1015
1016    #[test]
1017    fn cache_clear_parses() {
1018        assert_eq!(parse(&["cache", "clear"]).unwrap(), Cmd::CacheClear);
1019    }
1020
1021    #[test]
1022    fn cache_info_parses() {
1023        assert_eq!(parse(&["cache", "info"]).unwrap(), Cmd::CacheInfo);
1024    }
1025
1026    #[test]
1027    fn aux_017_cache_info_rejects_positional() {
1028        // AUX-017 form contract: bare `cache info` only; any extra
1029        // positional is a CLI error.
1030        let err = parse(&["cache", "info", "extra"]).unwrap_err();
1031        assert!(
1032            !err.is_empty(),
1033            "extra positional must produce a diagnostic: {err}",
1034        );
1035    }
1036
1037    #[test]
1038    fn aux_017_cache_info_rejects_unknown_flag() {
1039        // AUX-017 form contract: no flags defined for v1.
1040        let err = parse(&["cache", "info", "--soft"]).unwrap_err();
1041        assert!(
1042            !err.is_empty(),
1043            "unknown flag must produce a diagnostic: {err}",
1044        );
1045    }
1046
1047    #[test]
1048    fn cache_clean_soft_parses() {
1049        assert_eq!(
1050            parse(&["cache", "clean", "--soft"]).unwrap(),
1051            Cmd::CacheClean(CacheCleanArgs {
1052                soft: true,
1053                ..CacheCleanArgs::default()
1054            }),
1055        );
1056    }
1057
1058    #[test]
1059    fn cache_clean_bare_parses_with_no_modes() {
1060        // AUX-025 lets workspace `cache.clean.*` defaults supply
1061        // a mode, so the parser MUST accept a bare invocation.
1062        // The "at least one mode" rule (AUX-022) is enforced at
1063        // the executor layer after workspace load.
1064        assert_eq!(
1065            parse(&["cache", "clean"]).unwrap(),
1066            Cmd::CacheClean(CacheCleanArgs::default()),
1067        );
1068    }
1069
1070    #[test]
1071    fn cache_clean_max_age_parses_valid_duration() {
1072        let want = MaxAge::parse("90d").unwrap();
1073        assert_eq!(
1074            parse(&["cache", "clean", "--max-age", "90d"]).unwrap(),
1075            Cmd::CacheClean(CacheCleanArgs {
1076                max_age: Some(want),
1077                ..CacheCleanArgs::default()
1078            }),
1079        );
1080    }
1081
1082    #[test]
1083    fn cache_clean_max_age_rejects_bare_integer() {
1084        // CFG-034 / AUX-022: the bare-integer form is ambiguous
1085        // and MUST be rejected by the duration grammar.
1086        let err = parse(&["cache", "clean", "--max-age", "90"]).unwrap_err();
1087        assert!(
1088            !err.is_empty(),
1089            "malformed --max-age must produce a diagnostic: {err}",
1090        );
1091    }
1092
1093    #[test]
1094    fn cache_clean_max_size_parses_si_suffix() {
1095        let want = MaxSize::parse("1GB").unwrap();
1096        assert_eq!(
1097            parse(&["cache", "clean", "--max-size", "1GB"]).unwrap(),
1098            Cmd::CacheClean(CacheCleanArgs {
1099                max_size: Some(want),
1100                ..CacheCleanArgs::default()
1101            }),
1102        );
1103    }
1104
1105    #[test]
1106    fn cache_clean_max_size_rejects_unknown_suffix() {
1107        let err = parse(&["cache", "clean", "--max-size", "1ZB"]).unwrap_err();
1108        assert!(
1109            !err.is_empty(),
1110            "malformed --max-size must produce a diagnostic: {err}",
1111        );
1112    }
1113
1114    #[test]
1115    fn cache_clean_dry_run_composes_with_other_flags() {
1116        let want_age = MaxAge::parse("30d").unwrap();
1117        assert_eq!(
1118            parse(&["cache", "clean", "--soft", "--max-age", "30d", "--dry-run"]).unwrap(),
1119            Cmd::CacheClean(CacheCleanArgs {
1120                soft: true,
1121                max_age: Some(want_age),
1122                max_size: None,
1123                dry_run: true,
1124            }),
1125        );
1126    }
1127
1128    #[test]
1129    fn no_subcommand_rejects() {
1130        let err = parse(&[]).unwrap_err();
1131        assert!(
1132            !err.is_empty(),
1133            "missing subcommand must produce a diagnostic",
1134        );
1135    }
1136
1137    // ---- query ----
1138
1139    #[test]
1140    fn qry_001_query_with_no_flags_parses_to_default() {
1141        assert_eq!(parse(&["query"]).unwrap(), Cmd::Query(QueryArgs::default()),);
1142    }
1143
1144    #[test]
1145    fn qry_003_tags_filter_captures_expression_verbatim() {
1146        assert_eq!(
1147            parse(&["query", "--tags", "backend & !legacy"]).unwrap(),
1148            Cmd::Query(QueryArgs {
1149                tags: Some("backend & !legacy".to_owned()),
1150                ..QueryArgs::default()
1151            }),
1152        );
1153    }
1154
1155    #[test]
1156    fn qry_003_projects_filter_captures_expression_verbatim() {
1157        assert_eq!(
1158            parse(&["query", "--projects", "lib | web"]).unwrap(),
1159            Cmd::Query(QueryArgs {
1160                projects: Some("lib | web".to_owned()),
1161                ..QueryArgs::default()
1162            }),
1163        );
1164    }
1165
1166    #[test]
1167    fn qry_003_tasks_filter_captures_expression_verbatim() {
1168        assert_eq!(
1169            parse(&["query", "--tasks", "build"]).unwrap(),
1170            Cmd::Query(QueryArgs {
1171                tasks: Some("build".to_owned()),
1172                ..QueryArgs::default()
1173            }),
1174        );
1175    }
1176
1177    #[test]
1178    fn qry_003_inputs_and_outputs_filters_capture_expressions_verbatim() {
1179        assert_eq!(
1180            parse(&[
1181                "query",
1182                "--inputs",
1183                "src/**/*.rs",
1184                "--outputs",
1185                "/lib/dist/*.so",
1186            ])
1187            .unwrap(),
1188            Cmd::Query(QueryArgs {
1189                inputs: Some("src/**/*.rs".to_owned()),
1190                outputs: Some("/lib/dist/*.so".to_owned()),
1191                ..QueryArgs::default()
1192            }),
1193        );
1194    }
1195
1196    #[test]
1197    fn qry_004_relational_filters_capture_expressions_verbatim() {
1198        assert_eq!(
1199            parse(&[
1200                "query",
1201                "--child-of",
1202                "name:build",
1203                "--parent-of",
1204                "tag:backend",
1205                "--depends-on",
1206                "project:lib",
1207                "--ancestor-of",
1208                "name:lint",
1209            ])
1210            .unwrap(),
1211            Cmd::Query(QueryArgs {
1212                child_of: Some("name:build".to_owned()),
1213                parent_of: Some("tag:backend".to_owned()),
1214                depends_on: Some("project:lib".to_owned()),
1215                ancestor_of: Some("name:lint".to_owned()),
1216                ..QueryArgs::default()
1217            }),
1218        );
1219    }
1220
1221    #[test]
1222    fn qry_005_individual_shortcut_flags_parse() {
1223        let parsed = parse(&[
1224            "query",
1225            "--has-deps",
1226            "--has-inputs",
1227            "--no-outputs",
1228            "--mutex",
1229        ])
1230        .unwrap();
1231        assert_eq!(
1232            parsed,
1233            Cmd::Query(QueryArgs {
1234                deps_presence: FieldPresence::NonEmpty,
1235                inputs_presence: FieldPresence::NonEmpty,
1236                outputs_presence: FieldPresence::Empty,
1237                mutex: true,
1238                ..QueryArgs::default()
1239            }),
1240        );
1241    }
1242
1243    #[test]
1244    fn qry_005_has_deps_plus_no_deps_rejects() {
1245        let err = parse(&["query", "--has-deps", "--no-deps"]).unwrap_err();
1246        assert!(
1247            err.contains("mutually exclusive"),
1248            "diagnostic should name the intra-pair mutex: {err}",
1249        );
1250    }
1251
1252    #[test]
1253    fn qry_005_has_inputs_plus_no_inputs_rejects() {
1254        let err = parse(&["query", "--has-inputs", "--no-inputs"]).unwrap_err();
1255        assert!(
1256            err.contains("mutually exclusive"),
1257            "diagnostic should name the intra-pair mutex: {err}",
1258        );
1259    }
1260
1261    #[test]
1262    fn qry_005_has_outputs_plus_no_outputs_rejects() {
1263        let err = parse(&["query", "--has-outputs", "--no-outputs"]).unwrap_err();
1264        assert!(
1265            err.contains("mutually exclusive"),
1266            "diagnostic should name the intra-pair mutex: {err}",
1267        );
1268    }
1269
1270    #[test]
1271    fn qry_008_format_plain_parses() {
1272        assert_eq!(
1273            parse(&["query", "--format", "plain"]).unwrap(),
1274            Cmd::Query(QueryArgs {
1275                format: OutputFormat::Plain,
1276                ..QueryArgs::default()
1277            }),
1278        );
1279    }
1280
1281    #[test]
1282    fn qry_008_format_json_parses() {
1283        assert_eq!(
1284            parse(&["query", "--format", "json"]).unwrap(),
1285            Cmd::Query(QueryArgs {
1286                format: OutputFormat::Json,
1287                ..QueryArgs::default()
1288            }),
1289        );
1290    }
1291
1292    #[test]
1293    fn qry_008_format_default_is_plain() {
1294        let Cmd::Query(args) = parse(&["query"]).unwrap() else {
1295            panic!("expected Cmd::Query");
1296        };
1297        assert_eq!(args.format, OutputFormat::Plain);
1298    }
1299
1300    #[test]
1301    fn qry_008_unknown_format_rejects() {
1302        let err = parse(&["query", "--format", "yaml"]).unwrap_err();
1303        assert!(
1304            err.contains("unknown output format"),
1305            "diagnostic should name the offending value: {err}",
1306        );
1307    }
1308
1309    #[test]
1310    fn qry_006_multiple_filter_families_combine_into_one_args() {
1311        let parsed = parse(&[
1312            "query",
1313            "--tags",
1314            "backend",
1315            "--tasks",
1316            "test",
1317            "--has-deps",
1318            "--format",
1319            "json",
1320        ])
1321        .unwrap();
1322        assert_eq!(
1323            parsed,
1324            Cmd::Query(QueryArgs {
1325                tags: Some("backend".to_owned()),
1326                tasks: Some("test".to_owned()),
1327                deps_presence: FieldPresence::NonEmpty,
1328                format: OutputFormat::Json,
1329                ..QueryArgs::default()
1330            }),
1331        );
1332    }
1333
1334    #[test]
1335    fn qry_001_query_rejects_positional_arguments() {
1336        let err = parse(&["query", "lib:build"]).unwrap_err();
1337        assert!(
1338            !err.is_empty(),
1339            "positional argument must produce a diagnostic: {err}",
1340        );
1341    }
1342
1343    // ---- init ----
1344
1345    #[test]
1346    fn aux_001_init_bare_parses_with_yes_false() {
1347        assert_eq!(
1348            parse(&["init"]).unwrap(),
1349            Cmd::Init(InitArgs { yes: false }),
1350        );
1351    }
1352
1353    #[test]
1354    fn aux_001_init_with_yes_flag_parses() {
1355        assert_eq!(
1356            parse(&["init", "--yes"]).unwrap(),
1357            Cmd::Init(InitArgs { yes: true }),
1358        );
1359    }
1360
1361    #[test]
1362    fn aux_001_init_rejects_positional_argument() {
1363        let err = parse(&["init", "extra"]).unwrap_err();
1364        assert!(
1365            !err.is_empty(),
1366            "positional argument must produce a diagnostic: {err}",
1367        );
1368    }
1369
1370    #[test]
1371    fn aux_001_init_rejects_unknown_flag() {
1372        let err = parse(&["init", "--no-such-flag"]).unwrap_err();
1373        assert!(
1374            !err.is_empty(),
1375            "unknown flag must produce a diagnostic: {err}",
1376        );
1377    }
1378}