haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! End-to-end tests for `haz query` against the
//! `tests/fixtures/query/multi_project` workspace.
//!
//! Each test spawns the `haz` binary in a tempdir copy of the
//! fixture, captures stdout / stderr / exit code, and asserts the
//! observable behaviour the QRY-001..QRY-009 rules name:
//!
//! - QRY-001 / QRY-007: bare query enumerates the candidate set
//!   in canonical `(ProjectName, TaskName)` order.
//! - QRY-002: malformed boolean expressions are rejected.
//! - QRY-003: per-attribute filters (tags, projects, tasks,
//!   inputs, outputs) restrict the selection.
//! - QRY-004: relational filters (child-of, depends-on) consult
//!   the hard-edge graph.
//! - QRY-005: boolean shortcuts (has-deps, no-deps, mutex) and
//!   the intra-pair mutex.
//! - QRY-006: cross-family intersection.
//! - QRY-008: plain output is one `<project>:<task>` line per
//!   match; JSON output is an array of objects with at minimum
//!   `project`, `task`, `tags` fields.
//! - QRY-009: exit `0` regardless of how many tasks match
//!   (including zero); workspace-load / filter-validation
//!   failures exit non-zero.
//! - EXEC-022: cwd-derived bearing project restricts the
//!   candidate set per the `QRY` vocabulary.

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();
        }
    }
}

/// Spawn `haz query <args>...` in `cwd`. Returns the captured
/// `(stdout, stderr, exit_code)` tuple.
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(),
    )
}

// --- QRY-001 / QRY-007: candidate-set enumeration -----------

#[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}");
    // BTreeMap order: lib < tools < web. Within each project,
    // task names sort lexicographically.
    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");
}

// --- QRY-003: per-attribute filters -------------------------

#[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");
}

// --- QRY-004: relational filters ----------------------------

#[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}");
    // lib:build -> lib:test, lib:build -> web:bundle.
    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}");
    // lib:build's downstream: lib:test (direct), web:bundle
    // (direct). tools:lint has no dependency. lib:build itself
    // is in target_set, excluded per QRY-004.
    assert_eq!(stdout, "lib:test\nweb:bundle\n");
}

// --- QRY-005: boolean shortcuts -----------------------------

#[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() {
    // QRY-009: a mutually-exclusive flag pair MUST surface as the
    // EXEC-021 internal / configuration-error class.
    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}",
    );
}

// --- QRY-006: cross-family composition ----------------------

#[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}");
    // tag:backend => lib:build, lib:test, tools:lint.
    // has-deps  => lib:test, web:bundle.
    // Intersection => lib:test.
    assert_eq!(stdout, "lib:test\n");
}

// --- QRY-008: JSON output -----------------------------------

#[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());
}

// --- QRY-009: exit-code contract ----------------------------

#[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() {
    // QRY-009: a malformed filter expression MUST surface as the
    // EXEC-021 internal / configuration-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() {
    // QRY-009: a CLI value that fails parser-side validation
    // (here, `--format yaml`) MUST surface as the EXEC-021
    // internal / configuration-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}",
    );
}