nautilus_common/logging/
config.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Logging configuration types and parsing.
17//!
18//! This module provides configuration for the Nautilus logging subsystem via
19//! the `LoggerConfig` and `FileWriterConfig` types.
20//!
21//! # Spec String Format
22//!
23//! The `NAUTILUS_LOG` environment variable uses a semicolon-separated format:
24//!
25//! ```text
26//! stdout=Info;fileout=Debug;RiskEngine=Error;my_crate::module=Debug;is_colored
27//! ```
28//!
29//! ## Supported Keys
30//!
31//! | Key                   | Type      | Description                                 |
32//! |-----------------------|-----------|---------------------------------------------|
33//! | `stdout`              | Log level | Maximum level for stdout output.            |
34//! | `fileout`             | Log level | Maximum level for file output.              |
35//! | `is_colored`          | Boolean   | Enable ANSI colors (default: true).         |
36//! | `print_config`        | Boolean   | Print config to stdout at startup.          |
37//! | `log_components_only` | Boolean   | Only log components with explicit filters.  |
38//! | `<component>`         | Log level | Component-specific log level (exact match). |
39//! | `<module::path>`      | Log level | Module-specific log level (prefix match).   |
40//!
41//! ## Log Levels
42//!
43//! `Off`, `Error`, `Warn`, `Info`, `Debug`, `Trace` (case-insensitive)
44//!
45//! ## Boolean Values
46//!
47//! - Bare flag: `is_colored` → true
48//! - Explicit: `is_colored=true`, `is_colored=false`, `is_colored=0`, `is_colored=no`
49
50use std::{env, str::FromStr};
51
52use ahash::AHashMap;
53use log::LevelFilter;
54use ustr::Ustr;
55
56/// Configuration for the Nautilus logger.
57#[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    /// Maximum log level for stdout output.
64    pub stdout_level: LevelFilter,
65    /// Maximum log level for file output (`Off` disables file logging).
66    pub fileout_level: LevelFilter,
67    /// Per-component log level overrides (exact match).
68    pub component_level: AHashMap<Ustr, LevelFilter>,
69    /// Per-module path log level overrides (prefix match).
70    pub module_level: AHashMap<Ustr, LevelFilter>,
71    /// Log only components with explicit level filters.
72    pub log_components_only: bool,
73    /// Use ANSI color codes in output.
74    pub is_colored: bool,
75    /// Print configuration to stdout at startup.
76    pub print_config: bool,
77}
78
79impl Default for LoggerConfig {
80    /// Creates a new default [`LoggerConfig`] instance.
81    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    /// Creates a new [`LoggerConfig`] instance.
96    #[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    /// Parses a configuration from a spec string.
118    ///
119    /// # Format
120    ///
121    /// Semicolon-separated key-value pairs or bare flags:
122    /// ```text
123    /// stdout=Info;fileout=Debug;RiskEngine=Error;my_crate::module=Debug;is_colored
124    /// ```
125    ///
126    /// # Errors
127    ///
128    /// Returns an error if the spec string contains invalid syntax or log levels.
129    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            // Handle bare flags (without =)
141            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    /// Parses configuration from the `NAUTILUS_LOG` environment variable.
191    ///
192    /// # Errors
193    ///
194    /// Returns an error if the variable is unset or contains invalid syntax.
195    pub fn from_env() -> anyhow::Result<Self> {
196        let spec = env::var("NAUTILUS_LOG")?;
197        Self::from_spec(&spec)
198    }
199}
200
201/// Parses a boolean value from a string.
202///
203/// Returns `true` unless the value is explicitly "false", "0", or "no" (case-insensitive).
204fn parse_bool_value(v: &str) -> bool {
205    !matches!(v.to_lowercase().as_str(), "false" | "0" | "no")
206}
207
208/// Parses a log level from a string.
209fn 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        // Component names should preserve their original case
346        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        // "stdout=" with no value is technically valid empty string, which is invalid level
411        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        // Single colon is NOT a module path separator in Rust
530        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}