Skip to main content

xianyun_executor/logging/
cliflags.rs

1//! CLI flag definitions for the logging package.
2//!
3//! Ported from `internal/logging/cliflags/flags.go`. Not yet implemented.
4//!
5//! Go's flags are built on `urfave/cli/v3`; this workspace's CLI crate
6//! (`xianyun-cli`) uses `clap` (see the workspace `Cargo.toml`), which is
7//! this port's target instead. `clap::Args` (derivable, composable flag
8//! groups meant to be `#[command(flatten)]`d into a parent command) is the
9//! idiomatic replacement for `urfave/cli`'s `Flags() []cli.Flag` returning
10//! a slice to attach to any app. See `PORT_NOTES.md`.
11
12use clap::Args;
13
14use super::{Config, Format, Output};
15
16/// Ported from `ErrInvalidColorValue`.
17#[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/// Ported from the flags returned by `Flags()`. Meant to be
26/// `#[command(flatten)]`d into a parent `clap::Parser` struct, which is
27/// the `clap` equivalent of Go's "append this slice of flags to any app".
28#[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
74/// Matches each flag's `default_value`, so a caller that builds a
75/// `LoggingArgs` without parsing a command line gets the same logging as one
76/// that parsed no flags. (A derived `Default` gave empty strings here, which
77/// made the default logger fail to build.)
78impl 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
91/// Ported from `ParseConfig`.
92pub 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
126/// Ported from `parseLevel`.
127fn 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    /// `LoggingArgs::default()` must agree with the `default_value`s the
138    /// flags declare. It used to be `#[derive(Default)]`, which gave empty
139    /// strings: an empty log output parsed as a file path, creating the
140    /// logger failed, and the caller ignored the error, so a command run
141    /// with no logging flags logged nothing at all.
142    #[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    /// Ported from `TestFlags`. Go's version calls `Flags()` and inspects
174    /// each `cli.Flag.Names()[0]`; this port instead asserts on
175    /// `LoggingArgs`' derived flag names via `clap::CommandFactory`,
176    /// which is the equivalent introspection surface for a `clap::Args`
177    /// group.
178    #[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}