haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! `haz check` subcommand.
//!
//! Loads the discovered workspace through the full
//! DISC / CFG / REF / DAG pipeline (`AUX-010`), accumulating every
//! detected error via the per-phase accumulators (`LoadErrors`,
//! `LoadOverlayErrors`, `BuildErrors`, ...). On success, emits a
//! single line of the form `<N> task(s) validated.` to stdout
//! (`AUX-011`) and returns exit code 0 (`AUX-012`). On failure,
//! propagates the typed diagnostic up to the binary, which renders
//! it to stderr and exits with the internal-or-configuration-error
//! class.
//!
//! The candidate set whose size enters the count line is determined
//! per `AUX-009`:
//!
//! - `--workspace`: every task in every project, regardless of cwd.
//! - `--all` + bearing project: the bearing project's effective
//!   task set.
//! - `--all` + no bearing project: every task in every project.
//! - No scope flag + bearing project: the bearing project's
//!   effective task set.
//! - No scope flag + no bearing project: every task in every
//!   project.

use std::io::{self, Write};

use haz_domain::name::ProjectName;
use haz_domain::workspace::Workspace;
use miette::{IntoDiagnostic, Result, WrapErr};

use crate::cli::{CheckArgs, Scope};
use crate::context::Context;

/// Execute the `haz check` subcommand against the supplied [`Context`].
///
/// Returns the process exit code that the binary should propagate
/// (`AUX-012`).
///
/// # Errors
///
/// Returns a [`miette::Report`] on any failure of the underlying
/// validation pipeline (`AUX-010`). The binary maps the report to
/// the internal-or-configuration-error exit class
/// ([`crate::EXIT_INTERNAL_OR_CONFIG_ERROR`]).
pub fn execute(args: &CheckArgs, ctx: &Context) -> Result<i32> {
    let count = count_validated_tasks(args, ctx.bearing(), ctx.workspace());
    let mut out = io::stdout().lock();
    print_validation_summary(count, &mut out)
        .into_diagnostic()
        .wrap_err("failed to write validation summary")?;
    Ok(0)
}

/// Compute the size of the candidate task set per `AUX-009`.
fn count_validated_tasks(
    args: &CheckArgs,
    bearing: Option<&ProjectName>,
    workspace: &Workspace,
) -> usize {
    // AUX-009: `--workspace` counts every project regardless of
    // cwd; `--all` and the no-flag default both count the bearing
    // project when one applies, otherwise the whole workspace (so
    // the two collapse to one arm here).
    match args.scope {
        Scope::Workspace => workspace_task_count(workspace),
        Scope::All | Scope::Default => match bearing {
            Some(name) => bearing_task_count(workspace, name),
            None => workspace_task_count(workspace),
        },
    }
}

fn workspace_task_count(workspace: &Workspace) -> usize {
    workspace.projects.values().map(|p| p.tasks.len()).sum()
}

fn bearing_task_count(workspace: &Workspace, name: &ProjectName) -> usize {
    workspace.projects.get(name).map_or(0, |p| p.tasks.len())
}

/// Emit the `AUX-011` success line.
///
/// The literal `task(s)` form is taken verbatim from the spec
/// (`AUX-011`): the count is the only field that varies.
fn print_validation_summary(count: usize, out: &mut impl Write) -> io::Result<()> {
    writeln!(out, "{count} task(s) validated.")
}

#[cfg(test)]
mod tests {
    use super::{count_validated_tasks, execute, print_validation_summary};
    use crate::cli::{CheckArgs, Scope};
    use crate::context::Context;
    use miette::Result;
    use std::fs;
    use std::path::Path;
    use std::str::FromStr;

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

    /// Run the full dispatch pipeline for `haz check`: open the
    /// Context against `cwd` and execute, surfacing the
    /// load-failure and execute-failure paths as a single `Result`.
    fn run_check(args: &CheckArgs, cwd: &Path) -> Result<i32> {
        let ctx = Context::open(cwd)?;
        execute(args, &ctx)
    }

    fn explicit_two_project_workspace(root: &Path) {
        write_file(
            &root.join(".haz/workspace.yml"),
            "projects:\n  - /lib_core\n  - /apps/web\n",
        );
        write_file(
            &root.join("lib_core/haz.yml"),
            "name: lib_core\ntasks:\n  build:\n    command: [\"true\"]\n",
        );
        write_file(
            &root.join("apps/web/haz.yml"),
            "name: web\ntasks:\n  bundle:\n    command: [\"true\"]\n    deps:\n      - lib_core:build\n",
        );
    }

    #[test]
    fn aux_010_check_runs_every_validation_step_against_clean_workspace() {
        // A two-project workspace with a valid cross-project
        // dependency exercises the full DISC / CFG / REF / DAG
        // pipeline: discovery loads `workspace.yml` + both
        // `haz.yml`s, reference resolution turns `lib_core:build`
        // into a node, and the DAG builder accepts the resulting
        // hard edge. A non-error return value proves every layer
        // ran without short-circuiting.
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        explicit_two_project_workspace(root);

        let args = CheckArgs::default();
        let code = run_check(&args, root).unwrap();
        assert_eq!(code, 0);
    }

    #[test]
    fn aux_010_check_accumulates_multiple_dag_errors_without_short_circuit() {
        // Three independent DAG-layer errors in one workspace:
        // 1. Cycle between lib_a:build and lib_b:build (DAG-014).
        // 2. Unresolved reference in lib_c:build (DAG-011).
        // 3. Duplicate literal output `dup.txt` declared by
        //    lib_d:build and lib_e:build (DAG-015).
        // `BuildErrors` MUST surface all three in one report.
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        write_file(
            &root.join(".haz/workspace.yml"),
            "projects:\n  - /lib_a\n  - /lib_b\n  - /lib_c\n  - /lib_d\n  - /lib_e\n",
        );
        write_file(
            &root.join("lib_a/haz.yml"),
            "name: lib_a\ntasks:\n  build:\n    command: [\"true\"]\n    deps:\n      - lib_b:build\n",
        );
        write_file(
            &root.join("lib_b/haz.yml"),
            "name: lib_b\ntasks:\n  build:\n    command: [\"true\"]\n    deps:\n      - lib_a:build\n",
        );
        write_file(
            &root.join("lib_c/haz.yml"),
            "name: lib_c\ntasks:\n  build:\n    command: [\"true\"]\n    deps:\n      - lib_x:nonexistent\n",
        );
        write_file(
            &root.join("lib_d/haz.yml"),
            "name: lib_d\ntasks:\n  build:\n    command: [\"true\"]\n    outputs:\n      - dup.txt\n",
        );
        write_file(
            &root.join("lib_e/haz.yml"),
            "name: lib_e\ntasks:\n  build:\n    command: [\"true\"]\n    outputs:\n      - /lib_d/dup.txt\n",
        );

        let args = CheckArgs::default();
        let err = run_check(&args, root).unwrap_err();
        let rendered = format!("{err:?}");
        assert!(
            rendered.contains("DAG-014"),
            "diagnostic should surface the cycle rule: {rendered}",
        );
        assert!(
            rendered.contains("DAG-011") || rendered.contains("unresolved"),
            "diagnostic should surface the unresolved reference: {rendered}",
        );
        assert!(
            rendered.contains("DAG-015"),
            "diagnostic should surface the duplicate literal output rule: {rendered}",
        );
    }

    #[test]
    fn aux_011_success_output_emits_count_line() {
        // The literal `<N> task(s) validated.` form per AUX-011.
        let mut out = Vec::<u8>::new();
        print_validation_summary(3, &mut out).unwrap();
        assert_eq!(String::from_utf8(out).unwrap(), "3 task(s) validated.\n");
    }

    #[test]
    fn aux_011_failure_surfaces_every_dag_error_in_one_report() {
        // Two DAG-layer errors share one workspace; `execute`'s
        // returned `Report` must render BOTH (no truncation per
        // AUX-011).
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        write_file(
            &root.join(".haz/workspace.yml"),
            "projects:\n  - /lib_a\n  - /lib_b\n",
        );
        // DAG-015: duplicate literal output between two tasks.
        write_file(
            &root.join("lib_a/haz.yml"),
            "name: lib_a\ntasks:\n  build:\n    command: [\"true\"]\n    outputs:\n      - dup.txt\n",
        );
        write_file(
            &root.join("lib_b/haz.yml"),
            "name: lib_b\ntasks:\n  build:\n    command: [\"true\"]\n    outputs:\n      - /lib_a/dup.txt\n    deps:\n      - lib_a:nonexistent\n",
        );

        let args = CheckArgs::default();
        let err = run_check(&args, root).unwrap_err();
        let rendered = format!("{err:?}");
        assert!(
            rendered.contains("DAG-015"),
            "duplicate-output diagnostic missing: {rendered}",
        );
        assert!(
            rendered.contains("DAG-011") || rendered.contains("unresolved"),
            "unresolved-reference diagnostic missing: {rendered}",
        );
    }

    #[test]
    fn aux_012_check_returns_zero_on_clean_workspace() {
        // Implicit-mode singleton; the simplest possible clean
        // workspace.
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        fs::create_dir_all(root.join(".git")).unwrap();
        write_file(
            &root.join("haz.yml"),
            "name: only_proj\ntasks:\n  build:\n    command: [\"true\"]\n",
        );

        let args = CheckArgs::default();
        let code = run_check(&args, root).unwrap();
        assert_eq!(code, 0);
    }

    #[test]
    fn aux_012_check_returns_err_on_validation_failure() {
        // The numeric exit code mapping to
        // `EXIT_INTERNAL_OR_CONFIG_ERROR` lives in the binary's
        // `main`; this in-process test verifies only that
        // `execute` surfaces the failure as `Err(_)` so the
        // binary's mapping logic ever has a chance to run.
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        write_file(&root.join(".haz/workspace.yml"), "projects:\n  - /lib\n");
        write_file(
            &root.join("lib/haz.yml"),
            "name: lib\ntasks:\n  build:\n    command: [\"true\"]\n    deps:\n      - lib:nonexistent\n",
        );

        let args = CheckArgs::default();
        assert!(run_check(&args, root).is_err());
    }

    // Scope-flag counting --------------------------------------

    #[test]
    fn aux_009_count_default_with_bearing_picks_bearing_project() {
        // cwd inside lib_core: default mode validates only the
        // bearing project (1 task), not the workspace's 2 tasks.
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        explicit_two_project_workspace(root);
        fs::create_dir_all(root.join("lib_core/src")).unwrap();

        let (workspace, _graph) = crate::workspace_load::load_workspace(root).unwrap();
        let bearing_name = haz_domain::name::ProjectName::from_str("lib_core").unwrap();
        let bearing = Some(&bearing_name);

        let args = CheckArgs::default();
        let n = count_validated_tasks(&args, bearing, &workspace);
        assert_eq!(n, 1, "bearing-project count");
    }

    #[test]
    fn aux_009_count_workspace_flag_picks_every_project() {
        // --workspace overrides the bearing context: both tasks
        // counted, regardless of the cwd.
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        explicit_two_project_workspace(root);

        let (workspace, _graph) = crate::workspace_load::load_workspace(root).unwrap();
        let bearing_name = haz_domain::name::ProjectName::from_str("lib_core").unwrap();
        let bearing = Some(&bearing_name);

        let args = CheckArgs {
            scope: Scope::Workspace,
        };
        let n = count_validated_tasks(&args, bearing, &workspace);
        assert_eq!(n, 2, "workspace-flag count");
    }

    #[test]
    fn aux_009_count_default_without_bearing_picks_every_project() {
        // cwd at the workspace root (no bearing): the default
        // candidate set is the entire workspace.
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        explicit_two_project_workspace(root);

        let (workspace, _graph) = crate::workspace_load::load_workspace(root).unwrap();

        let args = CheckArgs::default();
        let n = count_validated_tasks(&args, None, &workspace);
        assert_eq!(n, 2, "no-bearing default count");
    }
}