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