haz-cli 0.1.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! Load a haz workspace from a starting directory.
//!
//! Wires the three crates that turn an on-disk workspace into
//! the value objects every downstream subcommand consumes:
//!
//! 1. `haz-discovery` walks upward from the starting directory
//!    to find the workspace root, loads `.haz/workspace.yml`,
//!    expands the project list, loads each project's `haz.yml`,
//!    and loads any `.haz/tasks/*.yml` overlays.
//! 2. The discovery-produced [`DiscoveredWorkspace`] is
//!    converted to a pure-domain
//!    [`haz_domain::workspace::Workspace`] (drops the
//!    filesystem-coupled `F::CanonicalPath` type info; keeps the
//!    validated projects, overlays, and settings).
//! 3. `haz-dag` validates the workspace as a directed task graph
//!    (overlay merging, reference resolution, cycle and output-
//!    uniqueness checks) and produces the
//!    [`haz_dag::graph::TaskGraph`] the scheduler consumes.
//!
//! The single public entry point [`load_workspace`] returns a
//! [`miette::Result`] so the binary can render diagnostics with
//! source-span snippets where the underlying crates provide
//! them.

use std::collections::BTreeMap;
use std::path::Path;

use haz_dag::construction::build_task_graph;
use haz_dag::graph::TaskGraph;
use haz_discovery::discover::{DiscoveredWorkspace, DiscoveryError};
use haz_discovery::discovery::Discovery;
use haz_discovery::overlays::load::{LoadOverlayError, LoadOverlayErrors, overlays_map};
use haz_discovery::projects::load::{LoadError, LoadErrors};
use haz_discovery::workspace::load::WorkspaceLoadError;
use haz_domain::name::ProjectName;
use haz_domain::project::Project;
use haz_domain::workspace::Workspace;
use haz_vfs::{Filesystem, StdFilesystem};
use miette::{IntoDiagnostic, NamedSource, Report, Result, WrapErr};

/// Load a workspace rooted somewhere at or above `starting_dir`.
///
/// Returns the pair `(Workspace, TaskGraph)` on success. The
/// returned `Workspace` is the *effective* workspace per
/// `DAG-001`..`DAG-006`: each project's `tasks` map is the
/// post-overlay-merge effective task set, not the un-merged
/// `haz.yml` declaration. Downstream phases (target resolution,
/// cache-key derivation, `haz why`, `haz query`) MUST consult
/// this view; reading from a project's pre-merge `tasks` map
/// misses every overlay-attached task.
///
/// # Errors
///
/// Returns a [`miette::Report`] on any failure. The underlying
/// failure modes:
///
/// - Discovery: no workspace marker found, symlink cycle, etc.
///   (`DISC-*`).
/// - YAML parse: invalid UTF-8 or YAML grammar in any of the
///   loaded files (`CFG-*`).
/// - Graph validation: cycle (`DAG-014`), output collision
///   (`DAG-015`), unresolved reference (`DAG-011`), overlay
///   collision (`DAG-006`).
pub fn load_workspace(starting_dir: &Path) -> Result<(Workspace, TaskGraph)> {
    let fs = StdFilesystem::new();
    let discovered = Discovery::new(&fs)
        .discover_workspace(starting_dir)
        .map_err(|e| discovery_error_to_report(e, starting_dir))?;
    let workspace = assemble_domain_workspace(discovered);
    let (graph, effective_workspace) = build_task_graph(&workspace)
        .into_diagnostic()
        .wrap_err("task-graph construction failed")?;
    Ok((effective_workspace, graph))
}

/// Build a [`miette::Report`] from a [`DiscoveryError`].
///
/// When the chain contains a parse-family error (a
/// [`LoadError::Parse`] for project files, a
/// [`WorkspaceLoadError::Parse`] for `.haz/workspace.yml`, or a
/// [`LoadOverlayError::Parse`] for `.haz/tasks/*.yml`), the
/// inner mapper error (and its rich [`miette::Diagnostic`] info:
/// `code`, `labels`, `help`) is promoted to the Report's primary
/// error, with the offending file attached as a [`NamedSource`]
/// so miette can render the source snippet. Without this
/// promotion, the renderer only surfaces `Display` strings from
/// each chain level and the rich Diagnostic info is silently
/// dropped.
///
/// For non-parse failures (workspace-root walk, filesystem
/// errors, etc.) the function falls back to the chain rendering
/// with a `workspace discovery failed starting from <dir>`
/// wrap.
fn discovery_error_to_report(err: DiscoveryError, starting_dir: &Path) -> Report {
    let wrap = || {
        format!(
            "workspace discovery failed starting from {}",
            starting_dir.display(),
        )
    };
    match err {
        DiscoveryError::LoadProjects { source } => match take_first_parse(source) {
            Ok((path, text, inner)) => rich_parse_report(&path, text, *inner),
            Err(load_err) => Report::new(load_err).wrap_err(wrap()),
        },
        DiscoveryError::LoadImplicitProject { source } => match source {
            LoadError::Parse {
                path,
                source_text,
                source,
                ..
            } => rich_parse_report(&path, source_text, *source),
            other => Report::new(other).wrap_err(wrap()),
        },
        DiscoveryError::LoadWorkspaceConfig { source } => match source {
            WorkspaceLoadError::Parse {
                path,
                source_text,
                source,
            } => rich_parse_report(&path, source_text, *source),
            other => Report::new(other).wrap_err(wrap()),
        },
        DiscoveryError::LoadOverlays { source } => match take_first_overlay_parse(source) {
            Ok((path, text, inner)) => rich_parse_report(&path, text, *inner),
            Err(load_err) => Report::new(load_err).wrap_err(wrap()),
        },
        other => Report::new(other).wrap_err(wrap()),
    }
}

/// Walk a [`LoadErrors`] container looking for the first
/// [`LoadError::Parse`] (suitable for rich rendering). Returns
/// `Ok((path, source_text, inner))` if found; otherwise returns
/// `Err(first_non_parse)` so the caller can fall back to chain
/// rendering on a representative error.
///
/// [`LoadErrors`] is non-empty by construction (the type's
/// own documented invariant); the post-loop `None` branch is
/// therefore statically impossible.
type ParseTriple = (
    std::path::PathBuf,
    String,
    Box<haz_config::mapper::project::ProjectMapError>,
);

fn take_first_parse(errors: LoadErrors) -> Result<ParseTriple, LoadError> {
    let mut other: Option<LoadError> = None;
    for e in errors {
        match e {
            LoadError::Parse {
                path,
                source_text,
                source,
                ..
            } => return Ok((path, source_text, source)),
            non_parse => {
                if other.is_none() {
                    other = Some(non_parse);
                }
            }
        }
    }
    match other {
        Some(e) => Err(e),
        None => unreachable!("LoadErrors is non-empty by construction"),
    }
}

/// Walk a [`LoadOverlayErrors`] container looking for the first
/// [`LoadOverlayError::Parse`] (suitable for rich rendering).
/// Returns `Ok((path, source_text, inner))` if found; otherwise
/// returns `Err(first_non_parse)` so the caller can fall back to
/// chain rendering on a representative error.
///
/// [`LoadOverlayErrors`] is non-empty by construction; the
/// post-loop `None` branch is therefore statically impossible.
type OverlayParseTriple = (
    std::path::PathBuf,
    String,
    Box<haz_config::mapper::overlay::OverlayMapError>,
);

fn take_first_overlay_parse(
    errors: LoadOverlayErrors,
) -> Result<OverlayParseTriple, LoadOverlayError> {
    let mut other: Option<LoadOverlayError> = None;
    for e in errors {
        match e {
            LoadOverlayError::Parse {
                path,
                source_text,
                source,
                ..
            } => return Ok((path, source_text, source)),
            non_parse => {
                if other.is_none() {
                    other = Some(non_parse);
                }
            }
        }
    }
    match other {
        Some(e) => Err(e),
        None => unreachable!("LoadOverlayErrors is non-empty by construction"),
    }
}

/// Build a [`Report`] whose primary error is the inner mapper
/// diagnostic and whose source code is the parsed file. Used
/// for both project (`haz.yml`) and workspace (`workspace.yml`)
/// parse failures; both inner error types implement
/// [`miette::Diagnostic`] with the same shape (`code`, `labels`,
/// `help`).
fn rich_parse_report<D>(path: &Path, source_text: String, inner: D) -> Report
where
    D: miette::Diagnostic + Send + Sync + 'static,
{
    Report::new(inner).with_source_code(NamedSource::new(path.display().to_string(), source_text))
}

/// Convert a [`DiscoveredWorkspace`] (which carries
/// filesystem-coupled `F::CanonicalPath` type info) into a
/// pure-domain [`Workspace`].
///
/// The conversion drops the host-canonical project roots
/// (haz-cli does not need them after the cache root has been
/// established) and keeps the validated projects, overlays, and
/// settings.
fn assemble_domain_workspace<F: Filesystem>(dw: DiscoveredWorkspace<F>) -> Workspace {
    let root = dw.workspace.lexical().clone();
    let projects: BTreeMap<ProjectName, Project> = dw
        .projects
        .iter()
        .map(|lp| (lp.project().name.clone(), lp.project().clone()))
        .collect();
    let overlays = overlays_map(&dw.overlays);
    // Implicit-mode workspaces have no `.haz/workspace.yml`, so
    // every workspace-wide setting defaults per `CFG-027`..`CFG-031`.
    let settings = dw
        .workspace_config
        .map(|c| c.config.settings)
        .unwrap_or_default();
    Workspace {
        root,
        projects,
        overlays,
        settings,
    }
}

#[cfg(test)]
mod tests {
    use super::load_workspace;
    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();
    }

    #[test]
    fn explicit_mode_two_projects_load_cleanly() {
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.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",
        );

        let (workspace, graph) = load_workspace(root).unwrap();
        assert_eq!(workspace.projects.len(), 2);
        // Two task nodes; one hard edge (lib_core:build -> web:bundle).
        assert_eq!(graph.nodes.len(), 2);
        assert_eq!(graph.edges.len(), 1);
    }

    #[test]
    fn implicit_mode_single_project_loads_cleanly() {
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();

        // `.git` directory makes this an implicit-mode workspace
        // per `DISC-001`; `haz.yml` at the root defines the
        // singleton project per `DISC-003`.
        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 (workspace, graph) = load_workspace(root).unwrap();
        assert_eq!(workspace.projects.len(), 1);
        assert_eq!(graph.nodes.len(), 1);
        assert!(graph.edges.is_empty());
    }

    #[test]
    fn no_workspace_marker_surfaces_discovery_error() {
        let tempdir = tempfile::TempDir::new().unwrap();
        // No `.haz/` and no `.git/`; the walk reaches the
        // filesystem root without finding a marker (`DISC-002`).
        let err = load_workspace(tempdir.path()).unwrap_err();
        let rendered = format!("{err:?}");
        assert!(
            rendered.contains("discovery failed"),
            "diagnostic should mention discovery failure: {rendered}",
        );
    }

    #[test]
    fn malformed_workspace_yml_surfaces_load_error() {
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        // `:` at column 0 makes this invalid YAML.
        write_file(&root.join(".haz/workspace.yml"), ":\n");
        let err = load_workspace(root).unwrap_err();
        let rendered = format!("{err:?}");
        // The rich rendering promotes the inner WorkspaceMapError
        // to the Report's primary error, so the wrap string is
        // absent. The header carries the haz::cfg::yaml family
        // code instead.
        assert!(
            rendered.contains("haz::cfg::"),
            "rendered report must include a CFG-family code; got: {rendered}",
        );
    }

    #[test]
    fn malformed_overlay_yml_surfaces_rich_load_error() {
        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",
        );
        // A scalar `command:` instead of a sequence: CFG-021.
        write_file(
            &root.join(".haz/tasks/lint.yml"),
            "tasks:\n  lint:\n    command: \"eslint .\"\n",
        );

        let err = load_workspace(root).unwrap_err();
        let rendered = format!("{err:?}");
        assert!(
            rendered.contains("haz::cfg::021"),
            "rendered report must promote the inner overlay-mapper code; got: {rendered}",
        );
        assert!(
            rendered.contains("tasks.lint.command"),
            "rendered report must include the resolved overlay key path; got: {rendered}",
        );
        assert!(
            rendered.contains("eslint ."),
            "rendered report must include the offending overlay snippet; got: {rendered}",
        );
    }

    #[test]
    fn unresolved_task_ref_surfaces_graph_build_error() {
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();

        write_file(&root.join(".haz/workspace.yml"), "projects:\n  - /lib\n");
        // `lib:build` deps on a non-existent task; `DAG-011`
        // surfaces the unresolved reference at build_task_graph
        // time, not at discovery time.
        write_file(
            &root.join("lib/haz.yml"),
            "name: lib\ntasks:\n  build:\n    command: [\"true\"]\n    deps:\n      - lib:nonexistent\n",
        );
        let err = load_workspace(root).unwrap_err();
        let rendered = format!("{err:?}");
        assert!(
            rendered.contains("task-graph construction failed"),
            "diagnostic should mention graph construction failure: {rendered}",
        );
    }
}