haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! `haz init` per `AUX-001`..`AUX-008`.
//!
//! Two invocation shapes are normative:
//!
//! - `haz init` (bare): the interactive scaffold flow
//!   (`AUX-003`..`AUX-005`). The pure state-machine layer of the
//!   interactive tree lives in [`model`], the AUX-005 / AUX-006
//!   commit pass in [`commit`], and the ratatui driver in
//!   [`tui`]. On confirmation, [`commit::apply_commit`] writes
//!   the planned files; declines exit `0` without changes per
//!   `AUX-005` step 4; `Ctrl+C` aborts with the EXEC-021
//!   signal-interrupt code.
//! - `haz init --yes`: writes a minimal
//!   `<cwd>/.haz/workspace.yml` containing exactly
//!   `projects: []` per `AUX-007`. The body is constructed as an
//!   empty [`commit::CommitPlan`] and shipped through the same
//!   [`commit::apply_commit`] code path the interactive arm uses,
//!   so both shapes share the rename-into-place pattern
//!   (`AUX-006`).
//!
//! Both shapes refuse to overlay an existing `.haz/` per
//! `AUX-002`.

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};

/// Body written by `--yes` per `AUX-007`. The trailing newline is
/// intentional: it leaves the file POSIX-text-shaped and matches
/// the literal example the spec quotes.
const MINIMAL_WORKSPACE_BODY: &str = "projects: []\n";

/// Execute `haz init` against `cwd`.
///
/// Returns `0` on a successful scaffold per `AUX-008`. Conflicts
/// (`AUX-002`) and filesystem write failures map to a non-zero
/// return through the internal-or-configuration-error class.
///
/// # Errors
///
/// Returns a [`miette::Report`] when `<cwd>/.haz/` already
/// exists, when the interactive flow surfaces a commit-plan
/// failure (e.g. AUX-005 name sanitisation), or when any
/// filesystem step (mkdir, tmp write, fsync, rename) fails. The
/// binary maps the report to
/// [`crate::EXIT_INTERNAL_OR_CONFIG_ERROR`].
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)
}

/// Drive the interactive scaffold flow and apply the plan on
/// confirmation. Exit codes follow `AUX-008`:
///
/// - confirmed and applied successfully -> `0`,
/// - user declined the confirmation -> `0` with no changes,
/// - user aborted via `Ctrl+C` -> `130` per the EXEC-021 signal
///   class.
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),
    }
}

/// Write the `AUX-007` minimal workspace: create `<cwd>/.haz/`
/// and drop a `workspace.yml` containing `projects: []` inside,
/// using the shared [`commit::apply_commit`] path so the
/// rename-into-place pattern of `AUX-006` lives in exactly one
/// place.
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)
}

/// Write `contents` to `target` atomically per `AUX-006`: write a
/// sibling temp file, fsync it, then rename onto `target`.
///
/// A crash before the rename leaves the temp file in place; this
/// function removes it on its own failure path so `haz init`
/// never leaks state inside `.haz/`. A crash *during* the rename
/// is bounded by `rename(2)`'s atomicity on a single filesystem.
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() {
        // `AUX-006` requires removing leftover temp paths before
        // returning. Best-effort cleanup: the underlying error is
        // already surfaced and a stranded tmp file is not worse
        // than the original failure.
        let _ = fs::remove_file(&tmp);
    }
    result
}

/// Pick a sibling-of-`target` path under the same parent that no
/// other process should be holding. 64 bits of randomness is
/// overwhelming for the collision domain (a single `haz init`
/// invocation on a single host).
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;

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

    #[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();
        // `.haz/` exists and contains exactly one entry (workspace.yml).
        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")]);
    }

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

    #[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());
    }

    // --- AUX-006: atomic write ------------------------------

    #[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();
        // After a successful rename, the parent directory contains
        // exactly one entry (the target). No `.hello.txt.tmp-*`
        // siblings remain.
        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() {
        // Use a path whose parent directory doesn't exist: the
        // tmp `fs::File::create` cannot make it, so atomic_write
        // returns Err and (per AUX-006) leaves no leftover tmp
        // file.
        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}",
        );
        // Tempdir parent should still be empty (no orphan
        // `missing-dir/` directory left behind by us, no orphan
        // tmp files).
        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:?}"
        );
    }

    // --- aux_007_yes path produces a parsable workspace.yml ---

    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");
    }
}