getargs-derive 0.1.0

Clap-style #[derive] command-line argument parser for no_std and embedded Rust: zero allocation, compile-time help, subcommands. Built on getargs.
Documentation
#![allow(dead_code)]

use core::num::ParseIntError;
use getargs_derive::{Error, GetArgs};

#[derive(Debug, PartialEq)]
struct Port(u16);

fn parse_port(value: &str) -> Result<Port, ParseIntError> {
    value.parse().map(Port)
}

fn non_empty(value: &str) -> Result<&str, ()> {
    match value.trim() {
        "" => Err(()),
        trimmed => Ok(trimmed),
    }
}

mod parsers {
    pub fn even(value: &str) -> Result<u32, ()> {
        match value.parse::<u32>() {
            Ok(number) if number % 2 == 0 => Ok(number),
            _ => Err(()),
        }
    }
}

#[derive(Debug, GetArgs)]
#[command(name = "validation")]
struct Validated<'a> {
    #[arg(value_parser = non_empty)]
    name: &'a str,
    #[arg(short, long, value_parser = parse_port, default_value = "8080")]
    port: Port,
    #[arg(short, long, value_parser = 1..=64, default_value_t = 4)]
    jobs: u8,
    #[arg(long, value_parser = parsers::even)]
    even: Option<u32>,
    #[arg(short, long, possible_values = ["fast", "slow"], default_value = "fast")]
    mode: &'a str,
    #[arg(long, possible_values = ["1", "2", "4"])]
    level: Option<u8>,
}

#[test]
fn defaults_go_through_validation() {
    let params = Validated::from_args(["app"].into_iter()).unwrap();
    assert_eq!(params.name, "app");
    assert_eq!(params.port, Port(8080));
    assert_eq!(params.jobs, 4);
    assert_eq!(params.even, None);
    assert_eq!(params.mode, "fast");
    assert_eq!(params.level, None);
}

#[test]
fn value_parser_functions() {
    let args = [" app ", "-p", "3000", "--even", "6"];
    let params = Validated::from_args(args.into_iter()).unwrap();
    assert_eq!(params.name, "app");
    assert_eq!(params.port, Port(3000));
    assert_eq!(params.even, Some(6));

    let err = Validated::from_args(["  "].into_iter()).unwrap_err();
    assert_eq!(
        err,
        Error::InvalidValue {
            name: "<NAME>",
            value: "  "
        }
    );

    let err = Validated::from_args(["app", "--port", "http"].into_iter()).unwrap_err();
    assert_eq!(
        err,
        Error::InvalidValue {
            name: "--port <PORT>",
            value: "http"
        }
    );

    let err = Validated::from_args(["app", "--even", "3"].into_iter()).unwrap_err();
    assert_eq!(
        err,
        Error::InvalidValue {
            name: "--even <EVEN>",
            value: "3"
        }
    );
}

#[test]
fn ranges() {
    let params = Validated::from_args(["app", "-j", "1"].into_iter()).unwrap();
    assert_eq!(params.jobs, 1);
    let params = Validated::from_args(["app", "-j", "64"].into_iter()).unwrap();
    assert_eq!(params.jobs, 64);

    let err = Validated::from_args(["app", "-j", "0"].into_iter()).unwrap_err();
    assert_eq!(
        err,
        Error::ValueOutOfRange {
            name: "--jobs <JOBS>",
            value: "0",
            range: "1..=64"
        }
    );
    assert_eq!(
        err.to_string(),
        "invalid value '0' for '--jobs <JOBS>': 0 is not in 1..=64"
    );

    let err = Validated::from_args(["app", "-j", "65"].into_iter()).unwrap_err();
    assert!(matches!(err, Error::ValueOutOfRange { value: "65", .. }));

    let err = Validated::from_args(["app", "-j", "x"].into_iter()).unwrap_err();
    assert_eq!(
        err,
        Error::InvalidValue {
            name: "--jobs <JOBS>",
            value: "x"
        }
    );
}

#[test]
fn possible_values() {
    let params = Validated::from_args(["app", "--mode=slow", "--level", "4"].into_iter()).unwrap();
    assert_eq!(params.mode, "slow");
    assert_eq!(params.level, Some(4));

    let err = Validated::from_args(["app", "-m", "turbo"].into_iter()).unwrap_err();
    assert_eq!(
        err,
        Error::InvalidPossibleValue {
            name: "--mode <MODE>",
            value: "turbo",
            possible_values: &["fast", "slow"],
        }
    );
    assert_eq!(
        err.to_string(),
        "invalid value 'turbo' for '--mode <MODE>'\n  [possible values: fast, slow]"
    );

    let err = Validated::from_args(["app", "--level", "04"].into_iter()).unwrap_err();
    assert_eq!(
        err,
        Error::InvalidPossibleValue {
            name: "--level <LEVEL>",
            value: "04",
            possible_values: &["1", "2", "4"],
        }
    );
}

#[derive(Debug, GetArgs)]
struct Ranges {
    #[arg(long, value_parser = 1..)]
    from: Option<u32>,
    #[arg(long, value_parser = ..10)]
    to: Option<u32>,
    #[arg(long, value_parser = ..=-1)]
    negative: Option<i8>,
    #[arg(long, value_parser = -5..5)]
    window: Option<i64>,
    #[arg(long, value_parser = 1..=8, default_value = "9")]
    invalid_default: u8,
}

#[test]
fn range_forms() {
    let args = [
        "--from",
        "7",
        "--to",
        "9",
        "--negative=-3",
        "--window=-5",
        "--invalid-default",
        "8",
    ];
    let params = Ranges::from_args(args.into_iter()).unwrap();
    assert_eq!(params.from, Some(7));
    assert_eq!(params.to, Some(9));
    assert_eq!(params.negative, Some(-3));
    assert_eq!(params.window, Some(-5));
    assert_eq!(params.invalid_default, 8);

    let cases: [(&[&str], &str); 4] = [
        (&["--from", "0"], "1.."),
        (&["--to", "10"], "..10"),
        (&["--negative", "0"], "..=-1"),
        (&["--window", "5"], "-5..5"),
    ];
    for (args, expected) in cases {
        let args = args.iter().copied().chain(["--invalid-default", "1"]);
        match Ranges::from_args(args).unwrap_err() {
            Error::ValueOutOfRange { range, .. } => assert_eq!(range, expected),
            other => panic!("unexpected error: {other:?}"),
        }
    }

    let err = Ranges::from_args([].into_iter()).unwrap_err();
    assert_eq!(
        err,
        Error::ValueOutOfRange {
            name: "--invalid-default <INVALID_DEFAULT>",
            value: "9",
            range: "1..=8"
        }
    );
}

#[derive(Debug, GetArgs)]
#[command(name = "validation")]
struct Combined<'a> {
    #[arg(possible_values = ["2", "3", "20"], value_parser = 1..=10)]
    count: u32,
    #[arg(long, possible_values = ["a b", "c"], value_parser = non_empty)]
    spaced: Option<&'a str>,
}

#[test]
fn combined_validation_order() {
    let params = Combined::from_args(["3", "--spaced", "a b"].into_iter()).unwrap();
    assert_eq!(params.count, 3);
    assert_eq!(params.spaced, Some("a b"));

    let err = Combined::from_args(["5"].into_iter()).unwrap_err();
    assert!(matches!(
        err,
        Error::InvalidPossibleValue { value: "5", .. }
    ));

    let err = Combined::from_args(["20"].into_iter()).unwrap_err();
    assert!(matches!(err, Error::ValueOutOfRange { value: "20", .. }));

    let err = Combined::from_args(["2", "--spaced", "d"].into_iter()).unwrap_err();
    assert_eq!(
        err.to_string(),
        "invalid value 'd' for '--spaced <SPACED>'\n  [possible values: \"a b\", c]"
    );
}

#[test]
fn help_lists_possible_values() {
    assert_eq!(
        Validated::HELP,
        "Usage: validation [OPTIONS] <NAME>

Arguments:
  <NAME>

Options:
  -p, --port <PORT>    [default: 8080]
  -j, --jobs <JOBS>    [default: 4]
      --even <EVEN>
  -m, --mode <MODE>    [default: fast] [possible values: fast, slow]
      --level <LEVEL>  [possible values: 1, 2, 4]
  -h, --help           Print help"
    );
    assert_eq!(
        Combined::HELP,
        "Usage: validation [OPTIONS] <COUNT>

Arguments:
  <COUNT>  [possible values: 2, 3, 20]

Options:
      --spaced <SPACED>  [possible values: \"a b\", c]
  -h, --help             Print help"
    );
}

#[derive(Debug, GetArgs)]
struct FromEnv<'a> {
    #[arg(long, env = "JOBS", value_parser = 1..=64)]
    jobs: Option<u8>,
    #[arg(long, env = "MODE", possible_values = ["fast", "slow"])]
    mode: Option<&'a str>,
}

#[test]
fn env_values_are_validated() {
    let vars = [("JOBS", "8"), ("MODE", "slow")];
    let lookup = |name: &str| {
        vars.iter()
            .find(|(key, _)| *key == name)
            .map(|(_, value)| *value)
    };
    let cli = FromEnv::from_args_with_env([].into_iter(), lookup).unwrap();
    assert_eq!(cli.jobs, Some(8));
    assert_eq!(cli.mode, Some("slow"));

    let err = FromEnv::from_args_with_env([].into_iter(), |name: &str| {
        (name == "JOBS").then_some("65")
    })
    .unwrap_err();
    assert_eq!(
        err,
        Error::ValueOutOfRange {
            name: "--jobs <JOBS> [env: JOBS]",
            value: "65",
            range: "1..=64"
        }
    );

    let err = FromEnv::from_args_with_env([].into_iter(), |name: &str| {
        (name == "MODE").then_some("turbo")
    })
    .unwrap_err();
    assert_eq!(
        err,
        Error::InvalidPossibleValue {
            name: "--mode <MODE> [env: MODE]",
            value: "turbo",
            possible_values: &["fast", "slow"]
        }
    );
}