use std::{env, fmt, process}; #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Cli { pub help: bool, pub verbose: u8, pub output: Option, pub command: Option, pub positionals: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ParseError { MissingValue(&'static str), UnknownOption(String), } impl Cli { pub fn parse(args: I) -> Result where I: IntoIterator, S: Into, { let mut args = args.into_iter().map(Into::into); let _program = args.next(); let mut cli = Cli::default(); let mut after_options = false; while let Some(arg) = args.next() { if after_options { cli.push_positional(arg); continue; } match arg.as_str() { "--" => after_options = true, "-h" | "--help" => cli.help = true, "-v" | "--verbose" => cli.add_verbose(1), "-o" | "--output" => { let value = args.next().ok_or(ParseError::MissingValue("--output"))?; cli.output = Some(value); } _ if arg.starts_with("--output=") => { let value = &arg["--output=".len()..]; if value.is_empty() { return Err(ParseError::MissingValue("--output")); } cli.output = Some(value.to_string()); } _ if is_verbose_cluster(&arg) => cli.add_verbose((arg.len() - 1) as u8), _ if arg.starts_with('-') => return Err(ParseError::UnknownOption(arg)), _ => cli.push_positional(arg), } } Ok(cli) } fn add_verbose(&mut self, count: u8) { self.verbose = self.verbose.saturating_add(count); } fn push_positional(&mut self, value: String) { if self.command.is_none() { self.command = Some(value); } else { self.positionals.push(value); } } } fn is_verbose_cluster(arg: &str) -> bool { arg.len() > 2 && arg.starts_with('-') && arg[1..].chars().all(|c| c == 'v') } impl fmt::Display for ParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { ParseError::MissingValue(option) => write!(f, "missing value for {option}"), ParseError::UnknownOption(option) => write!(f, "unknown option: {option}"), } } } fn print_help(program: &str) { println!( "Usage: {program} [OPTIONS] [COMMAND] [ARGS...] Options: -h, --help Show this help message -v, --verbose Increase verbosity; may be repeated -o, --output Write output to FILE --output=FILE Write output to FILE -- Stop parsing options" ); } fn main() { let program = env::args().next().unwrap_or_else(|| "cli".to_string()); match Cli::parse(env::args()) { Ok(cli) if cli.help => print_help(&program), Ok(cli) => { println!("{cli:?}"); } Err(err) => { eprintln!("{program}: {err}"); eprintln!("Try '{program} --help' for usage."); process::exit(2); } } } #[cfg(test)] mod tests { use super::*; #[test] fn parses_options_and_positionals() { let cli = Cli::parse([ "tool", "-vv", "--output=result.txt", "build", "src/main.rs", ]) .unwrap(); assert_eq!(cli.verbose, 2); assert_eq!(cli.output.as_deref(), Some("result.txt")); assert_eq!(cli.command.as_deref(), Some("build")); assert_eq!(cli.positionals, vec!["src/main.rs"]); } #[test] fn dash_dash_stops_option_parsing() { let cli = Cli::parse(["tool", "--", "--not-an-option", "-v"]).unwrap(); assert_eq!(cli.command.as_deref(), Some("--not-an-option")); assert_eq!(cli.positionals, vec!["-v"]); assert_eq!(cli.verbose, 0); } #[test] fn reports_missing_option_value() { let err = Cli::parse(["tool", "--output"]).unwrap_err(); assert_eq!(err, ParseError::MissingValue("--output")); } }