xianyun_executor/logging/
mod.rs1pub 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#[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
39struct 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
60fn resolve_color(cfg: &Config) -> bool {
68 cfg.color.unwrap_or(false)
69}
70
71pub 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
113pub 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
138pub 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 ];
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}