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