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.2.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    /// Get parsed value as specific type
226    /// 
227    /// # Example
228    /// ```
229    /// let port: u16 = matches.value_as("port").unwrap_or(8080);
230    /// ```
231    pub fn value_as<T>(&self, name: &str) -> Option<T>
232    where
233        T: std::str::FromStr,
234    {
235        self.value_of(name)?.parse().ok()
236    }
237
238    /// Check if any of the given argument names is present
239    /// 
240    /// # Example
241    /// ```
242    /// if matches.any_present(&["verbose", "debug"]) {
243    ///     println!("Logging enabled");
244    /// }
245    /// ```
246    pub fn any_present(&self, names: &[&str]) -> bool {
247        names.iter().any(|name| self.is_present(name))
248    }
249
250    /// Check if all of the given argument names are present
251    pub fn all_present(&self, names: &[&str]) -> bool {
252        names.iter().all(|name| self.is_present(name))
253    }
254
255    /// Get value or return a default
256    pub fn value_or<'a>(&'a self, name: &str, default: &'a str) -> &'a str {
257        self.value_of(name).unwrap_or(default)
258    }
259
260    /// Get the number of positional arguments
261    pub fn values_count(&self) -> usize {
262        self.values.len()
263    }
264
265    fn parse_command_args(&mut self, cmd: &Command, args: &[String]) {
266        self.parse_args_list(&cmd.args, args);
267    }
268
269    fn parse_args_list(&mut self, arg_defs: &[Arg], args: &[String]) {
270        let mut i = 0;
271        while i < args.len() {
272            let arg = &args[i];
273
274            if arg.starts_with("--") {
275                let key = &arg[2..];
276                if let Some(arg_def) = arg_defs.iter().find(|a| a.long == key) {
277                    if arg_def.takes_value && i + 1 < args.len() {
278                        self.args.insert(arg_def.name.clone(), Some(args[i + 1].clone()));
279                        i += 2;
280                    } else {
281                        self.args.insert(arg_def.name.clone(), None);
282                        i += 1;
283                    }
284                } else {
285                    i += 1;
286                }
287            } else if arg.starts_with('-') && arg.len() == 2 {
288                let short = &arg[1..];
289                if let Some(arg_def) = arg_defs.iter().find(|a| a.short.as_deref() == Some(short)) {
290                    if arg_def.takes_value && i + 1 < args.len() {
291                        self.args.insert(arg_def.name.clone(), Some(args[i + 1].clone()));
292                        i += 2;
293                    } else {
294                        self.args.insert(arg_def.name.clone(), None);
295                        i += 1;
296                    }
297                } else {
298                    i += 1;
299                }
300            } else {
301                self.values.push(arg.clone());
302                i += 1;
303            }
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn test_arg_creation() {
314        let arg = Arg::new("test")
315            .long("test")
316            .short('t')
317            .help("Test argument")
318            .takes_value(true);
319
320        assert_eq!(arg.name, "test");
321        assert_eq!(arg.long, "test");
322        assert_eq!(arg.short, Some("t".to_string()));
323    }
324
325    #[test]
326    fn test_command_creation() {
327        let cmd = Command::new("test")
328            .about("Test command")
329            .arg(Arg::new("arg1"));
330
331        assert_eq!(cmd.name, "test");
332        assert_eq!(cmd.args.len(), 1);
333    }
334
335    #[test]
336    fn test_value_as_parsing() {
337        let mut matches = Matches {
338            command: None,
339            args: HashMap::new(),
340            values: Vec::new(),
341        };
342        matches.args.insert("port".to_string(), Some("8080".to_string()));
343        
344        let port: u16 = matches.value_as("port").unwrap();
345        assert_eq!(port, 8080);
346    }
347
348    #[test]
349    fn test_any_present() {
350        let mut matches = Matches {
351            command: None,
352            args: HashMap::new(),
353            values: Vec::new(),
354        };
355        matches.args.insert("verbose".to_string(), None);
356        
357        assert!(matches.any_present(&["verbose", "debug"]));
358        assert!(!matches.any_present(&["quiet", "silent"]));
359    }
360
361    #[test]
362    fn test_all_present() {
363        let mut matches = Matches {
364            command: None,
365            args: HashMap::new(),
366            values: Vec::new(),
367        };
368        matches.args.insert("verbose".to_string(), None);
369        matches.args.insert("debug".to_string(), None);
370        
371        assert!(matches.all_present(&["verbose", "debug"]));
372        assert!(!matches.all_present(&["verbose", "debug", "trace"]));
373    }
374
375    #[test]
376    fn test_value_or_default() {
377        let matches = Matches {
378            command: None,
379            args: HashMap::new(),
380            values: Vec::new(),
381        };
382        
383        assert_eq!(matches.value_or("port", "8080"), "8080");
384    }
385
386    #[test]
387    fn test_values_count() {
388        let matches = Matches {
389            command: None,
390            args: HashMap::new(),
391            values: vec!["file1".to_string(), "file2".to_string()],
392        };
393        
394        assert_eq!(matches.values_count(), 2);
395    }
396}