Skip to main content

rns_cli/
args.rs

1//! Simple command-line argument parser.
2//!
3//! No external dependencies. Supports `--flag`, `--key value`, `-v` (count),
4//! and positional arguments.
5
6use std::collections::HashMap;
7
8/// Parsed command-line arguments.
9pub struct Args {
10    pub flags: HashMap<String, String>,
11    pub positional: Vec<String>,
12    pub verbosity: u8,
13    pub quiet: u8,
14}
15
16impl Args {
17    /// Parse command-line arguments (skipping argv[0]).
18    pub fn parse() -> Self {
19        Self::parse_from(std::env::args().skip(1).collect())
20    }
21
22    /// Parse from a list of argument strings.
23    pub fn parse_from(args: Vec<String>) -> Self {
24        let mut flags = HashMap::new();
25        let mut positional = Vec::new();
26        let mut verbosity: u8 = 0;
27        let mut quiet: u8 = 0;
28        let mut iter = args.into_iter();
29
30        while let Some(arg) = iter.next() {
31            if arg == "--" {
32                // Everything after -- is positional
33                positional.extend(iter);
34                break;
35            } else if arg.starts_with("--") {
36                let key = arg[2..].to_string();
37                // Check for --key=value syntax
38                if let Some(eq_pos) = key.find('=') {
39                    let (k, v) = key.split_at(eq_pos);
40                    flags.insert(k.to_string(), v[1..].to_string());
41                } else {
42                    // Boolean flags that don't take values
43                    match key.as_str() {
44                        "version" | "exampleconfig" | "help" | "stdin" | "stdout" | "force"
45                        | "blackholed" | "base256" => {
46                            flags.insert(key, "true".into());
47                        }
48                        _ => {
49                            // Next arg is the value
50                            if let Some(val) = iter.next() {
51                                flags.insert(key, val);
52                            } else {
53                                flags.insert(key, "true".into());
54                            }
55                        }
56                    }
57                }
58            } else if arg.starts_with('-') && arg.len() > 1 {
59                // Short flags
60                let chars: Vec<char> = arg[1..].chars().collect();
61                for &c in &chars {
62                    match c {
63                        'v' => verbosity = verbosity.saturating_add(1),
64                        'q' => quiet = quiet.saturating_add(1),
65                        'a' | 'r' | 't' | 'j' | 'p' | 'P' | 'x' | 'D' | 'l' | 'f' | 'A' | 'Z' => {
66                            flags.insert(c.to_string(), "true".into());
67                        }
68                        _ => {
69                            // Short flag that may take a value: -c /path, -s rate
70                            // Only consume next arg if it doesn't look like a flag
71                            if chars.len() == 1 {
72                                let next_is_value = iter
73                                    .as_slice()
74                                    .first()
75                                    .map(|s| !s.starts_with('-') || s == "-")
76                                    .unwrap_or(false);
77                                if next_is_value {
78                                    if let Some(val) = iter.next() {
79                                        flags.insert(c.to_string(), val);
80                                    } else {
81                                        flags.insert(c.to_string(), "true".into());
82                                    }
83                                } else {
84                                    flags.insert(c.to_string(), "true".into());
85                                }
86                            } else {
87                                flags.insert(c.to_string(), "true".into());
88                            }
89                        }
90                    }
91                }
92            } else {
93                positional.push(arg);
94            }
95        }
96
97        Args {
98            flags,
99            positional,
100            verbosity,
101            quiet,
102        }
103    }
104
105    /// Get a flag value by long or short name.
106    pub fn get(&self, key: &str) -> Option<&str> {
107        self.flags.get(key).map(|s| s.as_str())
108    }
109
110    /// Check if a flag is set.
111    pub fn has(&self, key: &str) -> bool {
112        self.flags.contains_key(key)
113    }
114
115    /// Get config path from --config or -c flag.
116    pub fn config_path(&self) -> Option<&str> {
117        self.get("config").or_else(|| self.get("c"))
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    fn args(s: &[&str]) -> Args {
126        Args::parse_from(s.iter().map(|s| s.to_string()).collect())
127    }
128
129    #[test]
130    fn parse_config_and_verbose() {
131        let a = args(&["--config", "/path/to/config", "-vv", "-s"]);
132        assert_eq!(a.config_path(), Some("/path/to/config"));
133        assert_eq!(a.verbosity, 2);
134        assert!(a.has("s"));
135    }
136
137    #[test]
138    fn parse_version() {
139        let a = args(&["--version"]);
140        assert!(a.has("version"));
141    }
142
143    #[test]
144    fn parse_positional() {
145        let a = args(&["-t", "abcd1234"]);
146        assert!(a.has("t"));
147        assert_eq!(a.positional, vec!["abcd1234"]);
148    }
149
150    #[test]
151    fn parse_short_config() {
152        let a = args(&["-c", "/my/config"]);
153        assert_eq!(a.config_path(), Some("/my/config"));
154    }
155
156    #[test]
157    fn parse_quiet() {
158        let a = args(&["-qq"]);
159        assert_eq!(a.quiet, 2);
160    }
161
162    #[test]
163    fn parse_new_boolean_flags() {
164        let a = args(&["-l", "-f", "-m", "-A", "-Z"]);
165        assert!(a.has("l"));
166        assert!(a.has("f"));
167        assert!(a.has("m"));
168        assert!(a.has("A"));
169        assert!(a.has("Z"));
170    }
171
172    #[test]
173    fn parse_long_boolean_flags() {
174        let a = args(&[
175            "--stdin",
176            "--stdout",
177            "--force",
178            "--blackholed",
179            "--base256",
180        ]);
181        assert!(a.has("stdin"));
182        assert!(a.has("stdout"));
183        assert!(a.has("force"));
184        assert!(a.has("blackholed"));
185        assert!(a.has("base256"));
186    }
187
188    #[test]
189    fn parse_exampleconfig() {
190        let a = args(&["--exampleconfig"]);
191        assert!(a.has("exampleconfig"));
192    }
193
194    #[test]
195    fn flag_with_value_vs_boolean() {
196        // -s with a non-flag value should capture it
197        let a = args(&["-s", "rate"]);
198        assert_eq!(a.get("s"), Some("rate"));
199
200        // -s followed by another flag should be boolean
201        let a = args(&["-s", "-v"]);
202        assert!(a.has("s"));
203        assert_eq!(a.get("s"), Some("true"));
204        assert_eq!(a.verbosity, 1);
205
206        // -m with a value
207        let a = args(&["-m", "5"]);
208        assert_eq!(a.get("m"), Some("5"));
209
210        // -m alone (boolean)
211        let a = args(&["-m"]);
212        assert!(a.has("m"));
213
214        // -B with a hash value
215        let a = args(&["-B", "abcdef1234567890"]);
216        assert_eq!(a.get("B"), Some("abcdef1234567890"));
217
218        // -B alone (boolean for base32 mode)
219        let a = args(&["-B"]);
220        assert!(a.has("B"));
221    }
222}