haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! Per-invocation context for `haz` subcommands.
//!
//! Every `commands/*/execute` function opens with the same
//! sequence: load the discovered workspace + validated graph,
//! locate the bearing project from cwd, and (for cache-touching
//! commands) construct a cache handle keyed by the workspace's
//! configured hash function (`CFG-027`).
//!
//! [`Context`] centralises that sequence. A single [`Context::open`]
//! call replaces the per-command preamble; per-command code then
//! reaches the workspace, graph, bearing project, filesystem, and
//! cache handles through stable accessors. Adding a new subcommand
//! no longer requires reading an existing command to crib the
//! preamble shape.
//!
//! The cache hash algorithm flows from `workspace.settings.cache.hash`
//! (`CFG-027`), not a per-command constant. A workspace whose
//! `cache.hash` is `sha256` produces a [`Context`] whose
//! [`Context::hash_algo`] returns `HashAlgo::Sha256`, and every
//! command transitively consumes that value. The previous per-
//! command `const HASH_ALGO: HashAlgo = HashAlgo::Blake3;` constants
//! are gone.

use std::path::{Path, PathBuf};

use haz_cache::{CacheReader, CacheWriter};
use haz_dag::graph::TaskGraph;
use haz_discovery::bearing::bearing_project;
use haz_domain::name::ProjectName;
use haz_domain::settings::cache::HashAlgo;
use haz_domain::workspace::Workspace;
use haz_vfs::StdFilesystem;
use miette::Result;

use crate::workspace_load::{load_workspace, load_workspace_accumulating};

/// Per-invocation handle binding the cwd, the discovered workspace,
/// the validated task graph, and the host filesystem.
///
/// Constructed once per `haz` invocation by [`Context::open`] in
/// [`crate::dispatch`] and moved into the matched subcommand by
/// value; per-command `execute` functions take [`Context`] directly
/// and no longer carry a `cwd` parameter (it lives on the Context).
/// A future change to the loading pipeline (e.g. the `AUX-010`
/// error-accumulating shape) lands in one place.
#[derive(Debug, Clone)]
pub struct Context {
    cwd: PathBuf,
    workspace: Workspace,
    graph: TaskGraph,
    fs: StdFilesystem,
}

impl Context {
    /// Open the workspace rooted at or above `cwd` and build the
    /// per-invocation [`Context`].
    ///
    /// This runs the full discovery + DAG-construction pipeline
    /// (`DISC-001..DISC-009`, `DAG-001..DAG-018`) via
    /// `load_workspace` and pairs the result with the cwd and a
    /// fresh [`StdFilesystem`] handle.
    ///
    /// # Errors
    ///
    /// Returns a [`miette::Report`] for any discovery, parse, or
    /// graph-construction failure. The binary maps the report to
    /// [`crate::EXIT_INTERNAL_OR_CONFIG_ERROR`].
    pub fn open(cwd: &Path) -> Result<Self> {
        let (workspace, graph) = load_workspace(cwd)?;
        Ok(Self {
            cwd: cwd.to_path_buf(),
            workspace,
            graph,
            fs: StdFilesystem::new(),
        })
    }

    /// `haz check` variant of [`Context::open`] that surfaces every
    /// per-file load diagnostic at once rather than promoting only
    /// the first parse error, per `AUX-010` / `AUX-011`. See
    /// `load_workspace_accumulating`.
    ///
    /// Used only by the `Cmd::Check` dispatch arm; the other
    /// subcommands open through [`Context::open`], whose first-error
    /// short-circuit is spec-compliant for them.
    ///
    /// # Errors
    ///
    /// As [`Context::open`].
    pub fn open_accumulating(cwd: &Path) -> Result<Self> {
        let (workspace, graph) = load_workspace_accumulating(cwd)?;
        Ok(Self {
            cwd: cwd.to_path_buf(),
            workspace,
            graph,
            fs: StdFilesystem::new(),
        })
    }

    /// The cwd the [`Context`] was opened against.
    #[must_use]
    pub fn cwd(&self) -> &Path {
        &self.cwd
    }

    /// The effective workspace value object (post-overlay-merge
    /// per `DAG-001..DAG-006`).
    #[must_use]
    pub fn workspace(&self) -> &Workspace {
        &self.workspace
    }

    /// The validated task graph (`DAG-008`/`DAG-009`/`DAG-013` edges
    /// over `DAG-007` nodes).
    #[must_use]
    pub fn graph(&self) -> &TaskGraph {
        &self.graph
    }

    /// The host filesystem the binary speaks against.
    ///
    /// Exposed for callers that need a `&StdFilesystem` outside the
    /// cache surface (notably the `haz_exec` `RunContext`'s `fs`
    /// field, which borrows it through the cache writer in
    /// [`commands::run`]).
    ///
    /// [`commands::run`]: crate::commands::run
    #[must_use]
    pub fn fs(&self) -> &StdFilesystem {
        &self.fs
    }

    /// The bearing project for this invocation, per `EXEC-022`.
    ///
    /// Resolved against [`Context::cwd`].
    #[must_use]
    pub fn bearing(&self) -> Option<&ProjectName> {
        bearing_project(&self.cwd, &self.workspace)
    }

    /// The hash algorithm in effect for this workspace, per
    /// `CFG-027` (`cache.hash`).
    ///
    /// Defaults to `HashAlgo::Blake3` when the workspace omits
    /// the setting (the `WorkspaceSettings::default` path used by
    /// implicit-mode workspaces and by explicit-mode workspaces
    /// whose `workspace.yml` omits the `cache:` mapping).
    #[must_use]
    pub fn hash_algo(&self) -> HashAlgo {
        self.workspace.settings.cache.hash
    }

    /// Build a fresh [`CacheReader`] bound to this workspace's
    /// cache root and hash algorithm.
    ///
    /// Read-only consumers (`haz cache info` per `AUX-018`, the
    /// cache-introspection step of `haz why` per `AUX-014`) take
    /// this handle and gain the compile-time read-only guarantee
    /// of the cache reader / writer split.
    #[must_use]
    pub fn cache_reader(&self) -> CacheReader<StdFilesystem> {
        CacheReader::new(self.fs, self.workspace.root.as_path(), self.hash_algo())
    }

    /// Build a fresh [`CacheWriter`] bound to this workspace's
    /// cache root and hash algorithm.
    ///
    /// Write-side consumers (`haz run` for stores; `haz cache
    /// clear` and `haz cache clean` for invalidation) take this
    /// handle.
    #[must_use]
    pub fn cache_writer(&self) -> CacheWriter<StdFilesystem> {
        CacheWriter::new(self.fs, self.workspace.root.as_path(), self.hash_algo())
    }
}

#[cfg(test)]
mod tests {
    use super::Context;
    use haz_domain::settings::cache::HashAlgo;
    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();
    }

    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 open_loads_workspace_and_graph() {
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        explicit_two_project_workspace(root);

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

    #[test]
    fn open_surfaces_workspace_load_errors() {
        let tempdir = tempfile::TempDir::new().unwrap();
        // No `.haz/` and no `.git/`: the discovery walk reaches the
        // filesystem root without finding a marker (`DISC-002`).
        assert!(Context::open(tempdir.path()).is_err());
    }

    #[test]
    fn open_accumulating_surfaces_every_parse_error() {
        // AUX-010 / AUX-011: the `haz check` constructor surfaces
        // every broken project file at once, not just the first.
        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 err = Context::open_accumulating(root).unwrap_err();
        let rendered = format!("{err:?}");
        assert!(
            rendered.contains("AAA") && rendered.contains("BBB"),
            "both broken files must surface through the check context: {rendered}",
        );
    }

    #[test]
    fn bearing_inside_a_project_picks_that_project() {
        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 ctx = Context::open(&root.join("lib_core/src")).unwrap();
        let bearing = ctx.bearing().unwrap();
        assert_eq!(bearing.to_string(), "lib_core");
    }

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

        let ctx = Context::open(root).unwrap();
        assert!(ctx.bearing().is_none());
    }

    #[test]
    fn cwd_accessor_returns_the_value_passed_to_open() {
        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 starting = root.join("lib_core/src");

        let ctx = Context::open(&starting).unwrap();
        assert_eq!(ctx.cwd(), starting.as_path());
    }

    #[test]
    fn hash_algo_defaults_to_blake3() {
        // The workspace's `cache:` mapping is absent, so
        // `WorkspaceSettings::default` populates
        // `cache.hash = Blake3` per `CFG-027`.
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        explicit_two_project_workspace(root);

        let ctx = Context::open(root).unwrap();
        assert_eq!(ctx.hash_algo(), HashAlgo::Blake3);
    }

    #[test]
    fn hash_algo_honours_workspace_cache_hash_setting() {
        // CFG-027: `cache.hash: sha256` flows through to
        // `Context::hash_algo`. This is the only place the CLI
        // reads the setting, so a regression here would silently
        // produce manifests under the wrong algorithm.
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        write_file(
            &root.join(".haz/workspace.yml"),
            "projects:\n  - /lib\ncache:\n  hash: sha256\n",
        );
        write_file(
            &root.join("lib/haz.yml"),
            "name: lib\ntasks:\n  build:\n    command: [\"true\"]\n",
        );

        let ctx = Context::open(root).unwrap();
        assert_eq!(ctx.hash_algo(), HashAlgo::Sha256);
    }

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

        let ctx = Context::open(root).unwrap();
        let reader = ctx.cache_reader();
        assert_eq!(reader.cache_root(), root.join(".haz/cache"));
        assert_eq!(reader.hash_algo(), HashAlgo::Blake3);
    }

    #[test]
    fn cache_writer_is_bound_to_workspace_root_and_hash() {
        // CFG-027: the writer carries the configured hash so a
        // store under sha256 lands in a manifest whose
        // `hash_function` matches.
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        write_file(
            &root.join(".haz/workspace.yml"),
            "projects:\n  - /lib\ncache:\n  hash: sha256\n",
        );
        write_file(
            &root.join("lib/haz.yml"),
            "name: lib\ntasks:\n  build:\n    command: [\"true\"]\n",
        );

        let ctx = Context::open(root).unwrap();
        let writer = ctx.cache_writer();
        assert_eq!(writer.cache_root(), root.join(".haz/cache"));
        assert_eq!(writer.hash_algo(), HashAlgo::Sha256);
    }
}