use std::fs;
use std::path::Path;
use std::process::Command;
use haz_cli::EXIT_INTERNAL_OR_CONFIG_ERROR;
use tempfile::TempDir;
const HAZ_BIN: &str = env!("CARGO_BIN_EXE_haz");
fn fixture_tempdir(name: &str) -> TempDir {
let src = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/query")
.join(name);
let dst = TempDir::new().expect("tempdir creation failed");
copy_dir_recursive(&src, dst.path());
dst
}
fn copy_dir_recursive(src: &Path, dst: &Path) {
fs::create_dir_all(dst).unwrap();
for entry in fs::read_dir(src).unwrap() {
let entry = entry.unwrap();
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if entry.file_type().unwrap().is_dir() {
copy_dir_recursive(&src_path, &dst_path);
} else {
fs::copy(&src_path, &dst_path).unwrap();
}
}
}
fn haz_query(cwd: &Path, extra: &[&str]) -> (String, String, Option<i32>) {
let mut argv: Vec<&str> = vec!["query"];
argv.extend_from_slice(extra);
let output = Command::new(HAZ_BIN)
.args(&argv)
.current_dir(cwd)
.output()
.expect("failed to spawn haz");
(
String::from_utf8(output.stdout).expect("haz stdout was not utf-8"),
String::from_utf8(output.stderr).expect("haz stderr was not utf-8"),
output.status.code(),
)
}
#[test]
fn qry_001_bare_query_lists_full_candidate_set_in_canonical_order() {
let tmp = fixture_tempdir("multi_project");
let (stdout, stderr, code) = haz_query(tmp.path(), &[]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(stdout, "lib:build\nlib:test\ntools:lint\nweb:bundle\n");
}
#[test]
fn exec_022_bearing_project_restricts_candidate_set_to_that_project() {
let tmp = fixture_tempdir("multi_project");
let cwd = tmp.path().join("lib");
let (stdout, stderr, code) = haz_query(&cwd, &[]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(stdout, "lib:build\nlib:test\n");
}
#[test]
fn qry_003_tags_filter_restricts_by_owning_project_tag() {
let tmp = fixture_tempdir("multi_project");
let (stdout, stderr, code) = haz_query(tmp.path(), &["--tags", "frontend"]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(stdout, "web:bundle\n");
}
#[test]
fn qry_003_tasks_filter_restricts_by_task_name() {
let tmp = fixture_tempdir("multi_project");
let (stdout, stderr, code) = haz_query(tmp.path(), &["--tasks", "build"]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(stdout, "lib:build\n");
}
#[test]
fn qry_003_projects_filter_restricts_by_owning_project_name() {
let tmp = fixture_tempdir("multi_project");
let (stdout, stderr, code) = haz_query(tmp.path(), &["--projects", "lib | web"]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(stdout, "lib:build\nlib:test\nweb:bundle\n");
}
#[test]
fn qry_003_inputs_pattern_filter_matches_declared_pattern() {
let tmp = fixture_tempdir("multi_project");
let (stdout, stderr, code) = haz_query(tmp.path(), &["--inputs", "/lib/src/lib_test.rs"]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(stdout, "lib:test\n");
}
#[test]
fn qry_003_outputs_pattern_filter_matches_declared_pattern() {
let tmp = fixture_tempdir("multi_project");
let (stdout, stderr, code) = haz_query(tmp.path(), &["--outputs", "/lib/dist/*.a"]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(stdout, "lib:build\n");
}
#[test]
fn qry_004_child_of_selects_direct_hard_edge_successors() {
let tmp = fixture_tempdir("multi_project");
let (stdout, stderr, code) = haz_query(tmp.path(), &["--child-of", "name:build"]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(stdout, "lib:test\nweb:bundle\n");
}
#[test]
fn qry_004_depends_on_traverses_transitively_and_excludes_target_set() {
let tmp = fixture_tempdir("multi_project");
let (stdout, stderr, code) = haz_query(tmp.path(), &["--depends-on", "name:build"]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(stdout, "lib:test\nweb:bundle\n");
}
#[test]
fn qry_005_has_deps_selects_tasks_with_non_empty_deps_field() {
let tmp = fixture_tempdir("multi_project");
let (stdout, stderr, code) = haz_query(tmp.path(), &["--has-deps"]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(stdout, "lib:test\nweb:bundle\n");
}
#[test]
fn qry_005_no_deps_selects_tasks_with_empty_deps_field() {
let tmp = fixture_tempdir("multi_project");
let (stdout, stderr, code) = haz_query(tmp.path(), &["--no-deps"]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(stdout, "lib:build\ntools:lint\n");
}
#[test]
fn qry_005_mutex_shortcut_selects_only_mutex_bearing_tasks() {
let tmp = fixture_tempdir("multi_project");
let (stdout, stderr, code) = haz_query(tmp.path(), &["--mutex"]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(stdout, "lib:test\n");
}
#[test]
fn qry_005_has_deps_plus_no_deps_rejected_at_parse_time() {
let tmp = fixture_tempdir("multi_project");
let (_, stderr, code) = haz_query(tmp.path(), &["--has-deps", "--no-deps"]);
assert_eq!(
code,
Some(EXIT_INTERNAL_OR_CONFIG_ERROR),
"stderr: {stderr}",
);
assert!(
stderr.contains("mutually exclusive"),
"diagnostic should name the intra-pair mutex: {stderr}",
);
}
#[test]
fn qry_006_combining_filter_families_intersects() {
let tmp = fixture_tempdir("multi_project");
let (stdout, stderr, code) = haz_query(tmp.path(), &["--tags", "backend", "--has-deps"]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(stdout, "lib:test\n");
}
#[test]
fn qry_008_json_format_emits_array_of_required_fields() {
let tmp = fixture_tempdir("multi_project");
let (stdout, stderr, code) = haz_query(tmp.path(), &["--tasks", "build", "--format", "json"]);
assert_eq!(code, Some(0), "stderr: {stderr}");
let value: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("stdout is not valid JSON ({e}): {stdout}"));
let arr = value.as_array().expect("top-level JSON must be an array");
assert_eq!(arr.len(), 1);
let entry = &arr[0];
assert_eq!(entry["project"], serde_json::json!("lib"));
assert_eq!(entry["task"], serde_json::json!("build"));
let tags = entry["tags"].as_array().expect("tags must be a JSON array");
let tag_strs: Vec<&str> = tags
.iter()
.map(|t| t.as_str().expect("tag entry must be a JSON string"))
.collect();
assert!(tag_strs.contains(&"backend"));
assert!(tag_strs.contains(&"rust"));
}
#[test]
fn qry_008_json_format_emits_empty_array_for_zero_match() {
let tmp = fixture_tempdir("multi_project");
let (stdout, stderr, code) =
haz_query(tmp.path(), &["--tags", "absent_tag", "--format", "json"]);
assert_eq!(code, Some(0), "stderr: {stderr}");
let value: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert!(value.as_array().unwrap().is_empty());
}
#[test]
fn qry_009_zero_match_exits_zero_with_no_output_lines() {
let tmp = fixture_tempdir("multi_project");
let (stdout, stderr, code) = haz_query(tmp.path(), &["--tags", "absent_tag"]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert!(stdout.is_empty(), "QRY-008: zero matches => zero lines");
}
#[test]
fn qry_009_malformed_filter_expression_uses_config_error_class() {
let tmp = fixture_tempdir("multi_project");
let (_, stderr, code) = haz_query(tmp.path(), &["--tags", "(unclosed"]);
assert_eq!(
code,
Some(EXIT_INTERNAL_OR_CONFIG_ERROR),
"stderr: {stderr}",
);
assert!(
stderr.contains("--tags"),
"diagnostic should name the offending flag: {stderr}",
);
}
#[test]
fn qry_009_unknown_format_value_uses_config_error_class() {
let tmp = fixture_tempdir("multi_project");
let (_, stderr, code) = haz_query(tmp.path(), &["--format", "yaml"]);
assert_eq!(
code,
Some(EXIT_INTERNAL_OR_CONFIG_ERROR),
"stderr: {stderr}",
);
assert!(
stderr.contains("unknown output format"),
"diagnostic should name the rejected value: {stderr}",
);
}