1use clap::Args;
13
14use super::{Config, Format, Output};
15
16#[derive(Debug, thiserror::Error)]
18pub enum CliFlagsError {
19 #[error("invalid log-color value: {0:?} (must be auto, true, or false)")]
20 InvalidColorValue(String),
21 #[error("invalid log level {0:?}: {1}")]
22 InvalidLevel(String, String),
23}
24
25#[derive(Args, Debug)]
29pub struct LoggingArgs {
30 #[arg(
31 id = "log-format",
32 long = "log-format",
33 env = "LOG_FORMAT",
34 default_value = "text"
35 )]
36 pub log_format: String,
37
38 #[arg(
39 id = "log-level",
40 long = "log-level",
41 env = "LOG_LEVEL",
42 default_value = "info"
43 )]
44 pub log_level: String,
45
46 #[arg(
47 id = "log-color",
48 long = "log-color",
49 env = "LOG_COLOR",
50 default_value = "auto"
51 )]
52 pub log_color: String,
53
54 #[arg(
55 id = "log-output",
56 long = "log-output",
57 env = "LOG_OUTPUT",
58 default_value = "stderr"
59 )]
60 pub log_output: String,
61
62 #[arg(
63 id = "log-source",
64 long = "log-source",
65 env = "LOG_SOURCE",
66 default_value_t = false
67 )]
68 pub log_source: bool,
69
70 #[arg(long = "debug", env = "DEBUG", default_value_t = false)]
71 pub debug: bool,
72}
73
74impl Default for LoggingArgs {
79 fn default() -> Self {
80 Self {
81 log_format: "text".to_string(),
82 log_level: "info".to_string(),
83 log_color: "auto".to_string(),
84 log_output: "stderr".to_string(),
85 log_source: false,
86 debug: false,
87 }
88 }
89}
90
91pub fn parse_config(args: &LoggingArgs) -> Result<Config, CliFlagsError> {
93 let format = if args.log_format.eq_ignore_ascii_case("json") {
94 Format::Json
95 } else {
96 Format::Text
97 };
98
99 let level = if args.debug {
100 tracing::Level::DEBUG
101 } else {
102 parse_level(&args.log_level)?
103 };
104
105 let color = match args.log_color.to_lowercase().as_str() {
106 "true" => Some(true),
107 "false" => Some(false),
108 _ => None,
109 };
110
111 let output = match args.log_output.as_str() {
112 "stderr" => Output::Stderr,
113 "stdout" => Output::Stdout,
114 other => Output::File(other.into()),
115 };
116
117 Ok(Config {
118 format,
119 level,
120 color,
121 output,
122 add_source: args.log_source,
123 })
124}
125
126fn parse_level(level: &str) -> Result<tracing::Level, CliFlagsError> {
128 level
129 .parse::<tracing::Level>()
130 .map_err(|e| CliFlagsError::InvalidLevel(level.to_string(), e.to_string()))
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
143 fn default_args_match_the_declared_flag_defaults() {
144 #[derive(Parser)]
145 struct TestApp {
146 #[command(flatten)]
147 logging: LoggingArgs,
148 }
149
150 let from_clap = TestApp::try_parse_from(["app"])
151 .expect("no flags is valid")
152 .logging;
153 let from_default = LoggingArgs::default();
154
155 assert_eq!(from_default.log_format, from_clap.log_format);
156 assert_eq!(from_default.log_level, from_clap.log_level);
157 assert_eq!(from_default.log_color, from_clap.log_color);
158 assert_eq!(from_default.log_output, from_clap.log_output);
159 assert_eq!(from_default.log_source, from_clap.log_source);
160 assert_eq!(from_default.debug, from_clap.debug);
161 }
162
163 #[test]
164 fn default_args_parse_to_info_level_text_on_stderr() {
165 let config = parse_config(&LoggingArgs::default()).expect("defaults must parse");
166
167 assert_eq!(config.level, tracing::Level::INFO);
168 assert_eq!(config.format, Format::Text);
169 assert!(matches!(config.output, Output::Stderr));
170 }
171 use clap::{CommandFactory, Parser};
172
173 #[test]
179 fn flags_expose_expected_names() {
180 #[derive(Parser)]
181 struct TestApp {
182 #[command(flatten)]
183 logging: LoggingArgs,
184 }
185
186 let mut flag_names: Vec<String> = TestApp::command()
187 .get_arguments()
188 .map(|a| a.get_id().to_string())
189 .collect();
190 flag_names.sort();
191
192 assert_eq!(
193 flag_names,
194 vec![
195 "debug",
196 "log-color",
197 "log-format",
198 "log-level",
199 "log-output",
200 "log-source",
201 ]
202 .into_iter()
203 .map(String::from)
204 .collect::<Vec<_>>(),
205 "flag names doesn't match expected names"
206 );
207 }
208
209 struct Case {
210 name: &'static str,
211 args: &'static [&'static str],
212 expected_format: Format,
213 expected_level: tracing::Level,
214 expected_color: Option<bool>,
215 expected_output: Output,
216 expected_source: bool,
217 }
218
219 #[test]
220 fn parse_config_cases() {
221 #[derive(Parser)]
222 struct TestApp {
223 #[command(flatten)]
224 logging: LoggingArgs,
225 }
226
227 let cases = vec![
228 Case {
229 name: "defaults",
230 args: &["app"],
231 expected_format: Format::Text,
232 expected_level: tracing::Level::INFO,
233 expected_color: None,
234 expected_output: Output::Stderr,
235 expected_source: false,
236 },
237 Case {
238 name: "json format",
239 args: &["app", "--log-format", "json"],
240 expected_format: Format::Json,
241 expected_level: tracing::Level::INFO,
242 expected_color: None,
243 expected_output: Output::Stderr,
244 expected_source: false,
245 },
246 Case {
247 name: "debug level",
248 args: &["app", "--log-level", "debug"],
249 expected_format: Format::Text,
250 expected_level: tracing::Level::DEBUG,
251 expected_color: None,
252 expected_output: Output::Stderr,
253 expected_source: false,
254 },
255 Case {
256 name: "debug flag",
257 args: &["app", "--debug"],
258 expected_format: Format::Text,
259 expected_level: tracing::Level::DEBUG,
260 expected_color: None,
261 expected_output: Output::Stderr,
262 expected_source: false,
263 },
264 Case {
265 name: "debug flag overrides level",
266 args: &["app", "--log-level", "error", "--debug"],
267 expected_format: Format::Text,
268 expected_level: tracing::Level::DEBUG,
269 expected_color: None,
270 expected_output: Output::Stderr,
271 expected_source: false,
272 },
273 Case {
274 name: "color enabled",
275 args: &["app", "--log-color=true"],
276 expected_format: Format::Text,
277 expected_level: tracing::Level::INFO,
278 expected_color: Some(true),
279 expected_output: Output::Stderr,
280 expected_source: false,
281 },
282 Case {
283 name: "color disabled",
284 args: &["app", "--log-color=false"],
285 expected_format: Format::Text,
286 expected_level: tracing::Level::INFO,
287 expected_color: Some(false),
288 expected_output: Output::Stderr,
289 expected_source: false,
290 },
291 Case {
292 name: "color auto",
293 args: &["app", "--log-color=auto"],
294 expected_format: Format::Text,
295 expected_level: tracing::Level::INFO,
296 expected_color: None,
297 expected_output: Output::Stderr,
298 expected_source: false,
299 },
300 Case {
301 name: "stdout output",
302 args: &["app", "--log-output", "stdout"],
303 expected_format: Format::Text,
304 expected_level: tracing::Level::INFO,
305 expected_color: None,
306 expected_output: Output::Stdout,
307 expected_source: false,
308 },
309 Case {
310 name: "file output",
311 args: &["app", "--log-output", "/tmp/test.log"],
312 expected_format: Format::Text,
313 expected_level: tracing::Level::INFO,
314 expected_color: None,
315 expected_output: Output::File("/tmp/test.log".into()),
316 expected_source: false,
317 },
318 Case {
319 name: "source enabled",
320 args: &["app", "--log-source"],
321 expected_format: Format::Text,
322 expected_level: tracing::Level::INFO,
323 expected_color: None,
324 expected_output: Output::Stderr,
325 expected_source: true,
326 },
327 Case {
328 name: "all flags combined",
329 args: &[
330 "app",
331 "--log-format",
332 "json",
333 "--log-level",
334 "warn",
335 "--log-output",
336 "stdout",
337 "--log-source",
338 ],
339 expected_format: Format::Json,
340 expected_level: tracing::Level::WARN,
341 expected_color: None,
342 expected_output: Output::Stdout,
343 expected_source: true,
344 },
345 ];
346
347 for case in cases {
348 let app = TestApp::parse_from(case.args);
349 let cfg = parse_config(&app.logging).unwrap();
350
351 assert_eq!(cfg.format, case.expected_format, "{}: format", case.name);
352 assert_eq!(cfg.level, case.expected_level, "{}: level", case.name);
353 assert_eq!(cfg.color, case.expected_color, "{}: color", case.name);
354 assert_eq!(cfg.output, case.expected_output, "{}: output", case.name);
355 assert_eq!(
356 cfg.add_source, case.expected_source,
357 "{}: add_source",
358 case.name
359 );
360 }
361 }
362
363 #[test]
364 fn parse_config_invalid_level() {
365 #[derive(Parser)]
366 struct TestApp {
367 #[command(flatten)]
368 logging: LoggingArgs,
369 }
370
371 let app = TestApp::parse_from(["app", "--log-level", "invalid"]);
372 assert!(parse_config(&app.logging).is_err());
373 }
374}