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 check` against per-scenario fixtures
//! under `tests/fixtures/check/`. Each fixture is a checked-in
//! workspace topology; each test copies its fixture into a fresh
//! tempdir before invoking the binary, so any incidental state
//! does not leak into the source tree.
//!
//! Test names follow the `<rule>_<description>` convention from
//! CLAUDE.md so the rules a regression touches are visible from
//! the test name alone.

use std::fs;
use std::path::Path;
use std::process::Command;

use haz_cli::EXIT_INTERNAL_OR_CONFIG_ERROR;
use tempfile::TempDir;

/// Path to the compiled `haz` binary, provided by Cargo at compile
/// time. Subprocess invocation is required because every test in
/// this file observes either the binary's exit code or its stdout
/// / stderr.
const HAZ_BIN: &str = env!("CARGO_BIN_EXE_haz");

/// Copy a static fixture directory into a fresh tempdir. The
/// caller passes the leaf name under `tests/fixtures/check/`; the
/// returned `TempDir` drops its contents at test end.
fn fixture_tempdir(name: &str) -> TempDir {
    let src = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures/check")
        .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();
        }
    }
}

/// Run `haz check [args...]` with the given cwd. Returns the
/// captured stdout, stderr, and exit code.
fn haz_check(cwd: &Path, args: &[&str]) -> (String, String, Option<i32>) {
    let output = Command::new(HAZ_BIN)
        .arg("check")
        .args(args)
        .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(),
    )
}

// --- clean_workspace: AUX-010 happy path + AUX-011 count line ---

#[test]
fn aux_010_check_clean_workspace_emits_count_line_and_exits_zero() {
    // AUX-010 happy path through the full DISC / CFG / REF / DAG
    // pipeline against a 2-project workspace whose `lib_b:build`
    // hard-depends on `lib_a:build`. AUX-011 prescribes the
    // success line; with cwd at the workspace root no bearing
    // project applies, so the candidate set is the entire
    // workspace (2 tasks).
    let tmp = fixture_tempdir("clean_workspace");
    let (stdout, stderr, code) = haz_check(tmp.path(), &[]);
    assert_eq!(code, Some(0), "expected exit 0; stderr: {stderr}");
    assert_eq!(stdout, "2 task(s) validated.\n");
}

// --- multi_error: AUX-010 multi-rule + AUX-012 exit class ---

#[test]
fn aux_010_check_surfaces_every_dag_error_in_one_run() {
    // BuildErrors accumulates every DAG-* error in one pass per
    // DAG-011's "MUST NOT abort at the first" discipline. The
    // multi-error fixture contains a cycle (DAG-014), an
    // unresolved reference (DAG-011), and a duplicate literal
    // output (DAG-015); all three rule markers MUST surface in a
    // single stderr render.
    let tmp = fixture_tempdir("multi_error");
    let (stdout, stderr, _code) = haz_check(tmp.path(), &["--workspace"]);
    assert!(
        stdout.is_empty(),
        "validation failures should not print the success line: {stdout}",
    );
    assert!(
        stderr.contains("DAG-014"),
        "diagnostic should surface the cycle rule: {stderr}",
    );
    assert!(
        stderr.contains("DAG-011") || stderr.contains("unresolved"),
        "diagnostic should surface the unresolved reference: {stderr}",
    );
    assert!(
        stderr.contains("DAG-015"),
        "diagnostic should surface the duplicate literal output rule: {stderr}",
    );
}

#[test]
fn aux_012_check_returns_config_error_class_on_validation_failure() {
    // AUX-012 + EXEC-021 fourth class: validation failures must
    // exit with EXIT_INTERNAL_OR_CONFIG_ERROR (=2), distinct from
    // the task-failure class (=1) and the signal classes.
    let tmp = fixture_tempdir("multi_error");
    let (_stdout, _stderr, code) = haz_check(tmp.path(), &["--workspace"]);
    assert_eq!(
        code,
        Some(EXIT_INTERNAL_OR_CONFIG_ERROR),
        "expected EXIT_INTERNAL_OR_CONFIG_ERROR (=2)",
    );
}

// --- scope_flags: AUX-009 scope-flag candidate-set determination ---

#[test]
fn aux_009_check_default_inside_project_counts_bearing_only() {
    // AUX-009: with cwd inside lib_a and no scope flag, the
    // candidate set is the bearing project's tasks (1).
    let tmp = fixture_tempdir("scope_flags");
    let (stdout, stderr, code) = haz_check(&tmp.path().join("lib_a"), &[]);
    assert_eq!(code, Some(0), "expected exit 0; stderr: {stderr}");
    assert_eq!(stdout, "1 task(s) validated.\n");
}

#[test]
fn aux_009_check_all_inside_project_counts_bearing_only() {
    // AUX-009 mirroring EXEC-024: `--all` with a bearing project
    // selects every task in that project (1).
    let tmp = fixture_tempdir("scope_flags");
    let (stdout, stderr, code) = haz_check(&tmp.path().join("lib_a"), &["--all"]);
    assert_eq!(code, Some(0), "expected exit 0; stderr: {stderr}");
    assert_eq!(stdout, "1 task(s) validated.\n");
}

#[test]
fn aux_009_check_workspace_counts_every_project() {
    // AUX-009 mirroring EXEC-025: `--workspace` counts every task
    // in every project regardless of cwd. lib_a has 1 task and
    // lib_b has 2; the combined count is 3.
    let tmp = fixture_tempdir("scope_flags");
    let (stdout, stderr, code) = haz_check(tmp.path(), &["--workspace"]);
    assert_eq!(code, Some(0), "expected exit 0; stderr: {stderr}");
    assert_eq!(stdout, "3 task(s) validated.\n");
}

// --- Parse-error rendering through `haz check`. `haz check` and
//     `haz run` share the same workspace-load path, so a malformed
//     project / workspace / overlay YAML surfaces an identical
//     rich diagnostic from either command. These tests pin the
//     parity (per the original plan's "haz check parity: yes"
//     answer): the same CFG code, key path, snippet, and help
//     text reach stderr, and the CFG code appears exactly once.

#[test]
fn cfg_021_command_scalar_check_renders_rich_diagnostic() {
    let tmp = fixture_tempdir("workspace_load_errors/parse_errors/cfg_021_command_scalar");
    let (stdout, stderr, code) = haz_check(tmp.path(), &[]);
    assert_eq!(
        code,
        Some(EXIT_INTERNAL_OR_CONFIG_ERROR),
        "expected internal/config-error class; stderr: {stderr}",
    );
    assert!(
        stdout.is_empty(),
        "validation failures should not print the success line: {stdout}",
    );
    assert!(
        stderr.contains("haz::cfg::021"),
        "stderr must surface the CFG-021 code; got: {stderr}",
    );
    assert!(
        stderr.contains("tasks.build.command"),
        "stderr must surface the resolved YAML key path; got: {stderr}",
    );
    assert!(
        stderr.contains("echo hi"),
        "stderr must include the offending source snippet; got: {stderr}",
    );
    assert!(
        stderr.contains("command:") && stderr.contains("shell:"),
        "stderr must surface the argv-vs-shell help text; got: {stderr}",
    );
    assert_eq!(
        stderr.matches("haz::cfg::021").count(),
        1,
        "CFG code must appear exactly once; got: {stderr}",
    );
}

#[test]
fn cfg_009_projects_scalar_check_renders_rich_diagnostic() {
    let tmp = fixture_tempdir("workspace_load_errors/parse_errors/cfg_009_projects_scalar");
    let (stdout, stderr, code) = haz_check(tmp.path(), &[]);
    assert_eq!(
        code,
        Some(EXIT_INTERNAL_OR_CONFIG_ERROR),
        "expected internal/config-error class; stderr: {stderr}",
    );
    assert!(
        stdout.is_empty(),
        "validation failures should not print the success line: {stdout}",
    );
    assert!(
        stderr.contains("haz::cfg::009"),
        "stderr must surface the CFG-009 code; got: {stderr}",
    );
    // The closing `` `projects`) `` is unique to the
    // where-clause (`(at `projects`)`); the help text uses
    // `` `projects` `` only as a noun, never followed by a
    // closing paren. miette may wrap the line, so we don't
    // require the opening `(at` here.
    assert!(
        stderr.contains("`projects`)"),
        "stderr must surface the resolved YAML key path; got: {stderr}",
    );
    assert!(
        stderr.contains("lib_core"),
        "stderr must include the offending source snippet; got: {stderr}",
    );
    assert!(
        stderr.contains("projects: []"),
        "stderr must surface the empty-sequence example from the help; got: {stderr}",
    );
    assert_eq!(
        stderr.matches("haz::cfg::009").count(),
        1,
        "CFG code must appear exactly once; got: {stderr}",
    );
}

#[test]
fn cfg_025_overlay_missing_tasks_check_renders_rich_diagnostic() {
    let tmp = fixture_tempdir("workspace_load_errors/parse_errors/cfg_025_overlay_missing_tasks");
    let (stdout, stderr, code) = haz_check(tmp.path(), &[]);
    assert_eq!(
        code,
        Some(EXIT_INTERNAL_OR_CONFIG_ERROR),
        "expected internal/config-error class; stderr: {stderr}",
    );
    assert!(
        stdout.is_empty(),
        "validation failures should not print the success line: {stdout}",
    );
    assert!(
        stderr.contains("haz::cfg::025"),
        "stderr must surface the CFG-025 code; got: {stderr}",
    );
    assert!(
        stderr.contains("tasks"),
        "stderr must mention the missing `tasks` key; got: {stderr}",
    );
    assert!(
        stderr.contains(".haz/tasks/lint.yml"),
        "stderr must include the offending overlay file path; got: {stderr}",
    );
    assert!(
        stderr.contains("must contain a top-level `tasks` key")
            || stderr.contains("MUST contain a top-level `tasks` key"),
        "stderr must surface the overlay help text; got: {stderr}",
    );
    assert_eq!(
        stderr.matches("haz::cfg::025").count(),
        1,
        "CFG code must appear exactly once; got: {stderr}",
    );
}