rustfoundry/
cli.rs

1//! Command line interface-related functionality.
2
3use super::settings::Settings;
4use super::{BootstrapResult, ServiceInfo};
5use anyhow::anyhow;
6use clap::error::ErrorKind;
7use clap::Command;
8use std::ffi::OsString;
9
10pub use clap::{Arg, ArgAction, ArgMatches};
11
12const GENERATE_CONFIG_OPT_ID: &str = "generate";
13const USE_CONFIG_OPT_ID: &str = "config";
14
15/// A command line interface (CLI) helper that takes care of the command line arguments parsing
16/// basics.
17///
18/// Rustfoundry-based services are expected to primarily use [`Settings`] for its configuration. This
19/// helper takes care of setting up CLI with the provided [`ServiceInfo`] and service configuration
20/// parsing.
21///
22/// By default the following command line options are added:
23///
24/// - `-c`, `--config` - specifies an existing configuration file for the service.
25/// - `-g`, `--generate` - generates a new default configuration file for the service.
26/// - `-h`, `--help` - prints CLI help information and exits.
27/// - `-v`, `--version` - prints the service version and exits.
28///
29/// Additional arguments can be added via `custom_args` argument of the [`Cli::new`] function.
30///
31/// [`Settings`]: crate::settings::Settings
32pub struct Cli<S: Settings> {
33    /// Parsed service settings.
34    pub settings: S,
35
36    /// Parsed service arguments.
37    pub arg_matches: ArgMatches,
38}
39
40impl<S: Settings> Cli<S> {
41    /// Bootstraps a new command line interface (CLI) for the service.
42    ///
43    /// `custom_args` argument can be used to add extra service-specific arguments to the CLI.
44    ///
45    /// The function will implicitly print relevant information and exit the process if
46    /// `--help` or `--version` command line options are specified.
47    ///
48    /// Any command line parsing errors are intentionally propagated as a [`BootstrapResult`],
49    /// so they can be reported to a panic handler (e.g. [Sentry]) if the service uses one.
50    ///
51    /// [Sentry]: https://sentry.io/
52    pub fn new(service_info: &ServiceInfo, custom_args: Vec<Arg>) -> BootstrapResult<Self> {
53        Self::new_from_os_args(service_info, custom_args, std::env::args_os())
54    }
55
56    /// Bootstraps a new command line interface (CLI) for the service with the provided `os_args`.
57    ///
58    /// This method is the same as [`Cli::new`], but accepts source OS arguments instead of taking
59    /// them fron [`std::env::args_os`].
60    ///
61    /// Useful for testing purposes.
62    pub fn new_from_os_args(
63        service_info: &ServiceInfo,
64        custom_args: Vec<Arg>,
65        os_args: impl IntoIterator<Item = impl Into<OsString> + Clone>,
66    ) -> BootstrapResult<Self> {
67        let mut cmd = Command::new(service_info.name)
68            .version(service_info.version)
69            .author(service_info.author)
70            .about(service_info.description)
71            .arg(
72                Arg::new("config")
73                    .required_unless_present(GENERATE_CONFIG_OPT_ID)
74                    .action(ArgAction::Set)
75                    .long("config")
76                    .short('c')
77                    .help("Specifies the config to run the service with"),
78            )
79            .arg(
80                Arg::new(GENERATE_CONFIG_OPT_ID)
81                    .action(ArgAction::Set)
82                    .long("generate")
83                    .short('g')
84                    .help("Generates a new default config for the service"),
85            );
86
87        for arg in custom_args {
88            cmd = cmd.arg(arg);
89        }
90
91        let arg_matches = get_arg_matches(cmd, os_args)?;
92        let settings = get_settings(&arg_matches)?;
93
94        Ok(Self {
95            settings,
96            arg_matches,
97        })
98    }
99}
100
101fn get_arg_matches(
102    cmd: Command,
103    os_args: impl IntoIterator<Item = impl Into<OsString> + Clone>,
104) -> BootstrapResult<ArgMatches> {
105    cmd.try_get_matches_from(os_args).map_err(|e| {
106        let kind = e.kind();
107
108        // NOTE: print info and terminate the process
109        if kind == ErrorKind::DisplayHelp || kind == ErrorKind::DisplayVersion {
110            e.exit();
111        }
112
113        // NOTE: otherwise propagate as an error, so it can be captured by a panic handler
114        // if necesary (e.g. Sentry).
115        e.into()
116    })
117}
118
119fn get_settings<S: Settings>(arg_matches: &ArgMatches) -> BootstrapResult<S> {
120    if let Some(path) = arg_matches.get_one::<String>(GENERATE_CONFIG_OPT_ID) {
121        let settings = S::default();
122
123        crate::settings::to_yaml_file(&settings, path)?;
124
125        return Ok(settings);
126    }
127
128    if let Some(path) = arg_matches.get_one::<String>(USE_CONFIG_OPT_ID) {
129        return crate::settings::from_file(path).map_err(|e| anyhow!(e));
130    }
131
132    unreachable!("clap should require config options to be present")
133}