//! A small, dependency-free CLI argument parser. use std::collections::{HashMap, HashSet}; use std::env; use std::fmt; #[derive(Debug, Default, PartialEq, Eq)] pub struct Arguments { flags: HashSet, options: HashMap, positionals: Vec, } #[derive(Debug, PartialEq, Eq)] pub enum ParseError { MissingValue(String), UnexpectedOption(String), } impl fmt::Display for ParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MissingValue(option) => write!(f, "missing value for option '{option}'"), Self::UnexpectedOption(option) => write!(f, "unexpected option '{option}'"), } } } impl std::error::Error for ParseError {} impl Arguments { /// Parses arguments according to the provided flag and value-option names. pub fn parse( input: I, known_flags: &[&str], known_options: &[&str], ) -> Result where I: IntoIterator, S: Into, { let flags: HashSet<&str> = known_flags.iter().copied().collect(); let options: HashSet<&str> = known_options.iter().copied().collect(); let mut input = input.into_iter().map(Into::into).peekable(); let mut parsed = Self::default(); let mut options_enabled = true; while let Some(argument) = input.next() { if options_enabled && argument == "--" { options_enabled = false; continue; } if options_enabled && argument.starts_with("--") { let body = &argument[2..]; let (name, inline_value) = match body.split_once('=') { Some((name, value)) => (name, Some(value.to_owned())), None => (body, None), }; if flags.contains(name) { if inline_value.is_some() { return Err(ParseError::UnexpectedOption(argument)); } parsed.flags.insert(name.to_owned()); } else if options.contains(name) { let value = match inline_value { Some(value) => value, None => input .next() .ok_or_else(|| ParseError::MissingValue(format!("--{name}")))?, }; parsed.options.insert(name.to_owned(), value); } else { return Err(ParseError::UnexpectedOption(argument)); } } else { parsed.positionals.push(argument); } } Ok(parsed) } pub fn has_flag(&self, name: &str) -> bool { self.flags.contains(name) } pub fn option(&self, name: &str) -> Option<&str> { self.options.get(name).map(String::as_str) } pub fn positionals(&self) -> &[String] { &self.positionals } } fn print_usage(program: &str) { println!("Usage: {program} [--verbose] [--name NAME] [--count N] [--] [ARGS...]"); } fn main() { let mut raw = env::args(); let program = raw.next().unwrap_or_else(|| "app".to_owned()); let arguments = match Arguments::parse(raw, &["help", "verbose"], &["name", "count"]) { Ok(arguments) => arguments, Err(error) => { eprintln!("error: {error}"); print_usage(&program); std::process::exit(2); } }; if arguments.has_flag("help") { print_usage(&program); return; } let count = match arguments.option("count") { Some(value) => match value.parse::() { Ok(count) => count, Err(_) => { eprintln!("error: --count must be a non-negative integer"); std::process::exit(2); } }, None => 1, }; if arguments.has_flag("verbose") { println!("verbose mode enabled"); } let name = arguments.option("name").unwrap_or("world"); for _ in 0..count { println!("Hello, {name}!"); } if !arguments.positionals().is_empty() { println!("Positional arguments: {:?}", arguments.positionals()); } } #[cfg(test)] mod tests { use super::*; #[test] fn parses_flags_options_and_positionals() { let parsed = Arguments::parse( ["--verbose", "--name=Ada", "--count", "2", "file.txt"], &["verbose"], &["name", "count"], ) .unwrap(); assert!(parsed.has_flag("verbose")); assert_eq!(parsed.option("name"), Some("Ada")); assert_eq!(parsed.option("count"), Some("2")); assert_eq!(parsed.positionals(), &["file.txt"]); } #[test] fn double_dash_stops_option_parsing() { let parsed = Arguments::parse(["--", "--verbose", "file.txt"], &["verbose"], &[]).unwrap(); assert!(!parsed.has_flag("verbose")); assert_eq!(parsed.positionals(), &["--verbose", "file.txt"]); } #[test] fn reports_missing_option_values() { let error = Arguments::parse(["--name"], &[], &["name"]).unwrap_err(); assert_eq!(error, ParseError::MissingValue("--name".to_owned())); } }