use std::fs;
use std::path::Path;
use std::process::Command;
use haz_cli::EXIT_INTERNAL_OR_CONFIG_ERROR;
use haz_cli::cli::{RunArgs, Scope, TaskRef};
use haz_cli::commands::run;
use haz_cli::context::Context;
use miette::Result;
use tempfile::TempDir;
fn run_run(args: &RunArgs, cwd: &Path) -> Result<i32> {
run::execute(args, &Context::open(cwd)?)
}
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/run")
.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();
}
}
}
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()
}
fn true_on_path() -> bool {
binary_on_path("true")
}
fn binary_on_path(name: &str) -> bool {
let path = std::env::var_os("PATH").unwrap_or_default();
std::env::split_paths(&path).any(|dir| dir.join(name).exists())
}
fn write_git_marker(root: &Path) {
let contents = "gitdir: /nonexistent\n";
fs::write(root.join(".git"), contents).expect("write .git marker");
}
#[test]
fn disc_003_implicit_mode_dry_run_emits_singleton_plan() {
let tmp = fixture_tempdir("implicit_mode");
write_git_marker(tmp.path());
let args = RunArgs {
dry_run: true,
..bare_run_args()
};
let code = run_run(&args, tmp.path()).unwrap();
assert_eq!(code, 0);
assert!(
!tmp.path().join(".haz").exists(),
".haz/ was created by a --dry-run run; EXEC-030 forbids side effects",
);
}
#[test]
fn disc_003_implicit_mode_default_target_runs() {
if !true_on_path() {
eprintln!("skipping: `true` not on $PATH");
return;
}
let tmp = fixture_tempdir("implicit_mode");
write_git_marker(tmp.path());
let args = bare_run_args();
let code = run_run(&args, tmp.path()).unwrap();
assert_eq!(code, 0);
assert!(
tmp.path().join(".haz/cache").is_dir(),
".haz/cache/ missing after a successful run of the default target",
);
}
#[test]
fn dag_001_overlay_task_attaches_to_matching_project() {
let tmp = fixture_tempdir("tag_overlay/matching_and_unmatching");
let args = RunArgs {
targets: vec![task_ref("lib:lint")],
dry_run: true,
..bare_run_args()
};
let code = run_run(&args, tmp.path()).unwrap();
assert_eq!(code, 0);
}
#[test]
fn dag_001_overlay_task_not_attached_to_unmatching_project() {
let tmp = fixture_tempdir("tag_overlay/matching_and_unmatching");
let args = RunArgs {
targets: vec![task_ref("web:lint")],
dry_run: true,
..bare_run_args()
};
let err = run_run(&args, tmp.path()).unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("target resolution failed") || msg.to_lowercase().contains("unresolved"),
"expected unresolved-target diagnostic, got: {msg}",
);
}
#[test]
fn dag_005_project_level_source_wins_over_overlay() {
if !true_on_path() {
eprintln!("skipping: `true` not on $PATH");
return;
}
let tmp = fixture_tempdir("tag_overlay/project_level_wins");
let args = RunArgs {
targets: vec![task_ref("lib:lint")],
..bare_run_args()
};
let code = run_run(&args, tmp.path()).unwrap();
assert_eq!(
code, 0,
"lib:lint exited non-zero; the overlay's [\"false\"] body must have won, \
violating DAG-005's project-level-wins rule",
);
}
#[test]
fn dag_006_two_overlays_with_incomparable_filters_reject_load() {
let tmp = fixture_tempdir("tag_overlay/incomparable_filters_reject");
let args = bare_run_args();
let err = run_run(&args, tmp.path()).unwrap_err();
let msg = format!("{err:?}").to_lowercase();
assert!(
msg.contains("workspace") || msg.contains("overlay") || msg.contains("collision"),
"expected a workspace-load diagnostic mentioning the overlay collision, got: {msg}",
);
}
fn run_failure_cascade_all() -> (i32, TempDir) {
let tmp = fixture_tempdir("failure_cascade");
let args = RunArgs {
scope: Scope::All,
..bare_run_args()
};
let code = run_run(&args, &tmp.path().join("lib")).unwrap();
(code, tmp)
}
#[test]
fn exec_011_failed_predecessor_skips_descendant() {
if !binary_on_path("touch") || !binary_on_path("false") {
eprintln!("skipping: `touch` or `false` not on $PATH");
return;
}
let (_code, tmp) = run_failure_cascade_all();
assert!(
!tmp.path().join("lib/marker_tip").exists(),
"marker_tip exists; `tip` was not skipped after `mid`'s failure (EXEC-011)",
);
}
#[test]
fn exec_010_failed_branch_does_not_skip_sibling() {
if !binary_on_path("touch") || !binary_on_path("false") {
eprintln!("skipping: `touch` or `false` not on $PATH");
return;
}
let (_code, tmp) = run_failure_cascade_all();
assert!(
tmp.path().join("lib/marker_independent").exists(),
"marker_independent missing; `independent` was not run despite \
being on an unrelated branch from `mid`'s failure (EXEC-010)",
);
assert!(
tmp.path().join("lib/marker_seed").exists(),
"marker_seed missing; `seed` did not run before `mid`",
);
}
#[test]
fn exec_021_task_failure_exit_code_distinct_from_success() {
if !binary_on_path("touch") || !binary_on_path("false") {
eprintln!("skipping: `touch` or `false` not on $PATH");
return;
}
let (code, _tmp) = run_failure_cascade_all();
assert_ne!(code, 0, "expected non-zero exit on task failure");
assert_ne!(
code, EXIT_INTERNAL_OR_CONFIG_ERROR,
"task-failure exit code MUST distinguish itself from the \
internal/config-error class per EXEC-021",
);
}
#[test]
fn exec_008_soft_edge_predecessor_not_pulled_by_consumer_alone() {
if !binary_on_path("sh") || !binary_on_path("printf") {
eprintln!("skipping: `sh` or `printf` not on $PATH");
return;
}
let tmp = fixture_tempdir("soft_edges/trigger_via_cache_invalidation");
let args = RunArgs {
targets: vec![task_ref("lib:consumer")],
..bare_run_args()
};
let code = run_run(&args, &tmp.path().join("lib")).unwrap();
assert_eq!(code, 0);
let shared_after = fs::read_to_string(tmp.path().join("lib/shared.txt")).unwrap();
assert_eq!(
shared_after, "initial\n",
"shared.txt was rewritten; `producer` ran despite being soft-only \
(EXEC-008: soft predecessor MUST NOT be pulled by consumer's readiness)",
);
}
#[test]
fn exec_008_soft_edge_triggers_via_cache_invalidation_across_runs() {
if !binary_on_path("sh") || !binary_on_path("printf") {
eprintln!("skipping: `sh` or `printf` not on $PATH");
return;
}
let tmp = fixture_tempdir("soft_edges/trigger_via_cache_invalidation");
let lib_dir = tmp.path().join("lib");
let consumer = RunArgs {
targets: vec![task_ref("lib:consumer")],
..bare_run_args()
};
let producer = RunArgs {
targets: vec![task_ref("lib:producer")],
..bare_run_args()
};
assert_eq!(run_run(&consumer, &lib_dir).unwrap(), 0);
assert_eq!(run_run(&producer, &lib_dir).unwrap(), 0);
assert_eq!(run_run(&consumer, &lib_dir).unwrap(), 0);
let marker = fs::read(lib_dir.join("marker_consumer")).expect("marker_consumer missing");
assert_eq!(
marker.len(),
2,
"expected 2 fresh consumer runs (one per inputs-hash); \
got {} byte(s) -- the second invocation likely hit cache, \
violating CACHE-006 / EXEC-008's invalidation mechanism",
marker.len(),
);
}
#[test]
fn exec_030_dry_run_plan_excludes_soft_predecessor() {
let tmp = fixture_tempdir("soft_edges/trigger_via_cache_invalidation");
let output = Command::new(HAZ_BIN)
.args(["run", "--dry-run", "lib:consumer"])
.current_dir(tmp.path().join("lib"))
.output()
.expect("failed to spawn haz binary");
assert!(
output.status.success(),
"haz run --dry-run exited non-zero: {:?}; stderr: {}",
output.status,
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("lib:consumer"),
"dry-run plan missing lib:consumer: {stdout}",
);
assert!(
!stdout.contains("lib:producer"),
"dry-run plan unexpectedly includes lib:producer; a soft-edge \
predecessor was pulled (EXEC-008 / EXEC-030 violation): {stdout}",
);
}
#[test]
fn exec_011_soft_edge_failure_does_not_cascade() {
if !binary_on_path("touch") || !binary_on_path("false") {
eprintln!("skipping: `touch` or `false` not on $PATH");
return;
}
let tmp = fixture_tempdir("soft_edges/non_cascade_on_failure");
let args = RunArgs {
scope: Scope::All,
..bare_run_args()
};
let code = run_run(&args, &tmp.path().join("lib")).unwrap();
assert_ne!(code, 0, "producer failed; expected non-zero exit code");
assert!(
tmp.path().join("lib/marker_consumer").exists(),
"marker_consumer missing; consumer was skipped despite the \
producer-consumer edge being soft (EXEC-011 violation)",
);
}
#[test]
fn dag_010_same_node_in_deps_and_weakdeps_rejects_load() {
let tmp = fixture_tempdir("soft_edges/deps_weakdeps_collision");
let args = RunArgs {
scope: Scope::All,
..bare_run_args()
};
let err = run_run(&args, &tmp.path().join("lib")).unwrap_err();
let msg = format!("{err:?}").to_lowercase();
assert!(
msg.contains("deps") && (msg.contains("weakdeps") || msg.contains("weak_deps")),
"expected a DAG-010 diagnostic mentioning deps + weakDeps overlap, got: {msg}",
);
}
#[test]
fn dag_015_literal_output_uniqueness_rejects_workspace_load() {
let tmp = fixture_tempdir("runtime_invariants/literal_output_collision");
let args = RunArgs {
scope: Scope::All,
..bare_run_args()
};
let err = run_run(&args, &tmp.path().join("lib")).unwrap_err();
let msg = format!("{err:?}").to_lowercase();
assert!(
msg.contains("collision.txt") || msg.contains("literal output"),
"expected a DAG-015 diagnostic naming the colliding literal output, got: {msg}",
);
}
#[test]
fn exec_020_glob_output_overlap_surfaces_at_runtime() {
if !binary_on_path("sh") || !binary_on_path("mkdir") || !binary_on_path("printf") {
eprintln!("skipping: required shell utilities not on $PATH");
return;
}
let tmp = fixture_tempdir("runtime_invariants/glob_output_overlap");
let args = RunArgs {
scope: Scope::All,
..bare_run_args()
};
let code = run_run(&args, &tmp.path().join("lib")).unwrap();
assert_ne!(
code, 0,
"expected a non-zero exit code; the runtime overlap of \
out/result.txt across `a` and `b` was not surfaced (EXEC-020)",
);
assert_ne!(
code, EXIT_INTERNAL_OR_CONFIG_ERROR,
"EXEC-020 surfaces as task-failure class, not config-error class",
);
}
#[test]
fn exec_019_glob_producer_matching_cycle_aborts_at_runtime() {
if !binary_on_path("sh") || !binary_on_path("printf") {
eprintln!("skipping: `sh` or `printf` not on $PATH");
return;
}
let tmp = fixture_tempdir("runtime_invariants/runtime_cycle");
let args = RunArgs {
scope: Scope::All,
..bare_run_args()
};
let code = run_run(&args, &tmp.path().join("lib")).unwrap();
assert_ne!(
code, 0,
"expected non-zero exit; runtime cycle (EXEC-019) was not detected",
);
assert_ne!(
code, EXIT_INTERNAL_OR_CONFIG_ERROR,
"EXEC-019 surfaces as task-failure class, not config-error class",
);
}
fn read_interval(marker_path: &Path) -> (u128, u128) {
let raw = fs::read_to_string(marker_path)
.unwrap_or_else(|e| panic!("read marker {}: {e}", marker_path.display()));
let mut lines = raw.lines().map(str::trim).filter(|l| !l.is_empty());
let start: u128 = lines
.next()
.expect("marker missing start line")
.parse()
.expect("marker start line not a u128");
let end: u128 = lines
.next()
.expect("marker missing end line")
.parse()
.expect("marker end line not a u128");
(start, end)
}
fn intervals_overlap(a: (u128, u128), b: (u128, u128)) -> bool {
a.0.max(b.0) < a.1.min(b.1)
}
fn timing_utils_available() -> bool {
binary_on_path("sh") && binary_on_path("date") && binary_on_path("sleep")
}
#[test]
fn exec_007_exclusive_mutex_serialises_concurrent_db_writers() {
if !timing_utils_available() {
eprintln!("skipping: sh/date/sleep not on $PATH");
return;
}
let tmp = fixture_tempdir("concurrency_and_mutex/mutex_modes");
let args = RunArgs {
targets: vec![task_ref("lib:writer1"), task_ref("lib:writer2")],
..bare_run_args()
};
let code = run_run(&args, &tmp.path().join("lib")).unwrap();
assert_eq!(code, 0);
let writer1 = read_interval(&tmp.path().join("lib/marker_writer1"));
let writer2 = read_interval(&tmp.path().join("lib/marker_writer2"));
assert!(
!intervals_overlap(writer1, writer2),
"writers' intervals overlap; exclusive mutex `db` failed to \
serialise them (EXEC-007/MUTEX-003). writer1={writer1:?} writer2={writer2:?}",
);
}
#[test]
fn mutex_004_shared_mode_allows_parallel_readers() {
if !timing_utils_available() {
eprintln!("skipping: sh/date/sleep not on $PATH");
return;
}
let tmp = fixture_tempdir("concurrency_and_mutex/mutex_modes");
let args = RunArgs {
targets: vec![task_ref("lib:reader_a"), task_ref("lib:reader_b")],
..bare_run_args()
};
let code = run_run(&args, &tmp.path().join("lib")).unwrap();
assert_eq!(code, 0);
let reader_a = read_interval(&tmp.path().join("lib/marker_reader_a"));
let reader_b = read_interval(&tmp.path().join("lib/marker_reader_b"));
assert!(
intervals_overlap(reader_a, reader_b),
"readers' intervals do not overlap; shared-mode mutex SHOULD \
have admitted concurrent holds (MUTEX-004). reader_a={reader_a:?} reader_b={reader_b:?}",
);
}
#[test]
fn cfg_028_workspace_default_concurrency_round_trips() {
if !timing_utils_available() {
eprintln!("skipping: sh/date/sleep not on $PATH");
return;
}
let tmp = fixture_tempdir("concurrency_and_mutex/default_cap_one");
let args = RunArgs {
scope: Scope::All,
..bare_run_args()
};
let code = run_run(&args, &tmp.path().join("lib")).unwrap();
assert_eq!(code, 0);
let task_a = read_interval(&tmp.path().join("lib/marker_task_a"));
let task_b = read_interval(&tmp.path().join("lib/marker_task_b"));
assert!(
!intervals_overlap(task_a, task_b),
"tasks overlapped despite `concurrency.default: 1`; \
CFG-028 round-trip failed. a={task_a:?} b={task_b:?}",
);
}
#[test]
fn exec_005_per_tag_cap_serialises_gpu_tasks() {
if !timing_utils_available() {
eprintln!("skipping: sh/date/sleep not on $PATH");
return;
}
let tmp = fixture_tempdir("concurrency_and_mutex/per_tag_cap");
let args = RunArgs {
scope: Scope::Workspace,
..bare_run_args()
};
let code = run_run(&args, tmp.path()).unwrap();
assert_eq!(code, 0);
let task_a = read_interval(&tmp.path().join("lib_a/marker_task_a"));
let task_b = read_interval(&tmp.path().join("lib_b/marker_task_b"));
assert!(
!intervals_overlap(task_a, task_b),
"gpu-tagged tasks overlapped despite tag cap 1 (EXEC-005). \
a={task_a:?} b={task_b:?}",
);
}
#[cfg(unix)]
mod cancellation_unix {
use super::{HAZ_BIN, binary_on_path, fixture_tempdir};
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
fn run_and_signal(signal: Signal) -> (Option<i32>, Duration) {
let tmp = fixture_tempdir("cancellation");
let mut child = Command::new(HAZ_BIN)
.args(["run", "--workspace"])
.current_dir(tmp.path())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("failed to spawn haz binary");
thread::sleep(Duration::from_millis(250));
let pid = Pid::from_raw(i32::try_from(child.id()).expect("pid fits i32"));
kill(pid, signal).expect("failed to deliver signal");
let signal_sent = Instant::now();
let deadline = signal_sent + Duration::from_secs(5);
loop {
match child.try_wait() {
Ok(Some(status)) => {
let elapsed = signal_sent.elapsed();
return (status.code(), elapsed);
}
Ok(None) => {
if Instant::now() >= deadline {
child.kill().ok();
let _ = child.wait();
panic!(
"haz did not exit within 5s of {signal:?}; \
cancellation handling broken"
);
}
thread::sleep(Duration::from_millis(25));
}
Err(e) => panic!("try_wait failed: {e}"),
}
}
}
#[test]
fn exec_013_sigint_during_run_cancels_in_flight_and_exits_130() {
if !binary_on_path("sleep") {
eprintln!("skipping: `sleep` not on $PATH");
return;
}
let (code, _elapsed) = run_and_signal(Signal::SIGINT);
assert_eq!(
code,
Some(130),
"expected exit code 130 (128 + SIGINT) per EXEC-021 POSIX convention",
);
}
#[test]
fn exec_014_grace_period_round_trips_from_workspace_yml() {
if !binary_on_path("sleep") {
eprintln!("skipping: `sleep` not on $PATH");
return;
}
let (_code, elapsed) = run_and_signal(Signal::SIGINT);
assert!(
elapsed < Duration::from_secs(3),
"cancellation took {elapsed:?}; the workspace's \
execution.cancel_grace: 0.5 was not honored (CFG-031 round-trip failed)",
);
}
#[test]
fn exec_021_signal_class_distinct_from_task_failure() {
if !binary_on_path("sleep") {
eprintln!("skipping: `sleep` not on $PATH");
return;
}
let (code, _elapsed) = run_and_signal(Signal::SIGTERM);
let code = code.expect("haz produced no exit code");
assert_ne!(code, 0, "signal class is non-zero");
assert_ne!(
code,
haz_cli::EXIT_INTERNAL_OR_CONFIG_ERROR,
"signal class must differ from internal/config class",
);
assert_eq!(
code, 143,
"expected exit code 143 (128 + SIGTERM) per EXEC-021 POSIX convention",
);
}
}
fn capture_run_stdout(fixture: &str, run_args: &[&str]) -> String {
let tmp = fixture_tempdir(fixture);
capture_haz_stdout_in(tmp.path(), run_args)
}
fn capture_haz_stdout_in(workspace_root: &Path, run_args: &[&str]) -> String {
let mut cmd = Command::new(HAZ_BIN);
cmd.arg("run").args(run_args).current_dir(workspace_root);
let output = cmd.output().expect("failed to spawn haz");
assert!(
output.status.success(),
"haz run {run_args:?} exited non-zero: {:?}; stderr: {}",
output.status,
String::from_utf8_lossy(&output.stderr),
);
String::from_utf8(output.stdout).expect("haz stdout was not utf-8")
}
#[test]
fn exec_016_live_mode_tags_lines_with_project_task_prefix() {
if !binary_on_path("sh") || !binary_on_path("echo") {
eprintln!("skipping: sh/echo not on $PATH");
return;
}
let stdout = capture_run_stdout("output_modes/default_live", &["lib:task_a", "lib:task_b"]);
assert!(
stdout.contains("[lib:task_a] hello-from-a"),
"expected `[lib:task_a] hello-from-a` line in live mode; got: {stdout}",
);
assert!(
stdout.contains("[lib:task_b] hello-from-b"),
"expected `[lib:task_b] hello-from-b` line in live mode; got: {stdout}",
);
}
#[test]
fn exec_016_buffered_mode_blocks_per_task_no_prefix() {
if !binary_on_path("sh") || !binary_on_path("echo") {
eprintln!("skipping: sh/echo not on $PATH");
return;
}
let stdout = capture_run_stdout(
"output_modes/workspace_buffered",
&["lib:task_a", "lib:task_b"],
);
assert!(
stdout.contains("hello-from-a"),
"expected `hello-from-a` in buffered output; got: {stdout}",
);
assert!(
stdout.contains("hello-from-b"),
"expected `hello-from-b` in buffered output; got: {stdout}",
);
assert!(
!stdout.contains("[lib:task_a]"),
"buffered mode MUST NOT prefix lines with the project:task tag; got: {stdout}",
);
assert!(
!stdout.contains("[lib:task_b]"),
"buffered mode MUST NOT prefix lines with the project:task tag; got: {stdout}",
);
}
#[test]
fn exec_016_cli_flag_overrides_workspace_default() {
if !binary_on_path("sh") || !binary_on_path("echo") {
eprintln!("skipping: sh/echo not on $PATH");
return;
}
let stdout = capture_run_stdout(
"output_modes/workspace_buffered",
&["--output-mode", "live", "lib:task_a", "lib:task_b"],
);
assert!(
stdout.contains("[lib:task_a] hello-from-a"),
"CLI --output-mode live did not override workspace `buffered`; got: {stdout}",
);
}
#[test]
fn exec_017_cache_hit_emits_recorded_streams_under_active_mode() {
if !binary_on_path("sh") || !binary_on_path("echo") {
eprintln!("skipping: sh/echo not on $PATH");
return;
}
let tmp = fixture_tempdir("output_modes/default_live");
let cold = capture_haz_stdout_in(tmp.path(), &["lib:task_a"]);
assert!(
cold.contains("[lib:task_a] hello-from-a"),
"cold run missing live-tagged line: {cold}",
);
let warm = capture_haz_stdout_in(tmp.path(), &["lib:task_a"]);
assert!(
warm.contains("[lib:task_a] hello-from-a"),
"warm (cache-hit) run did not re-emit the recorded stdout under \
the active live mode: {warm}",
);
}
fn git_in(cwd: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.output()
.unwrap_or_else(|e| panic!("git {args:?}: {e}"));
assert!(
output.status.success(),
"git {args:?} failed: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
fn git_capture(cwd: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.output()
.unwrap_or_else(|e| panic!("git {args:?}: {e}"));
assert!(
output.status.success(),
"git {args:?} failed: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
String::from_utf8(output.stdout).expect("git stdout not utf-8")
}
fn init_repo_and_commit_initial(repo_root: &Path) {
git_in(repo_root, &["init", "--quiet", "--initial-branch=main"]);
git_in(repo_root, &["add", "-A"]);
git_in(repo_root, &["commit", "--quiet", "-m", "initial"]);
}
#[test]
fn exec_028_git_staged_includes_only_index_diff_files() {
if !binary_on_path("git") {
eprintln!("skipping: `git` not on $PATH");
return;
}
let tmp = fixture_tempdir("git_changeset_modes");
init_repo_and_commit_initial(tmp.path());
let staged_file = tmp.path().join("work/lib/src/staged.txt");
let unstaged_file = tmp.path().join("work/lib/src/unstaged.txt");
fs::write(&staged_file, "modified-staged\n").unwrap();
git_in(tmp.path(), &["add", "work/lib/src/staged.txt"]);
fs::write(&unstaged_file, "modified-unstaged\n").unwrap();
let stdout = capture_haz_stdout_in(
&tmp.path().join("work"),
&["--workspace", "--git-staged", "--dry-run"],
);
assert!(
stdout.contains("lib:task_staged"),
"lib:task_staged missing from --git-staged plan; got: {stdout}",
);
assert!(
!stdout.contains("lib:task_unstaged"),
"lib:task_unstaged unexpectedly present in --git-staged plan; \
only staged diffs should count. got: {stdout}",
);
assert!(
!stdout.contains("lib:task_committed"),
"lib:task_committed should not be affected; got: {stdout}",
);
}
#[test]
fn exec_029_git_changed_since_rev_includes_diff_from_rev() {
if !binary_on_path("git") {
eprintln!("skipping: `git` not on $PATH");
return;
}
let tmp = fixture_tempdir("git_changeset_modes");
init_repo_and_commit_initial(tmp.path());
let rev_base = git_capture(tmp.path(), &["rev-parse", "HEAD"])
.trim()
.to_string();
fs::write(
tmp.path().join("work/lib/src/committed.txt"),
"modified-committed\n",
)
.unwrap();
git_in(tmp.path(), &["add", "work/lib/src/committed.txt"]);
git_in(tmp.path(), &["commit", "--quiet", "-m", "committed-change"]);
fs::write(
tmp.path().join("work/lib/src/staged.txt"),
"modified-staged\n",
)
.unwrap();
git_in(tmp.path(), &["add", "work/lib/src/staged.txt"]);
fs::write(
tmp.path().join("work/lib/src/unstaged.txt"),
"modified-unstaged\n",
)
.unwrap();
let stdout = capture_haz_stdout_in(
&tmp.path().join("work"),
&["--workspace", "--git-changed-since", &rev_base, "--dry-run"],
);
for task in ["lib:task_staged", "lib:task_unstaged", "lib:task_committed"] {
assert!(
stdout.contains(task),
"{task} missing from --git-changed-since {rev_base} plan; got: {stdout}",
);
}
}
#[test]
fn exec_029_unresolvable_rev_surfaces_as_typed_cli_error() {
if !binary_on_path("git") {
eprintln!("skipping: `git` not on $PATH");
return;
}
let tmp = fixture_tempdir("git_changeset_modes");
init_repo_and_commit_initial(tmp.path());
let output = Command::new(HAZ_BIN)
.args([
"run",
"--workspace",
"--git-changed-since",
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
"--dry-run",
])
.current_dir(tmp.path().join("work"))
.output()
.expect("failed to spawn haz binary");
assert!(
!output.status.success(),
"expected non-zero exit on unresolvable rev",
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("deadbeef")
|| stderr.to_lowercase().contains("rev")
|| stderr.to_lowercase().contains("git"),
"expected a diagnostic naming the unresolvable rev; got stderr: {stderr}",
);
}
#[test]
fn exec_026_changeset_outside_workspace_root_is_dropped() {
if !binary_on_path("git") {
eprintln!("skipping: `git` not on $PATH");
return;
}
let tmp = fixture_tempdir("git_changeset_modes");
fs::create_dir_all(tmp.path().join("outside")).unwrap();
fs::write(tmp.path().join("outside/foo.txt"), "initial\n").unwrap();
init_repo_and_commit_initial(tmp.path());
fs::write(tmp.path().join("outside/foo.txt"), "modified\n").unwrap();
let stdout = capture_haz_stdout_in(
&tmp.path().join("work"),
&["--workspace", "--git-changed", "--dry-run"],
);
assert!(
stdout.contains("0 task"),
"expected zero-task plan when only outside-workspace files \
changed; EXEC-026 should have dropped them. got: {stdout}",
);
}
#[test]
fn ref_004_positional_fan_out_includes_every_project_with_task() {
let tmp = fixture_tempdir("references");
let stdout = capture_haz_stdout_in(tmp.path(), &["^:build", "--dry-run"]);
assert!(
stdout.contains("lib_backend:build"),
"lib_backend:build missing from fan-out plan: {stdout}",
);
assert!(
stdout.contains("lib_frontend:build"),
"lib_frontend:build missing from fan-out plan: {stdout}",
);
assert!(
!stdout.contains("lib_other:"),
"lib_other unexpectedly present in fan-out plan; \
REF-004 should iterate only projects that define `build`: {stdout}",
);
}
#[test]
fn ref_005_positional_tag_reference_filters_by_tag() {
let tmp = fixture_tempdir("references");
let stdout = capture_haz_stdout_in(tmp.path(), &["[backend]:build", "--dry-run"]);
assert!(
stdout.contains("lib_backend:build"),
"lib_backend:build missing from tag-reference plan: {stdout}",
);
assert!(
!stdout.contains("lib_frontend:build"),
"lib_frontend:build unexpectedly present; REF-005 should \
exclude projects without tag `backend`: {stdout}",
);
assert!(
!stdout.contains("lib_other:"),
"lib_other unexpectedly present: {stdout}",
);
}
#[test]
fn ref_008_positional_sibling_fan_out_excludes_bearing() {
let tmp = fixture_tempdir("references");
let stdout = capture_haz_stdout_in(&tmp.path().join("lib_backend"), &["^~:build", "--dry-run"]);
assert!(
stdout.contains("lib_frontend:build"),
"lib_frontend:build missing from sibling-fan-out plan: {stdout}",
);
assert!(
!stdout.contains("lib_backend:build"),
"lib_backend:build unexpectedly present; the sibling \
modifier `~` MUST exclude the bearing project: {stdout}",
);
assert!(
!stdout.contains("lib_other:"),
"lib_other unexpectedly present: {stdout}",
);
}
#[test]
fn exec_001_multi_positional_union_pulls_each_closure() {
let tmp = fixture_tempdir("references");
let stdout = capture_haz_stdout_in(
tmp.path(),
&["lib_backend:build", "lib_frontend:build", "--dry-run"],
);
assert!(
stdout.contains("lib_backend:build"),
"lib_backend:build missing from multi-positional plan: {stdout}",
);
assert!(
stdout.contains("lib_frontend:build"),
"lib_frontend:build missing from multi-positional plan: {stdout}",
);
}
fn capture_haz_stdout_with_env(
workspace_root: &Path,
env_var: (&str, &str),
run_args: &[&str],
) -> String {
let output = Command::new(HAZ_BIN)
.arg("run")
.args(run_args)
.current_dir(workspace_root)
.env(env_var.0, env_var.1)
.output()
.expect("failed to spawn haz");
assert!(
output.status.success(),
"haz run {run_args:?} (env {}={}) exited non-zero: {:?}; stderr: {}",
env_var.0,
env_var.1,
output.status,
String::from_utf8_lossy(&output.stderr),
);
String::from_utf8(output.stdout).expect("haz stdout not utf-8")
}
fn count_cache_manifests(workspace_root: &Path) -> usize {
let cache_root = workspace_root.join(".haz/cache");
if !cache_root.is_dir() {
return 0;
}
let mut count = 0;
let Ok(shards) = fs::read_dir(&cache_root) else {
return 0;
};
for shard in shards.flatten() {
if !shard.file_type().is_ok_and(|t| t.is_dir()) {
continue;
}
let Ok(entries) = fs::read_dir(shard.path()) else {
continue;
};
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|t| t.is_dir()) {
continue;
}
if entry.path().join("manifest.json").is_file() {
count += 1;
}
}
}
count
}
#[test]
fn cfg_022_shell_action_with_shell_type_executes_through_named_shell() {
if !binary_on_path("bash") {
eprintln!("skipping: `bash` not on $PATH");
return;
}
let tmp = fixture_tempdir("task_bodies");
let stdout = capture_haz_stdout_in(
tmp.path(),
&["lib:task_shell_bash", "--output-mode", "buffered"],
);
assert!(
stdout.contains("bash-ok"),
"expected `bash-ok` in stdout; the bash-typed shell action did not run: {stdout}",
);
}
#[test]
fn cache_008_from_host_value_change_invalidates_cache() {
if !binary_on_path("sh") || !binary_on_path("printf") {
eprintln!("skipping: `sh`/`printf` not on $PATH");
return;
}
let tmp = fixture_tempdir("task_bodies");
capture_haz_stdout_with_env(tmp.path(), ("MY_HOST_VAR", "v1"), &["lib:task_from_host"]);
capture_haz_stdout_with_env(tmp.path(), ("MY_HOST_VAR", "v2"), &["lib:task_from_host"]);
let marker =
fs::read(tmp.path().join("lib/marker_from_host")).expect("marker_from_host missing");
assert_eq!(
marker.len(),
2,
"expected 2 fresh runs (one per MY_HOST_VAR value); \
got {} byte(s) -- env.from_host VALUE did not enter the cache key",
marker.len(),
);
}
#[test]
fn cache_008_override_value_appears_in_cache_key() {
if !binary_on_path("sh") {
eprintln!("skipping: `sh` not on $PATH");
return;
}
let tmp = fixture_tempdir("task_bodies");
capture_haz_stdout_in(
tmp.path(),
&["lib:task_override_v1", "lib:task_override_v2"],
);
let manifests = count_cache_manifests(tmp.path());
assert!(
manifests >= 2,
"expected at least two distinct cache entries (one per \
distinct env.override value); found {manifests} manifest(s). \
CACHE-008 override key contribution failed.",
);
}
#[test]
fn path_011_glob_inputs_match_files_under_src_recursively() {
if !binary_on_path("sh") || !binary_on_path("printf") {
eprintln!("skipping: `sh`/`printf` not on $PATH");
return;
}
let tmp = fixture_tempdir("task_bodies");
capture_haz_stdout_in(tmp.path(), &["lib:task_glob_inputs"]);
fs::write(
tmp.path().join("lib/src/a.rs"),
"// modified-a\nfn a() {}\n",
)
.expect("rewrite a.rs");
capture_haz_stdout_in(tmp.path(), &["lib:task_glob_inputs"]);
let marker = fs::read(tmp.path().join("lib/marker_glob")).expect("marker_glob missing");
assert_eq!(
marker.len(),
2,
"expected 2 fresh runs after mutating a glob-matched input; \
got {} byte(s). The glob may not have been expanded into \
the cache key contribution.",
marker.len(),
);
fs::write(
tmp.path().join("lib/src/nested/b.rs"),
"// modified-b\nfn b() {}\n",
)
.expect("rewrite b.rs");
capture_haz_stdout_in(tmp.path(), &["lib:task_glob_inputs"]);
let marker = fs::read(tmp.path().join("lib/marker_glob"))
.expect("marker_glob missing after nested mutation");
assert_eq!(
marker.len(),
3,
"nested src/nested/b.rs mutation did not invalidate the \
cache; `src/**/*.rs` glob did not match recursively. \
marker now has {} byte(s).",
marker.len(),
);
}
#[test]
fn exec_024_all_inside_project_selects_bearing_project_tasks() {
let tmp = fixture_tempdir("all_scopes/two_projects");
let stdout = capture_haz_stdout_in(&tmp.path().join("lib_a"), &["--all", "--dry-run"]);
assert!(
stdout.contains("lib_a:build") && stdout.contains("lib_a:lint"),
"expected both lib_a tasks in the plan; got: {stdout}",
);
assert!(
!stdout.contains("lib_b:"),
"lib_b tasks unexpectedly present; --all from inside lib_a \
should NOT widen to other projects (EXEC-024 arm 1): {stdout}",
);
}
#[test]
fn exec_024_all_outside_every_project_selects_every_workspace_task() {
let tmp = fixture_tempdir("all_scopes/two_projects");
let stdout = capture_haz_stdout_in(tmp.path(), &["--all", "--dry-run"]);
for task in ["lib_a:build", "lib_a:lint", "lib_b:build", "lib_b:lint"] {
assert!(
stdout.contains(task),
"{task} missing from --all plan run from workspace root \
(EXEC-024 arm 2): {stdout}",
);
}
}
#[test]
fn exec_026_all_plus_git_changed_intersects_candidate_set_with_changeset() {
if !binary_on_path("git") {
eprintln!("skipping: `git` not on $PATH");
return;
}
let tmp = fixture_tempdir("all_scopes/changed_with_all");
init_repo_and_commit_initial(tmp.path());
fs::write(tmp.path().join("work/lib_a/a.txt"), "modified-a\n").unwrap();
let stdout = capture_haz_stdout_in(
&tmp.path().join("work"),
&["--all", "--git-changed", "--dry-run"],
);
assert!(
stdout.contains("lib_a:lint"),
"lib_a:lint missing from --all --git-changed plan after \
mutating a.txt: {stdout}",
);
assert!(
!stdout.contains("lib_b:lint"),
"lib_b:lint unexpectedly present; the changeset filter \
should have excluded it (b.txt was not modified): {stdout}",
);
}
fn haz_exit_code(workspace_root: &Path) -> Option<i32> {
Command::new(HAZ_BIN)
.args(["run", "--workspace", "--dry-run"])
.current_dir(workspace_root)
.output()
.expect("failed to spawn haz")
.status
.code()
}
#[test]
fn dag_011_unresolved_ref_accumulates_and_surfaces_exit_class_four() {
let tmp = fixture_tempdir("workspace_load_errors/unresolved_ref");
let code = haz_exit_code(tmp.path()).expect("haz produced no exit code");
assert_eq!(
code, EXIT_INTERNAL_OR_CONFIG_ERROR,
"expected internal/config-error class for unresolved reference",
);
}
#[test]
fn dag_012_self_reference_surfaces_exit_class_four() {
let tmp = fixture_tempdir("workspace_load_errors/self_reference");
let code = haz_exit_code(tmp.path()).expect("haz produced no exit code");
assert_eq!(
code, EXIT_INTERNAL_OR_CONFIG_ERROR,
"expected internal/config-error class for self-reference",
);
}
#[test]
fn dag_015_literal_output_uniqueness_surfaces_exit_class_four() {
let tmp = fixture_tempdir("workspace_load_errors/literal_output_collision");
let code = haz_exit_code(tmp.path()).expect("haz produced no exit code");
assert_eq!(
code, EXIT_INTERNAL_OR_CONFIG_ERROR,
"expected internal/config-error class for literal output collision",
);
}
#[test]
fn cfg_021_malformed_command_surfaces_exit_class_four() {
let tmp = fixture_tempdir("workspace_load_errors/cfg_malformed_command");
let code = haz_exit_code(tmp.path()).expect("haz produced no exit code");
assert_eq!(
code, EXIT_INTERNAL_OR_CONFIG_ERROR,
"expected internal/config-error class for malformed command",
);
}
fn haz_run_capture_stderr(workspace_root: &Path) -> (String, Option<i32>) {
let output = Command::new(HAZ_BIN)
.args(["run", "p:anything"])
.current_dir(workspace_root)
.output()
.expect("failed to spawn haz");
let stderr = String::from_utf8(output.stderr).expect("haz stderr was not utf-8");
(stderr, output.status.code())
}
#[test]
fn cfg_021_command_scalar_renders_rich_diagnostic() {
let tmp = fixture_tempdir("workspace_load_errors/parse_errors/cfg_021_command_scalar");
let (stderr, code) = haz_run_capture_stderr(tmp.path());
assert_eq!(
code,
Some(EXIT_INTERNAL_OR_CONFIG_ERROR),
"expected internal/config-error class; stderr: {stderr}",
);
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_renders_rich_diagnostic() {
let tmp = fixture_tempdir("workspace_load_errors/parse_errors/cfg_009_projects_scalar");
let (stderr, code) = haz_run_capture_stderr(tmp.path());
assert_eq!(
code,
Some(EXIT_INTERNAL_OR_CONFIG_ERROR),
"expected internal/config-error class; stderr: {stderr}",
);
assert!(
stderr.contains("haz::cfg::009"),
"stderr must surface the CFG-009 code; got: {stderr}",
);
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_renders_rich_diagnostic() {
let tmp = fixture_tempdir("workspace_load_errors/parse_errors/cfg_025_overlay_missing_tasks");
let (stderr, code) = haz_run_capture_stderr(tmp.path());
assert_eq!(
code,
Some(EXIT_INTERNAL_OR_CONFIG_ERROR),
"expected internal/config-error class; stderr: {stderr}",
);
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}",
);
}