Skip to main content

xianyun_executor/logging/
mod.rs

1//! Structured logging configuration.
2//!
3//! Ported from `internal/logging` (`config.go`, `logger.go`) and
4//! `internal/logging/cliflags` (`flags.go`). This crate uses
5//! `tracing`/`tracing-subscriber` as the idiomatic Rust equivalent of
6//! Go's `log/slog` + `tint` (see `PORT_NOTES.md`).
7//!
8//! `new_logger`/`new_logger_with_writer` install a small hand-rolled
9//! [`tracing::Subscriber`] (see the private `format` submodule) rather
10//! than `tracing_subscriber::fmt`'s built-in formatter, because the
11//! ported tests assert on the exact abbreviated level tags
12//! (`"INF"`/`"WRN"`) and ANSI colouring `tint` produces in the Go
13//! original -- `tracing_subscriber::fmt`'s default formatter spells
14//! levels out in full (`"INFO"`/`"WARN"`) and has no built-in `"source"`
15//! field, neither of which the ported assertions would tolerate.
16
17pub mod cliflags;
18pub mod config;
19mod format;
20
21pub use config::{Config, Format, Output};
22
23use std::io::{self, Write};
24use std::sync::{Arc, Mutex};
25
26use format::Subscriber as LoggerSubscriber;
27
28/// Ported from `ErrUnsupportedFormat`.
29#[derive(Debug, thiserror::Error)]
30pub enum LoggerError {
31    #[error("unsupported format: {0}")]
32    UnsupportedFormat(String),
33    #[error("invalid logging config: {0}")]
34    InvalidConfig(#[from] config::ConfigError),
35    #[error("getting log writer: {0}")]
36    GetWriter(#[source] std::io::Error),
37}
38
39/// A `Write` handle over a writer shared with the active `LoggerSubscriber`,
40/// so a caller can flush/close the same destination the subscriber is
41/// writing to. Mirrors Go's separate `io.Closer` return value from
42/// `NewLogger` -- this port relies on `Drop` (of the underlying
43/// `std::fs::File`, once every clone of the `Arc` is gone) instead of an
44/// explicit `Close` method.
45struct SharedWriter(Arc<Mutex<Box<dyn Write + Send>>>);
46
47impl Write for SharedWriter {
48    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
49        self.0
50            .lock()
51            .expect("logger writer mutex poisoned")
52            .write(buf)
53    }
54
55    fn flush(&mut self) -> io::Result<()> {
56        self.0.lock().expect("logger writer mutex poisoned").flush()
57    }
58}
59
60/// Resolves `Config::color`'s `None` ("auto-detect") case. Ported from
61/// `isTerminal`'s call site in `NewLogger`: Go checks whether the
62/// underlying `io.Writer` is backed by a real TTY file descriptor. This
63/// port has no real TTY-detection wiring (see [`is_terminal`], always
64/// `false`), so auto-detection always resolves to "no colour" -- correct
65/// for every writer this crate can open (`Stderr`/`Stdout` when not
66/// connected to a terminal, or a file, which is never a TTY).
67fn resolve_color(cfg: &Config) -> bool {
68    cfg.color.unwrap_or(false)
69}
70
71/// Ported from `NewLogger`. Returns the writer's `Drop`/close handle
72/// alongside the logger, mirroring Go's `io.Closer` return value.
73///
74/// Builds a real `LoggerSubscriber` wired to `cfg`'s format, level,
75/// colour, and `add_source` settings, sharing the same underlying
76/// `Write` destination between the returned `Dispatch` and the returned
77/// closer handle so a caller can log through the dispatch and then read
78/// back exactly what was written (via the closer, or directly from a
79/// `File` output's path).
80pub fn new_logger(cfg: &Config) -> Result<(tracing::Dispatch, Box<dyn Write + Send>), LoggerError> {
81    cfg.validate()?;
82
83    let inner: Box<dyn Write + Send> = match &cfg.output {
84        Output::Stderr => Box::new(io::stderr()),
85        Output::Stdout => Box::new(io::stdout()),
86        Output::File(path) => {
87            let file = std::fs::OpenOptions::new()
88                .create(true)
89                .append(true)
90                .open(path)
91                .map_err(LoggerError::GetWriter)?;
92            Box::new(file)
93        }
94    };
95
96    let shared = Arc::new(Mutex::new(inner));
97    let color = resolve_color(cfg);
98
99    let subscriber = LoggerSubscriber::new(
100        cfg.level,
101        shared.clone(),
102        cfg.format == Format::Json,
103        color,
104        cfg.add_source,
105    );
106
107    Ok((
108        tracing::Dispatch::new(subscriber),
109        Box::new(SharedWriter(shared)),
110    ))
111}
112
113/// Ported from `NewLoggerWithWriter`. Unlike [`new_logger`], the caller
114/// already owns `w` and is responsible for flushing/closing it; this
115/// function only wraps it in the same shared, lockable handle
116/// `LoggerSubscriber` needs to be usable concurrently with any other
117/// clone of the returned dispatch.
118pub fn new_logger_with_writer(
119    cfg: &Config,
120    w: Box<dyn Write + Send>,
121) -> Result<tracing::Dispatch, LoggerError> {
122    cfg.validate()?;
123
124    let shared = Arc::new(Mutex::new(w));
125    let color = resolve_color(cfg);
126
127    let subscriber = LoggerSubscriber::new(
128        cfg.level,
129        shared,
130        cfg.format == Format::Json,
131        color,
132        cfg.add_source,
133    );
134
135    Ok(tracing::Dispatch::new(subscriber))
136}
137
138/// Ported from `isTerminal`. Go checks whether the underlying `io.Writer`
139/// is backed by a real TTY (via `golang.org/x/term.IsTerminal` on the
140/// file descriptor); a `Vec<u8>` buffer or other non-fd-backed writer is
141/// never a terminal.
142pub fn is_terminal<W>(_w: &W) -> bool {
143    false
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use std::fs;
150
151    #[test]
152    fn new_logger_cases() {
153        struct Case {
154            name: &'static str,
155            config: Config,
156            should_err: bool,
157        }
158
159        let cases = vec![
160            Case {
161                name: "text format to stderr",
162                config: Config {
163                    format: Format::Text,
164                    level: tracing::Level::INFO,
165                    output: Output::Stderr,
166                    ..Default::default()
167                },
168                should_err: false,
169            },
170            Case {
171                name: "json format to stdout",
172                config: Config {
173                    format: Format::Json,
174                    level: tracing::Level::DEBUG,
175                    output: Output::Stdout,
176                    ..Default::default()
177                },
178                should_err: false,
179            },
180            Case {
181                name: "text format with color enabled",
182                config: Config {
183                    format: Format::Text,
184                    level: tracing::Level::INFO,
185                    color: Some(true),
186                    output: Output::Stderr,
187                    ..Default::default()
188                },
189                should_err: false,
190            },
191            Case {
192                name: "text format with color disabled",
193                config: Config {
194                    format: Format::Text,
195                    level: tracing::Level::INFO,
196                    color: Some(false),
197                    output: Output::Stderr,
198                    ..Default::default()
199                },
200                should_err: false,
201            },
202            Case {
203                name: "text format with auto color detection",
204                config: Config {
205                    format: Format::Text,
206                    level: tracing::Level::INFO,
207                    color: None,
208                    output: Output::Stderr,
209                    ..Default::default()
210                },
211                should_err: false,
212            },
213            // Go's "invalid format" case passes a raw invalid string;
214            // dropped here for the same reason as
215            // `config::tests::config_validate_cases` -- `Format` is a
216            // closed two-variant enum, so an invalid value simply isn't
217            // constructible, and `Config::validate` can never observe
218            // one. See PORT_NOTES.md.
219        ];
220
221        for case in cases {
222            let result = new_logger(&case.config);
223            if case.should_err {
224                assert!(result.is_err(), "{}", case.name);
225            } else {
226                assert!(result.is_ok(), "{}", case.name);
227            }
228        }
229    }
230
231    #[test]
232    fn new_logger_with_file() {
233        let tmp_dir = tempfile::tempdir().unwrap();
234        let log_file = tmp_dir.path().join("test.log");
235
236        let cfg = Config {
237            format: Format::Json,
238            level: tracing::Level::INFO,
239            output: Output::File(log_file.clone()),
240            ..Default::default()
241        };
242
243        let (logger, closer) = new_logger(&cfg).unwrap();
244
245        tracing::dispatcher::with_default(&logger, || {
246            tracing::info!(key = "value", "test message");
247        });
248        drop(closer);
249
250        let content = fs::read_to_string(&log_file).unwrap();
251        assert!(content.contains("test message"));
252        assert!(content.contains("key"));
253        assert!(content.contains("value"));
254    }
255
256    #[test]
257    fn new_logger_text_format() {
258        let tmp_dir = tempfile::tempdir().unwrap();
259        let log_file = tmp_dir.path().join("test.log");
260
261        let cfg = Config {
262            format: Format::Text,
263            level: tracing::Level::INFO,
264            output: Output::File(log_file.clone()),
265            ..Default::default()
266        };
267
268        let (logger, closer) = new_logger(&cfg).unwrap();
269
270        tracing::dispatcher::with_default(&logger, || {
271            tracing::info!("test info message");
272            tracing::warn!("test warn message");
273        });
274        drop(closer);
275
276        let content = fs::read_to_string(&log_file).unwrap();
277        assert!(content.contains("test info message"));
278        assert!(content.contains("test warn message"));
279        assert!(content.contains("INF"));
280        assert!(content.contains("WRN"));
281    }
282
283    #[test]
284    fn new_logger_json_format() {
285        let tmp_dir = tempfile::tempdir().unwrap();
286        let log_file = tmp_dir.path().join("test.log");
287
288        let cfg = Config {
289            format: Format::Json,
290            level: tracing::Level::DEBUG,
291            output: Output::File(log_file.clone()),
292            ..Default::default()
293        };
294
295        let (logger, closer) = new_logger(&cfg).unwrap();
296
297        tracing::dispatcher::with_default(&logger, || {
298            tracing::debug!(debug_key = "debug_value", "debug message");
299            tracing::info!(info_key = "info_value", "info message");
300        });
301        drop(closer);
302
303        let content = fs::read_to_string(&log_file).unwrap();
304        assert!(content.contains("debug message"));
305        assert!(content.contains("info message"));
306        assert!(content.contains("debug_key"));
307        assert!(content.contains("info_key"));
308        assert!(content.contains("\"msg\""));
309        assert!(content.contains("\"level\""));
310    }
311
312    #[test]
313    fn new_logger_levels() {
314        let tmp_dir = tempfile::tempdir().unwrap();
315        let log_file = tmp_dir.path().join("test.log");
316
317        let cfg = Config {
318            format: Format::Text,
319            level: tracing::Level::WARN,
320            output: Output::File(log_file.clone()),
321            ..Default::default()
322        };
323
324        let (logger, closer) = new_logger(&cfg).unwrap();
325
326        tracing::dispatcher::with_default(&logger, || {
327            tracing::debug!("debug message");
328            tracing::info!("info message");
329            tracing::warn!("warn message");
330            tracing::error!("error message");
331        });
332        drop(closer);
333
334        let content = fs::read_to_string(&log_file).unwrap();
335        assert!(
336            !content.contains("debug message"),
337            "Debug should be filtered out"
338        );
339        assert!(
340            !content.contains("info message"),
341            "Info should be filtered out"
342        );
343        assert!(content.contains("warn message"), "Warn should be included");
344        assert!(
345            content.contains("error message"),
346            "Error should be included"
347        );
348    }
349
350    #[test]
351    fn new_logger_add_source() {
352        let tmp_dir = tempfile::tempdir().unwrap();
353        let log_file = tmp_dir.path().join("test.log");
354
355        let cfg = Config {
356            format: Format::Json,
357            level: tracing::Level::INFO,
358            output: Output::File(log_file.clone()),
359            add_source: true,
360            ..Default::default()
361        };
362
363        let (logger, closer) = new_logger(&cfg).unwrap();
364
365        tracing::dispatcher::with_default(&logger, || {
366            tracing::info!("test message with source");
367        });
368        drop(closer);
369
370        let content = fs::read_to_string(&log_file).unwrap();
371        assert!(
372            content.contains("source"),
373            "Log should contain source information"
374        );
375    }
376
377    #[test]
378    fn new_logger_colored_output() {
379        let tmp_dir = tempfile::tempdir().unwrap();
380        let log_file = tmp_dir.path().join("test.log");
381
382        let cfg = Config {
383            format: Format::Text,
384            level: tracing::Level::INFO,
385            color: Some(true),
386            output: Output::File(log_file.clone()),
387            ..Default::default()
388        };
389
390        let (logger, closer) = new_logger(&cfg).unwrap();
391
392        tracing::dispatcher::with_default(&logger, || {
393            tracing::info!("info message");
394            tracing::error!("error message");
395        });
396        drop(closer);
397
398        let content = fs::read_to_string(&log_file).unwrap();
399        assert!(content.contains("info message"));
400        assert!(content.contains("error message"));
401        assert!(
402            content.contains("\u{1b}["),
403            "Log should contain ANSI color codes"
404        );
405    }
406
407    #[test]
408    fn is_terminal_false_for_buffer() {
409        let buf: Vec<u8> = Vec::new();
410        assert!(
411            !is_terminal(&buf),
412            "non-terminal writer should not be detected as terminal"
413        );
414    }
415}