gitignore_template_generator/parser/
api.rs

1use clap::Parser;
2
3use crate::{
4    constant,
5    validator::{CliArgsValidator, DefaultCliArgsValidator},
6};
7
8use std::ffi::OsString;
9
10use crate::ProgramExit;
11pub use crate::parser::impls::DefaultArgsParser;
12
13/// Struct to parse and gather cli args parsing result.
14///
15/// It should not be used directly to parse cli args, but should be
16/// used along [`crate::parser::ArgsParser`], which wraps all the complex
17/// parsing logic.
18#[derive(Parser, Debug, PartialEq, Default)]
19#[command(version, author, long_about = None)]
20#[command(about = constant::parser_infos::ABOUT)]
21#[command(help_template = "\
22{before-help}
23{usage-heading} {usage}
24
25{about-with-newline}
26{all-args}{after-help}
27
28Version: {version}
29Author: {author}
30")]
31#[command(disable_help_flag = true, disable_version_flag = true)]
32pub struct Args {
33    /// A non-empty list of gitignore template names.
34    ///
35    /// Represented by the provided positional arguments, and required
36    /// unless any of `author`, `version` or `help` options are given.
37    #[arg(
38        required_unless_present_any = vec!["author", "version", "help"],
39        value_parser = DefaultCliArgsValidator::has_no_commas,
40        help = constant::help_messages::TEMPLATE_NAMES
41    )]
42    pub template_names: Vec<String>,
43
44    /// The gitignore template generator service url.
45    ///
46    /// Optional value represented by the cli option
47    /// [`constant::cli_options::SERVER_URL`] that takes a string value, and
48    /// falling back to [`constant::template_generator::BASE_URL`] if not
49    /// provided in cli args.
50    #[arg(
51        short = constant::cli_options::SERVER_URL.short,
52        long = constant::cli_options::SERVER_URL.long,
53        help = constant::help_messages::SERVER_URL,
54        default_value = constant::template_generator::BASE_URL
55    )]
56    pub server_url: String,
57
58    /// The boolean indicator of whether to display help infos or not.
59    ///
60    /// Optional value represented by the cli option
61    /// [`constant::cli_options::HELP`], and falling back to `false` if
62    /// not provided in cli args.
63    #[arg(
64        id = "help",
65        short = constant::cli_options::HELP.short,
66        long = constant::cli_options::HELP.long,
67        action = clap::ArgAction::SetTrue,
68        help = constant::help_messages::HELP
69    )]
70    pub show_help: bool,
71
72    /// The boolean indicator of whether to display version infos or not.
73    ///
74    /// Optional value represented by the cli option
75    /// [`constant::cli_options::VERSION`], and falling back to `false` if
76    /// not provided in cli args.
77    #[arg(
78        id = "version",
79        short = constant::cli_options::VERSION.short,
80        long = constant::cli_options::VERSION.long,
81        action = clap::ArgAction::SetTrue,
82        help = constant::help_messages::VERSION
83    )]
84    pub show_version: bool,
85
86    /// The boolean indicator of whether to display author infos or not.
87    ///
88    /// Optional value represented by the cli option
89    /// [`constant::cli_options::AUTHOR`], and falling back to `false` if
90    /// not provided in cli args.
91    #[arg(
92        id = "author",
93        short = constant::cli_options::AUTHOR.short,
94        long = constant::cli_options::AUTHOR.long,
95        action = clap::ArgAction::SetTrue,
96        help = constant::help_messages::AUTHOR
97    )]
98    pub show_author: bool,
99}
100
101impl Args {
102    /// Sets new value for `template_names` field.
103    ///
104    /// It needs to be called on struct instance and effectively mutates it.
105    ///
106    /// # Arguments
107    ///
108    /// * `template_names` - The new value to be assigned to `template_names`
109    ///     field.
110    ///
111    /// # Returns
112    ///
113    /// The mutated borrowed instance.
114    pub fn with_template_names(mut self, template_names: Vec<String>) -> Self {
115        self.template_names = template_names;
116        self
117    }
118
119    /// Sets new value for `server_url` field.
120    ///
121    /// It needs to be called on struct instance and effectively mutates it.
122    ///
123    /// # Arguments
124    ///
125    /// * `server_url` - The new value to be assigned to `server_url`
126    ///     field.
127    ///
128    /// # Returns
129    ///
130    /// The mutated borrowed instance.
131    pub fn with_server_url(mut self, server_url: &str) -> Self {
132        self.server_url = server_url.to_string();
133        self
134    }
135}
136
137/// Cli args parser trait to parse CLI args and return them in an [`Args`].
138pub trait ArgsParser {
139    /// Parses given cli args and return them as an [`Args`] instance.
140    ///
141    /// * First CLI args should be the binary name
142    /// * Rely on [`ArgsParser::try_parse`] method but additionally wrap
143    ///     error handling logic
144    ///
145    /// # Arguments
146    ///
147    /// * `args` - The CLI args to be parsed. Typically retrieved from
148    ///     [`std::env::args_os`].
149    ///
150    /// # Returns
151    ///
152    /// An owned instance of [`Args`] containing parsing result of given args.
153    fn parse(args: impl IntoIterator<Item = OsString>) -> Args;
154
155    /// Parses given cli args and return them as an [`Args`] instance if no
156    /// error or early exit occurred.
157    ///
158    /// * First CLI args should be the binary name
159    /// * Version, author and help options are considered as early program
160    ///     exit
161    ///
162    /// # Arguments
163    ///
164    ///  * `args` - The CLI args to be parsed. Typically retrieved from
165    ///     [`std::env::args_os`].
166    ///
167    /// # Returns
168    ///
169    /// A result containing an owned instance of [`Args`] if successful parsing,
170    /// or a [`ProgramExit`] if any error or early exit occurred (e.g. version/
171    /// author/help infos printing, invalid cli args...)
172    fn try_parse(
173        args: impl IntoIterator<Item = OsString>,
174    ) -> Result<Args, ProgramExit>;
175}
176
177#[cfg(test)]
178mod tests {
179    use rstest::*;
180
181    use super::*;
182    use crate::helper::*;
183
184    mod default_args_parser {
185        use super::*;
186
187        mod try_parse {
188            use super::*;
189
190            mod success {
191                use crate::{ExitKind, constant};
192
193                use super::*;
194
195                #[rstest]
196                #[case("-V")]
197                #[case("--version")]
198                #[case("-V rust")]
199                #[case("rust -V")]
200                #[case("rust -s foo -V")]
201                #[case("-aV")]
202                fn it_parses_version_cli_option(#[case] cli_args: &str) {
203                    let cli_args = parse_cli_args(cli_args);
204                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
205
206                    let actual_error = parsed_args.as_ref().err();
207                    let expected_error = ProgramExit {
208                        message: format!(
209                            "{} {}",
210                            env!("CARGO_PKG_NAME"),
211                            env!("CARGO_PKG_VERSION")
212                        ),
213                        exit_status: 0,
214                        styled_message: None,
215                        kind: ExitKind::VersionInfos,
216                    };
217                    let expected_error = Some(&expected_error);
218
219                    assert!(actual_error.is_some());
220                    assert_eq!(actual_error, expected_error);
221                }
222
223                #[rstest]
224                #[case("-h")]
225                #[case("--help")]
226                #[case("-h rust")]
227                #[case("rust -h")]
228                #[case("rust -s foo -h")]
229                #[case("-aVh")]
230                fn it_parses_help_cli_option(#[case] cli_args: &str) {
231                    let cli_args = parse_cli_args(cli_args);
232                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
233
234                    let actual_error = parsed_args.as_ref().err();
235                    let expected_error = ProgramExit {
236                        message: get_help_message(),
237                        exit_status: 0,
238                        styled_message: Some(get_ansi_help_message()),
239                        kind: ExitKind::HelpInfos,
240                    };
241                    let expected_error = Some(&expected_error);
242
243                    assert!(actual_error.is_some());
244                    assert_eq!(actual_error, expected_error);
245                }
246
247                #[rstest]
248                #[case("-a")]
249                #[case("--author")]
250                #[case("-a rust")]
251                #[case("rust -a")]
252                #[case("rust -s foo -a")]
253                fn it_parses_author_cli_option_preemptively(
254                    #[case] cli_args: &str,
255                ) {
256                    let cli_args = parse_cli_args(cli_args);
257                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
258
259                    let actual_error = parsed_args.as_ref().err();
260                    let expected_error = ProgramExit {
261                        message: env!("CARGO_PKG_AUTHORS").to_string(),
262                        exit_status: 0,
263                        styled_message: None,
264                        kind: ExitKind::AuthorInfos,
265                    };
266                    let expected_error = Some(&expected_error);
267
268                    assert!(actual_error.is_some());
269                    assert_eq!(actual_error, expected_error);
270                }
271
272                #[rstest]
273                #[case("rust")]
274                #[case("rust python node")]
275                fn it_parses_pos_args_without_server_url_cli_option(
276                    #[case] cli_options: &str,
277                ) {
278                    let cli_args = parse_cli_args(cli_options);
279                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
280
281                    let actual_result = parsed_args.as_ref().ok();
282                    let expected_result = Args::default()
283                        .with_template_names(make_string_vec(cli_options))
284                        .with_server_url(
285                            constant::template_generator::BASE_URL,
286                        );
287                    let expected_result = Some(&expected_result);
288
289                    assert!(actual_result.is_some());
290                    assert_eq!(actual_result, expected_result);
291                }
292
293                #[rstest]
294                #[case("rust -s https://test.com")]
295                #[case("rust --server-url https://test.com")]
296                fn it_parses_pos_args_with_server_url_cli_option(
297                    #[case] cli_args: &str,
298                ) {
299                    let cli_args = parse_cli_args(cli_args);
300                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
301
302                    let actual_result = parsed_args.as_ref().ok();
303                    let expected_result = Args::default()
304                        .with_template_names(make_string_vec("rust"))
305                        .with_server_url("https://test.com");
306                    let expected_result = Some(&expected_result);
307
308                    assert!(actual_result.is_some());
309                    assert_eq!(actual_result, expected_result);
310                }
311            }
312
313            mod failure {
314                use crate::{ExitKind, constant};
315
316                use super::*;
317
318                #[test]
319                fn it_fails_parsing_when_no_pos_args_given() {
320                    let cli_args = parse_cli_args("");
321                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
322
323                    let actual_error = parsed_args.as_ref().err();
324                    let expected_error = ProgramExit {
325                        message: load_expectation_file_as_string(
326                            "no_pos_args_error",
327                        ),
328                        exit_status: constant::exit_status::GENERIC,
329
330                        styled_message: Some(load_expectation_file_as_string(
331                            "ansi_no_pos_args_error",
332                        )),
333                        kind: ExitKind::Error,
334                    };
335                    let expected_error = Some(&expected_error);
336
337                    assert!(actual_error.is_some());
338                    assert_eq!(actual_error, expected_error);
339                }
340
341                #[test]
342                fn it_fails_parsing_when_commas_in_pos_args() {
343                    let cli_args = parse_cli_args("python,java");
344                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
345
346                    let actual_error = parsed_args.as_ref().err();
347                    let expected_error = ProgramExit {
348                        message: load_expectation_file_as_string(
349                            "comma_pos_args_error",
350                        ),
351                        exit_status: constant::exit_status::GENERIC,
352
353                        styled_message: Some(load_expectation_file_as_string(
354                            "ansi_comma_pos_args_error",
355                        )),
356                        kind: ExitKind::Error,
357                    };
358                    let expected_error = Some(&expected_error);
359
360                    assert!(actual_error.is_some());
361                    assert_eq!(actual_error, expected_error);
362                }
363
364                #[test]
365                fn it_fails_parsing_when_server_url_but_no_pos_args() {
366                    let cli_args = parse_cli_args("-s https://test.com");
367                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
368
369                    let actual_error = parsed_args.as_ref().err();
370                    let expected_error = ProgramExit {
371                        message: load_expectation_file_as_string(
372                            "server_url_no_pos_args_error",
373                        ),
374                        exit_status: constant::exit_status::GENERIC,
375
376                        styled_message: Some(load_expectation_file_as_string(
377                            "ansi_server_url_no_pos_args_error",
378                        )),
379                        kind: ExitKind::Error,
380                    };
381                    let expected_error = Some(&expected_error);
382
383                    assert!(actual_error.is_some());
384                    assert_eq!(actual_error, expected_error);
385                }
386
387                #[test]
388                fn it_fails_parsing_when_inexistent_cli_option() {
389                    let cli_args = parse_cli_args("-x");
390                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
391
392                    let actual_error = parsed_args.as_ref().err();
393                    let expected_error = ProgramExit {
394                        message: load_expectation_file_as_string(
395                            "unexpected_argument_error",
396                        ),
397                        exit_status: constant::exit_status::GENERIC,
398                        styled_message: Some(load_expectation_file_as_string(
399                            "ansi_unexpected_argument_error",
400                        )),
401                        kind: ExitKind::Error,
402                    };
403                    let expected_error = Some(&expected_error);
404
405                    assert!(actual_error.is_some());
406                    assert_eq!(actual_error, expected_error);
407                }
408            }
409        }
410    }
411}