1use std::{env, str::FromStr};
59
60use ahash::AHashMap;
61use log::LevelFilter;
62use ustr::Ustr;
63
64#[cfg_attr(
66 feature = "python",
67 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.common")
68)]
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct LoggerConfig {
71 pub stdout_level: LevelFilter,
73 pub fileout_level: LevelFilter,
75 pub component_level: AHashMap<Ustr, LevelFilter>,
77 pub module_level: AHashMap<Ustr, LevelFilter>,
79 pub log_components_only: bool,
81 pub is_colored: bool,
83 pub print_config: bool,
85 pub use_tracing: bool,
87}
88
89impl Default for LoggerConfig {
90 fn default() -> Self {
92 Self {
93 stdout_level: LevelFilter::Info,
94 fileout_level: LevelFilter::Off,
95 component_level: AHashMap::new(),
96 module_level: AHashMap::new(),
97 log_components_only: false,
98 is_colored: true,
99 print_config: false,
100 use_tracing: false,
101 }
102 }
103}
104
105impl LoggerConfig {
106 #[must_use]
108 #[allow(clippy::too_many_arguments)]
109 pub fn new(
110 stdout_level: LevelFilter,
111 fileout_level: LevelFilter,
112 component_level: AHashMap<Ustr, LevelFilter>,
113 module_level: AHashMap<Ustr, LevelFilter>,
114 log_components_only: bool,
115 is_colored: bool,
116 print_config: bool,
117 use_tracing: bool,
118 ) -> Self {
119 Self {
120 stdout_level,
121 fileout_level,
122 component_level,
123 module_level,
124 log_components_only,
125 is_colored,
126 print_config,
127 use_tracing,
128 }
129 }
130
131 pub fn from_spec(spec: &str) -> anyhow::Result<Self> {
144 let mut config = Self::default();
145
146 for kv in spec.split(';') {
147 let kv = kv.trim();
148 if kv.is_empty() {
149 continue;
150 }
151
152 let kv_lower = kv.to_lowercase();
153
154 if !kv.contains('=') {
156 match kv_lower.as_str() {
157 "log_components_only" => config.log_components_only = true,
158 "is_colored" => config.is_colored = true,
159 "print_config" => config.print_config = true,
160 "use_tracing" => config.use_tracing = true,
161 _ => anyhow::bail!("Invalid spec pair: {kv}"),
162 }
163 continue;
164 }
165
166 let parts: Vec<&str> = kv.splitn(2, '=').collect();
167 if parts.len() != 2 {
168 anyhow::bail!("Invalid spec pair: {kv}");
169 }
170
171 let k = parts[0].trim();
172 let v = parts[1].trim();
173 let k_lower = k.to_lowercase();
174
175 match k_lower.as_str() {
176 "is_colored" => {
177 config.is_colored = parse_bool_value(v);
178 }
179 "log_components_only" => {
180 config.log_components_only = parse_bool_value(v);
181 }
182 "print_config" => {
183 config.print_config = parse_bool_value(v);
184 }
185 "use_tracing" => {
186 config.use_tracing = parse_bool_value(v);
187 }
188 "stdout" => {
189 config.stdout_level = parse_level(v)?;
190 }
191 "fileout" => {
192 config.fileout_level = parse_level(v)?;
193 }
194 _ => {
195 let lvl = parse_level(v)?;
196 if k.contains("::") {
197 config.module_level.insert(Ustr::from(k), lvl);
198 } else {
199 config.component_level.insert(Ustr::from(k), lvl);
200 }
201 }
202 }
203 }
204
205 Ok(config)
206 }
207
208 pub fn from_env() -> anyhow::Result<Self> {
214 let spec = env::var("NAUTILUS_LOG")?;
215 Self::from_spec(&spec)
216 }
217}
218
219fn parse_bool_value(v: &str) -> bool {
223 !matches!(v.to_lowercase().as_str(), "false" | "0" | "no")
224}
225
226fn parse_level(v: &str) -> anyhow::Result<LevelFilter> {
228 LevelFilter::from_str(v).map_err(|_| anyhow::anyhow!("Invalid log level: {v}"))
229}
230
231#[cfg(test)]
232mod tests {
233 use rstest::rstest;
234
235 use super::*;
236
237 #[rstest]
238 fn test_default_config() {
239 let config = LoggerConfig::default();
240 assert_eq!(config.stdout_level, LevelFilter::Info);
241 assert_eq!(config.fileout_level, LevelFilter::Off);
242 assert!(config.component_level.is_empty());
243 assert!(!config.log_components_only);
244 assert!(config.is_colored);
245 assert!(!config.print_config);
246 }
247
248 #[rstest]
249 fn test_from_spec_stdout_and_fileout() {
250 let config = LoggerConfig::from_spec("stdout=Debug;fileout=Error").unwrap();
251 assert_eq!(config.stdout_level, LevelFilter::Debug);
252 assert_eq!(config.fileout_level, LevelFilter::Error);
253 }
254
255 #[rstest]
256 fn test_from_spec_case_insensitive_levels() {
257 let config = LoggerConfig::from_spec("stdout=debug;fileout=ERROR").unwrap();
258 assert_eq!(config.stdout_level, LevelFilter::Debug);
259 assert_eq!(config.fileout_level, LevelFilter::Error);
260 }
261
262 #[rstest]
263 fn test_from_spec_case_insensitive_keys() {
264 let config = LoggerConfig::from_spec("STDOUT=Info;FILEOUT=Debug").unwrap();
265 assert_eq!(config.stdout_level, LevelFilter::Info);
266 assert_eq!(config.fileout_level, LevelFilter::Debug);
267 }
268
269 #[rstest]
270 fn test_from_spec_empty_string() {
271 let config = LoggerConfig::from_spec("").unwrap();
272 assert_eq!(config, LoggerConfig::default());
273 }
274
275 #[rstest]
276 fn test_from_spec_with_whitespace() {
277 let config = LoggerConfig::from_spec(" stdout = Info ; fileout = Debug ").unwrap();
278 assert_eq!(config.stdout_level, LevelFilter::Info);
279 assert_eq!(config.fileout_level, LevelFilter::Debug);
280 }
281
282 #[rstest]
283 fn test_from_spec_trailing_semicolon() {
284 let config = LoggerConfig::from_spec("stdout=Warn;").unwrap();
285 assert_eq!(config.stdout_level, LevelFilter::Warn);
286 }
287
288 #[rstest]
289 fn test_from_spec_bare_is_colored() {
290 let config = LoggerConfig::from_spec("is_colored").unwrap();
291 assert!(config.is_colored);
292 }
293
294 #[rstest]
295 fn test_from_spec_is_colored_true() {
296 let config = LoggerConfig::from_spec("is_colored=true").unwrap();
297 assert!(config.is_colored);
298 }
299
300 #[rstest]
301 fn test_from_spec_is_colored_false() {
302 let config = LoggerConfig::from_spec("is_colored=false").unwrap();
303 assert!(!config.is_colored);
304 }
305
306 #[rstest]
307 fn test_from_spec_is_colored_zero() {
308 let config = LoggerConfig::from_spec("is_colored=0").unwrap();
309 assert!(!config.is_colored);
310 }
311
312 #[rstest]
313 fn test_from_spec_is_colored_no() {
314 let config = LoggerConfig::from_spec("is_colored=no").unwrap();
315 assert!(!config.is_colored);
316 }
317
318 #[rstest]
319 fn test_from_spec_is_colored_case_insensitive() {
320 let config = LoggerConfig::from_spec("IS_COLORED=FALSE").unwrap();
321 assert!(!config.is_colored);
322 }
323
324 #[rstest]
325 fn test_from_spec_print_config() {
326 let config = LoggerConfig::from_spec("print_config").unwrap();
327 assert!(config.print_config);
328 }
329
330 #[rstest]
331 fn test_from_spec_print_config_false() {
332 let config = LoggerConfig::from_spec("print_config=false").unwrap();
333 assert!(!config.print_config);
334 }
335
336 #[rstest]
337 fn test_from_spec_log_components_only() {
338 let config = LoggerConfig::from_spec("log_components_only").unwrap();
339 assert!(config.log_components_only);
340 }
341
342 #[rstest]
343 fn test_from_spec_log_components_only_false() {
344 let config = LoggerConfig::from_spec("log_components_only=false").unwrap();
345 assert!(!config.log_components_only);
346 }
347
348 #[rstest]
349 fn test_from_spec_component_level() {
350 let config = LoggerConfig::from_spec("RiskEngine=Error;DataEngine=Debug").unwrap();
351 assert_eq!(
352 config.component_level[&Ustr::from("RiskEngine")],
353 LevelFilter::Error
354 );
355 assert_eq!(
356 config.component_level[&Ustr::from("DataEngine")],
357 LevelFilter::Debug
358 );
359 }
360
361 #[rstest]
362 fn test_from_spec_component_preserves_case() {
363 let config = LoggerConfig::from_spec("MyComponent=Info").unwrap();
365 assert!(
366 config
367 .component_level
368 .contains_key(&Ustr::from("MyComponent"))
369 );
370 assert!(
371 !config
372 .component_level
373 .contains_key(&Ustr::from("mycomponent"))
374 );
375 }
376
377 #[rstest]
378 fn test_from_spec_full_example() {
379 let config = LoggerConfig::from_spec(
380 "stdout=Info;fileout=Debug;RiskEngine=Error;is_colored;print_config",
381 )
382 .unwrap();
383
384 assert_eq!(config.stdout_level, LevelFilter::Info);
385 assert_eq!(config.fileout_level, LevelFilter::Debug);
386 assert_eq!(
387 config.component_level[&Ustr::from("RiskEngine")],
388 LevelFilter::Error
389 );
390 assert!(config.is_colored);
391 assert!(config.print_config);
392 }
393
394 #[rstest]
395 fn test_from_spec_disabled_colors() {
396 let config = LoggerConfig::from_spec("stdout=Info;is_colored=false;fileout=Debug").unwrap();
397 assert!(!config.is_colored);
398 assert_eq!(config.stdout_level, LevelFilter::Info);
399 assert_eq!(config.fileout_level, LevelFilter::Debug);
400 }
401
402 #[rstest]
403 fn test_from_spec_invalid_level() {
404 let result = LoggerConfig::from_spec("stdout=InvalidLevel");
405 assert!(result.is_err());
406 assert!(
407 result
408 .unwrap_err()
409 .to_string()
410 .contains("Invalid log level")
411 );
412 }
413
414 #[rstest]
415 fn test_from_spec_invalid_bare_flag() {
416 let result = LoggerConfig::from_spec("unknown_flag");
417 assert!(result.is_err());
418 assert!(
419 result
420 .unwrap_err()
421 .to_string()
422 .contains("Invalid spec pair")
423 );
424 }
425
426 #[rstest]
427 fn test_from_spec_missing_value() {
428 let result = LoggerConfig::from_spec("stdout=");
430 assert!(result.is_err());
431 }
432
433 #[rstest]
434 #[case("Off", LevelFilter::Off)]
435 #[case("Error", LevelFilter::Error)]
436 #[case("Warn", LevelFilter::Warn)]
437 #[case("Info", LevelFilter::Info)]
438 #[case("Debug", LevelFilter::Debug)]
439 #[case("Trace", LevelFilter::Trace)]
440 fn test_all_log_levels(#[case] level_str: &str, #[case] expected: LevelFilter) {
441 let config = LoggerConfig::from_spec(&format!("stdout={level_str}")).unwrap();
442 assert_eq!(config.stdout_level, expected);
443 }
444
445 #[rstest]
446 fn test_from_spec_single_module_path() {
447 let config = LoggerConfig::from_spec("nautilus_okx::websocket=Debug").unwrap();
448 assert_eq!(
449 config.module_level[&Ustr::from("nautilus_okx::websocket")],
450 LevelFilter::Debug
451 );
452 assert!(config.component_level.is_empty());
453 }
454
455 #[rstest]
456 fn test_from_spec_multiple_module_paths() {
457 let config =
458 LoggerConfig::from_spec("nautilus_okx::websocket=Debug;nautilus_binance::data=Trace")
459 .unwrap();
460 assert_eq!(
461 config.module_level[&Ustr::from("nautilus_okx::websocket")],
462 LevelFilter::Debug
463 );
464 assert_eq!(
465 config.module_level[&Ustr::from("nautilus_binance::data")],
466 LevelFilter::Trace
467 );
468 assert!(config.component_level.is_empty());
469 }
470
471 #[rstest]
472 fn test_from_spec_mixed_module_and_component() {
473 let config = LoggerConfig::from_spec(
474 "nautilus_okx::websocket=Debug;RiskEngine=Error;nautilus_network::data=Trace",
475 )
476 .unwrap();
477
478 assert_eq!(
479 config.module_level[&Ustr::from("nautilus_okx::websocket")],
480 LevelFilter::Debug
481 );
482 assert_eq!(
483 config.module_level[&Ustr::from("nautilus_network::data")],
484 LevelFilter::Trace
485 );
486 assert_eq!(config.module_level.len(), 2);
487 assert_eq!(
488 config.component_level[&Ustr::from("RiskEngine")],
489 LevelFilter::Error
490 );
491 assert_eq!(config.component_level.len(), 1);
492 }
493
494 #[rstest]
495 fn test_from_spec_deeply_nested_module_path() {
496 let config =
497 LoggerConfig::from_spec("nautilus_okx::websocket::handler::auth=Trace").unwrap();
498 assert_eq!(
499 config.module_level[&Ustr::from("nautilus_okx::websocket::handler::auth")],
500 LevelFilter::Trace
501 );
502 }
503
504 #[rstest]
505 fn test_from_spec_module_path_with_underscores() {
506 let config =
507 LoggerConfig::from_spec("nautilus_trader::adapters::interactive_brokers=Debug")
508 .unwrap();
509 assert_eq!(
510 config.module_level[&Ustr::from("nautilus_trader::adapters::interactive_brokers")],
511 LevelFilter::Debug
512 );
513 }
514
515 #[rstest]
516 fn test_from_spec_full_example_with_modules() {
517 let config = LoggerConfig::from_spec(
518 "stdout=Info;fileout=Debug;RiskEngine=Error;nautilus_okx::websocket=Trace;is_colored",
519 )
520 .unwrap();
521
522 assert_eq!(config.stdout_level, LevelFilter::Info);
523 assert_eq!(config.fileout_level, LevelFilter::Debug);
524 assert_eq!(
525 config.component_level[&Ustr::from("RiskEngine")],
526 LevelFilter::Error
527 );
528 assert_eq!(
529 config.module_level[&Ustr::from("nautilus_okx::websocket")],
530 LevelFilter::Trace
531 );
532 assert!(config.is_colored);
533 }
534
535 #[rstest]
536 fn test_from_spec_module_path_preserves_case() {
537 let config = LoggerConfig::from_spec("MyModule::SubModule=Info").unwrap();
538 assert!(
539 config
540 .module_level
541 .contains_key(&Ustr::from("MyModule::SubModule"))
542 );
543 }
544
545 #[rstest]
546 fn test_from_spec_single_colon_is_component() {
547 let config = LoggerConfig::from_spec("Component:Name=Info").unwrap();
549 assert!(config.module_level.is_empty());
550 assert!(
551 config
552 .component_level
553 .contains_key(&Ustr::from("Component:Name"))
554 );
555 }
556
557 #[rstest]
558 fn test_default_module_level_is_empty() {
559 let config = LoggerConfig::default();
560 assert!(config.module_level.is_empty());
561 }
562}