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 why` subcommand.
//!
//! Owns [`WhyArgs`] and the [`why_command`] parser builder.
//! `haz why` takes exactly one positional task reference per
//! `AUX-013` and no flags for v1.

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

use crate::cli::TaskRef;

/// Arguments parsed for the `haz why` subcommand.
///
/// `haz why` takes exactly one positional task reference per
/// `AUX-013` and no flags for v1. The reference is parsed and
/// syntactically validated (`REF-001`..`REF-008`) here at the CLI
/// surface; resolution against the workspace and the "exactly one
/// node" check happen at the executor, after workspace load.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WhyArgs {
    /// The validated task reference the user named on the command
    /// line. Syntactic validation (`REF-001`..`REF-008`) happens at
    /// parse time; resolution happens after the workspace and graph
    /// have loaded.
    pub target: TaskRef,
}

pub(super) fn why_command() -> impl Parser<WhyArgs> {
    // AUX-013 form contract: exactly one positional task
    // reference and no flags defined for v1. Syntactic validation
    // (REF-001..REF-008) happens here; resolution and the
    // "exactly one node" check happen at the executor.
    let target = positional::<String>("TASK")
        .help("Task reference (REF-001..REF-008)")
        .parse(|s| TaskRef::parse(&s).map_err(|e| e.to_string()));
    construct!(WhyArgs { target })
}

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

    fn task_ref(s: &str) -> TaskRef {
        TaskRef::parse(s).unwrap()
    }

    #[test]
    fn aux_013_why_parses_a_single_positional_target() {
        assert_eq!(
            parse(&["why", "lib:build"]).unwrap(),
            Cmd::Why(WhyArgs {
                target: task_ref("lib:build"),
            }),
        );
    }

    #[test]
    fn aux_013_why_accepts_fan_out_reference_form_at_parse_time() {
        // REF-001..REF-008 admits forms like `^:test` (fan-out by
        // task name across projects). The parser validates the
        // reference syntax here; resolution happens at the executor
        // per AUX-013.
        assert_eq!(
            parse(&["why", "^:test"]).unwrap(),
            Cmd::Why(WhyArgs {
                target: task_ref("^:test"),
            }),
        );
    }

    #[test]
    fn aux_013_why_rejects_malformed_reference_at_parse_time() {
        // REF-006: a bare word with no `:` separator is not a valid
        // reference. The CLI surface rejects it before any workspace
        // load, rather than deferring to the executor.
        let err = parse(&["why", "no-colon"]).unwrap_err();
        assert!(
            !err.is_empty(),
            "malformed reference must produce a parse diagnostic: {err}",
        );
    }

    #[test]
    fn aux_013_why_rejects_missing_positional() {
        // AUX-013 form contract: exactly one positional argument.
        let err = parse(&["why"]).unwrap_err();
        assert!(
            !err.is_empty(),
            "missing positional must produce a diagnostic: {err}",
        );
    }

    #[test]
    fn aux_013_why_rejects_extra_positional() {
        // AUX-013 form contract: at most one positional argument.
        let err = parse(&["why", "lib:build", "other:task"]).unwrap_err();
        assert!(
            !err.is_empty(),
            "extra positional must produce a diagnostic: {err}",
        );
    }

    #[test]
    fn aux_013_why_rejects_unknown_flag() {
        // AUX-013 form contract: no flags defined for v1.
        let err = parse(&["why", "--all", "lib:build"]).unwrap_err();
        assert!(
            !err.is_empty(),
            "unknown flag must produce a diagnostic: {err}",
        );
    }
}