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