pub mod commit;
pub mod model;
pub mod tui;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use miette::{IntoDiagnostic, Result, WrapErr, miette};
use crate::cli::InitArgs;
use commit::{CommitPlan, apply_commit};
use tui::{ScaffoldOutcome, run_interactive_scaffold};
const MINIMAL_WORKSPACE_BODY: &str = "projects: []\n";
pub fn execute(args: &InitArgs, cwd: &Path) -> Result<i32> {
let haz_dir = cwd.join(".haz");
if haz_dir.exists() {
return Err(miette!(
"haz init refuses to overlay an existing workspace at `{}` (AUX-002)",
haz_dir.display(),
));
}
if !args.yes {
return run_interactive_scaffold_and_apply(cwd);
}
write_minimal_workspace(cwd)?;
Ok(0)
}
fn run_interactive_scaffold_and_apply(cwd: &Path) -> Result<i32> {
match run_interactive_scaffold(cwd)? {
ScaffoldOutcome::Confirmed(plan) => {
apply_commit(&plan, cwd).map_err(|err| miette!("{err}"))?;
Ok(0)
}
ScaffoldOutcome::Declined => Ok(0),
ScaffoldOutcome::Aborted => Ok(130),
}
}
fn write_minimal_workspace(cwd: &Path) -> Result<()> {
let plan = CommitPlan {
workspace_yml_body: MINIMAL_WORKSPACE_BODY.to_owned(),
project_ymls: Vec::new(),
};
apply_commit(&plan, cwd)
}
pub(super) fn atomic_write(target: &Path, contents: &[u8]) -> Result<()> {
let tmp = tmp_sibling_path(target)?;
let result = (|| -> Result<()> {
let mut file = fs::File::create(&tmp)
.into_diagnostic()
.wrap_err_with(|| format!("failed to create tmp file `{}`", tmp.display()))?;
file.write_all(contents)
.into_diagnostic()
.wrap_err_with(|| format!("failed to write tmp file `{}`", tmp.display()))?;
file.sync_all()
.into_diagnostic()
.wrap_err_with(|| format!("failed to fsync tmp file `{}`", tmp.display()))?;
drop(file);
fs::rename(&tmp, target)
.into_diagnostic()
.wrap_err_with(|| {
format!(
"failed to rename `{}` to `{}`",
tmp.display(),
target.display(),
)
})?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&tmp);
}
result
}
fn tmp_sibling_path(target: &Path) -> Result<PathBuf> {
let parent = target
.parent()
.ok_or_else(|| miette!("target `{}` has no parent directory", target.display()))?;
let file_name = target
.file_name()
.ok_or_else(|| miette!("target `{}` has no file name", target.display()))?;
let suffix: u64 = rand::random();
let tmp_name = format!(".{}.tmp-{suffix:016x}", file_name.to_string_lossy());
Ok(parent.join(tmp_name))
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use super::{MINIMAL_WORKSPACE_BODY, atomic_write, execute};
use crate::cli::InitArgs;
#[test]
fn aux_007_yes_writes_minimal_workspace_yml() {
let tmp = TempDir::new().unwrap();
let args = InitArgs { yes: true };
let code = execute(&args, tmp.path()).unwrap();
assert_eq!(code, 0);
let body = fs::read_to_string(tmp.path().join(".haz/workspace.yml")).unwrap();
assert_eq!(body, MINIMAL_WORKSPACE_BODY);
}
#[test]
fn aux_007_yes_creates_only_workspace_yml_and_haz_dir() {
let tmp = TempDir::new().unwrap();
let args = InitArgs { yes: true };
execute(&args, tmp.path()).unwrap();
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_002_refuses_when_haz_dir_already_exists() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join(".haz")).unwrap();
let args = InitArgs { yes: true };
let err = execute(&args, tmp.path()).unwrap_err();
let rendered = format!("{err}");
assert!(
rendered.contains("existing workspace"),
"diagnostic should name the conflict: {rendered}",
);
}
#[test]
fn aux_002_refuses_even_when_yes_is_unset() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join(".haz")).unwrap();
let args = InitArgs { yes: false };
assert!(execute(&args, tmp.path()).is_err());
}
#[test]
fn aux_006_atomic_write_creates_target_with_expected_bytes() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("hello.txt");
atomic_write(&target, b"hello, haz\n").unwrap();
let body = fs::read(&target).unwrap();
assert_eq!(body, b"hello, haz\n");
}
#[test]
fn aux_006_atomic_write_leaves_no_tmp_file_on_success() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("hello.txt");
atomic_write(&target, b"hello, haz\n").unwrap();
let entries: Vec<_> = fs::read_dir(tmp.path())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(entries, vec![std::ffi::OsString::from("hello.txt")]);
}
#[test]
fn aux_006_atomic_write_fails_when_parent_is_not_a_directory() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("missing-dir/hello.txt");
let err = atomic_write(&target, b"x").unwrap_err();
let rendered = format!("{err}");
assert!(
rendered.contains("failed to create tmp file"),
"diagnostic should pinpoint the failed step: {rendered}",
);
let entries: Vec<_> = fs::read_dir(tmp.path())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert!(
entries.is_empty(),
"expected empty tempdir, got: {entries:?}"
);
}
fn read_to_string(path: &Path) -> String {
String::from_utf8(fs::read(path).unwrap()).unwrap()
}
#[test]
fn aux_007_workspace_yml_body_is_exactly_projects_empty_array() {
let tmp = TempDir::new().unwrap();
let args = InitArgs { yes: true };
execute(&args, tmp.path()).unwrap();
let body = read_to_string(&tmp.path().join(".haz/workspace.yml"));
assert_eq!(body, "projects: []\n");
}
}