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 smoke tests against the static fixture under
//! `tests/fixtures/smoke/`.
//!
//! Covers both execution boundaries:
//!
//! - One process-spawn test invokes the actual `haz` binary via
//!   `env!("CARGO_BIN_EXE_haz")`, exercising argv parsing through
//!   bpaf, dispatch routing, and the EXEC-021 success exit-status
//!   mapping at the process level.
//! - Four in-process tests call [`haz_cli::commands`] directly,
//!   exercising the flag matrix end-to-end against on-disk YAML
//!   rather than the inline test strings the per-module unit
//!   tests use. The fixture is read-only checked-in data; each
//!   test copies it to a fresh tempdir.

use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use haz_cli::cli::{RunArgs, Scope, TaskRef};
use haz_cli::commands::{cache, run};
use haz_cli::context::Context;
use miette::Result;
use tempfile::TempDir;

/// Mimic the dispatch sequence for `haz run`: open a [`Context`]
/// against `cwd`, then execute.
fn run_run(args: &RunArgs, cwd: &Path) -> Result<i32> {
    run::execute(args, &Context::open(cwd)?)
}

/// Mimic the dispatch sequence for `haz cache clear`.
fn run_cache_clear(cwd: &Path) -> Result<i32> {
    cache::clear(&Context::open(cwd)?)
}

/// Path to the compiled `haz` binary, provided to integration
/// tests by Cargo at compile time.
const HAZ_BIN: &str = env!("CARGO_BIN_EXE_haz");

fn fixture_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/smoke")
}

/// Copy the static fixture into a fresh tempdir and return its
/// handle. The handle's `Drop` cleans the tempdir when the test
/// finishes, so any `.haz/cache/...` written during the run does
/// not leak into the source tree.
fn fixture_tempdir() -> TempDir {
    let src = fixture_root();
    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 bare_run_args() -> RunArgs {
    RunArgs {
        targets: Vec::new(),
        scope: Scope::Default,
        git_filter: None,
        dry_run: false,
        output_mode: None,
    }
}

fn task_ref(s: &str) -> TaskRef {
    TaskRef::parse(s).unwrap()
}

/// Walks $PATH looking for an executable file named `true`. The
/// cache-cycle test needs this because the fixture's `lib:build`
/// task uses an unqualified `["true"]` command relying on PATH
/// resolution.
fn true_on_path() -> bool {
    let path = std::env::var_os("PATH").unwrap_or_default();
    std::env::split_paths(&path).any(|dir| dir.join("true").exists())
}

/// Return the path to the first `manifest.json` found under
/// `<root>/.haz/cache/<shard>/<key>/`. Returns `None` when the
/// cache root or any of its shards is missing or empty.
fn first_manifest(root: &Path) -> Option<PathBuf> {
    let cache_root = root.join(".haz/cache");
    if !cache_root.is_dir() {
        return None;
    }
    let shards = fs::read_dir(&cache_root).ok()?;
    for shard in shards.flatten() {
        if !shard.file_type().ok()?.is_dir() {
            continue;
        }
        let entries = fs::read_dir(shard.path()).ok()?;
        for entry in entries.flatten() {
            if !entry.file_type().ok()?.is_dir() {
                continue;
            }
            let mf = entry.path().join("manifest.json");
            if mf.is_file() {
                return Some(mf);
            }
        }
    }
    None
}

// --- Process-spawn boundary ---

#[test]
fn process_spawn_haz_run_dry_run_inside_lib() {
    // Smokes the binary itself: argv parsing through bpaf,
    // dispatch routing, stdout capture, and the EXEC-021
    // success exit-status mapping. The dry-run path keeps the
    // test free of any subprocess work beyond `haz` itself.
    let tmp = fixture_tempdir();
    let lib_dir = tmp.path().join("lib");
    let output = Command::new(HAZ_BIN)
        .args(["run", "--dry-run"])
        .current_dir(&lib_dir)
        .output()
        .expect("failed to spawn haz binary");
    assert!(
        output.status.success(),
        "haz run --dry-run exited with {:?}\nstdout: {}\nstderr: {}",
        output.status,
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("would run") && stdout.contains("lib:default"),
        "stdout did not contain the dry-run plan: {stdout}",
    );
}

// --- In-process: dry-run flag matrix ---

#[test]
fn exec_023_default_target_dry_run_from_lib() {
    // EXEC-023: cwd inside `lib`, no flags, no positionals
    // resolves to `lib:default`. The hard-predecessor closure is
    // empty, so the plan lists exactly the one task.
    let tmp = fixture_tempdir();
    let args = RunArgs {
        dry_run: true,
        ..bare_run_args()
    };
    let code = run_run(&args, &tmp.path().join("lib")).unwrap();
    assert_eq!(code, 0);
}

#[test]
fn in_process_workspace_scope_dry_run_from_root() {
    // EXEC-025: `--workspace` selects every task across both
    // projects regardless of cwd. The fixture carries four
    // tasks (lib:default, lib:build, app:default, app:test);
    // this test only checks the wiring (exit 0); per-task
    // enumeration is covered by the resolver's unit tests.
    let tmp = fixture_tempdir();
    let args = RunArgs {
        scope: Scope::Workspace,
        dry_run: true,
        ..bare_run_args()
    };
    let code = run_run(&args, tmp.path()).unwrap();
    assert_eq!(code, 0);
}

#[test]
fn in_process_positional_pulls_transitive_hard_predecessor() {
    // EXEC-001 / EXEC-002 closure exercised end-to-end against
    // on-disk YAML: selecting `app:test` MUST pull `lib:build`
    // in transitively. The dry-run path keeps the test pure
    // (no subprocess spawn) and the success of `execute` proves
    // the closure ran without rejecting the cross-project
    // reference. Per-edge correctness is covered by the
    // resolver's unit tests.
    let tmp = fixture_tempdir();
    let args = RunArgs {
        targets: vec![task_ref("app:test")],
        dry_run: true,
        ..bare_run_args()
    };
    let code = run_run(&args, &tmp.path().join("app")).unwrap();
    assert_eq!(code, 0);
}

// --- In-process: cache cold -> warm cycle ---

#[test]
fn in_process_cache_cold_to_warm_to_clear_cycle() {
    // Verifies CACHE-017 store, CACHE-014 / CACHE-015 hit, and
    // CACHE-021 unconditional clear. The cache-hit step is
    // distinguished from a miss+restore by reading the manifest
    // bytes before and after the warm run: a hit leaves the
    // manifest untouched (the existing entry directory is not
    // rewritten); a miss+store would rewrite the manifest via
    // CACHE-017's rename-into-place, changing its bytes
    // (creation timestamp moves at minimum).
    if !true_on_path() {
        eprintln!("skipping: `true` not on $PATH");
        return;
    }
    let tmp = fixture_tempdir();
    let lib_dir = tmp.path().join("lib");
    let args = RunArgs {
        targets: vec![task_ref("~:build")],
        ..bare_run_args()
    };

    // Cold: cache empty, task runs fresh, store creates a
    // manifest at .haz/cache/<shard>/<key>/manifest.json.
    let code = run_run(&args, &lib_dir).unwrap();
    assert_eq!(code, 0);
    let manifest_path = first_manifest(tmp.path())
        .expect("manifest missing under .haz/cache/ after fresh successful run");
    let cold_bytes = fs::read(&manifest_path).unwrap();

    // Warm: same key, hit; the entry directory MUST NOT be
    // rewritten.
    let code = run_run(&args, &lib_dir).unwrap();
    assert_eq!(code, 0);
    let warm_bytes = fs::read(&manifest_path).unwrap();
    assert_eq!(
        cold_bytes, warm_bytes,
        "manifest bytes changed across cold->warm; the second run did not hit the cache",
    );

    // Clear: CACHE-021 removes everything under .haz/cache/.
    let code = run_cache_clear(tmp.path()).unwrap();
    assert_eq!(code, 0);
    assert!(
        !tmp.path().join(".haz/cache").exists(),
        ".haz/cache was not removed by cache clear",
    );

    // Post-clear: subsequent run misses (cache is empty) and
    // re-stores, recreating the manifest.
    let code = run_run(&args, &lib_dir).unwrap();
    assert_eq!(code, 0);
    assert!(
        first_manifest(tmp.path()).is_some(),
        "manifest missing under .haz/cache/ after post-clear run",
    );
}