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 init`.
//!
//! Two angles:
//!
//! - The `--yes` arm runs through the binary's subprocess
//!   boundary against fresh tempdirs (exit code, on-disk state
//!   under `.haz/`, AUX-002 refusal path).
//! - The AUX-005 commit pass runs in-process against checked-in
//!   fixtures, driving the model + commit modules directly.
//!   The interactive TUI loop itself is not in-process here; the
//!   dispatcher's inline unit tests under `commands/init/tui.rs`
//!   cover the keymap, and the model + commit modules are what
//!   the TUI dispatches to.

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

use haz_cli::EXIT_INTERNAL_OR_CONFIG_ERROR;
use haz_cli::commands::init::commit::{CommitPlanError, apply_commit, plan_commit};
use haz_cli::commands::init::model::{Node, SelectionState, Tree};
use tempfile::TempDir;

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

fn run_haz_init(cwd: &Path, extra: &[&str]) -> (String, String, Option<i32>) {
    let mut cmd = Command::new(HAZ_BIN);
    cmd.arg("init").args(extra).current_dir(cwd);
    let output = cmd.output().expect("failed to spawn haz binary");
    (
        String::from_utf8_lossy(&output.stdout).into_owned(),
        String::from_utf8_lossy(&output.stderr).into_owned(),
        output.status.code(),
    )
}

/// Copy a checked-in fixture from `tests/fixtures/init/<name>/`
/// into a fresh tempdir and return its handle. The tempdir is
/// cleaned automatically when the handle drops, so any
/// scaffolding writes the tests perform do not leak.
fn fixture_tempdir(name: &str) -> TempDir {
    let src = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures/init")
        .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();
        }
    }
}

/// Build a tree shaped like the TUI's end-of-selection output:
/// a root expanded over a list of children, each child marked
/// with the supplied [`SelectionState`].
fn tree_with_selected_children(children: &[(&str, SelectionState)]) -> Tree {
    let mut root = Node::new(OsString::from("/"), PathBuf::from("/"));
    for (name, state) in children {
        let mut child = Node::new(OsString::from(*name), PathBuf::from(format!("/{name}")));
        child.state = *state;
        root.children.push(child);
    }
    root.children_loaded = true;
    root.expanded = true;
    Tree::new(root)
}

// --- AUX-007: --yes writes the minimal workspace ---

#[test]
fn aux_007_yes_writes_minimal_workspace_and_exits_zero() {
    let tmp = TempDir::new().unwrap();
    let (_, stderr, code) = run_haz_init(tmp.path(), &["--yes"]);
    assert_eq!(code, Some(0), "stderr: {stderr}");
    let body = fs::read_to_string(tmp.path().join(".haz/workspace.yml")).unwrap();
    assert_eq!(body, "projects: []\n");
}

#[test]
fn aux_007_yes_creates_only_workspace_yml_inside_haz_dir() {
    let tmp = TempDir::new().unwrap();
    let (_, stderr, code) = run_haz_init(tmp.path(), &["--yes"]);
    assert_eq!(code, Some(0), "stderr: {stderr}");
    let entries: Vec<_> = fs::read_dir(tmp.path().join(".haz"))
        .unwrap()
        .map(|e| e.unwrap().file_name())
        .collect();
    assert_eq!(entries, vec![std::ffi::OsString::from("workspace.yml")]);
}

#[test]
fn aux_007_yes_leaves_no_other_filesystem_changes_in_cwd() {
    // The cwd contains pre-existing files that haz init MUST NOT
    // touch. Verifies AUX-006's "rename-into-place under
    // .haz/" boundary at the filesystem level.
    let tmp = TempDir::new().unwrap();
    fs::write(tmp.path().join("sentinel"), b"untouched").unwrap();
    let (_, _, code) = run_haz_init(tmp.path(), &["--yes"]);
    assert_eq!(code, Some(0));
    let body = fs::read(tmp.path().join("sentinel")).unwrap();
    assert_eq!(body, b"untouched");
}

// --- AUX-002: refusal on an existing workspace ---

#[test]
fn aux_002_yes_refuses_to_overlay_existing_haz_dir() {
    let tmp = TempDir::new().unwrap();
    fs::create_dir(tmp.path().join(".haz")).unwrap();
    let (_, stderr, code) = run_haz_init(tmp.path(), &["--yes"]);
    assert_eq!(code, Some(EXIT_INTERNAL_OR_CONFIG_ERROR));
    assert!(
        stderr.contains("existing workspace") || stderr.contains(".haz"),
        "diagnostic should name the conflict: {stderr}",
    );
}

#[test]
fn aux_002_yes_does_not_mutate_existing_haz_dir() {
    let tmp = TempDir::new().unwrap();
    let haz = tmp.path().join(".haz");
    fs::create_dir(&haz).unwrap();
    fs::write(haz.join("sentinel"), b"do not overwrite").unwrap();
    let (_, _, code) = run_haz_init(tmp.path(), &["--yes"]);
    assert_eq!(code, Some(EXIT_INTERNAL_OR_CONFIG_ERROR));
    let body = fs::read(haz.join("sentinel")).unwrap();
    assert_eq!(body, b"do not overwrite");
    // workspace.yml MUST NOT exist: the refusal precedes any
    // filesystem write per AUX-002.
    assert!(!haz.join("workspace.yml").exists());
}

// --- AUX-001: form rejects extra positional arguments ---

#[test]
fn aux_001_rejects_extra_positional_argument() {
    // AUX-001 + AUX-008: rejection of an extra positional MUST
    // land in the EXEC-021 internal / configuration-error class
    // (never task-failure).
    let tmp = TempDir::new().unwrap();
    let (_, _, code) = run_haz_init(tmp.path(), &["--yes", "extra"]);
    assert_eq!(
        code,
        Some(EXIT_INTERNAL_OR_CONFIG_ERROR),
        "AUX-008: `haz init --yes <extra>` MUST exit with the config-error class",
    );
    // AUX-001 says rejection must precede filesystem work.
    assert!(!tmp.path().join(".haz").exists());
}

// --- AUX-005: commit pass against checked-in fixtures ---

#[test]
fn aux_005_scaffolds_haz_yml_for_each_selected_project_root() {
    // Fixture has /lib and /web as empty subdirectories. Mark
    // both Project, run the commit pass, and verify the
    // scaffolded haz.yml + canonical workspace.yml land
    // verbatim.
    let tmp = fixture_tempdir("cwd_with_subdirs");
    let tree = tree_with_selected_children(&[
        ("lib", SelectionState::Project),
        ("web", SelectionState::Project),
    ]);

    let plan = plan_commit(&tree, tmp.path()).expect("plan_commit should succeed");
    apply_commit(&plan, tmp.path()).expect("apply_commit should succeed");

    // .haz/workspace.yml: canonical byte-ascending order.
    let workspace_yml = fs::read_to_string(tmp.path().join(".haz/workspace.yml")).unwrap();
    assert_eq!(workspace_yml, "projects:\n  - /lib\n  - /web\n");

    // /lib/haz.yml: scaffolded with sanitised basename.
    let lib_haz = fs::read_to_string(tmp.path().join("lib/haz.yml")).unwrap();
    assert_eq!(lib_haz, "name: lib\ntasks: {}\n");

    // /web/haz.yml: same shape.
    let web_haz = fs::read_to_string(tmp.path().join("web/haz.yml")).unwrap();
    assert_eq!(web_haz, "name: web\ntasks: {}\n");
}

#[test]
fn aux_005_init_surfaces_invalid_basename_with_config_error_class() {
    // Fixture has a subdirectory whose basename `___` sanitises
    // to the empty string under AUX-005 step 2 (no replacements
    // needed since `_` is in the alphabet, then leading
    // non-alphanumerics are trimmed, leaving "").
    let tmp = fixture_tempdir("cwd_with_invalid_basename");
    let tree = tree_with_selected_children(&[("___", SelectionState::Project)]);

    let err = plan_commit(&tree, tmp.path())
        .expect_err("plan_commit must reject a basename that sanitises to an invalid ProjectName");
    assert!(
        matches!(err, CommitPlanError::SanitisationFailed { .. }),
        "expected SanitisationFailed, got {err:?}",
    );

    // plan_commit is pure planning: nothing is written. The
    // fixture's pre-existing `___/` directory MUST remain, and
    // no `.haz/` or `haz.yml` MUST appear.
    assert!(tmp.path().join("___").exists());
    assert!(!tmp.path().join(".haz").exists());
    assert!(!tmp.path().join("___/haz.yml").exists());
}

// --- AUX-008: init exit-code class mapping ---

#[test]
fn aux_008_success_exits_zero_and_config_error_uses_class_2() {
    // AUX-008: `haz init --yes` MUST exit 0 on a fully successful
    // scaffold and MUST exit with the EXEC-021 internal /
    // configuration-error class (mapped to
    // `EXIT_INTERNAL_OR_CONFIG_ERROR` in this binary) on a
    // conflict (`AUX-002`). The task-failure class (`1`) MUST NOT
    // be returned in either case.
    const EXIT_TASK_FAILURE: i32 = 1;

    // Success path.
    let tmp_ok = TempDir::new().unwrap();
    let (_, stderr, code) = run_haz_init(tmp_ok.path(), &["--yes"]);
    assert_eq!(code, Some(0), "stderr: {stderr}");
    assert_ne!(
        code,
        Some(EXIT_TASK_FAILURE),
        "AUX-008: task-failure class MUST NOT be returned on success",
    );

    // Conflict path: pre-existing `.haz/` triggers AUX-002.
    let tmp_conflict = TempDir::new().unwrap();
    fs::create_dir(tmp_conflict.path().join(".haz")).unwrap();
    let (_, stderr, code) = run_haz_init(tmp_conflict.path(), &["--yes"]);
    assert_eq!(
        code,
        Some(EXIT_INTERNAL_OR_CONFIG_ERROR),
        "AUX-008: conflict MUST use the internal/config-error class; stderr: {stderr}",
    );
    assert_ne!(
        code,
        Some(EXIT_TASK_FAILURE),
        "AUX-008: task-failure class MUST NOT be returned on conflict",
    );
}