1use 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
40pub 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
72fn 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
125fn 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
146fn 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
175fn render_plain(selected: &[TaskId], out: &mut impl Write) -> io::Result<()> {
179 for id in selected {
180 writeln!(out, "{id}")?;
181 }
182 Ok(())
183}
184
185fn 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 fn run_query(args: &QueryArgs, cwd: &Path) -> Result<i32> {
235 let ctx = Context::open(cwd)?;
236 execute(args, &ctx)
237 }
238
239 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 #[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 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 #[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 #[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 #[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 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 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 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}