Skip to main content

hyperi_rustlib/cli/
args.rs

1// Project:   hyperi-rustlib
2// File:      src/cli/args.rs
3// Purpose:   Standard CLI arguments for DFE services
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Common CLI arguments shared across all DFE services.
10//!
11//! Use `#[command(flatten)]` to embed these in your application's Clap parser:
12//!
13//! ```rust,ignore
14//! use clap::Parser;
15//! use hyperi_rustlib::cli::CommonArgs;
16//!
17//! #[derive(Parser)]
18//! struct App {
19//!     #[command(flatten)]
20//!     common: CommonArgs,
21//! }
22//! ```
23
24/// Standard CLI arguments for DFE services.
25///
26/// Provides the 80% of flags that every service needs:
27/// config path, log level/format, metrics address, verbose/quiet modes.
28///
29/// Embed in your Clap parser with `#[command(flatten)]`.
30#[derive(Debug, Clone, clap::Args)]
31pub struct CommonArgs {
32    /// Path to configuration file.
33    #[arg(short = 'c', long = "config")]
34    pub config: Option<String>,
35
36    /// Log level (trace, debug, info, warn, error).
37    #[arg(
38        short = 'l',
39        long = "log-level",
40        env = "LOG_LEVEL",
41        default_value = "info"
42    )]
43    pub log_level: String,
44
45    /// Log output format (json, text, auto).
46    #[arg(long = "log-format", env = "LOG_FORMAT", default_value = "auto")]
47    pub log_format: String,
48
49    /// Metrics server bind address.
50    #[arg(
51        long = "metrics-addr",
52        env = "METRICS_ADDR",
53        default_value = "0.0.0.0:9090"
54    )]
55    pub metrics_addr: String,
56
57    /// Enable verbose output (sets log level to debug).
58    #[arg(short = 'v', long, conflicts_with = "quiet")]
59    pub verbose: bool,
60
61    /// Suppress all output except errors.
62    #[arg(short = 'q', long, conflicts_with = "verbose")]
63    pub quiet: bool,
64}
65
66impl CommonArgs {
67    /// Resolve the effective log level, accounting for --verbose and --quiet flags.
68    #[must_use]
69    pub fn effective_log_level(&self) -> &str {
70        if self.verbose {
71            "debug"
72        } else if self.quiet {
73            "error"
74        } else {
75            &self.log_level
76        }
77    }
78
79    /// Convert to `LoggerOptions` for use with `logger::setup()`.
80    ///
81    /// Parses the log level and format strings into their typed equivalents.
82    ///
83    /// # Errors
84    ///
85    /// Returns `CliError::InvalidArgument` if the log level or format is invalid.
86    #[cfg(feature = "logger")]
87    pub fn to_logger_options(&self) -> Result<crate::logger::LoggerOptions, super::CliError> {
88        use std::str::FromStr;
89
90        let level: tracing::Level =
91            self.effective_log_level()
92                .to_uppercase()
93                .parse()
94                .map_err(|_| {
95                    super::CliError::InvalidArgument(format!(
96                        "invalid log level: {}",
97                        self.effective_log_level()
98                    ))
99                })?;
100
101        let format = crate::logger::LogFormat::from_str(&self.log_format)
102            .map_err(|e| super::CliError::InvalidArgument(format!("invalid log format: {e}")))?;
103
104        Ok(crate::logger::LoggerOptions {
105            level,
106            format,
107            ..Default::default()
108        })
109    }
110
111    /// Convert to `ConfigOptions` for use with `config::setup()`.
112    ///
113    /// The `--config <path>` flag names a FILE, so it populates
114    /// [`ConfigOptions::config_file`](crate::config::ConfigOptions::config_file)
115    /// -- NOT `config_paths`, which is a list of DIRECTORIES to search for the
116    /// standard base names. (Before 2.8.11 this wrongly pushed the file path
117    /// into `config_paths`, where directory discovery never found it.)
118    #[cfg(feature = "config")]
119    #[must_use]
120    pub fn to_config_options(&self, env_prefix: &str) -> crate::config::ConfigOptions {
121        crate::config::ConfigOptions {
122            env_prefix: env_prefix.to_string(),
123            config_file: self.config.as_deref().map(std::path::PathBuf::from),
124            ..Default::default()
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn test_effective_log_level_default() {
135        let args = CommonArgs {
136            config: None,
137            log_level: "info".to_string(),
138            log_format: "auto".to_string(),
139            metrics_addr: "0.0.0.0:9090".to_string(),
140            verbose: false,
141            quiet: false,
142        };
143        assert_eq!(args.effective_log_level(), "info");
144    }
145
146    #[test]
147    fn test_effective_log_level_verbose() {
148        let args = CommonArgs {
149            config: None,
150            log_level: "info".to_string(),
151            log_format: "auto".to_string(),
152            metrics_addr: "0.0.0.0:9090".to_string(),
153            verbose: true,
154            quiet: false,
155        };
156        assert_eq!(args.effective_log_level(), "debug");
157    }
158
159    #[test]
160    fn test_effective_log_level_quiet() {
161        let args = CommonArgs {
162            config: None,
163            log_level: "info".to_string(),
164            log_format: "auto".to_string(),
165            metrics_addr: "0.0.0.0:9090".to_string(),
166            verbose: false,
167            quiet: true,
168        };
169        assert_eq!(args.effective_log_level(), "error");
170    }
171
172    #[cfg(feature = "config")]
173    #[test]
174    fn test_to_config_options_sets_config_file_not_paths() {
175        let args = CommonArgs {
176            config: Some("/etc/svc/config.yaml".to_string()),
177            log_level: "info".to_string(),
178            log_format: "auto".to_string(),
179            metrics_addr: "0.0.0.0:9090".to_string(),
180            verbose: false,
181            quiet: false,
182        };
183        let opts = args.to_config_options("MY_SVC");
184        assert_eq!(opts.env_prefix, "MY_SVC");
185        // The file path lands in config_file, NOT config_paths (the 2.8.11 fix).
186        assert_eq!(
187            opts.config_file,
188            Some(std::path::PathBuf::from("/etc/svc/config.yaml"))
189        );
190        assert!(opts.config_paths.is_empty());
191    }
192
193    #[cfg(feature = "config")]
194    #[test]
195    fn test_to_config_options_no_config_file_when_absent() {
196        let args = CommonArgs {
197            config: None,
198            log_level: "info".to_string(),
199            log_format: "auto".to_string(),
200            metrics_addr: "0.0.0.0:9090".to_string(),
201            verbose: false,
202            quiet: false,
203        };
204        let opts = args.to_config_options("MY_SVC");
205        assert!(opts.config_file.is_none());
206        assert!(opts.config_paths.is_empty());
207    }
208
209    #[test]
210    fn test_effective_log_level_custom() {
211        let args = CommonArgs {
212            config: None,
213            log_level: "warn".to_string(),
214            log_format: "auto".to_string(),
215            metrics_addr: "0.0.0.0:9090".to_string(),
216            verbose: false,
217            quiet: false,
218        };
219        assert_eq!(args.effective_log_level(), "warn");
220    }
221}