use std::io::{self, Write};
use haz_domain::task_id::TaskId;
use haz_domain::workspace::Workspace;
use haz_query::engine::execute as engine_execute;
use haz_query::engine::spec::QuerySpec;
use haz_query::expr::atom::AtomError;
use haz_query::expr::identifier::{parse_project_atom, parse_tag_atom, parse_task_atom};
use haz_query::expr::path::parse_path_pattern_atom;
use haz_query::expr::relational::parse_relational_atom;
use haz_query::expr::shortcut::BooleanShortcut;
use haz_query_lang::expr::{Expr, RawAtom};
use haz_query_lang::parser;
use miette::{IntoDiagnostic, Result, WrapErr, miette};
use crate::cli::{FieldPresence, OutputFormat, QueryArgs};
use crate::context::Context;
pub fn execute(args: &QueryArgs, ctx: &Context) -> Result<i32> {
let bearing = ctx.bearing().cloned();
let spec = build_query_spec(args)?;
let selected = engine_execute(ctx.workspace(), ctx.graph(), bearing.as_ref(), &spec)
.map_err(|e| miette!("query engine failed: {e}"))?;
let mut out = io::stdout().lock();
match args.format {
OutputFormat::Plain => render_plain(&selected, &mut out),
OutputFormat::Json => render_json(&selected, ctx.workspace(), &mut out),
}
.into_diagnostic()
.wrap_err("failed to write query output")?;
Ok(0)
}
fn build_query_spec(args: &QueryArgs) -> Result<QuerySpec> {
let tags = parse_typed_expr(args.tags.as_deref(), "--tags", parse_tag_atom)?;
let projects = parse_typed_expr(args.projects.as_deref(), "--projects", parse_project_atom)?;
let tasks = parse_typed_expr(args.tasks.as_deref(), "--tasks", parse_task_atom)?;
let inputs = parse_typed_expr(args.inputs.as_deref(), "--inputs", parse_path_pattern_atom)?;
let outputs = parse_typed_expr(
args.outputs.as_deref(),
"--outputs",
parse_path_pattern_atom,
)?;
let child_of = parse_typed_expr(
args.child_of.as_deref(),
"--child-of",
parse_relational_atom,
)?;
let parent_of = parse_typed_expr(
args.parent_of.as_deref(),
"--parent-of",
parse_relational_atom,
)?;
let depends_on = parse_typed_expr(
args.depends_on.as_deref(),
"--depends-on",
parse_relational_atom,
)?;
let ancestor_of = parse_typed_expr(
args.ancestor_of.as_deref(),
"--ancestor-of",
parse_relational_atom,
)?;
let shortcuts = collect_shortcuts(args);
Ok(QuerySpec {
tags,
projects,
tasks,
inputs,
outputs,
child_of,
parent_of,
depends_on,
ancestor_of,
shortcuts,
})
}
fn parse_typed_expr<A, F>(raw: Option<&str>, flag: &str, lift: F) -> Result<Option<Expr<A>>>
where
F: FnMut(RawAtom) -> Result<A, AtomError>,
{
let Some(s) = raw else {
return Ok(None);
};
let parsed =
parser::parse(s).map_err(|e| miette!("{flag}: failed to parse expression `{s}`: {e}"))?;
let typed = parsed
.try_map(lift)
.map_err(|e| miette!("{flag}: invalid atom in `{s}`: {e}"))?;
Ok(Some(typed))
}
fn collect_shortcuts(args: &QueryArgs) -> Vec<BooleanShortcut> {
let mut shortcuts: Vec<BooleanShortcut> = Vec::new();
match args.deps_presence {
FieldPresence::Any => {}
FieldPresence::NonEmpty => shortcuts.push(BooleanShortcut::HasDeps),
FieldPresence::Empty => shortcuts.push(BooleanShortcut::NoDeps),
}
match args.inputs_presence {
FieldPresence::Any => {}
FieldPresence::NonEmpty => shortcuts.push(BooleanShortcut::HasInputs),
FieldPresence::Empty => shortcuts.push(BooleanShortcut::NoInputs),
}
match args.outputs_presence {
FieldPresence::Any => {}
FieldPresence::NonEmpty => shortcuts.push(BooleanShortcut::HasOutputs),
FieldPresence::Empty => shortcuts.push(BooleanShortcut::NoOutputs),
}
if args.mutex {
shortcuts.push(BooleanShortcut::Mutex);
}
shortcuts
}
fn render_plain(selected: &[TaskId], out: &mut impl Write) -> io::Result<()> {
for id in selected {
writeln!(out, "{id}")?;
}
Ok(())
}
fn render_json(selected: &[TaskId], workspace: &Workspace, out: &mut impl Write) -> io::Result<()> {
let array: Vec<serde_json::Value> = selected
.iter()
.map(|id| json_object_for(id, workspace))
.collect();
let serialised = serde_json::to_string(&serde_json::Value::Array(array))
.expect("JSON serialisation of an array of identifier strings cannot fail");
writeln!(out, "{serialised}")
}
fn json_object_for(id: &TaskId, workspace: &Workspace) -> serde_json::Value {
let tags: Vec<String> = workspace
.projects
.get(&id.project)
.map(|p| p.tags.iter().map(ToString::to_string).collect())
.unwrap_or_default();
serde_json::json!({
"project": id.project.to_string(),
"task": id.task.to_string(),
"tags": tags,
})
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use std::str::FromStr;
use haz_domain::name::{ProjectName, TaskName};
use haz_domain::task_id::TaskId;
use super::{build_query_spec, execute, render_json, render_plain};
use crate::cli::QueryArgs;
use crate::context::Context;
use miette::Result;
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap();
}
fn run_query(args: &QueryArgs, cwd: &Path) -> Result<i32> {
let ctx = Context::open(cwd)?;
execute(args, &ctx)
}
fn two_project_workspace(root: &Path) {
write_file(
&root.join(".haz/workspace.yml"),
"projects:\n - /lib\n - /web\n",
);
write_file(
&root.join("lib/haz.yml"),
"name: lib\ntags:\n - backend\ntasks:\n build:\n command: [\"true\"]\n test:\n command: [\"true\"]\n deps:\n - ~:build\n",
);
write_file(
&root.join("web/haz.yml"),
"name: web\ntags:\n - frontend\ntasks:\n bundle:\n command: [\"true\"]\n deps:\n - lib:build\n",
);
}
#[test]
fn build_query_spec_returns_default_when_no_filters_set() {
let args = QueryArgs::default();
let spec = build_query_spec(&args).unwrap();
assert!(spec.tags.is_none());
assert!(spec.projects.is_none());
assert!(spec.tasks.is_none());
assert!(spec.inputs.is_none());
assert!(spec.outputs.is_none());
assert!(spec.child_of.is_none());
assert!(spec.parent_of.is_none());
assert!(spec.depends_on.is_none());
assert!(spec.ancestor_of.is_none());
assert!(spec.shortcuts.is_empty());
}
#[test]
fn build_query_spec_lifts_tag_expression() {
let args = QueryArgs {
tags: Some("backend".to_owned()),
..QueryArgs::default()
};
let spec = build_query_spec(&args).unwrap();
assert!(spec.tags.is_some());
}
#[test]
fn build_query_spec_rejects_malformed_expression() {
let args = QueryArgs {
tags: Some("backend &".to_owned()),
..QueryArgs::default()
};
let err = build_query_spec(&args).unwrap_err();
let rendered = format!("{err}");
assert!(
rendered.contains("--tags"),
"diagnostic should name the flag: {rendered}",
);
}
#[test]
fn build_query_spec_rejects_invalid_atom_in_relational() {
let args = QueryArgs {
child_of: Some("name:".to_owned()),
..QueryArgs::default()
};
let err = build_query_spec(&args).unwrap_err();
let rendered = format!("{err}");
assert!(
rendered.contains("--child-of"),
"diagnostic should name the flag: {rendered}",
);
}
#[test]
fn build_query_spec_collects_every_supplied_shortcut() {
use crate::cli::FieldPresence;
let args = QueryArgs {
deps_presence: FieldPresence::NonEmpty,
inputs_presence: FieldPresence::Empty,
mutex: true,
..QueryArgs::default()
};
let spec = build_query_spec(&args).unwrap();
assert_eq!(spec.shortcuts.len(), 3);
}
#[test]
fn render_plain_emits_one_line_per_task_id() {
let ids = vec![
TaskId {
project: ProjectName::from_str("lib").unwrap(),
task: TaskName::from_str("build").unwrap(),
},
TaskId {
project: ProjectName::from_str("web").unwrap(),
task: TaskName::from_str("bundle").unwrap(),
},
];
let mut out = Vec::<u8>::new();
render_plain(&ids, &mut out).unwrap();
assert_eq!(String::from_utf8(out).unwrap(), "lib:build\nweb:bundle\n",);
}
#[test]
fn render_plain_emits_no_output_for_empty_selection() {
let mut out = Vec::<u8>::new();
render_plain(&[], &mut out).unwrap();
assert!(out.is_empty(), "QRY-008 zero matches => zero lines");
}
#[test]
fn render_json_emits_array_with_required_fields() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
two_project_workspace(root);
let (workspace, _graph) = crate::workspace_load::load_workspace(root).unwrap();
let ids = vec![TaskId {
project: ProjectName::from_str("lib").unwrap(),
task: TaskName::from_str("build").unwrap(),
}];
let mut out = Vec::<u8>::new();
render_json(&ids, &workspace, &mut out).unwrap();
let rendered = String::from_utf8(out).unwrap();
let value: serde_json::Value = serde_json::from_str(rendered.trim()).unwrap();
let arr = value.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["project"], "lib");
assert_eq!(arr[0]["task"], "build");
assert_eq!(arr[0]["tags"], serde_json::json!(["backend"]));
}
#[test]
fn render_json_emits_empty_array_for_empty_selection() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
two_project_workspace(root);
let (workspace, _graph) = crate::workspace_load::load_workspace(root).unwrap();
let mut out = Vec::<u8>::new();
render_json(&[], &workspace, &mut out).unwrap();
assert_eq!(out, b"[]\n");
}
#[test]
fn execute_with_empty_filters_lists_every_task_under_workspace() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
two_project_workspace(root);
let args = QueryArgs::default();
let code = run_query(&args, root).unwrap();
assert_eq!(code, 0);
}
#[test]
fn execute_with_tags_filter_returns_zero_on_no_match() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
two_project_workspace(root);
let args = QueryArgs {
tags: Some("nonexistent".to_owned()),
..QueryArgs::default()
};
let code = run_query(&args, root).unwrap();
assert_eq!(code, 0);
}
#[test]
fn execute_rejects_malformed_filter_expression() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
two_project_workspace(root);
let args = QueryArgs {
projects: Some("(unclosed".to_owned()),
..QueryArgs::default()
};
let err = run_query(&args, root).unwrap_err();
let rendered = format!("{err}");
assert!(
rendered.contains("--projects"),
"diagnostic should name the offending flag: {rendered}",
);
}
#[test]
fn execute_with_relational_filter_uses_graph_edges() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
two_project_workspace(root);
let args = QueryArgs {
child_of: Some("name:build".to_owned()),
..QueryArgs::default()
};
let code = run_query(&args, root).unwrap();
assert_eq!(code, 0);
}
}