1use std::{env, str::FromStr};
61
62use ahash::AHashMap;
63use log::LevelFilter;
64use serde::{Deserialize, Serialize};
65use ustr::Ustr;
66
67use super::writer::FileWriterConfig;
68use crate::config::ConfigResult;
69
70#[cfg_attr(
72 feature = "python",
73 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.common", from_py_object)
74)]
75#[cfg_attr(
76 feature = "python",
77 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.common")
78)]
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
80#[builder(finish_fn(name = build_inner, vis = ""))]
81#[serde(default, deny_unknown_fields)]
82pub struct LoggerConfig {
83 #[builder(default = LevelFilter::Info)]
85 pub stdout_level: LevelFilter,
86 #[builder(default = LevelFilter::Off)]
88 pub fileout_level: LevelFilter,
89 #[builder(default)]
91 pub component_level: AHashMap<Ustr, LevelFilter>,
92 #[builder(default)]
94 pub module_level: AHashMap<Ustr, LevelFilter>,
95 #[builder(default)]
97 pub log_components_only: bool,
98 #[builder(default = true)]
100 pub is_colored: bool,
101 #[builder(default)]
103 pub print_config: bool,
104 #[builder(default)]
106 pub use_tracing: bool,
107 #[builder(default)]
109 pub bypass_logging: bool,
110 pub file_config: Option<FileWriterConfig>,
112 #[builder(default)]
114 pub clear_log_file: bool,
115 #[builder(default = true)]
117 pub fileout_sync_on_flush: bool,
118 #[builder(default)]
120 pub buffered_stdout: bool,
121}
122
123impl<S: logger_config_builder::IsComplete> LoggerConfigBuilder<S> {
124 pub fn build(self) -> ConfigResult<LoggerConfig> {
131 let config = self.build_inner();
132 config.validate()?;
133 Ok(config)
134 }
135}
136
137impl Default for LoggerConfig {
138 fn default() -> Self {
139 Self::builder()
140 .build()
141 .expect("default `LoggerConfig` should be valid")
142 }
143}
144
145impl LoggerConfig {
146 #[must_use]
148 #[expect(clippy::too_many_arguments)]
149 pub fn new(
150 stdout_level: LevelFilter,
151 fileout_level: LevelFilter,
152 component_level: AHashMap<Ustr, LevelFilter>,
153 module_level: AHashMap<Ustr, LevelFilter>,
154 log_components_only: bool,
155 is_colored: bool,
156 print_config: bool,
157 use_tracing: bool,
158 bypass_logging: bool,
159 file_config: Option<FileWriterConfig>,
160 clear_log_file: bool,
161 ) -> Self {
162 Self {
163 stdout_level,
164 fileout_level,
165 component_level,
166 module_level,
167 log_components_only,
168 is_colored,
169 print_config,
170 use_tracing,
171 bypass_logging,
172 file_config,
173 clear_log_file,
174 fileout_sync_on_flush: true,
175 buffered_stdout: false,
176 }
177 }
178
179 pub fn validate(&self) -> ConfigResult<()> {
186 if let Some(file_config) = &self.file_config {
187 file_config.validate()?;
188 }
189
190 Ok(())
191 }
192
193 pub fn from_spec(spec: &str) -> anyhow::Result<Self> {
206 let mut config = Self::default();
207
208 for kv in spec.split(';') {
209 let kv = kv.trim();
210 if kv.is_empty() {
211 continue;
212 }
213
214 let kv_lower = kv.to_lowercase();
215
216 if !kv.contains('=') {
218 match kv_lower.as_str() {
219 "log_components_only" => config.log_components_only = true,
220 "is_colored" => config.is_colored = true,
221 "print_config" => config.print_config = true,
222 "use_tracing" => config.use_tracing = true,
223 "bypass_logging" => config.bypass_logging = true,
224 "fileout_sync_on_flush" => config.fileout_sync_on_flush = true,
225 "buffered_stdout" => config.buffered_stdout = true,
226 _ => anyhow::bail!("Invalid spec pair: {kv}"),
227 }
228 continue;
229 }
230
231 let parts: Vec<&str> = kv.splitn(2, '=').collect();
232 if parts.len() != 2 {
233 anyhow::bail!("Invalid spec pair: {kv}");
234 }
235
236 let k = parts[0].trim();
237 let v = parts[1].trim();
238 let k_lower = k.to_lowercase();
239
240 match k_lower.as_str() {
241 "is_colored" => {
242 config.is_colored = parse_bool_value(v);
243 }
244 "log_components_only" => {
245 config.log_components_only = parse_bool_value(v);
246 }
247 "print_config" => {
248 config.print_config = parse_bool_value(v);
249 }
250 "use_tracing" => {
251 config.use_tracing = parse_bool_value(v);
252 }
253 "bypass_logging" => {
254 config.bypass_logging = parse_bool_value(v);
255 }
256 "fileout_sync_on_flush" => {
257 config.fileout_sync_on_flush = parse_bool_value(v);
258 }
259 "buffered_stdout" => {
260 config.buffered_stdout = parse_bool_value(v);
261 }
262 "stdout" => {
263 config.stdout_level = parse_level(v)?;
264 }
265 "fileout" => {
266 config.fileout_level = parse_level(v)?;
267 }
268 _ => {
269 let lvl = parse_level(v)?;
270
271 if k.contains("::") {
272 config.module_level.insert(Ustr::from(k), lvl);
273 } else {
274 config.component_level.insert(Ustr::from(k), lvl);
275 }
276 }
277 }
278 }
279
280 Ok(config)
281 }
282
283 pub fn from_env() -> anyhow::Result<Self> {
289 let spec = env::var("NAUTILUS_LOG")?;
290 Self::from_spec(&spec)
291 }
292}
293
294fn parse_bool_value(v: &str) -> bool {
298 !matches!(v.to_lowercase().as_str(), "false" | "0" | "no")
299}
300
301fn parse_level(v: &str) -> anyhow::Result<LevelFilter> {
303 LevelFilter::from_str(v).map_err(|_| anyhow::anyhow!("Invalid log level: {v}"))
304}
305
306#[cfg(test)]
307mod tests {
308 use rstest::rstest;
309
310 use super::*;
311 use crate::config::ConfigError;
312
313 #[rstest]
314 fn test_zero_rotation_max_file_size_rejected() {
315 let file_config = FileWriterConfig::new(None, None, None, Some((0, 5)));
316 let result = LoggerConfig::builder().file_config(file_config).build();
317 assert!(
318 matches!(result, Err(ConfigError::Range { field, .. }) if field == "file_config.file_rotate.max_file_size")
319 );
320 }
321
322 #[rstest]
323 fn test_positive_rotation_max_file_size_accepted() {
324 let file_config = FileWriterConfig::new(None, None, None, Some((1_048_576, 5)));
325 let result = LoggerConfig::builder().file_config(file_config).build();
326 assert!(result.is_ok());
327 }
328
329 #[rstest]
330 fn test_default_config() {
331 let config = LoggerConfig::default();
332 assert_eq!(config.stdout_level, LevelFilter::Info);
333 assert_eq!(config.fileout_level, LevelFilter::Off);
334 assert!(config.component_level.is_empty());
335 assert!(!config.log_components_only);
336 assert!(config.is_colored);
337 assert!(!config.print_config);
338 assert!(!config.bypass_logging);
339 assert!(config.file_config.is_none());
340 assert!(!config.clear_log_file);
341 assert!(config.fileout_sync_on_flush);
342 assert!(!config.buffered_stdout);
343 }
344
345 #[rstest]
346 fn test_from_spec_stdout_and_fileout() {
347 let config = LoggerConfig::from_spec("stdout=Debug;fileout=Error").unwrap();
348 assert_eq!(config.stdout_level, LevelFilter::Debug);
349 assert_eq!(config.fileout_level, LevelFilter::Error);
350 }
351
352 #[rstest]
353 fn test_from_spec_case_insensitive_levels() {
354 let config = LoggerConfig::from_spec("stdout=debug;fileout=ERROR").unwrap();
355 assert_eq!(config.stdout_level, LevelFilter::Debug);
356 assert_eq!(config.fileout_level, LevelFilter::Error);
357 }
358
359 #[rstest]
360 fn test_from_spec_case_insensitive_keys() {
361 let config = LoggerConfig::from_spec("STDOUT=Info;FILEOUT=Debug").unwrap();
362 assert_eq!(config.stdout_level, LevelFilter::Info);
363 assert_eq!(config.fileout_level, LevelFilter::Debug);
364 }
365
366 #[rstest]
367 fn test_from_spec_empty_string() {
368 let config = LoggerConfig::from_spec("").unwrap();
369 assert_eq!(config, LoggerConfig::default());
370 }
371
372 #[rstest]
373 fn test_from_spec_with_whitespace() {
374 let config = LoggerConfig::from_spec(" stdout = Info ; fileout = Debug ").unwrap();
375 assert_eq!(config.stdout_level, LevelFilter::Info);
376 assert_eq!(config.fileout_level, LevelFilter::Debug);
377 }
378
379 #[rstest]
380 fn test_from_spec_trailing_semicolon() {
381 let config = LoggerConfig::from_spec("stdout=Warn;").unwrap();
382 assert_eq!(config.stdout_level, LevelFilter::Warn);
383 }
384
385 #[rstest]
386 fn test_from_spec_bare_is_colored() {
387 let config = LoggerConfig::from_spec("is_colored").unwrap();
388 assert!(config.is_colored);
389 }
390
391 #[rstest]
392 fn test_from_spec_is_colored_true() {
393 let config = LoggerConfig::from_spec("is_colored=true").unwrap();
394 assert!(config.is_colored);
395 }
396
397 #[rstest]
398 fn test_from_spec_is_colored_false() {
399 let config = LoggerConfig::from_spec("is_colored=false").unwrap();
400 assert!(!config.is_colored);
401 }
402
403 #[rstest]
404 fn test_from_spec_is_colored_zero() {
405 let config = LoggerConfig::from_spec("is_colored=0").unwrap();
406 assert!(!config.is_colored);
407 }
408
409 #[rstest]
410 fn test_from_spec_is_colored_no() {
411 let config = LoggerConfig::from_spec("is_colored=no").unwrap();
412 assert!(!config.is_colored);
413 }
414
415 #[rstest]
416 fn test_from_spec_is_colored_case_insensitive() {
417 let config = LoggerConfig::from_spec("IS_COLORED=FALSE").unwrap();
418 assert!(!config.is_colored);
419 }
420
421 #[rstest]
422 fn test_from_spec_print_config() {
423 let config = LoggerConfig::from_spec("print_config").unwrap();
424 assert!(config.print_config);
425 }
426
427 #[rstest]
428 fn test_from_spec_print_config_false() {
429 let config = LoggerConfig::from_spec("print_config=false").unwrap();
430 assert!(!config.print_config);
431 }
432
433 #[rstest]
434 fn test_from_spec_log_components_only() {
435 let config = LoggerConfig::from_spec("log_components_only").unwrap();
436 assert!(config.log_components_only);
437 }
438
439 #[rstest]
440 fn test_from_spec_log_components_only_false() {
441 let config = LoggerConfig::from_spec("log_components_only=false").unwrap();
442 assert!(!config.log_components_only);
443 }
444
445 #[rstest]
446 fn test_from_spec_fileout_sync_on_flush_false() {
447 let config = LoggerConfig::from_spec("fileout_sync_on_flush=false").unwrap();
448 assert!(!config.fileout_sync_on_flush);
449 }
450
451 #[rstest]
452 fn test_from_spec_buffered_stdout() {
453 let config = LoggerConfig::from_spec("buffered_stdout").unwrap();
454 assert!(config.buffered_stdout);
455 }
456
457 #[rstest]
458 fn test_from_spec_component_level() {
459 let config = LoggerConfig::from_spec("RiskEngine=Error;DataEngine=Debug").unwrap();
460 assert_eq!(
461 config.component_level[&Ustr::from("RiskEngine")],
462 LevelFilter::Error
463 );
464 assert_eq!(
465 config.component_level[&Ustr::from("DataEngine")],
466 LevelFilter::Debug
467 );
468 }
469
470 #[rstest]
471 fn test_from_spec_component_preserves_case() {
472 let config = LoggerConfig::from_spec("MyComponent=Info").unwrap();
474 assert!(
475 config
476 .component_level
477 .contains_key(&Ustr::from("MyComponent"))
478 );
479 assert!(
480 !config
481 .component_level
482 .contains_key(&Ustr::from("mycomponent"))
483 );
484 }
485
486 #[rstest]
487 fn test_from_spec_full_example() {
488 let config = LoggerConfig::from_spec(
489 "stdout=Info;fileout=Debug;RiskEngine=Error;is_colored;print_config",
490 )
491 .unwrap();
492
493 assert_eq!(config.stdout_level, LevelFilter::Info);
494 assert_eq!(config.fileout_level, LevelFilter::Debug);
495 assert_eq!(
496 config.component_level[&Ustr::from("RiskEngine")],
497 LevelFilter::Error
498 );
499 assert!(config.is_colored);
500 assert!(config.print_config);
501 }
502
503 #[rstest]
504 fn test_from_spec_disabled_colors() {
505 let config = LoggerConfig::from_spec("stdout=Info;is_colored=false;fileout=Debug").unwrap();
506 assert!(!config.is_colored);
507 assert_eq!(config.stdout_level, LevelFilter::Info);
508 assert_eq!(config.fileout_level, LevelFilter::Debug);
509 }
510
511 #[rstest]
512 fn test_from_spec_invalid_level() {
513 let result = LoggerConfig::from_spec("stdout=InvalidLevel");
514 assert!(result.is_err());
515 assert!(
516 result
517 .unwrap_err()
518 .to_string()
519 .contains("Invalid log level")
520 );
521 }
522
523 #[rstest]
524 fn test_from_spec_invalid_bare_flag() {
525 let result = LoggerConfig::from_spec("unknown_flag");
526 assert!(result.is_err());
527 assert!(
528 result
529 .unwrap_err()
530 .to_string()
531 .contains("Invalid spec pair")
532 );
533 }
534
535 #[rstest]
536 fn test_from_spec_missing_value() {
537 let result = LoggerConfig::from_spec("stdout=");
539 assert!(result.is_err());
540 }
541
542 #[rstest]
543 #[case("Off", LevelFilter::Off)]
544 #[case("Error", LevelFilter::Error)]
545 #[case("Warn", LevelFilter::Warn)]
546 #[case("Info", LevelFilter::Info)]
547 #[case("Debug", LevelFilter::Debug)]
548 #[case("Trace", LevelFilter::Trace)]
549 fn test_all_log_levels(#[case] level_str: &str, #[case] expected: LevelFilter) {
550 let config = LoggerConfig::from_spec(&format!("stdout={level_str}")).unwrap();
551 assert_eq!(config.stdout_level, expected);
552 }
553
554 #[rstest]
555 fn test_from_spec_single_module_path() {
556 let config = LoggerConfig::from_spec("nautilus_okx::websocket=Debug").unwrap();
557 assert_eq!(
558 config.module_level[&Ustr::from("nautilus_okx::websocket")],
559 LevelFilter::Debug
560 );
561 assert!(config.component_level.is_empty());
562 }
563
564 #[rstest]
565 fn test_from_spec_multiple_module_paths() {
566 let config =
567 LoggerConfig::from_spec("nautilus_okx::websocket=Debug;nautilus_binance::data=Trace")
568 .unwrap();
569 assert_eq!(
570 config.module_level[&Ustr::from("nautilus_okx::websocket")],
571 LevelFilter::Debug
572 );
573 assert_eq!(
574 config.module_level[&Ustr::from("nautilus_binance::data")],
575 LevelFilter::Trace
576 );
577 assert!(config.component_level.is_empty());
578 }
579
580 #[rstest]
581 fn test_from_spec_mixed_module_and_component() {
582 let config = LoggerConfig::from_spec(
583 "nautilus_okx::websocket=Debug;RiskEngine=Error;nautilus_network::data=Trace",
584 )
585 .unwrap();
586
587 assert_eq!(
588 config.module_level[&Ustr::from("nautilus_okx::websocket")],
589 LevelFilter::Debug
590 );
591 assert_eq!(
592 config.module_level[&Ustr::from("nautilus_network::data")],
593 LevelFilter::Trace
594 );
595 assert_eq!(config.module_level.len(), 2);
596 assert_eq!(
597 config.component_level[&Ustr::from("RiskEngine")],
598 LevelFilter::Error
599 );
600 assert_eq!(config.component_level.len(), 1);
601 }
602
603 #[rstest]
604 fn test_from_spec_deeply_nested_module_path() {
605 let config =
606 LoggerConfig::from_spec("nautilus_okx::websocket::handler::auth=Trace").unwrap();
607 assert_eq!(
608 config.module_level[&Ustr::from("nautilus_okx::websocket::handler::auth")],
609 LevelFilter::Trace
610 );
611 }
612
613 #[rstest]
614 fn test_from_spec_module_path_with_underscores() {
615 let config =
616 LoggerConfig::from_spec("nautilus_trader::adapters::interactive_brokers=Debug")
617 .unwrap();
618 assert_eq!(
619 config.module_level[&Ustr::from("nautilus_trader::adapters::interactive_brokers")],
620 LevelFilter::Debug
621 );
622 }
623
624 #[rstest]
625 fn test_from_spec_full_example_with_modules() {
626 let config = LoggerConfig::from_spec(
627 "stdout=Info;fileout=Debug;RiskEngine=Error;nautilus_okx::websocket=Trace;is_colored",
628 )
629 .unwrap();
630
631 assert_eq!(config.stdout_level, LevelFilter::Info);
632 assert_eq!(config.fileout_level, LevelFilter::Debug);
633 assert_eq!(
634 config.component_level[&Ustr::from("RiskEngine")],
635 LevelFilter::Error
636 );
637 assert_eq!(
638 config.module_level[&Ustr::from("nautilus_okx::websocket")],
639 LevelFilter::Trace
640 );
641 assert!(config.is_colored);
642 }
643
644 #[rstest]
645 fn test_from_spec_module_path_preserves_case() {
646 let config = LoggerConfig::from_spec("MyModule::SubModule=Info").unwrap();
647 assert!(
648 config
649 .module_level
650 .contains_key(&Ustr::from("MyModule::SubModule"))
651 );
652 }
653
654 #[rstest]
655 fn test_from_spec_single_colon_is_component() {
656 let config = LoggerConfig::from_spec("Component:Name=Info").unwrap();
658 assert!(config.module_level.is_empty());
659 assert!(
660 config
661 .component_level
662 .contains_key(&Ustr::from("Component:Name"))
663 );
664 }
665
666 #[rstest]
667 fn test_default_module_level_is_empty() {
668 let config = LoggerConfig::default();
669 assert!(config.module_level.is_empty());
670 }
671
672 #[rstest]
673 fn test_from_spec_bypass_logging_bare() {
674 let config = LoggerConfig::from_spec("bypass_logging").unwrap();
675 assert!(config.bypass_logging);
676 }
677
678 #[rstest]
679 fn test_from_spec_bypass_logging_true() {
680 let config = LoggerConfig::from_spec("bypass_logging=true").unwrap();
681 assert!(config.bypass_logging);
682 }
683
684 #[rstest]
685 fn test_from_spec_bypass_logging_false() {
686 let config = LoggerConfig::from_spec("bypass_logging=false").unwrap();
687 assert!(!config.bypass_logging);
688 }
689
690 #[rstest]
691 fn test_toml_deserialize_minimal() {
692 let config: LoggerConfig = toml::from_str(
693 r#"
694stdout_level = "INFO"
695fileout_level = "DEBUG"
696is_colored = false
697
698[component_level]
699RiskEngine = "ERROR"
700
701[file_config]
702directory = "/var/log/nautilus"
703"#,
704 )
705 .unwrap();
706
707 assert_eq!(config.stdout_level, LevelFilter::Info);
708 assert_eq!(config.fileout_level, LevelFilter::Debug);
709 assert!(!config.is_colored);
710 assert_eq!(
711 config.component_level[&Ustr::from("RiskEngine")],
712 LevelFilter::Error
713 );
714 assert_eq!(
715 config.file_config.as_ref().unwrap().directory.as_deref(),
716 Some("/var/log/nautilus"),
717 );
718 }
719}