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;is_colored;print_config
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 at startup.                   |
37//! | `log_components_only` | Boolean   | Only log components with explicit filters. |
38//! | `<component>`         | Log level | Component-specific log level filter.       |
39//!
40//! ## Log Levels
41//!
42//! `Off`, `Error`, `Warn`, `Info`, `Debug`, `Trace` (case-insensitive)
43//!
44//! ## Boolean Values
45//!
46//! - Bare flag: `is_colored` → true
47//! - Explicit: `is_colored=true`, `is_colored=false`, `is_colored=0`, `is_colored=no`
48
49use std::{env, str::FromStr};
50
51use ahash::AHashMap;
52use log::LevelFilter;
53use ustr::Ustr;
54
55/// Configuration for the Nautilus logger.
56#[cfg_attr(
57    feature = "python",
58    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.common")
59)]
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct LoggerConfig {
62    /// Maximum log level for stdout output.
63    pub stdout_level: LevelFilter,
64    /// Maximum log level for file output (`Off` disables file logging).
65    pub fileout_level: LevelFilter,
66    /// Per-component log level overrides.
67    pub component_level: AHashMap<Ustr, LevelFilter>,
68    /// Log only components with explicit level filters.
69    pub log_components_only: bool,
70    /// Use ANSI color codes in output.
71    pub is_colored: bool,
72    /// Print configuration at startup.
73    pub print_config: bool,
74}
75
76impl Default for LoggerConfig {
77    /// Creates a new default [`LoggerConfig`] instance.
78    fn default() -> Self {
79        Self {
80            stdout_level: LevelFilter::Info,
81            fileout_level: LevelFilter::Off,
82            component_level: AHashMap::new(),
83            log_components_only: false,
84            is_colored: true,
85            print_config: false,
86        }
87    }
88}
89
90impl LoggerConfig {
91    /// Creates a new [`LoggerConfig`] instance.
92    #[must_use]
93    pub fn new(
94        stdout_level: LevelFilter,
95        fileout_level: LevelFilter,
96        component_level: AHashMap<Ustr, LevelFilter>,
97        log_components_only: bool,
98        is_colored: bool,
99        print_config: bool,
100    ) -> Self {
101        Self {
102            stdout_level,
103            fileout_level,
104            component_level,
105            log_components_only,
106            is_colored,
107            print_config,
108        }
109    }
110
111    /// Parses a configuration from a spec string.
112    ///
113    /// # Format
114    ///
115    /// Semicolon-separated key-value pairs or bare flags:
116    /// ```text
117    /// stdout=Info;fileout=Debug;RiskEngine=Error;is_colored;print_config
118    /// ```
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if the spec string contains invalid syntax or log levels.
123    pub fn from_spec(spec: &str) -> anyhow::Result<Self> {
124        let mut config = Self::default();
125
126        for kv in spec.split(';') {
127            let kv = kv.trim();
128            if kv.is_empty() {
129                continue;
130            }
131
132            let kv_lower = kv.to_lowercase();
133
134            // Handle bare flags (without =)
135            if !kv.contains('=') {
136                match kv_lower.as_str() {
137                    "log_components_only" => config.log_components_only = true,
138                    "is_colored" => config.is_colored = true,
139                    "print_config" => config.print_config = true,
140                    _ => anyhow::bail!("Invalid spec pair: {kv}"),
141                }
142                continue;
143            }
144
145            let parts: Vec<&str> = kv.splitn(2, '=').collect();
146            if parts.len() != 2 {
147                anyhow::bail!("Invalid spec pair: {kv}");
148            }
149
150            let k = parts[0].trim();
151            let v = parts[1].trim();
152            let k_lower = k.to_lowercase();
153
154            match k_lower.as_str() {
155                "is_colored" => {
156                    config.is_colored = parse_bool_value(v);
157                }
158                "log_components_only" => {
159                    config.log_components_only = parse_bool_value(v);
160                }
161                "print_config" => {
162                    config.print_config = parse_bool_value(v);
163                }
164                "stdout" => {
165                    config.stdout_level = parse_level(v)?;
166                }
167                "fileout" => {
168                    config.fileout_level = parse_level(v)?;
169                }
170                // Use original key case for component names
171                _ => {
172                    let lvl = parse_level(v)?;
173                    config.component_level.insert(Ustr::from(k), lvl);
174                }
175            }
176        }
177
178        Ok(config)
179    }
180
181    /// Parses configuration from the `NAUTILUS_LOG` environment variable.
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if the variable is unset or contains invalid syntax.
186    pub fn from_env() -> anyhow::Result<Self> {
187        let spec = env::var("NAUTILUS_LOG")?;
188        Self::from_spec(&spec)
189    }
190}
191
192/// Parses a boolean value from a string.
193///
194/// Returns `true` unless the value is explicitly "false", "0", or "no" (case-insensitive).
195fn parse_bool_value(v: &str) -> bool {
196    !matches!(v.to_lowercase().as_str(), "false" | "0" | "no")
197}
198
199/// Parses a log level from a string.
200fn parse_level(v: &str) -> anyhow::Result<LevelFilter> {
201    LevelFilter::from_str(v).map_err(|_| anyhow::anyhow!("Invalid log level: {v}"))
202}
203
204#[cfg(test)]
205mod tests {
206    use rstest::rstest;
207
208    use super::*;
209
210    #[rstest]
211    fn test_default_config() {
212        let config = LoggerConfig::default();
213        assert_eq!(config.stdout_level, LevelFilter::Info);
214        assert_eq!(config.fileout_level, LevelFilter::Off);
215        assert!(config.component_level.is_empty());
216        assert!(!config.log_components_only);
217        assert!(config.is_colored);
218        assert!(!config.print_config);
219    }
220
221    #[rstest]
222    fn test_from_spec_stdout_and_fileout() {
223        let config = LoggerConfig::from_spec("stdout=Debug;fileout=Error").unwrap();
224        assert_eq!(config.stdout_level, LevelFilter::Debug);
225        assert_eq!(config.fileout_level, LevelFilter::Error);
226    }
227
228    #[rstest]
229    fn test_from_spec_case_insensitive_levels() {
230        let config = LoggerConfig::from_spec("stdout=debug;fileout=ERROR").unwrap();
231        assert_eq!(config.stdout_level, LevelFilter::Debug);
232        assert_eq!(config.fileout_level, LevelFilter::Error);
233    }
234
235    #[rstest]
236    fn test_from_spec_case_insensitive_keys() {
237        let config = LoggerConfig::from_spec("STDOUT=Info;FILEOUT=Debug").unwrap();
238        assert_eq!(config.stdout_level, LevelFilter::Info);
239        assert_eq!(config.fileout_level, LevelFilter::Debug);
240    }
241
242    #[rstest]
243    fn test_from_spec_empty_string() {
244        let config = LoggerConfig::from_spec("").unwrap();
245        assert_eq!(config, LoggerConfig::default());
246    }
247
248    #[rstest]
249    fn test_from_spec_with_whitespace() {
250        let config = LoggerConfig::from_spec("  stdout = Info ; fileout = Debug  ").unwrap();
251        assert_eq!(config.stdout_level, LevelFilter::Info);
252        assert_eq!(config.fileout_level, LevelFilter::Debug);
253    }
254
255    #[rstest]
256    fn test_from_spec_trailing_semicolon() {
257        let config = LoggerConfig::from_spec("stdout=Warn;").unwrap();
258        assert_eq!(config.stdout_level, LevelFilter::Warn);
259    }
260
261    #[rstest]
262    fn test_from_spec_bare_is_colored() {
263        let config = LoggerConfig::from_spec("is_colored").unwrap();
264        assert!(config.is_colored);
265    }
266
267    #[rstest]
268    fn test_from_spec_is_colored_true() {
269        let config = LoggerConfig::from_spec("is_colored=true").unwrap();
270        assert!(config.is_colored);
271    }
272
273    #[rstest]
274    fn test_from_spec_is_colored_false() {
275        let config = LoggerConfig::from_spec("is_colored=false").unwrap();
276        assert!(!config.is_colored);
277    }
278
279    #[rstest]
280    fn test_from_spec_is_colored_zero() {
281        let config = LoggerConfig::from_spec("is_colored=0").unwrap();
282        assert!(!config.is_colored);
283    }
284
285    #[rstest]
286    fn test_from_spec_is_colored_no() {
287        let config = LoggerConfig::from_spec("is_colored=no").unwrap();
288        assert!(!config.is_colored);
289    }
290
291    #[rstest]
292    fn test_from_spec_is_colored_case_insensitive() {
293        let config = LoggerConfig::from_spec("IS_COLORED=FALSE").unwrap();
294        assert!(!config.is_colored);
295    }
296
297    #[rstest]
298    fn test_from_spec_print_config() {
299        let config = LoggerConfig::from_spec("print_config").unwrap();
300        assert!(config.print_config);
301    }
302
303    #[rstest]
304    fn test_from_spec_print_config_false() {
305        let config = LoggerConfig::from_spec("print_config=false").unwrap();
306        assert!(!config.print_config);
307    }
308
309    #[rstest]
310    fn test_from_spec_log_components_only() {
311        let config = LoggerConfig::from_spec("log_components_only").unwrap();
312        assert!(config.log_components_only);
313    }
314
315    #[rstest]
316    fn test_from_spec_log_components_only_false() {
317        let config = LoggerConfig::from_spec("log_components_only=false").unwrap();
318        assert!(!config.log_components_only);
319    }
320
321    #[rstest]
322    fn test_from_spec_component_level() {
323        let config = LoggerConfig::from_spec("RiskEngine=Error;DataEngine=Debug").unwrap();
324        assert_eq!(
325            config.component_level[&Ustr::from("RiskEngine")],
326            LevelFilter::Error
327        );
328        assert_eq!(
329            config.component_level[&Ustr::from("DataEngine")],
330            LevelFilter::Debug
331        );
332    }
333
334    #[rstest]
335    fn test_from_spec_component_preserves_case() {
336        // Component names should preserve their original case
337        let config = LoggerConfig::from_spec("MyComponent=Info").unwrap();
338        assert!(
339            config
340                .component_level
341                .contains_key(&Ustr::from("MyComponent"))
342        );
343        assert!(
344            !config
345                .component_level
346                .contains_key(&Ustr::from("mycomponent"))
347        );
348    }
349
350    #[rstest]
351    fn test_from_spec_full_example() {
352        let config = LoggerConfig::from_spec(
353            "stdout=Info;fileout=Debug;RiskEngine=Error;is_colored;print_config",
354        )
355        .unwrap();
356
357        assert_eq!(config.stdout_level, LevelFilter::Info);
358        assert_eq!(config.fileout_level, LevelFilter::Debug);
359        assert_eq!(
360            config.component_level[&Ustr::from("RiskEngine")],
361            LevelFilter::Error
362        );
363        assert!(config.is_colored);
364        assert!(config.print_config);
365    }
366
367    #[rstest]
368    fn test_from_spec_disabled_colors() {
369        let config = LoggerConfig::from_spec("stdout=Info;is_colored=false;fileout=Debug").unwrap();
370        assert!(!config.is_colored);
371        assert_eq!(config.stdout_level, LevelFilter::Info);
372        assert_eq!(config.fileout_level, LevelFilter::Debug);
373    }
374
375    #[rstest]
376    fn test_from_spec_invalid_level() {
377        let result = LoggerConfig::from_spec("stdout=InvalidLevel");
378        assert!(result.is_err());
379        assert!(
380            result
381                .unwrap_err()
382                .to_string()
383                .contains("Invalid log level")
384        );
385    }
386
387    #[rstest]
388    fn test_from_spec_invalid_bare_flag() {
389        let result = LoggerConfig::from_spec("unknown_flag");
390        assert!(result.is_err());
391        assert!(
392            result
393                .unwrap_err()
394                .to_string()
395                .contains("Invalid spec pair")
396        );
397    }
398
399    #[rstest]
400    fn test_from_spec_missing_value() {
401        // "stdout=" with no value is technically valid empty string, which is invalid level
402        let result = LoggerConfig::from_spec("stdout=");
403        assert!(result.is_err());
404    }
405
406    #[rstest]
407    #[case("Off", LevelFilter::Off)]
408    #[case("Error", LevelFilter::Error)]
409    #[case("Warn", LevelFilter::Warn)]
410    #[case("Info", LevelFilter::Info)]
411    #[case("Debug", LevelFilter::Debug)]
412    #[case("Trace", LevelFilter::Trace)]
413    fn test_all_log_levels(#[case] level_str: &str, #[case] expected: LevelFilter) {
414        let config = LoggerConfig::from_spec(&format!("stdout={level_str}")).unwrap();
415        assert_eq!(config.stdout_level, expected);
416    }
417}