1use 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
43pub 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
76fn 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
129fn 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
150fn 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
179fn render_plain(selected: &[TaskId], out: &mut impl Write) -> io::Result<()> {
183 for id in selected {
184 writeln!(out, "{id}")?;
185 }
186 Ok(())
187}
188
189fn 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 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 #[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 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 #[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 #[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 #[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 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 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 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}