use std::collections::HashMap;
use std::env;
use std::fs;
mod colors {
pub const RESET: &str = "\x1b[0m";
pub const BOLD: &str = "\x1b[1m";
pub const RED: &str = "\x1b[31m";
pub const GREEN: &str = "\x1b[32m";
pub const YELLOW: &str = "\x1b[33m";
pub const BLUE: &str = "\x1b[34m";
pub const CYAN: &str = "\x1b[36m";
pub const GRAY: &str = "\x1b[90m";
pub fn colorize(text: &str, color: &str) -> String {
if is_color_supported() {
format!("{}{}{}", color, text, RESET)
} else {
text.to_string()
}
}
fn is_color_supported() -> bool {
std::env::var("NO_COLOR").is_err()
&& (std::env::var("TERM").map(|t| t != "dumb").unwrap_or(false)
|| std::env::var("COLORTERM").is_ok())
}
}
#[derive(Clone)]
pub struct ArgGroup {
name: String,
args: Vec<String>,
required: bool,
multiple: bool,
}
impl ArgGroup {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
args: Vec::new(),
required: false,
multiple: false,
}
}
pub fn args(mut self, args: &[&str]) -> Self {
self.args = args.iter().map(|s| s.to_string()).collect();
self
}
pub fn required(mut self, req: bool) -> Self {
self.required = req;
self
}
pub fn multiple(mut self, mult: bool) -> Self {
self.multiple = mult;
self
}
}
pub type Validator = fn(&str) -> Result<(), String>;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Shell {
Bash,
Zsh,
Fish,
PowerShell,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ValueSource {
CommandLine,
Environment,
ConfigFile,
Default,
}
#[macro_export]
macro_rules! cli {
($name:expr => {
version: $version:expr,
about: $about:expr,
args: [$($arg:expr),* $(,)?]
}) => {
{
let mut app = $crate::App::new($name)
.version($version)
.about($about);
$(
app = app.arg($arg);
)*
app
}
};
}
#[macro_export]
macro_rules! arg {
($name:expr) => {
$crate::Arg::new($name)
};
($name:expr, short: $short:expr) => {
$crate::Arg::new($name).short($short)
};
($name:expr, required) => {
$crate::Arg::new($name).required(true).takes_value(true)
};
($name:expr, $($key:ident: $value:expr),* $(,)?) => {
{
let mut a = $crate::Arg::new($name);
$(
a = arg!(@set a, $key: $value);
)*
a
}
};
(@set $arg:expr, short: $short:expr) => { $arg.short($short) };
(@set $arg:expr, help: $help:expr) => { $arg.help($help) };
(@set $arg:expr, required: $req:expr) => { $arg.required($req) };
(@set $arg:expr, takes_value: $tv:expr) => { $arg.takes_value($tv) };
(@set $arg:expr, default: $def:expr) => { $arg.default_value($def) };
}
pub struct App {
name: String,
version: String,
about: String,
author: Option<String>,
commands: Vec<Command>,
global_args: Vec<Arg>,
groups: Vec<ArgGroup>,
colored_help: bool,
config_file: Option<String>,
env_prefix: Option<String>,
}
impl App {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
version: "1.0.0".to_string(),
about: String::new(),
author: None,
commands: Vec::new(),
global_args: Vec::new(),
groups: Vec::new(),
colored_help: true,
config_file: None,
env_prefix: None,
}
}
pub fn version(mut self, version: impl Into<String>) -> Self {
self.version = version.into();
self
}
pub fn about(mut self, about: impl Into<String>) -> Self {
self.about = about.into();
self
}
pub fn author(mut self, author: impl Into<String>) -> Self {
self.author = Some(author.into());
self
}
pub fn command(mut self, cmd: Command) -> Self {
self.commands.push(cmd);
self
}
pub fn arg(mut self, arg: Arg) -> Self {
self.global_args.push(arg);
self
}
pub fn group(mut self, group: ArgGroup) -> Self {
self.groups.push(group);
self
}
pub fn colored_help(mut self, colored: bool) -> Self {
self.colored_help = colored;
self
}
pub fn config_file(mut self, path: impl Into<String>) -> Self {
self.config_file = Some(path.into());
self
}
pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
self.env_prefix = Some(prefix.into());
self
}
pub fn generate_completion(&self, shell: Shell) -> String {
match shell {
Shell::Bash => self.generate_bash_completion(),
Shell::Zsh => self.generate_zsh_completion(),
Shell::Fish => self.generate_fish_completion(),
Shell::PowerShell => self.generate_powershell_completion(),
}
}
fn generate_bash_completion(&self) -> String {
let mut script = format!("_{}_completion() {{\n", self.name);
script.push_str(" local cur prev opts\n");
script.push_str(" COMPREPLY=()\n");
script.push_str(" cur=\"${COMP_WORDS[COMP_CWORD]}\"\n");
script.push_str(" prev=\"${COMP_WORDS[COMP_CWORD-1]}\"\n\n");
script.push_str(" opts=\"");
for arg in &self.global_args {
script.push_str(&format!("--{} ", arg.long));
if let Some(short) = &arg.short {
script.push_str(&format!("-{} ", short));
}
}
script.push_str("\"\n\n");
if !self.commands.is_empty() {
script.push_str(" local commands=\"");
for cmd in &self.commands {
script.push_str(&format!("{} ", cmd.name));
}
script.push_str("\"\n\n");
}
script.push_str(" COMPREPLY=( $(compgen -W \"${opts} ${commands}\" -- ${cur}) )\n");
script.push_str(" return 0\n");
script.push_str("}\n\n");
script.push_str(&format!("complete -F _{}_completion {}\n", self.name, self.name));
script
}
fn generate_zsh_completion(&self) -> String {
let mut script = format!("#compdef {}\n\n", self.name);
script.push_str(&format!("_{}_completion() {{\n", self.name));
script.push_str(" local -a opts\n");
script.push_str(" opts=(\n");
for arg in &self.global_args {
let help = arg.help.replace('\"', "'");
if let Some(short) = &arg.short {
script.push_str(&format!(" '(-{})--{}[{}]'\n", short, arg.long, help));
} else {
script.push_str(&format!(" '--{}[{}]'\n", arg.long, help));
}
}
script.push_str(" )\n");
script.push_str(" _arguments $opts\n");
script.push_str("}\n\n");
script.push_str(&format!("_{}_completion\n", self.name));
script
}
fn generate_fish_completion(&self) -> String {
let mut script = String::new();
for arg in &self.global_args {
script.push_str(&format!("complete -c {} -l {} -d '{}'\n",
self.name, arg.long, arg.help.replace('\'', "\\'")));
if let Some(short) = &arg.short {
script.push_str(&format!("complete -c {} -s {} -d '{}'\n",
self.name, short, arg.help.replace('\'', "\\'")));
}
}
for cmd in &self.commands {
script.push_str(&format!("complete -c {} -f -a '{}' -d '{}'\n",
self.name, cmd.name, cmd.about.replace('\'', "\\'")));
}
script
}
fn generate_powershell_completion(&self) -> String {
let mut script = format!("Register-ArgumentCompleter -CommandName {} -ScriptBlock {{\n", self.name);
script.push_str(" param($commandName, $wordToComplete, $commandAst, $fakeBoundParameter)\n\n");
script.push_str(" $completions = @(\n");
for arg in &self.global_args {
script.push_str(&format!(" @{{ CompletionText = '--{}'; ListItemText = '--{}'; ToolTip = '{}' }},\n",
arg.long, arg.long, arg.help.replace('\"', "'")));
}
for cmd in &self.commands {
script.push_str(&format!(" @{{ CompletionText = '{}'; ListItemText = '{}'; ToolTip = '{}' }},\n",
cmd.name, cmd.name, cmd.about.replace('\"', "'")));
}
script.push_str(" )\n\n");
script.push_str(" $completions | Where-Object { $_.CompletionText -like \"$wordToComplete*\" } | \n");
script.push_str(" ForEach-Object { [System.Management.Automation.CompletionResult]::new($_.CompletionText, $_.ListItemText, 'ParameterValue', $_.ToolTip) }\n");
script.push_str("}\n");
script
}
pub fn parse(self) -> Matches {
let args: Vec<String> = env::args().skip(1).collect();
self.parse_args(&args)
}
fn parse_args(self, args: &[String]) -> Matches {
let mut matches = Matches {
command: None,
args: HashMap::new(),
values: Vec::new(),
sources: HashMap::new(),
};
if args.is_empty() {
return matches;
}
if args[0] == "--help" || args[0] == "-h" {
self.print_help();
std::process::exit(0);
}
if args[0] == "--version" || args[0] == "-V" {
println!("{} {}", self.name, self.version);
std::process::exit(0);
}
if let Some(cmd) = self.commands.iter().find(|c| c.name == args[0]) {
matches.command = Some(args[0].clone());
matches.parse_command_args(cmd, &args[1..]);
} else {
matches.parse_args_list(&self.global_args, args);
}
self.apply_defaults_and_validate(&mut matches);
matches
}
fn apply_defaults_and_validate(&self, matches: &mut Matches) {
let config_values = if let Some(ref path) = self.config_file {
self.load_config_file(path)
} else {
HashMap::new()
};
for arg in &self.global_args {
let arg_name = &arg.name;
if !matches.is_present(arg_name) {
if let Some(ref env_var) = arg.env_var {
if let Ok(env_val) = env::var(env_var) {
matches.args.insert(arg_name.clone(), Some(env_val));
matches.sources.insert(arg_name.clone(), ValueSource::Environment);
continue;
}
}
if let Some(ref prefix) = self.env_prefix {
let env_key = format!("{}_{}", prefix.to_uppercase(), arg.long.to_uppercase());
if let Ok(env_val) = env::var(&env_key) {
matches.args.insert(arg_name.clone(), Some(env_val));
matches.sources.insert(arg_name.clone(), ValueSource::Environment);
continue;
}
}
if let Some(config_val) = config_values.get(arg_name) {
matches.args.insert(arg_name.clone(), Some(config_val.clone()));
matches.sources.insert(arg_name.clone(), ValueSource::ConfigFile);
continue;
}
if arg.default_value.is_some() {
matches.args.insert(arg_name.clone(), arg.default_value.clone());
matches.sources.insert(arg_name.clone(), ValueSource::Default);
}
} else {
matches.sources.insert(arg_name.clone(), ValueSource::CommandLine);
}
if arg.required && !matches.is_present(arg_name) {
let msg = if self.colored_help {
format!("Error: {} is required", colors::colorize(&format!("--{}", arg.long), colors::RED))
} else {
format!("Error: --{} is required", arg.long)
};
eprintln!("{}", msg);
std::process::exit(1);
}
if !arg.possible_values.is_empty() {
if let Some(value) = matches.value_of(&arg.name) {
if !arg.possible_values.iter().any(|v| v == value) {
let msg = if self.colored_help {
format!(
"Error: invalid value {} for {}",
colors::colorize(&format!("'{}'", value), colors::RED),
colors::colorize(&format!("--{}", arg.long), colors::CYAN)
)
} else {
format!("Error: invalid value '{}' for --{}", value, arg.long)
};
eprintln!("{}", msg);
eprintln!("Possible values: {}", arg.possible_values.join(", "));
std::process::exit(1);
}
}
}
if let Some(validator) = &arg.validator {
if let Some(value) = matches.value_of(&arg.name) {
if let Err(err) = validator(value) {
let msg = if self.colored_help {
format!(
"Error: validation failed for {}: {}",
colors::colorize(&format!("--{}", arg.long), colors::CYAN),
colors::colorize(&err, colors::RED)
)
} else {
format!("Error: validation failed for --{}: {}", arg.long, err)
};
eprintln!("{}", msg);
std::process::exit(1);
}
}
}
for conflict in &arg.conflicts_with {
if matches.is_present(arg_name) && matches.is_present(conflict) {
let msg = if self.colored_help {
format!(
"Error: {} conflicts with {}",
colors::colorize(&format!("--{}", arg.long), colors::RED),
colors::colorize(&format!("--{}", conflict), colors::RED)
)
} else {
format!("Error: --{} conflicts with --{}", arg.long, conflict)
};
eprintln!("{}", msg);
std::process::exit(1);
}
}
for required in &arg.requires {
if matches.is_present(arg_name) && !matches.is_present(required) {
let msg = if self.colored_help {
format!(
"Error: {} requires {}",
colors::colorize(&format!("--{}", arg.long), colors::CYAN),
colors::colorize(&format!("--{}", required), colors::YELLOW)
)
} else {
format!("Error: --{} requires --{}", arg.long, required)
};
eprintln!("{}", msg);
std::process::exit(1);
}
}
}
for group in &self.groups {
let present_args: Vec<String> = group.args.iter()
.filter(|arg_name| matches.is_present(arg_name))
.map(|s| s.clone())
.collect();
if group.required && present_args.is_empty() {
let msg = if self.colored_help {
format!(
"Error: at least one of {} is required",
colors::colorize(&format!("[{}]", group.args.join(", ")), colors::YELLOW)
)
} else {
format!("Error: at least one of [{}] is required", group.args.join(", "))
};
eprintln!("{}", msg);
std::process::exit(1);
}
if !group.multiple && present_args.len() > 1 {
let msg = if self.colored_help {
format!(
"Error: arguments {} are mutually exclusive",
colors::colorize(&present_args.join(", "), colors::RED)
)
} else {
format!("Error: arguments {} are mutually exclusive", present_args.join(", "))
};
eprintln!("{}", msg);
std::process::exit(1);
}
}
}
fn print_help(&self) {
let name = if self.colored_help {
colors::colorize(&self.name, colors::BOLD)
} else {
self.name.clone()
};
println!("{}", name);
if !self.about.is_empty() {
println!("{}\n", self.about);
}
let usage = if self.colored_help {
format!("{}: {} [OPTIONS] [COMMAND]",
colors::colorize("Usage", colors::BOLD),
self.name.to_lowercase()
)
} else {
format!("Usage: {} [OPTIONS] [COMMAND]", self.name.to_lowercase())
};
println!("{}\n", usage);
if !self.commands.is_empty() {
let header = if self.colored_help {
colors::colorize("Commands:", colors::BOLD)
} else {
"Commands:".to_string()
};
println!("{}", header);
for cmd in &self.commands {
let cmd_name = if self.colored_help {
colors::colorize(&cmd.name, colors::CYAN)
} else {
cmd.name.clone()
};
println!(" {:<12} {}", cmd_name, cmd.about);
}
println!();
}
let options_header = if self.colored_help {
colors::colorize("Options:", colors::BOLD)
} else {
"Options:".to_string()
};
println!("{}", options_header);
let help_text = if self.colored_help {
format!(" {}, {} Print help",
colors::colorize("-h", colors::GREEN),
colors::colorize("--help", colors::GREEN)
)
} else {
" -h, --help Print help".to_string()
};
println!("{}", help_text);
let version_text = if self.colored_help {
format!(" {}, {} Print version",
colors::colorize("-V", colors::GREEN),
colors::colorize("--version", colors::GREEN)
)
} else {
" -V, --version Print version".to_string()
};
println!("{}", version_text);
for arg in &self.global_args {
let short = arg.short.as_ref().map(|s| format!("-{}, ", s)).unwrap_or_default();
let long_with_color = if self.colored_help {
colors::colorize(&format!("--{}", arg.long), colors::GREEN)
} else {
format!("--{}", arg.long)
};
let required_marker = if arg.required && self.colored_help {
format!(" {}", colors::colorize("[required]", colors::RED))
} else if arg.required {
" [required]".to_string()
} else {
String::new()
};
println!(" {}{:<12} {}{}", short, long_with_color, arg.help, required_marker);
}
}
fn load_config_file(&self, path: &str) -> HashMap<String, String> {
let mut config = HashMap::new();
if let Ok(contents) = fs::read_to_string(path) {
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
continue;
}
let parts: Vec<&str> = if line.contains('=') {
line.splitn(2, '=').collect()
} else if line.contains(':') {
line.splitn(2, ':').collect()
} else {
continue;
};
if parts.len() == 2 {
let key = parts[0].trim().to_lowercase();
let value = parts[1].trim().trim_matches('"').to_string();
config.insert(key, value);
}
}
}
config
}
}
pub struct Command {
name: String,
about: String,
args: Vec<Arg>,
}
impl Command {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
about: String::new(),
args: Vec::new(),
}
}
pub fn about(mut self, about: impl Into<String>) -> Self {
self.about = about.into();
self
}
pub fn arg(mut self, arg: Arg) -> Self {
self.args.push(arg);
self
}
}
pub struct Arg {
name: String,
long: String,
short: Option<String>,
help: String,
takes_value: bool,
required: bool,
default_value: Option<String>,
possible_values: Vec<String>,
validator: Option<Validator>,
env_var: Option<String>,
hidden: bool,
conflicts_with: Vec<String>,
requires: Vec<String>,
}
impl Arg {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
Self {
long: name.clone(),
name,
short: None,
help: String::new(),
takes_value: false,
required: false,
default_value: None,
possible_values: Vec::new(),
validator: None,
env_var: None,
hidden: false,
conflicts_with: Vec::new(),
requires: Vec::new(),
}
}
pub fn long(mut self, long: impl Into<String>) -> Self {
self.long = long.into();
self
}
pub fn short(mut self, short: char) -> Self {
self.short = Some(short.to_string());
self
}
pub fn help(mut self, help: impl Into<String>) -> Self {
self.help = help.into();
self
}
pub fn takes_value(mut self, takes: bool) -> Self {
self.takes_value = takes;
self
}
pub fn required(mut self, req: bool) -> Self {
self.required = req;
self
}
pub fn default_value(mut self, value: impl Into<String>) -> Self {
self.default_value = Some(value.into());
self
}
pub fn possible_values(mut self, values: &[&str]) -> Self {
self.possible_values = values.iter().map(|s| s.to_string()).collect();
self
}
pub fn validator(mut self, f: Validator) -> Self {
self.validator = Some(f);
self
}
pub fn env(mut self, var: impl Into<String>) -> Self {
self.env_var = Some(var.into());
self
}
pub fn hidden(mut self, hide: bool) -> Self {
self.hidden = hide;
self
}
pub fn conflicts_with(mut self, arg: impl Into<String>) -> Self {
self.conflicts_with.push(arg.into());
self
}
pub fn requires(mut self, arg: impl Into<String>) -> Self {
self.requires.push(arg.into());
self
}
}
pub struct Matches {
command: Option<String>,
args: HashMap<String, Option<String>>,
values: Vec<String>,
sources: HashMap<String, ValueSource>,
}
impl Matches {
pub fn subcommand(&self) -> Option<&str> {
self.command.as_deref()
}
pub fn is_present(&self, name: &str) -> bool {
self.args.contains_key(name)
}
pub fn value_of(&self, name: &str) -> Option<&str> {
self.args.get(name)?.as_deref()
}
pub fn values(&self) -> &[String] {
&self.values
}
pub fn value_source(&self, name: &str) -> Option<&ValueSource> {
self.sources.get(name)
}
pub fn value_as<T>(&self, name: &str) -> Option<T>
where
T: std::str::FromStr,
{
self.value_of(name)?.parse().ok()
}
pub fn any_present(&self, names: &[&str]) -> bool {
names.iter().any(|name| self.is_present(name))
}
pub fn all_present(&self, names: &[&str]) -> bool {
names.iter().all(|name| self.is_present(name))
}
pub fn value_or<'a>(&'a self, name: &str, default: &'a str) -> &'a str {
self.value_of(name).unwrap_or(default)
}
pub fn values_count(&self) -> usize {
self.values.len()
}
fn parse_command_args(&mut self, cmd: &Command, args: &[String]) {
self.parse_args_list(&cmd.args, args);
}
fn parse_args_list(&mut self, arg_defs: &[Arg], args: &[String]) {
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if arg.starts_with("--") {
let key = &arg[2..];
if let Some(arg_def) = arg_defs.iter().find(|a| a.long == key) {
if arg_def.takes_value && i + 1 < args.len() {
self.args.insert(arg_def.name.clone(), Some(args[i + 1].clone()));
i += 2;
} else {
self.args.insert(arg_def.name.clone(), None);
i += 1;
}
} else {
i += 1;
}
} else if arg.starts_with('-') && arg.len() == 2 {
let short = &arg[1..];
if let Some(arg_def) = arg_defs.iter().find(|a| a.short.as_deref() == Some(short)) {
if arg_def.takes_value && i + 1 < args.len() {
self.args.insert(arg_def.name.clone(), Some(args[i + 1].clone()));
i += 2;
} else {
self.args.insert(arg_def.name.clone(), None);
i += 1;
}
} else {
i += 1;
}
} else {
self.values.push(arg.clone());
i += 1;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_arg_creation() {
let arg = Arg::new("test")
.long("test")
.short('t')
.help("Test argument")
.takes_value(true);
assert_eq!(arg.name, "test");
assert_eq!(arg.long, "test");
assert_eq!(arg.short, Some("t".to_string()));
}
#[test]
fn test_command_creation() {
let cmd = Command::new("test")
.about("Test command")
.arg(Arg::new("arg1"));
assert_eq!(cmd.name, "test");
assert_eq!(cmd.args.len(), 1);
}
#[test]
fn test_value_as_parsing() {
let mut matches = Matches {
command: None,
args: HashMap::new(),
values: Vec::new(),
sources: HashMap::new(),
};
matches.args.insert("port".to_string(), Some("8080".to_string()));
let port: u16 = matches.value_as("port").unwrap();
assert_eq!(port, 8080);
}
#[test]
fn test_any_present() {
let mut matches = Matches {
command: None,
args: HashMap::new(),
values: Vec::new(),
sources: HashMap::new(),
};
matches.args.insert("verbose".to_string(), None);
assert!(matches.any_present(&["verbose", "debug"]));
assert!(!matches.any_present(&["quiet", "silent"]));
}
#[test]
fn test_all_present() {
let mut matches = Matches {
command: None,
args: HashMap::new(),
values: Vec::new(),
sources: HashMap::new(),
};
matches.args.insert("verbose".to_string(), None);
matches.args.insert("debug".to_string(), None);
assert!(matches.all_present(&["verbose", "debug"]));
assert!(!matches.all_present(&["verbose", "debug", "trace"]));
}
#[test]
fn test_value_or_default() {
let matches = Matches {
command: None,
args: HashMap::new(),
values: Vec::new(),
sources: HashMap::new(),
};
assert_eq!(matches.value_or("port", "8080"), "8080");
}
#[test]
fn test_values_count() {
let matches = Matches {
command: None,
args: HashMap::new(),
values: vec!["file1".to_string(), "file2".to_string()],
sources: HashMap::new(),
};
assert_eq!(matches.values_count(), 2);
}
}