Skip to main content

haz_cli/cli/
query.rs

1//! Parser for the `haz query` subcommand.
2//!
3//! Owns [`QueryArgs`], the [`FieldPresence`] / [`OutputFormat`]
4//! value types, the [`query_command`] parser builder, and the
5//! [`presence_parser`] helper that lifts each `--has-X` / `--no-X`
6//! flag pair to a tri-state [`FieldPresence`].
7
8use bpaf::{Parser, construct, long};
9
10/// Arguments parsed for the `haz query` subcommand.
11///
12/// Each filter slot stores the user's raw expression string; the
13/// executor parses it through `haz_query_lang::parser` and lifts
14/// each atom to its typed form via the appropriate
15/// `haz_query::expr::*` helper. Storing strings here keeps the CLI
16/// layer agnostic to the query language's typed-atom surface and
17/// matches the convention already used for `targets` on
18/// [`crate::cli::RunArgs`].
19///
20/// The boolean shortcuts each carry their own field; the parser
21/// enforces the intra-pair mutex (`--has-X` vs `--no-X`) at parse
22/// time so the executor sees at most one of each pair set.
23#[derive(Debug, Clone, PartialEq, Eq, Default)]
24pub struct QueryArgs {
25    /// `--tags <EXPR>` per `QRY-003`.
26    pub tags: Option<String>,
27    /// `--projects <EXPR>` per `QRY-003`.
28    pub projects: Option<String>,
29    /// `--tasks <EXPR>` per `QRY-003`.
30    pub tasks: Option<String>,
31    /// `--inputs <EXPR>` per `QRY-003`.
32    pub inputs: Option<String>,
33    /// `--outputs <EXPR>` per `QRY-003`.
34    pub outputs: Option<String>,
35    /// `--child-of <EXPR>` per `QRY-004`.
36    pub child_of: Option<String>,
37    /// `--parent-of <EXPR>` per `QRY-004`.
38    pub parent_of: Option<String>,
39    /// `--depends-on <EXPR>` per `QRY-004`.
40    pub depends_on: Option<String>,
41    /// `--ancestor-of <EXPR>` per `QRY-004`.
42    pub ancestor_of: Option<String>,
43    /// `--has-deps` / `--no-deps` per `QRY-005`.
44    pub deps_presence: FieldPresence,
45    /// `--has-inputs` / `--no-inputs` per `QRY-005`.
46    pub inputs_presence: FieldPresence,
47    /// `--has-outputs` / `--no-outputs` per `QRY-005`.
48    pub outputs_presence: FieldPresence,
49    /// `--mutex` per `QRY-005`.
50    pub mutex: bool,
51    /// `--format <FMT>` per `QRY-008`; defaults to `plain`.
52    pub format: OutputFormat,
53}
54
55/// Per-field presence selector for the `--has-X` / `--no-X` flag
56/// pairs of `QRY-005`. Encoding the pair as a single tri-state
57/// value lifts the spec's intra-pair mutex (within a pair, the
58/// two flags are mutually exclusive) into the type system, so the
59/// invariant cannot be violated at any layer.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum FieldPresence {
62    /// Neither `--has-X` nor `--no-X` was supplied; the field
63    /// contributes no constraint.
64    #[default]
65    Any,
66    /// `--has-X`: the field MUST be non-empty.
67    NonEmpty,
68    /// `--no-X`: the field MUST be empty.
69    Empty,
70}
71
72/// Output presentation for `haz query` per `QRY-008`.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum OutputFormat {
75    /// One line per task, formatted as `<project>:<task>`. The
76    /// default per `QRY-001`.
77    #[default]
78    Plain,
79    /// A single JSON array of objects with `project`, `task`, and
80    /// `tags` fields per `QRY-008`.
81    Json,
82}
83
84pub(super) fn query_command() -> impl Parser<QueryArgs> {
85    let tags = long("tags")
86        .help("Filter by owning-project tag expression (QRY-003)")
87        .argument::<String>("EXPR")
88        .optional();
89    let projects = long("projects")
90        .help("Filter by project-name expression (QRY-003)")
91        .argument::<String>("EXPR")
92        .optional();
93    let tasks = long("tasks")
94        .help("Filter by task-name expression (QRY-003)")
95        .argument::<String>("EXPR")
96        .optional();
97    let inputs = long("inputs")
98        .help("Filter by inputs path-pattern expression (QRY-003)")
99        .argument::<String>("EXPR")
100        .optional();
101    let outputs = long("outputs")
102        .help("Filter by outputs path-pattern expression (QRY-003)")
103        .argument::<String>("EXPR")
104        .optional();
105    let child_of = long("child-of")
106        .help("Direct hard-edge predecessor must satisfy EXPR (QRY-004)")
107        .argument::<String>("EXPR")
108        .optional();
109    let parent_of = long("parent-of")
110        .help("Direct hard-edge successor must satisfy EXPR (QRY-004)")
111        .argument::<String>("EXPR")
112        .optional();
113    let depends_on = long("depends-on")
114        .help("Transitive hard-edge predecessor must satisfy EXPR (QRY-004)")
115        .argument::<String>("EXPR")
116        .optional();
117    let ancestor_of = long("ancestor-of")
118        .help("Transitive hard-edge successor must satisfy EXPR (QRY-004)")
119        .argument::<String>("EXPR")
120        .optional();
121
122    let deps_presence = presence_parser(
123        "has-deps",
124        "no-deps",
125        "Select tasks whose `deps:` is non-empty (QRY-005)",
126        "Select tasks whose `deps:` is empty (QRY-005)",
127    );
128    let inputs_presence = presence_parser(
129        "has-inputs",
130        "no-inputs",
131        "Select tasks whose `inputs:` is non-empty (QRY-005)",
132        "Select tasks whose `inputs:` is empty (QRY-005)",
133    );
134    let outputs_presence = presence_parser(
135        "has-outputs",
136        "no-outputs",
137        "Select tasks whose `outputs:` is non-empty (QRY-005)",
138        "Select tasks whose `outputs:` is empty (QRY-005)",
139    );
140    let mutex = long("mutex")
141        .help("Select tasks that declare a `mutex:` field (QRY-005)")
142        .switch();
143
144    let format = long("format")
145        .help("Output format (plain | json) per QRY-008")
146        .argument::<String>("FMT")
147        .parse(|s| match s.as_ref() {
148            "plain" => Ok(OutputFormat::Plain),
149            "json" => Ok(OutputFormat::Json),
150            other => Err(format!(
151                "unknown output format `{other}` (expected `plain` or `json`)"
152            )),
153        })
154        .fallback(OutputFormat::Plain);
155
156    construct!(QueryArgs {
157        tags,
158        projects,
159        tasks,
160        inputs,
161        outputs,
162        child_of,
163        parent_of,
164        depends_on,
165        ancestor_of,
166        deps_presence,
167        inputs_presence,
168        outputs_presence,
169        mutex,
170        format,
171    })
172}
173
174/// Build the `--<has_flag>` / `--<no_flag>` pair parser. The
175/// reduction to [`FieldPresence`] is the `QRY-005` intra-pair
176/// mutex: two switches yield three valid states; the impossible
177/// "both" state is rejected at parse time.
178fn presence_parser(
179    has_flag: &'static str,
180    no_flag: &'static str,
181    has_help: &'static str,
182    no_help: &'static str,
183) -> impl Parser<FieldPresence> {
184    let has = long(has_flag).help(has_help).switch();
185    let no = long(no_flag).help(no_help).switch();
186    construct!(has, no).parse(move |(has, no)| match (has, no) {
187        (false, false) => Ok(FieldPresence::Any),
188        (true, false) => Ok(FieldPresence::NonEmpty),
189        (false, true) => Ok(FieldPresence::Empty),
190        (true, true) => Err(format!(
191            "`--{has_flag}` and `--{no_flag}` are mutually exclusive"
192        )),
193    })
194}
195
196#[cfg(test)]
197mod tests {
198    use super::{FieldPresence, OutputFormat, QueryArgs};
199    use crate::cli::{Cmd, parse};
200
201    #[test]
202    fn qry_001_query_with_no_flags_parses_to_default() {
203        assert_eq!(parse(&["query"]).unwrap(), Cmd::Query(QueryArgs::default()),);
204    }
205
206    #[test]
207    fn qry_003_tags_filter_captures_expression_verbatim() {
208        assert_eq!(
209            parse(&["query", "--tags", "backend & !legacy"]).unwrap(),
210            Cmd::Query(QueryArgs {
211                tags: Some("backend & !legacy".to_owned()),
212                ..QueryArgs::default()
213            }),
214        );
215    }
216
217    #[test]
218    fn qry_003_projects_filter_captures_expression_verbatim() {
219        assert_eq!(
220            parse(&["query", "--projects", "lib | web"]).unwrap(),
221            Cmd::Query(QueryArgs {
222                projects: Some("lib | web".to_owned()),
223                ..QueryArgs::default()
224            }),
225        );
226    }
227
228    #[test]
229    fn qry_003_tasks_filter_captures_expression_verbatim() {
230        assert_eq!(
231            parse(&["query", "--tasks", "build"]).unwrap(),
232            Cmd::Query(QueryArgs {
233                tasks: Some("build".to_owned()),
234                ..QueryArgs::default()
235            }),
236        );
237    }
238
239    #[test]
240    fn qry_003_inputs_and_outputs_filters_capture_expressions_verbatim() {
241        assert_eq!(
242            parse(&[
243                "query",
244                "--inputs",
245                "src/**/*.rs",
246                "--outputs",
247                "/lib/dist/*.so",
248            ])
249            .unwrap(),
250            Cmd::Query(QueryArgs {
251                inputs: Some("src/**/*.rs".to_owned()),
252                outputs: Some("/lib/dist/*.so".to_owned()),
253                ..QueryArgs::default()
254            }),
255        );
256    }
257
258    #[test]
259    fn qry_004_relational_filters_capture_expressions_verbatim() {
260        assert_eq!(
261            parse(&[
262                "query",
263                "--child-of",
264                "name:build",
265                "--parent-of",
266                "tag:backend",
267                "--depends-on",
268                "project:lib",
269                "--ancestor-of",
270                "name:lint",
271            ])
272            .unwrap(),
273            Cmd::Query(QueryArgs {
274                child_of: Some("name:build".to_owned()),
275                parent_of: Some("tag:backend".to_owned()),
276                depends_on: Some("project:lib".to_owned()),
277                ancestor_of: Some("name:lint".to_owned()),
278                ..QueryArgs::default()
279            }),
280        );
281    }
282
283    #[test]
284    fn qry_005_individual_shortcut_flags_parse() {
285        let parsed = parse(&[
286            "query",
287            "--has-deps",
288            "--has-inputs",
289            "--no-outputs",
290            "--mutex",
291        ])
292        .unwrap();
293        assert_eq!(
294            parsed,
295            Cmd::Query(QueryArgs {
296                deps_presence: FieldPresence::NonEmpty,
297                inputs_presence: FieldPresence::NonEmpty,
298                outputs_presence: FieldPresence::Empty,
299                mutex: true,
300                ..QueryArgs::default()
301            }),
302        );
303    }
304
305    #[test]
306    fn qry_005_has_deps_plus_no_deps_rejects() {
307        let err = parse(&["query", "--has-deps", "--no-deps"]).unwrap_err();
308        assert!(
309            err.contains("mutually exclusive"),
310            "diagnostic should name the intra-pair mutex: {err}",
311        );
312    }
313
314    #[test]
315    fn qry_005_has_inputs_plus_no_inputs_rejects() {
316        let err = parse(&["query", "--has-inputs", "--no-inputs"]).unwrap_err();
317        assert!(
318            err.contains("mutually exclusive"),
319            "diagnostic should name the intra-pair mutex: {err}",
320        );
321    }
322
323    #[test]
324    fn qry_005_has_outputs_plus_no_outputs_rejects() {
325        let err = parse(&["query", "--has-outputs", "--no-outputs"]).unwrap_err();
326        assert!(
327            err.contains("mutually exclusive"),
328            "diagnostic should name the intra-pair mutex: {err}",
329        );
330    }
331
332    #[test]
333    fn qry_008_format_plain_parses() {
334        assert_eq!(
335            parse(&["query", "--format", "plain"]).unwrap(),
336            Cmd::Query(QueryArgs {
337                format: OutputFormat::Plain,
338                ..QueryArgs::default()
339            }),
340        );
341    }
342
343    #[test]
344    fn qry_008_format_json_parses() {
345        assert_eq!(
346            parse(&["query", "--format", "json"]).unwrap(),
347            Cmd::Query(QueryArgs {
348                format: OutputFormat::Json,
349                ..QueryArgs::default()
350            }),
351        );
352    }
353
354    #[test]
355    fn qry_008_format_default_is_plain() {
356        let Cmd::Query(args) = parse(&["query"]).unwrap() else {
357            panic!("expected Cmd::Query");
358        };
359        assert_eq!(args.format, OutputFormat::Plain);
360    }
361
362    #[test]
363    fn qry_008_unknown_format_rejects() {
364        let err = parse(&["query", "--format", "yaml"]).unwrap_err();
365        assert!(
366            err.contains("unknown output format"),
367            "diagnostic should name the offending value: {err}",
368        );
369    }
370
371    #[test]
372    fn qry_006_multiple_filter_families_combine_into_one_args() {
373        let parsed = parse(&[
374            "query",
375            "--tags",
376            "backend",
377            "--tasks",
378            "test",
379            "--has-deps",
380            "--format",
381            "json",
382        ])
383        .unwrap();
384        assert_eq!(
385            parsed,
386            Cmd::Query(QueryArgs {
387                tags: Some("backend".to_owned()),
388                tasks: Some("test".to_owned()),
389                deps_presence: FieldPresence::NonEmpty,
390                format: OutputFormat::Json,
391                ..QueryArgs::default()
392            }),
393        );
394    }
395
396    #[test]
397    fn qry_001_query_rejects_positional_arguments() {
398        let err = parse(&["query", "lib:build"]).unwrap_err();
399        assert!(
400            !err.is_empty(),
401            "positional argument must produce a diagnostic: {err}",
402        );
403    }
404}