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;
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(),
)
}
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();
}
}
}
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)
}
#[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() {
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");
}
#[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");
assert!(!haz.join("workspace.yml").exists());
}
#[test]
fn aux_001_rejects_extra_positional_argument() {
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",
);
assert!(!tmp.path().join(".haz").exists());
}
#[test]
fn aux_005_scaffolds_haz_yml_for_each_selected_project_root() {
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");
let workspace_yml = fs::read_to_string(tmp.path().join(".haz/workspace.yml")).unwrap();
assert_eq!(workspace_yml, "projects:\n - /lib\n - /web\n");
let lib_haz = fs::read_to_string(tmp.path().join("lib/haz.yml")).unwrap();
assert_eq!(lib_haz, "name: lib\ntasks: {}\n");
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() {
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:?}",
);
assert!(tmp.path().join("___").exists());
assert!(!tmp.path().join(".haz").exists());
assert!(!tmp.path().join("___/haz.yml").exists());
}
#[test]
fn aux_008_success_exits_zero_and_config_error_uses_class_2() {
const EXIT_TASK_FAILURE: i32 = 1;
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",
);
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",
);
}