hen 0.15.0

Run protocol-aware API request collections from the command line or through MCP.
Documentation
#[derive(clap::Args, Debug, Clone, Default)]
pub(crate) struct RunArgs {
    pub(crate) path: Option<String>,
    pub(crate) selector: Option<String>,

    #[arg(long)]
    pub(crate) export: bool,

    #[arg(long)]
    pub(crate) benchmark: Option<usize>,

    #[arg(short = 'v', long)]
    pub(crate) verbose: bool,

    #[arg(long = "input", value_parser = parse_input_kv)]
    pub(crate) inputs: Vec<(String, String)>,

    #[arg(long, help = "Execute independent requests concurrently")]
    pub(crate) parallel: bool,

    #[arg(
        long = "max-concurrency",
        default_value_t = 0,
        help = "Limit concurrent requests when running with --parallel"
    )]
    pub(crate) max_concurrency: usize,

    #[arg(
        long = "continue-on-error",
        help = "Do not stop execution after the first request failure"
    )]
    pub(crate) continue_on_error: bool,

    #[arg(
        long,
        help = "Fail instead of prompting for collection or request selection"
    )]
    pub(crate) non_interactive: bool,

    #[arg(
        long,
        value_enum,
        default_value_t = OutputFormat::Text,
        help = "Render output as text, json, ndjson, or junit"
    )]
    pub(crate) output: OutputFormat,
}

#[derive(clap::Args, Debug, Clone)]
pub(crate) struct VerifyArgs {
    pub(crate) path: String,

    #[arg(
        long,
        value_enum,
        default_value_t = OutputFormat::Text,
        help = "Render output as text, json, ndjson, or junit"
    )]
    pub(crate) output: OutputFormat,
}

#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum OutputFormat {
    #[default]
    Text,
    Json,
    Ndjson,
    Junit,
}

impl OutputFormat {
    pub(crate) fn is_text(self) -> bool {
        matches!(self, Self::Text)
    }

    pub(crate) fn as_str(self) -> &'static str {
        match self {
            Self::Text => "text",
            Self::Json => "json",
            Self::Ndjson => "ndjson",
            Self::Junit => "junit",
        }
    }
}

#[derive(clap::Subcommand, Debug, Clone)]
pub(crate) enum Command {
    /// Execute a collection or request
    Run(RunArgs),
    /// Parse and validate a collection without executing shell substitutions or HTTP requests
    Verify(VerifyArgs),
}

pub(crate) enum Invocation {
    Run(RunArgs),
    Verify(VerifyArgs),
}

#[derive(clap::Parser, Debug)]
#[command(name = "hen")]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[command(about = "Command line API client.")]
pub(crate) struct Cli {
    #[command(subcommand)]
    command: Option<Command>,

    #[command(flatten)]
    run: RunArgs,
}

impl Cli {
    pub(crate) fn into_invocation(self) -> Invocation {
        match self.command {
            Some(Command::Run(args)) => Invocation::Run(args),
            Some(Command::Verify(args)) => Invocation::Verify(args),
            None => Invocation::Run(self.run),
        }
    }
}

pub(crate) struct CommandOutcome {
    pub(crate) exit_code: i32,
}

impl CommandOutcome {
    pub(crate) fn success() -> Self {
        Self { exit_code: 0 }
    }

    pub(crate) fn with_exit_code(exit_code: i32) -> Self {
        Self { exit_code }
    }
}

fn parse_input_kv(s: &str) -> Result<(String, String), String> {
    let mut parts = s.splitn(2, '=');
    let key = parts
        .next()
        .map(str::trim)
        .filter(|k| !k.is_empty())
        .ok_or_else(|| "--input expects key=value".to_string())?;
    let value = parts
        .next()
        .map(|v| v.trim().to_string())
        .ok_or_else(|| "--input expects key=value".to_string())?;
    Ok((key.to_string(), value))
}

#[cfg(test)]
mod tests {
    use clap::Parser;

    use super::*;

    #[test]
    fn cli_parses_run_subcommand_non_interactive_mode() {
        let cli = Cli::try_parse_from(["hen", "run", "./example.hen", "all", "--non-interactive"])
            .expect("cli should parse");

        let Invocation::Run(args) = cli.into_invocation() else {
            panic!("expected run invocation");
        };
        assert_eq!(args.path.as_deref(), Some("./example.hen"));
        assert_eq!(args.selector.as_deref(), Some("all"));
        assert!(args.non_interactive);
    }

    #[test]
    fn cli_parses_verify_subcommand() {
        let cli =
            Cli::try_parse_from(["hen", "verify", "./example.hen"]).expect("cli should parse");

        let Invocation::Verify(args) = cli.into_invocation() else {
            panic!("expected verify invocation");
        };
        assert_eq!(args.path, "./example.hen");
    }

    #[test]
    fn cli_parses_run_output_format() {
        let cli = Cli::try_parse_from(["hen", "run", "./example.hen", "--output", "json"])
            .expect("cli should parse");

        let Invocation::Run(args) = cli.into_invocation() else {
            panic!("expected run invocation");
        };
        assert_eq!(args.output, OutputFormat::Json);
    }
}