nautilus_common/logging/
writer.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
16use std::{
17    collections::VecDeque,
18    fs::{File, create_dir_all},
19    io::{self, BufWriter, Stderr, Stdout, Write},
20    path::PathBuf,
21    sync::OnceLock,
22};
23
24use chrono::{NaiveDate, Utc};
25use log::LevelFilter;
26use regex::Regex;
27
28use crate::logging::logger::LogLine;
29
30static ANSI_RE: OnceLock<Regex> = OnceLock::new();
31
32pub trait LogWriter {
33    /// Writes a log line.
34    fn write(&mut self, line: &str);
35    /// Flushes buffered logs.
36    fn flush(&mut self);
37    /// Checks if a line needs to be written to the writer or not.
38    fn enabled(&self, line: &LogLine) -> bool;
39}
40
41#[derive(Debug)]
42pub struct StdoutWriter {
43    pub is_colored: bool,
44    io: Stdout,
45    level: LevelFilter,
46}
47
48impl StdoutWriter {
49    /// Creates a new [`StdoutWriter`] instance.
50    #[must_use]
51    pub fn new(level: LevelFilter, is_colored: bool) -> Self {
52        Self {
53            io: io::stdout(),
54            level,
55            is_colored,
56        }
57    }
58}
59
60impl LogWriter for StdoutWriter {
61    fn write(&mut self, line: &str) {
62        match self.io.write_all(line.as_bytes()) {
63            Ok(()) => {}
64            Err(e) => eprintln!("Error writing to stdout: {e:?}"),
65        }
66    }
67
68    fn flush(&mut self) {
69        match self.io.flush() {
70            Ok(()) => {}
71            Err(e) => eprintln!("Error flushing stdout: {e:?}"),
72        }
73    }
74
75    fn enabled(&self, line: &LogLine) -> bool {
76        // Prevent error logs also writing to stdout
77        line.level > LevelFilter::Error && line.level <= self.level
78    }
79}
80
81#[derive(Debug)]
82pub struct StderrWriter {
83    pub is_colored: bool,
84    io: Stderr,
85}
86
87impl StderrWriter {
88    /// Creates a new [`StderrWriter`] instance.
89    #[must_use]
90    pub fn new(is_colored: bool) -> Self {
91        Self {
92            io: io::stderr(),
93            is_colored,
94        }
95    }
96}
97
98impl LogWriter for StderrWriter {
99    fn write(&mut self, line: &str) {
100        match self.io.write_all(line.as_bytes()) {
101            Ok(()) => {}
102            Err(e) => eprintln!("Error writing to stderr: {e:?}"),
103        }
104    }
105
106    fn flush(&mut self) {
107        match self.io.flush() {
108            Ok(()) => {}
109            Err(e) => eprintln!("Error flushing stderr: {e:?}"),
110        }
111    }
112
113    fn enabled(&self, line: &LogLine) -> bool {
114        line.level == LevelFilter::Error
115    }
116}
117
118/// File rotation config.
119#[derive(Debug, Clone)]
120pub struct FileRotateConfig {
121    /// Maximum file size in bytes before rotating.
122    pub max_file_size: u64,
123    /// Maximum number of backup files to keep.
124    pub max_backup_count: u32,
125    /// Current file size tracking.
126    cur_file_size: u64,
127    /// Current file creation date.
128    cur_file_creation_date: NaiveDate,
129    /// Queue of backup file paths (oldest first).
130    backup_files: VecDeque<PathBuf>,
131}
132
133impl Default for FileRotateConfig {
134    fn default() -> Self {
135        Self {
136            max_file_size: 100 * 1024 * 1024, // 100MB default
137            max_backup_count: 5,
138            cur_file_size: 0,
139            cur_file_creation_date: Utc::now().date_naive(),
140            backup_files: VecDeque::new(),
141        }
142    }
143}
144
145impl From<(u64, u32)> for FileRotateConfig {
146    fn from(value: (u64, u32)) -> Self {
147        let (max_file_size, max_backup_count) = value;
148        Self {
149            max_file_size,
150            max_backup_count,
151            cur_file_size: 0,
152            cur_file_creation_date: Utc::now().date_naive(),
153            backup_files: VecDeque::new(),
154        }
155    }
156}
157
158#[cfg_attr(
159    feature = "python",
160    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.common")
161)]
162#[derive(Debug, Clone, Default)]
163pub struct FileWriterConfig {
164    pub directory: Option<String>,
165    pub file_name: Option<String>,
166    pub file_format: Option<String>,
167    pub file_rotate: Option<FileRotateConfig>,
168}
169
170impl FileWriterConfig {
171    /// Creates a new [`FileWriterConfig`] instance.
172    #[must_use]
173    pub fn new(
174        directory: Option<String>,
175        file_name: Option<String>,
176        file_format: Option<String>,
177        file_rotate: Option<(u64, u32)>,
178    ) -> Self {
179        let file_rotate = file_rotate.map(FileRotateConfig::from);
180        Self {
181            directory,
182            file_name,
183            file_format,
184            file_rotate,
185        }
186    }
187}
188
189#[derive(Debug)]
190pub struct FileWriter {
191    pub json_format: bool,
192    buf: BufWriter<File>,
193    path: PathBuf,
194    file_config: FileWriterConfig,
195    trader_id: String,
196    instance_id: String,
197    level: LevelFilter,
198    cur_file_date: NaiveDate,
199}
200
201impl FileWriter {
202    /// Creates a new [`FileWriter`] instance.
203    pub fn new(
204        trader_id: String,
205        instance_id: String,
206        file_config: FileWriterConfig,
207        fileout_level: LevelFilter,
208    ) -> Option<Self> {
209        // Set up log file
210        let json_format = match file_config.file_format.as_ref().map(|s| s.to_lowercase()) {
211            Some(ref format) if format == "json" => true,
212            None => false,
213            Some(ref unrecognized) => {
214                tracing::error!(
215                    "Unrecognized log file format: {unrecognized}. Using plain text format as default."
216                );
217                false
218            }
219        };
220
221        let file_path =
222            match Self::create_log_file_path(&file_config, &trader_id, &instance_id, json_format) {
223                Ok(path) => path,
224                Err(e) => {
225                    tracing::error!("Error creating log directory: {e}");
226                    return None;
227                }
228            };
229
230        match File::options()
231            .create(true)
232            .append(true)
233            .open(file_path.clone())
234        {
235            Ok(file) => {
236                // Seed cur_file_size from existing file length if rotation is enabled
237                let mut file_config = file_config;
238                if let Some(ref mut rotate_config) = file_config.file_rotate
239                    && let Ok(metadata) = file.metadata()
240                {
241                    rotate_config.cur_file_size = metadata.len();
242                }
243
244                Some(Self {
245                    json_format,
246                    buf: BufWriter::new(file),
247                    path: file_path,
248                    file_config,
249                    trader_id,
250                    instance_id,
251                    level: fileout_level,
252                    cur_file_date: Utc::now().date_naive(),
253                })
254            }
255            Err(e) => {
256                tracing::error!("Error creating log file: {e}");
257                None
258            }
259        }
260    }
261
262    fn create_log_file_path(
263        file_config: &FileWriterConfig,
264        trader_id: &str,
265        instance_id: &str,
266        is_json_format: bool,
267    ) -> Result<PathBuf, io::Error> {
268        let utc_now = Utc::now();
269
270        let basename = match file_config.file_name.as_ref() {
271            Some(file_name) => {
272                if file_config.file_rotate.is_some() {
273                    let utc_datetime = utc_now.format("%Y-%m-%d_%H%M%S:%3f");
274                    format!("{file_name}_{utc_datetime}")
275                } else {
276                    file_name.clone()
277                }
278            }
279            None => {
280                // Default base name
281                let utc_component = if file_config.file_rotate.is_some() {
282                    utc_now.format("%Y-%m-%d_%H%M%S:%3f")
283                } else {
284                    utc_now.format("%Y-%m-%d")
285                };
286
287                format!("{trader_id}_{utc_component}_{instance_id}")
288            }
289        };
290
291        let suffix = if is_json_format { "json" } else { "log" };
292        let mut file_path = PathBuf::new();
293
294        if let Some(directory) = file_config.directory.as_ref() {
295            file_path.push(directory);
296            create_dir_all(&file_path)?;
297        }
298
299        file_path.push(basename);
300        file_path.set_extension(suffix);
301        Ok(file_path)
302    }
303
304    #[must_use]
305    fn should_rotate_file(&self, next_line_size: u64) -> bool {
306        // Size-based rotation takes priority when configured
307        if let Some(ref rotate_config) = self.file_config.file_rotate {
308            rotate_config.cur_file_size + next_line_size > rotate_config.max_file_size
309        // Otherwise, for default-named logs, rotate on UTC date change
310        } else if self.file_config.file_name.is_none() {
311            let today = Utc::now().date_naive();
312            self.cur_file_date != today
313        // No rotation for custom-named logs without size-based rotation
314        } else {
315            false
316        }
317    }
318
319    fn rotate_file(&mut self) {
320        self.flush();
321
322        let new_path = match Self::create_log_file_path(
323            &self.file_config,
324            &self.trader_id,
325            &self.instance_id,
326            self.json_format,
327        ) {
328            Ok(path) => path,
329            Err(e) => {
330                tracing::error!("Error creating log directory for rotation: {e}");
331                return;
332            }
333        };
334
335        match File::options().create(true).append(true).open(&new_path) {
336            Ok(new_file) => {
337                // Rotate existing file
338                if let Some(rotate_config) = &mut self.file_config.file_rotate {
339                    // Add current file to backup queue
340                    rotate_config.backup_files.push_back(self.path.clone());
341                    rotate_config.cur_file_size = 0;
342                    rotate_config.cur_file_creation_date = Utc::now().date_naive();
343                    cleanup_backups(rotate_config);
344                } else {
345                    // Update creation date for date-based rotation
346                    self.cur_file_date = Utc::now().date_naive();
347                }
348
349                self.buf = BufWriter::new(new_file);
350                self.path = new_path.clone();
351                tracing::info!("Rotated log file, now logging to: {}", new_path.display());
352            }
353            Err(e) => tracing::error!("Error creating log file: {e}"),
354        }
355    }
356}
357
358/// Clean up old backup files if we exceed the max backup count.
359///
360/// TODO: Minor consider using a more specific version to pop a single file
361/// since normal execution will not create more than 1 excess file
362fn cleanup_backups(rotate_config: &mut FileRotateConfig) {
363    // Remove oldest backup files until we are at or below max_backup_count
364    let excess = rotate_config
365        .backup_files
366        .len()
367        .saturating_sub(rotate_config.max_backup_count as usize);
368    for _ in 0..excess {
369        if let Some(path) = rotate_config.backup_files.pop_front() {
370            if path.exists() {
371                match std::fs::remove_file(&path) {
372                    Ok(_) => tracing::debug!("Removed old log file: {}", path.display()),
373                    Err(e) => {
374                        tracing::error!("Failed to remove old log file {}: {e}", path.display());
375                    }
376                }
377            }
378        } else {
379            break;
380        }
381    }
382}
383
384impl LogWriter for FileWriter {
385    fn write(&mut self, line: &str) {
386        let line = strip_ansi_codes(line);
387        let line_size = line.len() as u64;
388
389        // Rotate file if needed (size-based or date-based depending on configuration)
390        if self.should_rotate_file(line_size) {
391            self.rotate_file();
392        }
393
394        match self.buf.write_all(line.as_bytes()) {
395            Ok(()) => {
396                // Update current file size
397                if let Some(rotate_config) = &mut self.file_config.file_rotate {
398                    rotate_config.cur_file_size += line_size;
399                }
400            }
401            Err(e) => tracing::error!("Error writing to file: {e:?}"),
402        }
403    }
404
405    fn flush(&mut self) {
406        match self.buf.flush() {
407            Ok(()) => {}
408            Err(e) => tracing::error!("Error flushing file: {e:?}"),
409        }
410
411        match self.buf.get_ref().sync_all() {
412            Ok(()) => {}
413            Err(e) => tracing::error!("Error syncing file: {e:?}"),
414        }
415    }
416
417    fn enabled(&self, line: &LogLine) -> bool {
418        line.level <= self.level
419    }
420}
421
422fn strip_nonprinting_except_newline(s: &str) -> String {
423    s.chars()
424        .filter(|&c| c == '\n' || (!c.is_control() && c != '\u{7F}'))
425        .collect()
426}
427
428fn strip_ansi_codes(s: &str) -> String {
429    let re = ANSI_RE.get_or_init(|| Regex::new(r"\x1B\[[0-9;?=]*[A-Za-z]|\x1B\].*?\x07").unwrap());
430    // Strip ANSI codes first (while \x1B is still present), then remove other control chars
431    let no_ansi = re.replace_all(s, "");
432    strip_nonprinting_except_newline(&no_ansi)
433}
434
435#[cfg(test)]
436mod tests {
437    use log::LevelFilter;
438    use rstest::rstest;
439    use tempfile::tempdir;
440
441    use super::*;
442
443    #[rstest]
444    fn test_file_writer_with_rotation_creates_new_timestamped_file() {
445        let temp_dir = tempdir().unwrap();
446
447        let config = FileWriterConfig {
448            directory: Some(temp_dir.path().to_str().unwrap().to_string()),
449            file_name: Some("test".to_string()),
450            file_format: None,
451            file_rotate: Some(FileRotateConfig::from((2000, 5))),
452        };
453
454        let writer = FileWriter::new(
455            "TRADER-001".to_string(),
456            "instance-123".to_string(),
457            config,
458            LevelFilter::Info,
459        )
460        .unwrap();
461
462        assert_eq!(
463            writer
464                .file_config
465                .file_rotate
466                .as_ref()
467                .unwrap()
468                .cur_file_size,
469            0
470        );
471        assert!(writer.path.to_str().unwrap().contains("test_"));
472    }
473
474    #[rstest]
475    #[case("Hello, World!", "Hello, World!")]
476    #[case("Line1\nLine2", "Line1\nLine2")]
477    #[case("Tab\there", "Tabhere")]
478    #[case("Null\0char", "Nullchar")]
479    #[case("DEL\u{7F}char", "DELchar")]
480    #[case("Bell\u{07}sound", "Bellsound")]
481    #[case("Mix\t\0\u{7F}ed", "Mixed")]
482    fn test_strip_nonprinting_except_newline(#[case] input: &str, #[case] expected: &str) {
483        let result = strip_nonprinting_except_newline(input);
484        assert_eq!(result, expected);
485    }
486
487    #[rstest]
488    #[case("Plain text", "Plain text")]
489    #[case("\x1B[31mRed\x1B[0m", "Red")]
490    #[case("\x1B[1;32mBold Green\x1B[0m", "Bold Green")]
491    #[case("Before\x1B[0mAfter", "BeforeAfter")]
492    #[case("\x1B]0;Title\x07Content", "Content")]
493    #[case("Text\t\x1B[31mRed\x1B[0m", "TextRed")]
494    fn test_strip_ansi_codes(#[case] input: &str, #[case] expected: &str) {
495        let result = strip_ansi_codes(input);
496        assert_eq!(result, expected);
497    }
498
499    #[rstest]
500    fn test_file_writer_unwritable_directory_returns_none() {
501        let config = FileWriterConfig {
502            directory: Some("/nonexistent/path/that/should/not/exist".to_string()),
503            file_name: Some("test".to_string()),
504            file_format: None,
505            file_rotate: None,
506        };
507
508        let writer = FileWriter::new(
509            "TRADER-001".to_string(),
510            "instance-123".to_string(),
511            config,
512            LevelFilter::Info,
513        );
514
515        assert!(writer.is_none());
516    }
517
518    #[rstest]
519    fn test_file_writer_directory_is_file_returns_none() {
520        let temp_dir = tempdir().unwrap();
521        let file_path = temp_dir.path().join("not_a_directory");
522        std::fs::write(&file_path, "I am a file").unwrap();
523
524        let config = FileWriterConfig {
525            directory: Some(file_path.to_str().unwrap().to_string()),
526            file_name: Some("test".to_string()),
527            file_format: None,
528            file_rotate: None,
529        };
530
531        let writer = FileWriter::new(
532            "TRADER-001".to_string(),
533            "instance-123".to_string(),
534            config,
535            LevelFilter::Info,
536        );
537
538        assert!(writer.is_none());
539    }
540
541    #[rstest]
542    fn test_file_writer_unrecognized_format_defaults_to_text() {
543        let temp_dir = tempdir().unwrap();
544
545        let config = FileWriterConfig {
546            directory: Some(temp_dir.path().to_str().unwrap().to_string()),
547            file_name: Some("test".to_string()),
548            file_format: Some("invalid_format".to_string()),
549            file_rotate: None,
550        };
551
552        let writer = FileWriter::new(
553            "TRADER-001".to_string(),
554            "instance-123".to_string(),
555            config,
556            LevelFilter::Info,
557        )
558        .unwrap();
559
560        assert!(!writer.json_format);
561        assert!(writer.path.extension().unwrap() == "log");
562    }
563
564    #[rstest]
565    fn test_file_writer_json_format() {
566        let temp_dir = tempdir().unwrap();
567
568        let config = FileWriterConfig {
569            directory: Some(temp_dir.path().to_str().unwrap().to_string()),
570            file_name: Some("test".to_string()),
571            file_format: Some("json".to_string()),
572            file_rotate: None,
573        };
574
575        let writer = FileWriter::new(
576            "TRADER-001".to_string(),
577            "instance-123".to_string(),
578            config,
579            LevelFilter::Info,
580        )
581        .unwrap();
582
583        assert!(writer.json_format);
584        assert!(writer.path.extension().unwrap() == "json");
585    }
586
587    #[rstest]
588    fn test_stdout_writer_filters_error_level() {
589        let writer = StdoutWriter::new(LevelFilter::Info, true);
590
591        // Error level should NOT be enabled for stdout (goes to stderr)
592        let error_line = LogLine {
593            timestamp: 0.into(),
594            level: log::Level::Error,
595            color: crate::enums::LogColor::Normal,
596            component: ustr::Ustr::from("Test"),
597            message: "error".to_string(),
598        };
599        assert!(!writer.enabled(&error_line));
600
601        // Info level should be enabled
602        let info_line = LogLine {
603            timestamp: 0.into(),
604            level: log::Level::Info,
605            color: crate::enums::LogColor::Normal,
606            component: ustr::Ustr::from("Test"),
607            message: "info".to_string(),
608        };
609        assert!(writer.enabled(&info_line));
610
611        // Debug should NOT be enabled when stdout level is Info
612        let debug_line = LogLine {
613            timestamp: 0.into(),
614            level: log::Level::Debug,
615            color: crate::enums::LogColor::Normal,
616            component: ustr::Ustr::from("Test"),
617            message: "debug".to_string(),
618        };
619        assert!(!writer.enabled(&debug_line));
620    }
621
622    #[rstest]
623    fn test_stderr_writer_only_enables_error_level() {
624        let writer = StderrWriter::new(true);
625
626        let error_line = LogLine {
627            timestamp: 0.into(),
628            level: log::Level::Error,
629            color: crate::enums::LogColor::Normal,
630            component: ustr::Ustr::from("Test"),
631            message: "error".to_string(),
632        };
633        assert!(writer.enabled(&error_line));
634
635        let warn_line = LogLine {
636            timestamp: 0.into(),
637            level: log::Level::Warn,
638            color: crate::enums::LogColor::Normal,
639            component: ustr::Ustr::from("Test"),
640            message: "warn".to_string(),
641        };
642        assert!(!writer.enabled(&warn_line));
643    }
644}