use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Arguments {
command: String,
positional: Vec<String>,
options: HashMap<String, String>,
flags: HashSet<String>,
}
impl Arguments {
pub fn parse<I, S>(argv: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut parsed = Arguments::default();
let mut only_positional = false;
for raw in argv {
let argument: String = raw.into();
if only_positional {
parsed.push_positional(argument);
continue;
}
if argument == "--" {
only_positional = true;
continue;
}
if let Some(rest) = argument.strip_prefix("--") {
match rest.split_once('=') {
Some((name, value)) => {
parsed.options.insert(name.to_string(), value.to_string());
}
None => {
parsed.flags.insert(rest.to_string());
}
}
continue;
}
if let Some(rest) = argument.strip_prefix('-') {
if !rest.is_empty() && !rest.starts_with(|c: char| c.is_ascii_digit()) {
for flag in rest.chars() {
parsed.flags.insert(flag.to_string());
}
continue;
}
}
parsed.push_positional(argument);
}
parsed
}
fn push_positional(&mut self, argument: String) {
if self.command.is_empty() {
self.command = argument;
} else {
self.positional.push(argument);
}
}
pub fn command(&self) -> &str {
&self.command
}
pub fn is_empty(&self) -> bool {
self.command.is_empty()
}
pub fn positional(&self) -> &[String] {
&self.positional
}
pub fn argument(&self, index: usize) -> Option<&str> {
self.positional.get(index).map(String::as_str)
}
pub fn option(&self, name: &str) -> Option<&str> {
let value = self.options.get(name).map(String::as_str);
if value.is_none() && self.flags.contains(name) {
tracing::warn!(
"`--{name}` was given with no value; write `--{name}=value`. Ignoring it."
);
}
value
}
pub fn option_or<'a>(&'a self, name: &str, default: &'a str) -> &'a str {
self.option(name).unwrap_or(default)
}
pub fn parsed_or<T: std::str::FromStr>(&self, name: &str, default: T) -> T {
self.option(name).and_then(|value| value.parse().ok()).unwrap_or(default)
}
pub fn flag(&self, name: &str) -> bool {
self.flags.contains(name)
}
pub fn is_valueless(&self, name: &str) -> bool {
self.flags.contains(name) && !self.options.contains_key(name)
}
pub fn wants_help(&self) -> bool {
self.flag("help") || self.flag("h") || self.command == "help"
}
pub fn options(&self) -> &HashMap<String, String> {
&self.options
}
pub fn flags(&self) -> &HashSet<String> {
&self.flags
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(line: &str) -> Arguments {
Arguments::parse(line.split_whitespace())
}
#[test]
fn the_first_word_is_the_command() {
let args = parse("route:list");
assert_eq!(args.command(), "route:list");
assert!(args.positional().is_empty());
assert!(!args.is_empty());
}
#[test]
fn an_empty_line_has_no_command() {
assert!(Arguments::parse(Vec::<String>::new()).is_empty());
}
#[test]
fn later_words_are_positional() {
let args = parse("make:controller PostController extra");
assert_eq!(args.command(), "make:controller");
assert_eq!(args.argument(0), Some("PostController"));
assert_eq!(args.argument(1), Some("extra"));
assert_eq!(args.argument(2), None);
}
#[test]
fn long_options_carry_a_value() {
let args = parse("serve --port=3000 --host=0.0.0.0");
assert_eq!(args.option("port"), Some("3000"));
assert_eq!(args.option("host"), Some("0.0.0.0"));
assert_eq!(args.parsed_or("port", 8000u16), 3000);
}
#[test]
fn a_missing_or_unparsable_option_falls_back() {
let args = parse("serve --port=nonsense");
assert_eq!(args.parsed_or("port", 8000u16), 8000);
assert_eq!(args.parsed_or("timeout", 30u32), 30);
assert_eq!(args.option_or("host", "127.0.0.1"), "127.0.0.1");
}
#[test]
fn a_valueless_long_argument_is_a_flag() {
let args = parse("queue:work --once --quiet");
assert!(args.flag("once"));
assert!(args.flag("quiet"));
assert!(!args.flag("verbose"));
assert!(args.option("once").is_none());
}
#[test]
fn short_flags_can_be_bundled() {
let args = parse("migrate -vf");
assert!(args.flag("v"));
assert!(args.flag("f"));
assert_eq!(args.flags().len(), 2);
}
#[test]
fn a_separated_value_is_positional_not_an_option() {
let args = parse("serve --port 3000");
assert!(args.flag("port"));
assert_eq!(args.option("port"), None);
assert_eq!(args.argument(0), Some("3000"));
}
#[test]
fn a_double_dash_ends_option_parsing() {
let args = parse("run -- --not-a-flag positional");
assert_eq!(args.command(), "run");
assert_eq!(args.positional(), ["--not-a-flag", "positional"]);
assert!(args.flags().is_empty());
}
#[test]
fn a_negative_number_is_not_a_flag_bundle() {
let args = parse("offset -5");
assert_eq!(args.argument(0), Some("-5"));
assert!(args.flags().is_empty());
}
#[test]
fn an_empty_option_value_is_kept() {
let args = parse("serve --host=");
assert_eq!(args.option("host"), Some(""));
}
#[test]
fn help_is_recognised_however_it_is_spelled() {
assert!(parse("serve --help").wants_help());
assert!(parse("serve -h").wants_help());
assert!(parse("help").wants_help());
assert!(!parse("serve").wants_help());
}
#[test]
fn an_option_given_without_a_value_is_not_silently_a_flag() {
let args = Arguments::parse(["serve", "--port", "8091"]);
assert!(args.is_valueless("port"));
assert_eq!(args.option("port"), None);
assert_eq!(args.argument(0), Some("8091"), "the value became a positional");
let args = Arguments::parse(["serve", "--port=8091"]);
assert_eq!(args.option("port"), Some("8091"));
assert!(!args.is_valueless("port"));
}
}