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