avila_cli/
lib.rs

1//! Ávila CLI Parser
2//!
3//! Zero-dependency command-line argument parser with stack-allocated data structures.
4//! Provides compile-time type safety and constant-time argument lookups via HashMap.
5
6use std::collections::HashMap;
7use std::env;
8
9/// Command-line application parser
10///
11/// Stack-allocated structure that defines the command-line interface schema.
12/// All fields use heap-allocated collections for dynamic argument counts,
13/// but the parser itself is deterministic and type-safe.
14pub struct App {
15    name: String,
16    version: String,
17    about: String,
18    commands: Vec<Command>,
19    global_args: Vec<Arg>,
20}
21
22impl App {
23    pub fn new(name: impl Into<String>) -> Self {
24        Self {
25            name: name.into(),
26            version: "0.1.0".to_string(),
27            about: String::new(),
28            commands: Vec::new(),
29            global_args: Vec::new(),
30        }
31    }
32
33    pub fn version(mut self, version: impl Into<String>) -> Self {
34        self.version = version.into();
35        self
36    }
37
38    pub fn about(mut self, about: impl Into<String>) -> Self {
39        self.about = about.into();
40        self
41    }
42
43    pub fn command(mut self, cmd: Command) -> Self {
44        self.commands.push(cmd);
45        self
46    }
47
48    pub fn arg(mut self, arg: Arg) -> Self {
49        self.global_args.push(arg);
50        self
51    }
52
53    pub fn parse(self) -> Matches {
54        let args: Vec<String> = env::args().skip(1).collect();
55        self.parse_args(&args)
56    }
57
58    fn parse_args(self, args: &[String]) -> Matches {
59        let mut matches = Matches {
60            command: None,
61            args: HashMap::new(),
62            values: Vec::new(),
63        };
64
65        if args.is_empty() {
66            return matches;
67        }
68
69        // Check for help/version
70        if args[0] == "--help" || args[0] == "-h" {
71            self.print_help();
72            std::process::exit(0);
73        }
74        if args[0] == "--version" || args[0] == "-V" {
75            println!("{} {}", self.name, self.version);
76            std::process::exit(0);
77        }
78
79        // Parse command
80        if let Some(cmd) = self.commands.iter().find(|c| c.name == args[0]) {
81            matches.command = Some(args[0].clone());
82            matches.parse_command_args(cmd, &args[1..]);
83        } else {
84            matches.parse_args_list(&self.global_args, args);
85        }
86
87        matches
88    }
89
90    fn print_help(&self) {
91        println!("{}", self.name);
92        if !self.about.is_empty() {
93            println!("{}\n", self.about);
94        }
95        println!("Usage: {} [OPTIONS] [COMMAND]\n", self.name.to_lowercase());
96
97        if !self.commands.is_empty() {
98            println!("Commands:");
99            for cmd in &self.commands {
100                println!("  {:<12} {}", cmd.name, cmd.about);
101            }
102            println!();
103        }
104
105        println!("Options:");
106        println!("  -h, --help     Print help");
107        println!("  -V, --version  Print version");
108
109        for arg in &self.global_args {
110            let short = arg.short.as_ref().map(|s| format!("-{}, ", s)).unwrap_or_default();
111            println!("  {}{:<12} {}", short, format!("--{}", arg.long), arg.help);
112        }
113    }
114}
115
116/// Subcommand definition
117///
118/// Represents a distinct command with its own argument schema.
119/// Commands are parsed from the first positional argument.
120pub struct Command {
121    name: String,
122    about: String,
123    args: Vec<Arg>,
124}
125
126impl Command {
127    pub fn new(name: impl Into<String>) -> Self {
128        Self {
129            name: name.into(),
130            about: String::new(),
131            args: Vec::new(),
132        }
133    }
134
135    pub fn about(mut self, about: impl Into<String>) -> Self {
136        self.about = about.into();
137        self
138    }
139
140    pub fn arg(mut self, arg: Arg) -> Self {
141        self.args.push(arg);
142        self
143    }
144}
145
146/// Command-line argument specification
147///
148/// Defines a flag or option with optional short/long forms.
149/// Can be boolean (flag) or value-taking (option).
150pub struct Arg {
151    name: String,
152    long: String,
153    short: Option<String>,
154    help: String,
155    takes_value: bool,
156    required: bool,
157}
158
159impl Arg {
160    pub fn new(name: impl Into<String>) -> Self {
161        let name = name.into();
162        Self {
163            long: name.clone(),
164            name,
165            short: None,
166            help: String::new(),
167            takes_value: false,
168            required: false,
169        }
170    }
171
172    pub fn long(mut self, long: impl Into<String>) -> Self {
173        self.long = long.into();
174        self
175    }
176
177    pub fn short(mut self, short: char) -> Self {
178        self.short = Some(short.to_string());
179        self
180    }
181
182    pub fn help(mut self, help: impl Into<String>) -> Self {
183        self.help = help.into();
184        self
185    }
186
187    pub fn takes_value(mut self, takes: bool) -> Self {
188        self.takes_value = takes;
189        self
190    }
191
192    pub fn required(mut self, req: bool) -> Self {
193        self.required = req;
194        self
195    }
196}
197
198/// Parse result containing matched arguments
199///
200/// Uses HashMap for O(1) argument lookups.
201/// Stores the active subcommand and all parsed argument values.
202pub struct Matches {
203    command: Option<String>,
204    args: HashMap<String, Option<String>>,
205    values: Vec<String>,
206}
207
208impl Matches {
209    pub fn subcommand(&self) -> Option<&str> {
210        self.command.as_deref()
211    }
212
213    pub fn is_present(&self, name: &str) -> bool {
214        self.args.contains_key(name)
215    }
216
217    pub fn value_of(&self, name: &str) -> Option<&str> {
218        self.args.get(name)?.as_deref()
219    }
220
221    pub fn values(&self) -> &[String] {
222        &self.values
223    }
224
225    fn parse_command_args(&mut self, cmd: &Command, args: &[String]) {
226        self.parse_args_list(&cmd.args, args);
227    }
228
229    fn parse_args_list(&mut self, arg_defs: &[Arg], args: &[String]) {
230        let mut i = 0;
231        while i < args.len() {
232            let arg = &args[i];
233
234            if arg.starts_with("--") {
235                let key = &arg[2..];
236                if let Some(arg_def) = arg_defs.iter().find(|a| a.long == key) {
237                    if arg_def.takes_value && i + 1 < args.len() {
238                        self.args.insert(arg_def.name.clone(), Some(args[i + 1].clone()));
239                        i += 2;
240                    } else {
241                        self.args.insert(arg_def.name.clone(), None);
242                        i += 1;
243                    }
244                } else {
245                    i += 1;
246                }
247            } else if arg.starts_with('-') && arg.len() == 2 {
248                let short = &arg[1..];
249                if let Some(arg_def) = arg_defs.iter().find(|a| a.short.as_deref() == Some(short)) {
250                    if arg_def.takes_value && i + 1 < args.len() {
251                        self.args.insert(arg_def.name.clone(), Some(args[i + 1].clone()));
252                        i += 2;
253                    } else {
254                        self.args.insert(arg_def.name.clone(), None);
255                        i += 1;
256                    }
257                } else {
258                    i += 1;
259                }
260            } else {
261                self.values.push(arg.clone());
262                i += 1;
263            }
264        }
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn test_arg_creation() {
274        let arg = Arg::new("test")
275            .long("test")
276            .short('t')
277            .help("Test argument")
278            .takes_value(true);
279
280        assert_eq!(arg.name, "test");
281        assert_eq!(arg.long, "test");
282        assert_eq!(arg.short, Some("t".to_string()));
283    }
284
285    #[test]
286    fn test_command_creation() {
287        let cmd = Command::new("test")
288            .about("Test command")
289            .arg(Arg::new("arg1"));
290
291        assert_eq!(cmd.name, "test");
292        assert_eq!(cmd.args.len(), 1);
293    }
294}