haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! `haz` workspace task runner: library surface for the `haz`
//! binary.
//!
//! Exposes the runtime modules wired together by the `haz`
//! command-line tool (`src/main.rs`). Integration tests under
//! `tests/` drive these modules directly against fixture
//! workspaces; the binary itself is a thin adapter over
//! [`dispatch`].

pub mod cli;
pub mod commands;
pub mod context;
pub mod output;

mod workspace_load;

use std::path::Path;

use cli::Cmd;
use context::Context;
use miette::Result;

/// Exit code reserved for workspace-load failures, target-
/// resolution failures, and other internal / configuration errors
/// per `EXEC-021`'s fourth class. Distinct from
/// `haz_exec::exit_code`'s task-failure code (`1`) and the POSIX
/// signal codes (`130` / `143`).
pub const EXIT_INTERNAL_OR_CONFIG_ERROR: i32 = 2;

/// Dispatch a parsed [`Cmd`] against `cwd` and return the per-
/// command exit code.
///
/// `cwd` is a parameter rather than read from
/// [`std::env::current_dir`] so callers (the binary, integration
/// tests, embedders) drive the dispatch deterministically. The
/// binary's `main` reads cwd from the environment once and passes
/// it here.
///
/// `haz init` is the one subcommand that does NOT load a workspace
/// (it creates one), so the [`Context::open`] preamble is skipped
/// for it and the cwd flows through directly. Every other
/// subcommand consumes the freshly-built [`Context`] by value.
///
/// # Errors
///
/// Returns a [`miette::Report`] on workspace-load, target-
/// resolution, or scheduler failure. The binary maps any report
/// to [`EXIT_INTERNAL_OR_CONFIG_ERROR`].
pub fn dispatch(cmd: &Cmd, cwd: &Path) -> Result<i32> {
    if let Cmd::Init(args) = cmd {
        return commands::init::execute(args, cwd);
    }
    // `haz check` must surface every load error at once (AUX-010 /
    // AUX-011); the other subcommands keep the first-error
    // short-circuit, which AUX-010 permits (it binds only check).
    let ctx = if matches!(cmd, Cmd::Check(_)) {
        Context::open_accumulating(cwd)?
    } else {
        Context::open(cwd)?
    };
    match cmd {
        Cmd::Run(args) => commands::run::execute(args, &ctx),
        Cmd::Check(args) => commands::check::execute(args, &ctx),
        Cmd::Why(args) => commands::why::execute(args, &ctx),
        Cmd::CacheClear => commands::cache::clear(&ctx),
        Cmd::CacheClean(args) => commands::cache::clean(&ctx, args),
        Cmd::CacheInfo => commands::cache::info(&ctx),
        Cmd::Query(args) => commands::query::execute(args, &ctx),
        Cmd::Init(_) => unreachable!("Init handled above before Context::open"),
    }
}

#[cfg(test)]
mod tests {
    use super::dispatch;
    use crate::cli::{CheckArgs, Cmd};
    use std::fs;
    use std::path::Path;

    fn write_file(path: &Path, contents: &str) {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(path, contents).unwrap();
    }

    /// End-to-end AUX-010 / AUX-011: dispatching `haz check` against a
    /// workspace whose projects all fail to parse surfaces every error
    /// in one run. Exercises the `Cmd::Check` arm routing through the
    /// accumulating loader.
    #[test]
    fn dispatch_check_surfaces_every_parse_error() {
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        write_file(
            &root.join(".haz/workspace.yml"),
            "projects:\n  - /alpha\n  - /beta\n",
        );
        write_file(
            &root.join("alpha/haz.yml"),
            "name: alpha\ntasks:\n  build:\n    command: \"AAA\"\n",
        );
        write_file(
            &root.join("beta/haz.yml"),
            "name: beta\ntasks:\n  build:\n    command: \"BBB\"\n",
        );

        let cmd = Cmd::Check(CheckArgs::default());
        let err = dispatch(&cmd, root).unwrap_err();
        let rendered = format!("{err:?}");
        assert!(
            rendered.contains("AAA") && rendered.contains("BBB"),
            "haz check must surface every broken file in one run: {rendered}",
        );
    }
}