Skip to main content

cli_parser_helper/
lib.rs

1use std::{collections::HashMap, ops::Index};
2
3/// Represents the header text for the CLI help message.
4#[derive(Debug)]
5struct Header {
6    text: String,
7}
8
9/// Represents the footer text for the CLI help message.
10#[derive(Debug)]
11struct Footer {
12    text: String,
13}
14
15/// Alias for the name of a CLI option.
16type Name = String;
17
18/// Represents a CLI option with its short form, long form, help text, and name.
19#[derive(Debug)]
20struct CliOption {
21    long_form: Option<String>,
22    short_form: Option<String>,
23    help_text: String,
24}
25
26/// Represents the value(s) associated with a CLI option and whether it is enabled.
27#[derive(Debug)]
28struct OptionValue {
29    is_enabled: bool,
30    values: Vec<String>,
31}
32
33impl OptionValue {
34    /// Checks if the option is enabled.
35    fn is_enabled(&self) -> bool {
36        self.is_enabled
37    }
38
39    /// Returns the list of values associated with the option.
40    fn values(&self) -> &Vec<String> {
41        &self.values
42    }
43}
44
45/// A parser for CLI options and arguments.
46///
47/// This struct allows registering options with short and long forms, parsing
48/// command-line arguments, and retrieving the values of options or normal arguments.
49pub struct CliOptionParser {
50    name_to_cli_option_map: HashMap<Name, CliOption>,
51    name_to_option_value_map: HashMap<Name, OptionValue>,
52    short_form_to_name_map: HashMap<String, Name>,
53    long_form_to_name_map: HashMap<String, Name>,
54    empty_option_list: Vec<String>,
55    header: Header,
56    footer: Footer,
57}
58
59impl Index<&str> for CliOptionParser {
60    type Output = Vec<String>;
61
62    /// Allows indexing the parser with an option name to retrieve its values.
63    fn index(&self, index: &str) -> &Self::Output {
64        self.get_option_values(index)
65    }
66}
67
68impl CliOptionParser {
69    /// Creates a new `CliOptionParser` instance with the given header and footer text.
70    pub fn new(header: String, footer: String) -> CliOptionParser {
71        CliOptionParser {
72            name_to_cli_option_map: HashMap::new(),
73            name_to_option_value_map: HashMap::new(),
74            short_form_to_name_map: HashMap::new(),
75            long_form_to_name_map: HashMap::new(),
76            empty_option_list: vec![],
77            header: Header { text: header },
78            footer: Footer { text: footer },
79        }
80    }
81
82    /// Enables the option with the given name.
83    fn enable_option(&mut self, option_name: String) {
84        let option_value_entry = self
85            .name_to_option_value_map
86            .entry(option_name.to_string())
87            .or_insert(OptionValue {
88                is_enabled: false,
89                values: vec![],
90            });
91
92        option_value_entry.is_enabled = true;
93    }
94
95    /// Adds a value to the option with the given name.
96    fn add_value_to_option(&mut self, option_name: String, value: String) {
97        let option_value_entry = self
98            .name_to_option_value_map
99            .entry(option_name.to_string())
100            .or_insert(OptionValue {
101                is_enabled: false,
102                values: vec![],
103            });
104
105        option_value_entry.values.push(value);
106        option_value_entry.is_enabled = true;
107    }
108
109    /// Parses the given arguments and returns the list of normal arguments.
110    fn parse_from(&mut self, args: Vec<String>) -> Vec<String> {
111        let mut arguments: Vec<String> = vec![];
112
113        for arg in args {
114            if arg.starts_with("--") {
115                if arg.contains("=") {
116                    // Example: --hello=world
117                    let mut arg_split = arg.split("=");
118                    let option = arg_split.next().unwrap(); // --hello
119                    let value = arg_split.next().unwrap(); // world
120
121                    if !self.long_form_to_name_map.contains_key(option) {
122                        continue;
123                    }
124
125                    let option_name = &self.long_form_to_name_map[option];
126                    self.add_value_to_option(option_name.to_string(), value.to_string());
127                } else {
128                    if !self.long_form_to_name_map.contains_key(&arg) {
129                        continue;
130                    }
131
132                    let option_name = &self.long_form_to_name_map[&arg];
133                    self.enable_option(option_name.to_string());
134                }
135            } else if arg.starts_with("-") {
136                if arg.len() > 1 {
137                    // Example: -lHelloWorld
138                    let (option, value) = arg.split_at(2); // left = -l, right = HelloWorld
139                    if !self.short_form_to_name_map.contains_key(option) {
140                        continue;
141                    }
142                    let option_name = &self.short_form_to_name_map[option];
143                    self.add_value_to_option(option_name.to_string(), value.to_string());
144                } else {
145                    if !self.short_form_to_name_map.contains_key(&arg) {
146                        continue;
147                    }
148
149                    let option_name = &self.short_form_to_name_map[&arg];
150                    self.enable_option(option_name.to_string());
151                }
152            } else {
153                arguments.push(arg);
154            }
155        }
156
157        arguments
158    }
159
160    /// Parses the command-line arguments and returns the list of normal arguments.
161    pub fn parse(&mut self) -> Vec<String> {
162        self.parse_from(std::env::args().collect())
163    }
164
165    /// Checks if the option with the given name is enabled.
166    pub fn is_enabled(&self, name: &str) -> bool {
167        if !self.name_to_option_value_map.contains_key(name) {
168            return false;
169        }
170
171        self.name_to_option_value_map[name].is_enabled()
172    }
173
174    /// Returns the values associated with the option name, or an empty list if not enabled.
175    pub fn get_option_values(&self, name: &str) -> &Vec<String> {
176        if !self.is_enabled(name) {
177            return &self.empty_option_list;
178        }
179
180        &self.name_to_option_value_map[name].values()
181    }
182
183    /// Registers a new CLI option with the given short form, long form, help text, and name.
184    pub fn register_option(
185        &mut self,
186        short_form: Option<String>,
187        long_form: Option<String>,
188        help_text: &str,
189        name: &str,
190    ) {
191        let help_text = help_text.to_string();
192        let name = name.to_string();
193
194        if self.name_to_cli_option_map.contains_key(&name) {
195            panic!("name : {name} already registered as option");
196        }
197
198        if short_form.is_none() && long_form.is_none() {
199            panic!("Both long form and short form cannot be none for name : {name}");
200        }
201
202        if let Some(short_form_name) = short_form.as_ref() {
203            if self
204                .short_form_to_name_map
205                .contains_key(&short_form_name.clone())
206            {
207                panic!("Short form of {short_form_name} already defined");
208            }
209
210            self.short_form_to_name_map
211                .insert(short_form_name.clone(), name.clone());
212        }
213
214        if let Some(long_form_name) = long_form.as_ref() {
215            if self
216                .long_form_to_name_map
217                .contains_key(&long_form_name.clone())
218            {
219                panic!("Short form of {long_form_name} already defined");
220            }
221
222            self.long_form_to_name_map
223                .insert(long_form_name.clone(), name.clone());
224        }
225
226        self.name_to_cli_option_map.insert(
227            name.clone(),
228            CliOption {
229                long_form,
230                short_form,
231                help_text,
232            },
233        );
234
235        self.name_to_option_value_map.insert(
236            name.clone(),
237            OptionValue {
238                is_enabled: false,
239                values: vec![],
240            },
241        );
242    }
243
244    /// Generates and returns the help text for the CLI options.
245    pub fn help_text(&self) -> String {
246        let mut help_text = format!("{}\n\n", self.header.text);
247
248        for (_, cli_option) in &self.name_to_cli_option_map {
249            match cli_option.short_form.as_ref() {
250                Some(short) => help_text += &format!("{}    ", short),
251                None => help_text += &format!("     "),
252            }
253
254            match cli_option.long_form.as_ref() {
255                Some(long) => help_text += &format!("{}    ", long),
256                None => help_text += &format!("     "),
257            }
258
259            help_text += &format!("     {}\n", cli_option.help_text.replace('\n', "\n\t\t\t"));
260        }
261
262        help_text += "\n";
263        help_text += &format!("{}\n\n", self.footer.text);
264
265        help_text
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn check_parsing() {
275        let mut cli_option_parser =
276            CliOptionParser::new("header".to_string(), "footer".to_string());
277        cli_option_parser.register_option(
278            Some("-c".to_string()),
279            Some("--count".to_string()),
280            "print only count of selected lines",
281            "count",
282        );
283
284        cli_option_parser.register_option(
285            Some("-C".to_string()),
286            Some("--context".to_string()),
287            "print NUM lines of output contex when\n given with --context=NUM",
288            "context",
289        );
290
291        let mock_args = vec![
292            "program_name".to_string(),
293            "-c123".to_string(),
294            "--count=456".to_string(),
295            "-C456".to_string(),
296            "--context=712".to_string(),
297        ];
298
299        let arguments = cli_option_parser.parse_from(mock_args);
300
301        assert_eq!(arguments, vec!["program_name"]);
302
303        assert!(cli_option_parser.is_enabled("count"));
304        assert!(cli_option_parser.is_enabled("context"));
305
306        assert!(cli_option_parser["count"].len() == 2);
307        assert_eq!(cli_option_parser["count"], vec!["123", "456"]);
308
309        assert!(cli_option_parser["context"].len() == 2);
310        assert_eq!(cli_option_parser["context"], vec!["456", "712"]);
311
312        let help_text = cli_option_parser.help_text();
313
314        assert!(help_text.contains("header"));
315        assert!(help_text.contains("footer"));
316        assert!(help_text.contains("-c    --count         print only count of selected lines"));
317        assert!(help_text.contains("-C    --context         print NUM lines of output contex when\n\t\t\t given with --context=NUM"));
318    }
319}