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 gitignore template generator service endpoint uri.
59    ///
60    /// Optional value represented by the cli option
61    /// [`constant::cli_options::ENDPOINT_URI`] that takes a string value, and
62    /// falling back to [`constant::template_generator::URI`] if not
63    /// provided in cli args.
64    #[arg(
65        short = constant::cli_options::ENDPOINT_URI.short,
66        long = constant::cli_options::ENDPOINT_URI.long,
67        help = constant::help_messages::ENDPOINT_URI,
68        default_value = constant::template_generator::URI
69    )]
70    pub endpoint_uri: String,
71
72    /// The boolean indicator of whether to display help infos or not.
73    ///
74    /// Optional value represented by the cli option
75    /// [`constant::cli_options::HELP`], and falling back to `false` if
76    /// not provided in cli args.
77    #[arg(
78        id = "help",
79        short = constant::cli_options::HELP.short,
80        long = constant::cli_options::HELP.long,
81        action = clap::ArgAction::SetTrue,
82        help = constant::help_messages::HELP
83    )]
84    pub show_help: bool,
85
86    /// The boolean indicator of whether to display version infos or not.
87    ///
88    /// Optional value represented by the cli option
89    /// [`constant::cli_options::VERSION`], and falling back to `false` if
90    /// not provided in cli args.
91    #[arg(
92        id = "version",
93        short = constant::cli_options::VERSION.short,
94        long = constant::cli_options::VERSION.long,
95        action = clap::ArgAction::SetTrue,
96        help = constant::help_messages::VERSION
97    )]
98    pub show_version: bool,
99
100    /// The boolean indicator of whether to display author infos or not.
101    ///
102    /// Optional value represented by the cli option
103    /// [`constant::cli_options::AUTHOR`], and falling back to `false` if
104    /// not provided in cli args.
105    #[arg(
106        id = "author",
107        short = constant::cli_options::AUTHOR.short,
108        long = constant::cli_options::AUTHOR.long,
109        action = clap::ArgAction::SetTrue,
110        help = constant::help_messages::AUTHOR
111    )]
112    pub show_author: bool,
113}
114
115impl Args {
116    /// Sets new value for `template_names` field.
117    ///
118    /// It needs to be called on struct instance and effectively mutates it.
119    ///
120    /// # Arguments
121    ///
122    /// * `template_names` - The new value to be assigned to `template_names`
123    ///     field.
124    ///
125    /// # Returns
126    ///
127    /// The mutated borrowed instance.
128    pub fn with_template_names(mut self, template_names: Vec<String>) -> Self {
129        self.template_names = template_names;
130        self
131    }
132
133    /// Sets new value for `server_url` field.
134    ///
135    /// It needs to be called on struct instance and effectively mutates it.
136    ///
137    /// # Arguments
138    ///
139    /// * `server_url` - The new value to be assigned to `server_url`
140    ///     field.
141    ///
142    /// # Returns
143    ///
144    /// The mutated borrowed instance.
145    pub fn with_server_url(mut self, server_url: &str) -> Self {
146        self.server_url = server_url.to_string();
147        self
148    }
149
150    /// Sets new value for `endpoint_uri` field.
151    ///
152    /// It needs to be called on struct instance and effectively mutates it.
153    ///
154    /// # Arguments
155    ///
156    /// * `endpoint_uri` - The new value to be assigned to `endpoint_uri`
157    ///     field.
158    ///
159    /// # Returns
160    ///
161    /// The mutated borrowed instance.
162    pub fn with_endpoint_uri(mut self, endpoint_uri: &str) -> Self {
163        self.endpoint_uri = endpoint_uri.to_string();
164        self
165    }
166}
167
168/// Cli args parser trait to parse CLI args and return them in an [`Args`].
169pub trait ArgsParser {
170    /// Parses given cli args and return them as an [`Args`] instance.
171    ///
172    /// * First CLI args should be the binary name
173    /// * Rely on [`ArgsParser::try_parse`] method but additionally wrap
174    ///     error handling logic
175    ///
176    /// # Arguments
177    ///
178    /// * `args` - The CLI args to be parsed. Typically retrieved from
179    ///     [`std::env::args_os`].
180    ///
181    /// # Returns
182    ///
183    /// An owned instance of [`Args`] containing parsing result of given args.
184    fn parse(args: impl IntoIterator<Item = OsString>) -> Args;
185
186    /// Parses given cli args and return them as an [`Args`] instance if no
187    /// error or early exit occurred.
188    ///
189    /// * First CLI args should be the binary name
190    /// * Version, author and help options are considered as early program
191    ///     exit
192    ///
193    /// # Arguments
194    ///
195    ///  * `args` - The CLI args to be parsed. Typically retrieved from
196    ///     [`std::env::args_os`].
197    ///
198    /// # Returns
199    ///
200    /// A result containing an owned instance of [`Args`] if successful parsing,
201    /// or a [`ProgramExit`] if any error or early exit occurred (e.g. version/
202    /// author/help infos printing, invalid cli args...)
203    fn try_parse(
204        args: impl IntoIterator<Item = OsString>,
205    ) -> Result<Args, ProgramExit>;
206}
207
208#[cfg(test)]
209mod tests {
210    use rstest::*;
211
212    use super::*;
213    use crate::helper::*;
214
215    mod default_args_parser {
216        use super::*;
217
218        mod try_parse {
219            use super::*;
220
221            mod success {
222                use crate::{ExitKind, constant};
223
224                use super::*;
225
226                #[rstest]
227                #[case("-V")]
228                #[case("--version")]
229                #[case("-V rust")]
230                #[case("rust -V")]
231                #[case("rust -s foo -V")]
232                #[case("rust -e bar -V")]
233                #[case("-aV")]
234                fn it_parses_version_cli_option(#[case] cli_args: &str) {
235                    let cli_args = parse_cli_args(cli_args);
236                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
237
238                    let actual_error = parsed_args.as_ref().err();
239                    let expected_error = ProgramExit {
240                        message: format!(
241                            "{} {}",
242                            env!("CARGO_PKG_NAME"),
243                            env!("CARGO_PKG_VERSION")
244                        ),
245                        exit_status: 0,
246                        styled_message: None,
247                        kind: ExitKind::VersionInfos,
248                    };
249                    let expected_error = Some(&expected_error);
250
251                    assert!(actual_error.is_some());
252                    assert_eq!(actual_error, expected_error);
253                }
254
255                #[rstest]
256                #[case("-h")]
257                #[case("--help")]
258                #[case("-h rust")]
259                #[case("rust -h")]
260                #[case("rust -s foo -h")]
261                #[case("rust -e bar -h")]
262                #[case("-aVh")]
263                fn it_parses_help_cli_option(#[case] cli_args: &str) {
264                    let cli_args = parse_cli_args(cli_args);
265                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
266
267                    let actual_error = parsed_args.as_ref().err();
268                    let expected_error = ProgramExit {
269                        message: get_help_message(),
270                        exit_status: 0,
271                        styled_message: Some(get_ansi_help_message()),
272                        kind: ExitKind::HelpInfos,
273                    };
274                    let expected_error = Some(&expected_error);
275
276                    assert!(actual_error.is_some());
277                    assert_eq!(actual_error, expected_error);
278                }
279
280                #[rstest]
281                #[case("-a")]
282                #[case("--author")]
283                #[case("-a rust")]
284                #[case("rust -a")]
285                #[case("rust -s foo -a")]
286                #[case("rust -e bar -a")]
287                fn it_parses_author_cli_option_preemptively(
288                    #[case] cli_args: &str,
289                ) {
290                    let cli_args = parse_cli_args(cli_args);
291                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
292
293                    let actual_error = parsed_args.as_ref().err();
294                    let expected_error = ProgramExit {
295                        message: env!("CARGO_PKG_AUTHORS").to_string(),
296                        exit_status: 0,
297                        styled_message: None,
298                        kind: ExitKind::AuthorInfos,
299                    };
300                    let expected_error = Some(&expected_error);
301
302                    assert!(actual_error.is_some());
303                    assert_eq!(actual_error, expected_error);
304                }
305
306                #[rstest]
307                #[case("rust")]
308                #[case("rust python node")]
309                fn it_parses_pos_args_without_server_url_cli_option(
310                    #[case] cli_options: &str,
311                ) {
312                    let cli_args = parse_cli_args(cli_options);
313                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
314
315                    let actual_result = parsed_args.as_ref().ok();
316                    let expected_result = Args::default()
317                        .with_template_names(make_string_vec(cli_options))
318                        .with_server_url(constant::template_generator::BASE_URL)
319                        .with_endpoint_uri(constant::template_generator::URI);
320                    let expected_result = Some(&expected_result);
321
322                    assert!(actual_result.is_some());
323                    assert_eq!(actual_result, expected_result);
324                }
325
326                #[rstest]
327                #[case("rust -s https://test.com")]
328                #[case("rust --server-url https://test.com")]
329                fn it_parses_pos_args_with_server_url_cli_option(
330                    #[case] cli_args: &str,
331                ) {
332                    let cli_args = parse_cli_args(cli_args);
333                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
334
335                    let actual_result = parsed_args.as_ref().ok();
336                    let expected_result = Args::default()
337                        .with_template_names(make_string_vec("rust"))
338                        .with_server_url("https://test.com")
339                        .with_endpoint_uri(constant::template_generator::URI);
340                    let expected_result = Some(&expected_result);
341
342                    assert!(actual_result.is_some());
343                    assert_eq!(actual_result, expected_result);
344                }
345
346                #[rstest]
347                #[case("rust -e /test/api")]
348                #[case("rust --endpoint-uri /test/api")]
349                fn it_parses_pos_args_with_endpoint_uri_cli_option(
350                    #[case] cli_args: &str,
351                ) {
352                    let cli_args = parse_cli_args(cli_args);
353                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
354
355                    let actual_result = parsed_args.as_ref().ok();
356                    let expected_result = Args::default()
357                        .with_template_names(make_string_vec("rust"))
358                        .with_server_url(constant::template_generator::BASE_URL)
359                        .with_endpoint_uri("/test/api");
360                    let expected_result = Some(&expected_result);
361
362                    assert!(actual_result.is_some());
363                    assert_eq!(actual_result, expected_result);
364                }
365            }
366
367            mod failure {
368                use crate::{ExitKind, constant};
369
370                use super::*;
371
372                #[test]
373                fn it_fails_parsing_when_no_pos_args_given() {
374                    let cli_args = parse_cli_args("");
375                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
376
377                    let actual_error = parsed_args.as_ref().err();
378                    let expected_error = ProgramExit {
379                        message: load_expectation_file_as_string(
380                            "no_pos_args_error",
381                        ),
382                        exit_status: constant::exit_status::GENERIC,
383
384                        styled_message: Some(load_expectation_file_as_string(
385                            "ansi_no_pos_args_error",
386                        )),
387                        kind: ExitKind::Error,
388                    };
389                    let expected_error = Some(&expected_error);
390
391                    assert!(actual_error.is_some());
392                    assert_eq!(actual_error, expected_error);
393                }
394
395                #[test]
396                fn it_fails_parsing_when_commas_in_pos_args() {
397                    let cli_args = parse_cli_args("python,java");
398                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
399
400                    let actual_error = parsed_args.as_ref().err();
401                    let expected_error = ProgramExit {
402                        message: load_expectation_file_as_string(
403                            "comma_pos_args_error",
404                        ),
405                        exit_status: constant::exit_status::GENERIC,
406
407                        styled_message: Some(load_expectation_file_as_string(
408                            "ansi_comma_pos_args_error",
409                        )),
410                        kind: ExitKind::Error,
411                    };
412                    let expected_error = Some(&expected_error);
413
414                    assert!(actual_error.is_some());
415                    assert_eq!(actual_error, expected_error);
416                }
417
418                #[test]
419                fn it_fails_parsing_when_server_url_but_no_pos_args() {
420                    let cli_args = parse_cli_args("-s https://test.com");
421                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
422
423                    let actual_error = parsed_args.as_ref().err();
424                    let expected_error = ProgramExit {
425                        message: load_expectation_file_as_string(
426                            "server_url_no_pos_args_error",
427                        ),
428                        exit_status: constant::exit_status::GENERIC,
429
430                        styled_message: Some(load_expectation_file_as_string(
431                            "ansi_server_url_no_pos_args_error",
432                        )),
433                        kind: ExitKind::Error,
434                    };
435                    let expected_error = Some(&expected_error);
436
437                    assert!(actual_error.is_some());
438                    assert_eq!(actual_error, expected_error);
439                }
440
441                #[test]
442                fn it_fails_parsing_when_endpoint_uri_but_no_pos_args() {
443                    let cli_args = parse_cli_args("-e /test/api");
444                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
445
446                    let actual_error = parsed_args.as_ref().err();
447                    let expected_error = ProgramExit {
448                        message: load_expectation_file_as_string(
449                            "endpoint_uri_no_pos_args_error",
450                        ),
451                        exit_status: constant::exit_status::GENERIC,
452
453                        styled_message: Some(load_expectation_file_as_string(
454                            "ansi_endpoint_uri_no_pos_args_error",
455                        )),
456                        kind: ExitKind::Error,
457                    };
458                    let expected_error = Some(&expected_error);
459
460                    assert!(actual_error.is_some());
461                    assert_eq!(actual_error, expected_error);
462                }
463
464                #[test]
465                fn it_fails_parsing_when_inexistent_cli_option() {
466                    let cli_args = parse_cli_args("-x");
467                    let parsed_args = DefaultArgsParser::try_parse(cli_args);
468
469                    let actual_error = parsed_args.as_ref().err();
470                    let expected_error = ProgramExit {
471                        message: load_expectation_file_as_string(
472                            "unexpected_argument_error",
473                        ),
474                        exit_status: constant::exit_status::GENERIC,
475                        styled_message: Some(load_expectation_file_as_string(
476                            "ansi_unexpected_argument_error",
477                        )),
478                        kind: ExitKind::Error,
479                    };
480                    let expected_error = Some(&expected_error);
481
482                    assert!(actual_error.is_some());
483                    assert_eq!(actual_error, expected_error);
484                }
485            }
486        }
487    }
488}