Skip to main content

rns_ctl/
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" | "daemon" | "disable-auth" | "json" | "value-only"
46                        | "keys-only" => {
47                            flags.insert(key, "true".into());
48                        }
49                        _ => {
50                            // Next arg is the value
51                            if let Some(val) = iter.next() {
52                                flags.insert(key, val);
53                            } else {
54                                flags.insert(key, "true".into());
55                            }
56                        }
57                    }
58                }
59            } else if arg.starts_with('-') && arg.len() > 1 {
60                // Short flags
61                let chars: Vec<char> = arg[1..].chars().collect();
62                for &c in &chars {
63                    match c {
64                        'v' => verbosity = verbosity.saturating_add(1),
65                        'q' => quiet = quiet.saturating_add(1),
66                        'a' | 'r' | 'j' | 'P' | 'D' | 'l' | 'f' | 'A' => {
67                            flags.insert(c.to_string(), "true".into());
68                        }
69                        'h' => {
70                            flags.insert("help".into(), "true".into());
71                        }
72                        _ => {
73                            // Short flag that may take a value: -c /path, -s rate
74                            // Only consume next arg if it doesn't look like a flag
75                            if chars.len() == 1 {
76                                let next_is_value = iter
77                                    .as_slice()
78                                    .first()
79                                    .map(|s| !s.starts_with('-') || s == "-")
80                                    .unwrap_or(false);
81                                if next_is_value {
82                                    if let Some(val) = iter.next() {
83                                        flags.insert(c.to_string(), val);
84                                    } else {
85                                        flags.insert(c.to_string(), "true".into());
86                                    }
87                                } else {
88                                    flags.insert(c.to_string(), "true".into());
89                                }
90                            } else {
91                                flags.insert(c.to_string(), "true".into());
92                            }
93                        }
94                    }
95                }
96            } else {
97                positional.push(arg);
98            }
99        }
100
101        Args {
102            flags,
103            positional,
104            verbosity,
105            quiet,
106        }
107    }
108
109    /// Get a flag value by long or short name.
110    pub fn get(&self, key: &str) -> Option<&str> {
111        self.flags.get(key).map(|s| s.as_str())
112    }
113
114    /// Check if a flag is set.
115    pub fn has(&self, key: &str) -> bool {
116        self.flags.contains_key(key)
117    }
118
119    /// Get config path from --config or -c flag.
120    pub fn config_path(&self) -> Option<&str> {
121        self.get("config").or_else(|| self.get("c"))
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    fn args(s: &[&str]) -> Args {
130        Args::parse_from(s.iter().map(|s| s.to_string()).collect())
131    }
132
133    #[test]
134    fn parse_config_and_verbose() {
135        let a = args(&["--config", "/path/to/config", "-vv", "-s"]);
136        assert_eq!(a.config_path(), Some("/path/to/config"));
137        assert_eq!(a.verbosity, 2);
138        assert!(a.has("s"));
139    }
140
141    #[test]
142    fn parse_version() {
143        let a = args(&["--version"]);
144        assert!(a.has("version"));
145    }
146
147    #[test]
148    fn parse_short_flag_with_value() {
149        // -t with a non-flag value captures it (used by probe -t SECS, http -t TOKEN)
150        let a = args(&["-t", "abcd1234"]);
151        assert_eq!(a.get("t"), Some("abcd1234"));
152    }
153
154    #[test]
155    fn parse_short_flag_boolean() {
156        // -t alone is boolean (used by status -t, path -t)
157        let a = args(&["-t"]);
158        assert!(a.has("t"));
159        assert_eq!(a.get("t"), Some("true"));
160    }
161
162    #[test]
163    fn parse_short_config() {
164        let a = args(&["-c", "/my/config"]);
165        assert_eq!(a.config_path(), Some("/my/config"));
166    }
167
168    #[test]
169    fn parse_quiet() {
170        let a = args(&["-qq"]);
171        assert_eq!(a.quiet, 2);
172    }
173
174    #[test]
175    fn parse_new_boolean_flags() {
176        let a = args(&["-l", "-f", "-m", "-A"]);
177        assert!(a.has("l"));
178        assert!(a.has("f"));
179        assert!(a.has("m"));
180        assert!(a.has("A"));
181    }
182
183    #[test]
184    fn parse_long_boolean_flags() {
185        let a = args(&[
186            "--stdin",
187            "--stdout",
188            "--force",
189            "--blackholed",
190            "--json",
191            "--value-only",
192            "--keys-only",
193        ]);
194        assert!(a.has("stdin"));
195        assert!(a.has("stdout"));
196        assert!(a.has("force"));
197        assert!(a.has("blackholed"));
198        assert!(a.has("json"));
199        assert!(a.has("value-only"));
200        assert!(a.has("keys-only"));
201    }
202
203    #[test]
204    fn parse_exampleconfig() {
205        let a = args(&["--exampleconfig"]);
206        assert!(a.has("exampleconfig"));
207    }
208
209    #[test]
210    fn parse_short_d_boolean() {
211        // -d alone is boolean (used by http -d for daemon mode)
212        let a = args(&["-d"]);
213        assert!(a.has("d"));
214        assert_eq!(a.get("d"), Some("true"));
215    }
216
217    #[test]
218    fn parse_short_d_with_value() {
219        // -d with a value (used by id -d FILE, path -d HASH)
220        let a = args(&["-d", "file.enc"]);
221        assert_eq!(a.get("d"), Some("file.enc"));
222    }
223
224    #[test]
225    fn parse_daemon_long() {
226        let a = args(&["--daemon"]);
227        assert!(a.has("daemon"));
228    }
229
230    #[test]
231    fn parse_disable_auth() {
232        let a = args(&["--disable-auth"]);
233        assert!(a.has("disable-auth"));
234    }
235
236    #[test]
237    fn parse_help() {
238        let a = args(&["--help"]);
239        assert!(a.has("help"));
240        let a = args(&["-h"]);
241        assert!(a.has("help"));
242    }
243
244    #[test]
245    fn parse_short_p_with_value() {
246        // -p with a value (used by http -p PORT)
247        let a = args(&["-p", "8080"]);
248        assert_eq!(a.get("p"), Some("8080"));
249    }
250
251    #[test]
252    fn parse_short_p_boolean() {
253        // -p alone is boolean (used by id -p for print public key)
254        let a = args(&["-p"]);
255        assert!(a.has("p"));
256    }
257
258    #[test]
259    fn parse_short_x_with_value() {
260        // -x with a value (used by path -x HASH)
261        let a = args(&["-x", "abcd1234"]);
262        assert_eq!(a.get("x"), Some("abcd1234"));
263    }
264
265    #[test]
266    fn parse_short_x_boolean() {
267        // -x alone is boolean (used by id -x for export hex)
268        let a = args(&["-x"]);
269        assert!(a.has("x"));
270    }
271
272    #[test]
273    fn flag_with_value_vs_boolean() {
274        // -s with a non-flag value should capture it
275        let a = args(&["-s", "rate"]);
276        assert_eq!(a.get("s"), Some("rate"));
277
278        // -s followed by another flag should be boolean
279        let a = args(&["-s", "-v"]);
280        assert!(a.has("s"));
281        assert_eq!(a.get("s"), Some("true"));
282        assert_eq!(a.verbosity, 1);
283
284        // -m with a value
285        let a = args(&["-m", "5"]);
286        assert_eq!(a.get("m"), Some("5"));
287
288        // -m alone (boolean)
289        let a = args(&["-m"]);
290        assert!(a.has("m"));
291
292        // -B with a hash value
293        let a = args(&["-B", "abcdef1234567890"]);
294        assert_eq!(a.get("B"), Some("abcdef1234567890"));
295
296        // -B alone (boolean for base32 mode)
297        let a = args(&["-B"]);
298        assert!(a.has("B"));
299    }
300}