haz_cli/cli/why.rs
1//! Parser for the `haz why` subcommand.
2//!
3//! Owns [`WhyArgs`] and the [`why_command`] parser builder.
4//! `haz why` takes exactly one positional task reference per
5//! `AUX-013` and no flags for v1.
6
7use bpaf::{Parser, construct, positional};
8
9use crate::cli::TaskRef;
10
11/// Arguments parsed for the `haz why` subcommand.
12///
13/// `haz why` takes exactly one positional task reference per
14/// `AUX-013` and no flags for v1. The reference is parsed and
15/// syntactically validated (`REF-001`..`REF-008`) here at the CLI
16/// surface; resolution against the workspace and the "exactly one
17/// node" check happen at the executor, after workspace load.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct WhyArgs {
20 /// The validated task reference the user named on the command
21 /// line. Syntactic validation (`REF-001`..`REF-008`) happens at
22 /// parse time; resolution happens after the workspace and graph
23 /// have loaded.
24 pub target: TaskRef,
25}
26
27pub(super) fn why_command() -> impl Parser<WhyArgs> {
28 // AUX-013 form contract: exactly one positional task
29 // reference and no flags defined for v1. Syntactic validation
30 // (REF-001..REF-008) happens here; resolution and the
31 // "exactly one node" check happen at the executor.
32 let target = positional::<String>("TASK")
33 .help("Task reference (REF-001..REF-008)")
34 .parse(|s| TaskRef::parse(&s).map_err(|e| e.to_string()));
35 construct!(WhyArgs { target })
36}
37
38#[cfg(test)]
39mod tests {
40 use super::WhyArgs;
41 use crate::cli::{Cmd, TaskRef, parse};
42
43 fn task_ref(s: &str) -> TaskRef {
44 TaskRef::parse(s).unwrap()
45 }
46
47 #[test]
48 fn aux_013_why_parses_a_single_positional_target() {
49 assert_eq!(
50 parse(&["why", "lib:build"]).unwrap(),
51 Cmd::Why(WhyArgs {
52 target: task_ref("lib:build"),
53 }),
54 );
55 }
56
57 #[test]
58 fn aux_013_why_accepts_fan_out_reference_form_at_parse_time() {
59 // REF-001..REF-008 admits forms like `^:test` (fan-out by
60 // task name across projects). The parser validates the
61 // reference syntax here; resolution happens at the executor
62 // per AUX-013.
63 assert_eq!(
64 parse(&["why", "^:test"]).unwrap(),
65 Cmd::Why(WhyArgs {
66 target: task_ref("^:test"),
67 }),
68 );
69 }
70
71 #[test]
72 fn aux_013_why_rejects_malformed_reference_at_parse_time() {
73 // REF-006: a bare word with no `:` separator is not a valid
74 // reference. The CLI surface rejects it before any workspace
75 // load, rather than deferring to the executor.
76 let err = parse(&["why", "no-colon"]).unwrap_err();
77 assert!(
78 !err.is_empty(),
79 "malformed reference must produce a parse diagnostic: {err}",
80 );
81 }
82
83 #[test]
84 fn aux_013_why_rejects_missing_positional() {
85 // AUX-013 form contract: exactly one positional argument.
86 let err = parse(&["why"]).unwrap_err();
87 assert!(
88 !err.is_empty(),
89 "missing positional must produce a diagnostic: {err}",
90 );
91 }
92
93 #[test]
94 fn aux_013_why_rejects_extra_positional() {
95 // AUX-013 form contract: at most one positional argument.
96 let err = parse(&["why", "lib:build", "other:task"]).unwrap_err();
97 assert!(
98 !err.is_empty(),
99 "extra positional must produce a diagnostic: {err}",
100 );
101 }
102
103 #[test]
104 fn aux_013_why_rejects_unknown_flag() {
105 // AUX-013 form contract: no flags defined for v1.
106 let err = parse(&["why", "--all", "lib:build"]).unwrap_err();
107 assert!(
108 !err.is_empty(),
109 "unknown flag must produce a diagnostic: {err}",
110 );
111 }
112}