haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! Command-line argument parser for the `haz` binary.
//!
//! Built on `bpaf` 0.9 using the combinator API; the derive
//! macro is deliberately not used so the parser type tree stays
//! decoupled from bpaf's macros. The parser produces a [`Cmd`]
//! sum: one variant per command shape that the spec names
//! normatively (`haz run`, `haz check`, `haz cache clear`,
//! `haz cache clean --soft`).
//!
//! `--help` and `--version` are provided by bpaf automatically;
//! the version string is sourced from `CARGO_PKG_VERSION` at
//! compile time.

use bpaf::{OptionParser, Parser, construct};
pub use haz_domain::scope::Scope;
pub use haz_domain::settings::output::OutputMode;
pub use haz_domain::task_ref::TaskRef;
pub use haz_vcs::GitFilter;

pub(super) mod cache;
pub(super) mod check;
pub(super) mod init;
pub(super) mod query;
pub(super) mod run;
pub(super) mod why;

use crate::cli::cache::cache_command;
use crate::cli::check::check_command;
use crate::cli::init::init_command;
use crate::cli::query::query_command;
use crate::cli::run::run_command;
use crate::cli::why::why_command;

pub use crate::cli::cache::CacheCleanArgs;
pub use crate::cli::check::CheckArgs;
pub use crate::cli::init::InitArgs;
pub use crate::cli::query::{FieldPresence, OutputFormat, QueryArgs};
pub use crate::cli::run::RunArgs;
pub use crate::cli::why::WhyArgs;

/// Top-level command parsed from `argv`.
///
/// Each variant carries the per-command arguments needed to
/// dispatch. The parser layer does not load the workspace, run
/// the scheduler, or touch the filesystem; downstream modules
/// take a [`Cmd`] value and act on it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Cmd {
    /// `haz run [--all | --workspace] [--dry-run] [--output-mode <mode>]
    /// [<TASK_REF>...]` -- run (or, with `--dry-run`, enumerate)
    /// the selected tasks plus their transitive hard predecessors
    /// in the discovered workspace.
    Run(RunArgs),
    /// `haz check [--all | --workspace]` -- validate the discovered
    /// workspace without running any task (`AUX-009`..`AUX-012`).
    Check(CheckArgs),
    /// `haz why <TASK>` -- read-only introspection of one task per
    /// `AUX-013`..`AUX-016`.
    Why(WhyArgs),
    /// `haz cache clear` -- remove every cache entry (`CACHE-021`).
    CacheClear,
    /// `haz cache clean [--soft] [--max-age <DURATION>]
    /// [--max-size <BYTES>] [--dry-run]` -- selective cache
    /// reclamation (`CACHE-022`, `AUX-022`..`AUX-027`). The
    /// executor enforces the "at least one mode" rule
    /// (`AUX-022`) after merging CLI flags with workspace
    /// `cache.clean.*` defaults (`AUX-025`).
    CacheClean(CacheCleanArgs),
    /// `haz cache info` -- read-only cache-state report
    /// (`AUX-017`..`AUX-021`).
    CacheInfo,
    /// `haz query [filter flags]... [--format <fmt>]` -- enumerate
    /// the tasks in the workspace matching every supplied filter
    /// (`QRY-001`..`QRY-009`).
    Query(QueryArgs),
    /// `haz init [--yes]` -- scaffold a workspace
    /// (`AUX-001`..`AUX-008`). The bare form runs the interactive
    /// flow (`AUX-003`..`AUX-005`); `--yes` writes the minimal
    /// `projects: []` workspace per `AUX-007`.
    Init(InitArgs),
}

/// Build the top-level argument parser.
///
/// Returns an [`OptionParser`] that callers consume via
/// `.run()` (for `argv` parsing) or `.run_inner(...)` (for
/// programmatic / test parsing).
pub fn cli() -> OptionParser<Cmd> {
    let run = run_command().map(Cmd::Run).to_options();
    let check = check_command().map(Cmd::Check).to_options();
    let why = why_command().map(Cmd::Why).to_options();
    let cache = cache_command().to_options();
    let query = query_command().map(Cmd::Query).to_options();
    let init = init_command().map(Cmd::Init).to_options();

    let run_subcommand = run.command("run").help("Run one or more tasks");
    let check_subcommand = check
        .command("check")
        .help("Validate the workspace without running (AUX-009..AUX-012)");
    let why_subcommand = why
        .command("why")
        .help("Introspect a single task (AUX-013..AUX-016)");
    let cache_subcommand = cache
        .command("cache")
        .help("Cache management commands (CACHE-021, CACHE-022)");
    let query_subcommand = query
        .command("query")
        .help("Enumerate tasks under a filter expression (QRY-001..QRY-009)");
    let init_subcommand = init
        .command("init")
        .help("Scaffold a workspace (AUX-001..AUX-008)");

    construct!([
        run_subcommand,
        check_subcommand,
        why_subcommand,
        cache_subcommand,
        query_subcommand,
        init_subcommand,
    ])
    .to_options()
    .version(env!("CARGO_PKG_VERSION"))
    .descr("haz: task dependency graph runner")
}

/// Shared test parser harness: run the full `cli()` parser
/// against `args` and surface the bpaf stderr string on error.
/// Lives at module scope so the per-subcommand child test modules
/// can reach it as `crate::cli::parse`.
#[cfg(test)]
fn parse(args: &[&str]) -> Result<Cmd, String> {
    cli().run_inner(args).map_err(|err| err.unwrap_stderr())
}

#[cfg(test)]
mod tests {
    use super::parse;

    #[test]
    fn no_subcommand_rejects() {
        let err = parse(&[]).unwrap_err();
        assert!(
            !err.is_empty(),
            "missing subcommand must produce a diagnostic",
        );
    }
}