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