haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! Parser for the `haz check` subcommand.
//!
//! Owns [`CheckArgs`] and the [`check_command`] parser builder.
//! `haz check` accepts only the `AUX-009` scope flags; their
//! mutual exclusion is enforced at parse time.

use bpaf::{Parser, construct, long};

use crate::cli::Scope;

/// Arguments parsed for the `haz check` subcommand.
///
/// `haz check` accepts only the scope flags (`--all`,
/// `--workspace`) per `AUX-009`. Like `haz run`, the two scope
/// flags are mutually exclusive; the parser reduces them to a
/// single [`Scope`] so any [`CheckArgs`] value reaching the
/// executor is in a well-formed shape.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CheckArgs {
    /// How broadly to validate (`AUX-009`): the bearing project
    /// or whole workspace ([`Scope::Default`]), every task in the
    /// bearing scope ([`Scope::All`], mirroring `EXEC-024`), or
    /// every task in every project ([`Scope::Workspace`],
    /// mirroring `EXEC-025`). The `--all` / `--workspace` mutex is
    /// reduced from the two switches at parse time.
    pub scope: Scope,
}

pub(super) fn check_command() -> impl Parser<CheckArgs> {
    let all = long("all")
        .help("Validate every task in the bearing scope (AUX-009, mirrors EXEC-024)")
        .switch();
    let workspace = long("workspace")
        .help("Validate every task in every project, regardless of cwd (AUX-009, mirrors EXEC-025)")
        .switch();
    construct!(all, workspace)
        // AUX-009 scope mutex. Caught at parse time so the
        // executor never sees both flags set.
        .parse(|(all, workspace)| {
            Scope::from_flags(all, workspace)
                .map(|scope| CheckArgs { scope })
                .ok_or_else(|| "`--all` and `--workspace` are mutually exclusive".to_owned())
        })
}

#[cfg(test)]
mod tests {
    use super::CheckArgs;
    use crate::cli::{Cmd, Scope, parse};

    #[test]
    fn aux_009_check_with_no_args_parses() {
        assert_eq!(parse(&["check"]).unwrap(), Cmd::Check(CheckArgs::default()),);
    }

    #[test]
    fn aux_009_check_with_all_flag_parses() {
        assert_eq!(
            parse(&["check", "--all"]).unwrap(),
            Cmd::Check(CheckArgs { scope: Scope::All }),
        );
    }

    #[test]
    fn aux_009_check_with_workspace_flag_parses() {
        assert_eq!(
            parse(&["check", "--workspace"]).unwrap(),
            Cmd::Check(CheckArgs {
                scope: Scope::Workspace,
            }),
        );
    }

    #[test]
    fn aux_009_check_all_plus_workspace_rejects() {
        // AUX-009 scope mutex (mirrors EXEC-024 / EXEC-025).
        let err = parse(&["check", "--all", "--workspace"]).unwrap_err();
        assert!(
            err.contains("mutually exclusive"),
            "diagnostic should name the mutex: {err}",
        );
    }

    #[test]
    fn aux_009_check_rejects_unknown_flag() {
        // AUX-009 form contract: no positional or extra flags
        // beyond --all / --workspace.
        let err = parse(&["check", "--dry-run"]).unwrap_err();
        assert!(
            !err.is_empty(),
            "unknown flag must produce a diagnostic: {err}",
        );
    }

    #[test]
    fn aux_009_check_rejects_positional() {
        // AUX-009 form contract: haz check takes no positional
        // arguments.
        let err = parse(&["check", "lib:build"]).unwrap_err();
        assert!(
            !err.is_empty(),
            "positional argument must produce a diagnostic: {err}",
        );
    }
}