Skip to main content

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}