Skip to main content

haz_cli/commands/
query.rs

1//! `haz query` per `QRY-001`..`QRY-009`.
2//!
3//! Read-only enumeration of tasks under a boolean filter
4//! language. The executor:
5//!
6//! 1. Loads the workspace and its validated task graph
7//!    (`DISC-001..DISC-009`, `DAG-001..DAG-018`).
8//! 2. Resolves the bearing project from cwd per `EXEC-022`.
9//! 3. Parses each supplied filter expression through
10//!    [`haz_query_lang::parser::parse`] and lifts every atom to
11//!    its typed form (`TagName`, `ProjectName`, `TaskName`,
12//!    `PathPattern`, or `RelationalAtom`) via the appropriate
13//!    `haz_query::expr::*` helper.
14//! 4. Assembles a [`QuerySpec`] and invokes
15//!    [`haz_query::engine::execute`].
16//! 5. Renders the matching task identities in canonical
17//!    `(ProjectName, TaskName)` order per `QRY-007`. Plain output
18//!    is one `<project>:<task>` line per match (`QRY-008`); JSON
19//!    output is a single array of objects with `project`, `task`,
20//!    and `tags` fields.
21
22use std::io::{self, Write};
23use std::path::Path;
24
25use haz_domain::task_id::TaskId;
26use haz_domain::workspace::Workspace;
27use haz_query::engine::execute as engine_execute;
28use haz_query::engine::spec::QuerySpec;
29use haz_query::expr::atom::AtomError;
30use haz_query::expr::identifier::{parse_project_atom, parse_tag_atom, parse_task_atom};
31use haz_query::expr::path::parse_path_pattern_atom;
32use haz_query::expr::relational::parse_relational_atom;
33use haz_query::expr::shortcut::BooleanShortcut;
34use haz_query_lang::expr::{Expr, RawAtom};
35use haz_query_lang::parser;
36use miette::{IntoDiagnostic, Result, WrapErr, miette};
37
38use haz_discovery::bearing::bearing_project;
39
40use crate::cli::{FieldPresence, OutputFormat, QueryArgs};
41use crate::workspace_load::load_workspace;
42
43/// Execute `haz query` against `cwd`.
44///
45/// Returns `0` on a successful enumeration, regardless of how
46/// many tasks the selection contains (including zero) per
47/// `QRY-009`.
48///
49/// # Errors
50///
51/// Returns a [`miette::Report`] for workspace-load failures, for
52/// any filter-expression parse error (malformed boolean grammar,
53/// rejected atom value, unknown relational kind), and for
54/// engine-side errors (canonicalisation regression, glob-glob
55/// intersection failure). All map to the
56/// internal-or-configuration-error exit class per `QRY-009`.
57pub fn execute(args: &QueryArgs, cwd: &Path) -> Result<i32> {
58    let (workspace, graph) = load_workspace(cwd)?;
59    let bearing = bearing_project(cwd, &workspace).cloned();
60
61    let spec = build_query_spec(args)?;
62    let selected = engine_execute(&workspace, &graph, bearing.as_ref(), &spec)
63        .map_err(|e| miette!("query engine failed: {e}"))?;
64
65    let mut out = io::stdout().lock();
66    match args.format {
67        OutputFormat::Plain => render_plain(&selected, &mut out),
68        OutputFormat::Json => render_json(&selected, &workspace, &mut out),
69    }
70    .into_diagnostic()
71    .wrap_err("failed to write query output")?;
72
73    Ok(0)
74}
75
76/// Translate the CLI's [`QueryArgs`] into a typed [`QuerySpec`].
77///
78/// Each filter expression is parsed by the language parser and
79/// then `try_map`ped to its typed-atom form. Any parse or lift
80/// failure is surfaced as a `miette` diagnostic that names the
81/// offending flag and the offending atom.
82fn build_query_spec(args: &QueryArgs) -> Result<QuerySpec> {
83    let tags = parse_typed_expr(args.tags.as_deref(), "--tags", parse_tag_atom)?;
84    let projects = parse_typed_expr(args.projects.as_deref(), "--projects", parse_project_atom)?;
85    let tasks = parse_typed_expr(args.tasks.as_deref(), "--tasks", parse_task_atom)?;
86    let inputs = parse_typed_expr(args.inputs.as_deref(), "--inputs", parse_path_pattern_atom)?;
87    let outputs = parse_typed_expr(
88        args.outputs.as_deref(),
89        "--outputs",
90        parse_path_pattern_atom,
91    )?;
92    let child_of = parse_typed_expr(
93        args.child_of.as_deref(),
94        "--child-of",
95        parse_relational_atom,
96    )?;
97    let parent_of = parse_typed_expr(
98        args.parent_of.as_deref(),
99        "--parent-of",
100        parse_relational_atom,
101    )?;
102    let depends_on = parse_typed_expr(
103        args.depends_on.as_deref(),
104        "--depends-on",
105        parse_relational_atom,
106    )?;
107    let ancestor_of = parse_typed_expr(
108        args.ancestor_of.as_deref(),
109        "--ancestor-of",
110        parse_relational_atom,
111    )?;
112
113    let shortcuts = collect_shortcuts(args);
114
115    Ok(QuerySpec {
116        tags,
117        projects,
118        tasks,
119        inputs,
120        outputs,
121        child_of,
122        parent_of,
123        depends_on,
124        ancestor_of,
125        shortcuts,
126    })
127}
128
129/// Parse `raw` (when present) as a boolean expression and lift
130/// every atom via `lift`.
131///
132/// The `flag` argument is the CLI flag name (e.g. `"--tags"`),
133/// included verbatim in any diagnostic the function surfaces so
134/// the user can locate the offending input.
135fn parse_typed_expr<A, F>(raw: Option<&str>, flag: &str, lift: F) -> Result<Option<Expr<A>>>
136where
137    F: FnMut(RawAtom) -> Result<A, AtomError>,
138{
139    let Some(s) = raw else {
140        return Ok(None);
141    };
142    let parsed =
143        parser::parse(s).map_err(|e| miette!("{flag}: failed to parse expression `{s}`: {e}"))?;
144    let typed = parsed
145        .try_map(lift)
146        .map_err(|e| miette!("{flag}: invalid atom in `{s}`: {e}"))?;
147    Ok(Some(typed))
148}
149
150/// Map every supplied `QRY-005` shortcut to its
151/// [`BooleanShortcut`] form. The CLI's tri-state
152/// [`FieldPresence`] keeps the intra-pair mutex
153/// (`--has-X` vs `--no-X`) impossible-to-represent, so this
154/// function is a total mapping over the three policy fields plus
155/// the `--mutex` switch.
156fn collect_shortcuts(args: &QueryArgs) -> Vec<BooleanShortcut> {
157    let mut shortcuts: Vec<BooleanShortcut> = Vec::new();
158    match args.deps_presence {
159        FieldPresence::Any => {}
160        FieldPresence::NonEmpty => shortcuts.push(BooleanShortcut::HasDeps),
161        FieldPresence::Empty => shortcuts.push(BooleanShortcut::NoDeps),
162    }
163    match args.inputs_presence {
164        FieldPresence::Any => {}
165        FieldPresence::NonEmpty => shortcuts.push(BooleanShortcut::HasInputs),
166        FieldPresence::Empty => shortcuts.push(BooleanShortcut::NoInputs),
167    }
168    match args.outputs_presence {
169        FieldPresence::Any => {}
170        FieldPresence::NonEmpty => shortcuts.push(BooleanShortcut::HasOutputs),
171        FieldPresence::Empty => shortcuts.push(BooleanShortcut::NoOutputs),
172    }
173    if args.mutex {
174        shortcuts.push(BooleanShortcut::Mutex);
175    }
176    shortcuts
177}
178
179/// `QRY-008` plain-format output: one `<project>:<task>` line per
180/// match, no header / footer / count. Zero matches produces zero
181/// output lines.
182fn render_plain(selected: &[TaskId], out: &mut impl Write) -> io::Result<()> {
183    for id in selected {
184        writeln!(out, "{id}")?;
185    }
186    Ok(())
187}
188
189/// `QRY-008` JSON-format output: a single JSON array of objects
190/// each carrying `project`, `task`, and `tags` (an array of the
191/// task's owning project's tag names, sorted via `BTreeSet`).
192fn render_json(selected: &[TaskId], workspace: &Workspace, out: &mut impl Write) -> io::Result<()> {
193    let array: Vec<serde_json::Value> = selected
194        .iter()
195        .map(|id| json_object_for(id, workspace))
196        .collect();
197    let serialised = serde_json::to_string(&serde_json::Value::Array(array))
198        .expect("JSON serialisation of an array of identifier strings cannot fail");
199    writeln!(out, "{serialised}")
200}
201
202fn json_object_for(id: &TaskId, workspace: &Workspace) -> serde_json::Value {
203    let tags: Vec<String> = workspace
204        .projects
205        .get(&id.project)
206        .map(|p| p.tags.iter().map(ToString::to_string).collect())
207        .unwrap_or_default();
208    serde_json::json!({
209        "project": id.project.to_string(),
210        "task": id.task.to_string(),
211        "tags": tags,
212    })
213}
214
215#[cfg(test)]
216mod tests {
217    use std::fs;
218    use std::path::Path;
219    use std::str::FromStr;
220
221    use haz_domain::name::{ProjectName, TaskName};
222    use haz_domain::task_id::TaskId;
223
224    use super::{build_query_spec, execute, render_json, render_plain};
225    use crate::cli::QueryArgs;
226
227    fn write_file(path: &Path, contents: &str) {
228        if let Some(parent) = path.parent() {
229            fs::create_dir_all(parent).unwrap();
230        }
231        fs::write(path, contents).unwrap();
232    }
233
234    /// Two-project workspace: `lib` has a `build` task and a
235    /// `test` task that depends on `build`. `web` has `bundle`
236    /// that depends on `lib:build`.
237    fn two_project_workspace(root: &Path) {
238        write_file(
239            &root.join(".haz/workspace.yml"),
240            "projects:\n  - /lib\n  - /web\n",
241        );
242        write_file(
243            &root.join("lib/haz.yml"),
244            "name: lib\ntags:\n  - backend\ntasks:\n  build:\n    command: [\"true\"]\n  test:\n    command: [\"true\"]\n    deps:\n      - ~:build\n",
245        );
246        write_file(
247            &root.join("web/haz.yml"),
248            "name: web\ntags:\n  - frontend\ntasks:\n  bundle:\n    command: [\"true\"]\n    deps:\n      - lib:build\n",
249        );
250    }
251
252    // ---- build_query_spec ------------------------------------
253
254    #[test]
255    fn build_query_spec_returns_default_when_no_filters_set() {
256        let args = QueryArgs::default();
257        let spec = build_query_spec(&args).unwrap();
258        assert!(spec.tags.is_none());
259        assert!(spec.projects.is_none());
260        assert!(spec.tasks.is_none());
261        assert!(spec.inputs.is_none());
262        assert!(spec.outputs.is_none());
263        assert!(spec.child_of.is_none());
264        assert!(spec.parent_of.is_none());
265        assert!(spec.depends_on.is_none());
266        assert!(spec.ancestor_of.is_none());
267        assert!(spec.shortcuts.is_empty());
268    }
269
270    #[test]
271    fn build_query_spec_lifts_tag_expression() {
272        let args = QueryArgs {
273            tags: Some("backend".to_owned()),
274            ..QueryArgs::default()
275        };
276        let spec = build_query_spec(&args).unwrap();
277        assert!(spec.tags.is_some());
278    }
279
280    #[test]
281    fn build_query_spec_rejects_malformed_expression() {
282        let args = QueryArgs {
283            tags: Some("backend &".to_owned()),
284            ..QueryArgs::default()
285        };
286        let err = build_query_spec(&args).unwrap_err();
287        let rendered = format!("{err}");
288        assert!(
289            rendered.contains("--tags"),
290            "diagnostic should name the flag: {rendered}",
291        );
292    }
293
294    #[test]
295    fn build_query_spec_rejects_invalid_atom_in_relational() {
296        // `name:` lacks the value half of the kind:value shape.
297        let args = QueryArgs {
298            child_of: Some("name:".to_owned()),
299            ..QueryArgs::default()
300        };
301        let err = build_query_spec(&args).unwrap_err();
302        let rendered = format!("{err}");
303        assert!(
304            rendered.contains("--child-of"),
305            "diagnostic should name the flag: {rendered}",
306        );
307    }
308
309    #[test]
310    fn build_query_spec_collects_every_supplied_shortcut() {
311        use crate::cli::FieldPresence;
312        let args = QueryArgs {
313            deps_presence: FieldPresence::NonEmpty,
314            inputs_presence: FieldPresence::Empty,
315            mutex: true,
316            ..QueryArgs::default()
317        };
318        let spec = build_query_spec(&args).unwrap();
319        assert_eq!(spec.shortcuts.len(), 3);
320    }
321
322    // ---- render_plain ----------------------------------------
323
324    #[test]
325    fn render_plain_emits_one_line_per_task_id() {
326        let ids = vec![
327            TaskId {
328                project: ProjectName::from_str("lib").unwrap(),
329                task: TaskName::from_str("build").unwrap(),
330            },
331            TaskId {
332                project: ProjectName::from_str("web").unwrap(),
333                task: TaskName::from_str("bundle").unwrap(),
334            },
335        ];
336        let mut out = Vec::<u8>::new();
337        render_plain(&ids, &mut out).unwrap();
338        assert_eq!(String::from_utf8(out).unwrap(), "lib:build\nweb:bundle\n",);
339    }
340
341    #[test]
342    fn render_plain_emits_no_output_for_empty_selection() {
343        let mut out = Vec::<u8>::new();
344        render_plain(&[], &mut out).unwrap();
345        assert!(out.is_empty(), "QRY-008 zero matches => zero lines");
346    }
347
348    // ---- render_json -----------------------------------------
349
350    #[test]
351    fn render_json_emits_array_with_required_fields() {
352        let tempdir = tempfile::TempDir::new().unwrap();
353        let root = tempdir.path();
354        two_project_workspace(root);
355        let (workspace, _graph) = crate::workspace_load::load_workspace(root).unwrap();
356
357        let ids = vec![TaskId {
358            project: ProjectName::from_str("lib").unwrap(),
359            task: TaskName::from_str("build").unwrap(),
360        }];
361        let mut out = Vec::<u8>::new();
362        render_json(&ids, &workspace, &mut out).unwrap();
363        let rendered = String::from_utf8(out).unwrap();
364        let value: serde_json::Value = serde_json::from_str(rendered.trim()).unwrap();
365        let arr = value.as_array().unwrap();
366        assert_eq!(arr.len(), 1);
367        assert_eq!(arr[0]["project"], "lib");
368        assert_eq!(arr[0]["task"], "build");
369        assert_eq!(arr[0]["tags"], serde_json::json!(["backend"]));
370    }
371
372    #[test]
373    fn render_json_emits_empty_array_for_empty_selection() {
374        let tempdir = tempfile::TempDir::new().unwrap();
375        let root = tempdir.path();
376        two_project_workspace(root);
377        let (workspace, _graph) = crate::workspace_load::load_workspace(root).unwrap();
378
379        let mut out = Vec::<u8>::new();
380        render_json(&[], &workspace, &mut out).unwrap();
381        assert_eq!(out, b"[]\n");
382    }
383
384    // ---- execute (end-to-end) --------------------------------
385
386    #[test]
387    fn execute_with_empty_filters_lists_every_task_under_workspace() {
388        let tempdir = tempfile::TempDir::new().unwrap();
389        let root = tempdir.path();
390        two_project_workspace(root);
391        let args = QueryArgs::default();
392        // cwd at workspace root: no bearing, full candidate set.
393        let code = execute(&args, root).unwrap();
394        assert_eq!(code, 0);
395    }
396
397    #[test]
398    fn execute_with_tags_filter_returns_zero_on_no_match() {
399        // QRY-009: success regardless of how many tasks the
400        // selection contains.
401        let tempdir = tempfile::TempDir::new().unwrap();
402        let root = tempdir.path();
403        two_project_workspace(root);
404        let args = QueryArgs {
405            tags: Some("nonexistent".to_owned()),
406            ..QueryArgs::default()
407        };
408        let code = execute(&args, root).unwrap();
409        assert_eq!(code, 0);
410    }
411
412    #[test]
413    fn execute_rejects_malformed_filter_expression() {
414        let tempdir = tempfile::TempDir::new().unwrap();
415        let root = tempdir.path();
416        two_project_workspace(root);
417        let args = QueryArgs {
418            projects: Some("(unclosed".to_owned()),
419            ..QueryArgs::default()
420        };
421        let err = execute(&args, root).unwrap_err();
422        let rendered = format!("{err}");
423        assert!(
424            rendered.contains("--projects"),
425            "diagnostic should name the offending flag: {rendered}",
426        );
427    }
428
429    #[test]
430    fn execute_with_relational_filter_uses_graph_edges() {
431        let tempdir = tempfile::TempDir::new().unwrap();
432        let root = tempdir.path();
433        two_project_workspace(root);
434        // child-of name:build => tasks whose deps directly name a
435        // `build` task. In the fixture, `lib:test` depends on
436        // `lib:build`, and `web:bundle` depends on `lib:build`.
437        let args = QueryArgs {
438            child_of: Some("name:build".to_owned()),
439            ..QueryArgs::default()
440        };
441        let code = execute(&args, root).unwrap();
442        assert_eq!(code, 0);
443    }
444}