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 why <TASK>` against per-scenario
//! fixtures under `tests/fixtures/why/`. Each fixture is a
//! minimal workspace; any cache state and any "generated" input
//! files are materialised at runtime so the fixtures stay valid
//! as `CHAPTER_REVISION` and the cache layout evolve.
//!
//! The tests exercise the AUX-013..AUX-016 branches the CLI
//! must surface:
//!
//! - AUX-013 multi-match rejection (`^:build` against a
//!   workspace where two projects define `build`).
//! - AUX-014 / AUX-015 step 11 cache-key derivation failure
//!   when a hard predecessor has no cache entry.
//! - AUX-015 step 3 `(produced by <project>:<task>)`
//!   annotation when a static producer match exists.
//! - AUX-015 step 11 `miss: no entry` rendering on a fresh
//!   workspace.
//! - AUX-016 exit code `0` on a successful introspection
//!   regardless of the cache-lookup outcome.

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

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/why")
        .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 why <target>` from `cwd`. Returns the captured
/// `(stdout, stderr, exit_code)` tuple.
fn haz_why(cwd: &Path, target: &str) -> (String, String, Option<i32>) {
    let output = Command::new(HAZ_BIN)
        .args(["why", target])
        .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(),
    )
}

// --- AUX-016: exit zero on a successful introspection ---

#[test]
fn aux_016_exit_zero_on_isolated_task_cache_miss() {
    let tmp = fixture_tempdir("isolated_task");
    let cwd = tmp.path().join("lib");
    let (stdout, stderr, code) = haz_why(&cwd, "~:build");
    assert_eq!(code, Some(0), "expected exit 0; stderr: {stderr}");
    // The four mandatory AUX-015 lines.
    assert!(
        stdout.starts_with("lib:build\n"),
        "missing resolved identity line: {stdout}",
    );
    assert!(
        stdout.contains("command: [\"true\"]"),
        "missing command action line: {stdout}",
    );
    assert!(
        stdout.contains("cache_key: "),
        "missing cache_key line: {stdout}",
    );
    assert!(
        stdout.contains("cache_lookup: miss: no entry"),
        "missing cache_lookup line: {stdout}",
    );
    // No optional sections should appear for a bare task.
    for header in [
        "inputs:",
        "outputs:",
        "deps:",
        "weakDeps:",
        "mutex:",
        "env.from_host:",
        "env.override:",
    ] {
        assert!(
            !stdout.contains(header),
            "section `{header}` must be omitted for an isolated task: {stdout}",
        );
    }
}

// --- AUX-015 step 3: producer annotation when a static match exists ---

#[test]
fn aux_015_step3_annotates_producer_match() {
    let tmp = fixture_tempdir("with_producer_match");
    // `consumer`'s `inputs: [generated.txt]` requires the file
    // to exist on disk for `resolve_input_files` to enumerate
    // it. The producer-matching relation (DAG-013) is purely
    // pattern-level and does not depend on the producer having
    // run.
    let generated = tmp.path().join("lib/generated.txt");
    fs::write(&generated, b"placeholder bytes").unwrap();

    let cwd = tmp.path().join("lib");
    let (stdout, stderr, code) = haz_why(&cwd, "~:consumer");
    assert_eq!(code, Some(0), "expected exit 0; stderr: {stderr}");
    assert!(
        stdout.contains("inputs:\n  /lib/generated.txt (produced by lib:gen)"),
        "missing produced-by annotation: {stdout}",
    );
}

// --- AUX-013: multi-match reference is rejected ---

#[test]
fn aux_013_multi_match_reference_exits_nonzero() {
    let tmp = fixture_tempdir("multi_project");
    let cwd = tmp.path().join("lib_a");
    let (_, stderr, code) = haz_why(&cwd, "^:build");
    assert_ne!(
        code,
        Some(0),
        "multi-match reference must exit non-zero; stderr: {stderr}",
    );
    assert!(
        stderr.contains("resolves to") && stderr.contains("use a more specific form"),
        "missing multi-match diagnostic: {stderr}",
    );
}

// --- AUX-013: unresolved reference is rejected ---

#[test]
fn aux_013_unresolved_reference_exits_nonzero() {
    let tmp = fixture_tempdir("isolated_task");
    let cwd = tmp.path().join("lib");
    let (_, stderr, code) = haz_why(&cwd, "~:nonexistent");
    assert_ne!(
        code,
        Some(0),
        "unresolved reference must exit non-zero; stderr: {stderr}",
    );
    assert!(
        stderr.contains("could not resolve"),
        "missing resolution diagnostic: {stderr}",
    );
}

// --- CACHE-007 + AUX-014: predecessor not cached -> typed error ---

#[test]
fn aux_014_predecessor_not_cached_exits_nonzero_with_diagnostic() {
    let tmp = fixture_tempdir("with_hard_predecessor");
    let cwd = tmp.path().join("lib");
    let (_, stderr, code) = haz_why(&cwd, "~:build");
    assert_ne!(
        code,
        Some(0),
        "missing predecessor cache must exit non-zero; stderr: {stderr}",
    );
    // miette wraps lines to the terminal width, so we look for
    // markers that survive a soft wrap rather than the whole
    // sentence.
    assert!(
        stderr.contains("predecessor `lib:gen`") && stderr.contains("miss: no entry"),
        "missing predecessor-not-cached diagnostic: {stderr}",
    );
}