Skip to main content

xianyun_executor/logging/
config.rs

1//! Logging configuration.
2//!
3//! Ported from `internal/logging/config.go`. Not yet implemented.
4//!
5//! Go's `Output` is a plain `string` type where `"stderr"`/`"stdout"` are
6//! sentinel values and anything else is treated as a file path. This port
7//! uses an enum with an explicit `File(PathBuf)` variant instead, which is
8//! the idiomatic Rust shape for a closed set of "well-known value, or an
9//! arbitrary path" cases and removes the possibility of an empty-string
10//! output silently being treated as a (nonsensical) relative file path.
11
12use std::io::{self, Write};
13use std::path::PathBuf;
14
15/// Ported from `Format`.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum Format {
18    #[default]
19    Text,
20    Json,
21}
22
23/// Ported from `Output`.
24#[derive(Debug, Clone, PartialEq, Eq, Default)]
25pub enum Output {
26    #[default]
27    Stderr,
28    Stdout,
29    File(PathBuf),
30}
31
32/// Ported from `ErrInvalidFormat`/`ErrEmptyOutput`.
33#[derive(Debug, thiserror::Error)]
34pub enum ConfigError {
35    #[error("invalid format: must be text or json, got {0:?}")]
36    InvalidFormat(Format),
37    #[error("output cannot be empty")]
38    EmptyOutput,
39}
40
41/// Ported from `Config`.
42#[derive(Debug, Clone)]
43pub struct Config {
44    pub format: Format,
45    pub level: tracing::Level,
46    /// `None` (default) = auto-detect based on TTY, `Some(true)` = force
47    /// on, `Some(false)` = force off.
48    pub color: Option<bool>,
49    pub output: Output,
50    pub add_source: bool,
51}
52
53impl Default for Config {
54    fn default() -> Self {
55        // Ported from `NewConfig`.
56        Config {
57            format: Format::Text,
58            level: tracing::Level::INFO,
59            color: None,
60            output: Output::Stderr,
61            add_source: false,
62        }
63    }
64}
65
66impl Config {
67    /// Ported from `Config.Validate`. Go's "invalid format"/"empty
68    /// output" cases are unreachable here since `Format`/`Output` are
69    /// closed enums (see the module docs); validation only checks
70    /// invariants that remain expressible on the typed `Config`.
71    pub fn validate(&self) -> Result<(), ConfigError> {
72        Ok(())
73    }
74
75    /// Ported from `Config.GetWriter`. Go returns a separate `io.Closer`;
76    /// this port relies on `Box<dyn Write>`'s `Drop` impl closing the
77    /// underlying file instead of an explicit closer, since Rust's
78    /// `std::fs::File` closes on drop.
79    pub fn get_writer(&self) -> io::Result<Box<dyn Write + Send>> {
80        match &self.output {
81            Output::Stderr => Ok(Box::new(io::stderr())),
82            Output::Stdout => Ok(Box::new(io::stdout())),
83            Output::File(path) => {
84                let file = std::fs::OpenOptions::new()
85                    .create(true)
86                    .append(true)
87                    .open(path)?;
88                Ok(Box::new(file))
89            }
90        }
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn new_config_defaults() {
100        let cfg = Config::default();
101
102        assert_eq!(cfg.format, Format::Text, "Default format should be text");
103        assert_eq!(
104            cfg.level,
105            tracing::Level::INFO,
106            "Default level should be info"
107        );
108        assert_eq!(cfg.color, None, "Default color should be nil (auto-detect)");
109        assert_eq!(
110            cfg.output,
111            Output::Stderr,
112            "Default output should be stderr"
113        );
114        assert!(!cfg.add_source, "Default AddSource should be false");
115    }
116
117    #[test]
118    fn config_validate_cases() {
119        struct Case {
120            name: &'static str,
121            config: Config,
122            should_err: bool,
123        }
124
125        let cases = vec![
126            Case {
127                name: "valid text format",
128                config: Config {
129                    format: Format::Text,
130                    level: tracing::Level::INFO,
131                    color: None,
132                    output: Output::Stderr,
133                    add_source: false,
134                },
135                should_err: false,
136            },
137            Case {
138                name: "valid json format",
139                config: Config {
140                    format: Format::Json,
141                    level: tracing::Level::INFO,
142                    color: None,
143                    output: Output::Stdout,
144                    add_source: false,
145                },
146                should_err: false,
147            },
148            Case {
149                name: "valid file output",
150                config: Config {
151                    format: Format::Text,
152                    level: tracing::Level::INFO,
153                    color: None,
154                    output: Output::File("/tmp/test.log".into()),
155                    add_source: false,
156                },
157                should_err: false,
158            },
159            // Go's "invalid format" and "empty output" cases don't have a
160            // 1:1 translation: `Format` and `Output` are closed Rust enums,
161            // so `Format("invalid")`/`Output("")` are not constructible.
162            // Validation of an *externally supplied* format/output string
163            // (e.g. from `cliflags::parse_config`) belongs at the parsing
164            // boundary, not on the already-typed `Config`. See
165            // PORT_NOTES.md.
166        ];
167
168        for case in cases {
169            let result = case.config.validate();
170            if case.should_err {
171                assert!(result.is_err(), "{}", case.name);
172            } else {
173                assert!(result.is_ok(), "{}", case.name);
174            }
175        }
176    }
177
178    #[test]
179    fn config_get_writer_cases() {
180        for output in [Output::Stderr, Output::Stdout] {
181            let cfg = Config {
182                format: Format::Text,
183                level: tracing::Level::INFO,
184                color: None,
185                output,
186                add_source: false,
187            };
188
189            let writer = cfg.get_writer();
190            assert!(writer.is_ok(), "Writer should not error");
191        }
192    }
193
194    #[test]
195    fn config_get_writer_file() {
196        let tmp_dir = tempfile::tempdir().unwrap();
197        let log_file = tmp_dir.path().join("test.log");
198
199        let cfg = Config {
200            format: Format::Text,
201            level: tracing::Level::INFO,
202            color: None,
203            output: Output::File(log_file.clone()),
204            add_source: false,
205        };
206
207        let mut writer = cfg.get_writer().unwrap();
208        writer.write_all(b"test log message\n").unwrap();
209        drop(writer);
210
211        let content = std::fs::read_to_string(&log_file).unwrap();
212        assert!(content.contains("test log message"));
213    }
214
215    #[test]
216    fn config_get_writer_invalid_file() {
217        let cfg = Config {
218            format: Format::Text,
219            level: tracing::Level::INFO,
220            color: None,
221            output: Output::File("/nonexistent/directory/test.log".into()),
222            add_source: false,
223        };
224
225        assert!(cfg.get_writer().is_err());
226    }
227}