1use 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 fn write(&mut self, line: &str);
36 fn flush(&mut self);
38 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 #[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 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 #[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#[derive(Debug, Clone)]
121pub struct FileRotateConfig {
122 pub max_file_size: u64,
124 pub max_backup_count: u32,
126 cur_file_size: u64,
128 cur_file_creation_date: NaiveDate,
130 backup_files: VecDeque<PathBuf>,
132}
133
134impl PartialEq for FileRotateConfig {
135 fn eq(&self, other: &Self) -> bool {
136 self.max_file_size == other.max_file_size && self.max_backup_count == other.max_backup_count
137 }
138}
139
140impl Eq for FileRotateConfig {}
141
142impl Default for FileRotateConfig {
143 fn default() -> Self {
144 Self {
145 max_file_size: 100 * 1024 * 1024, max_backup_count: 5,
147 cur_file_size: 0,
148 cur_file_creation_date: Utc::now().date_naive(),
149 backup_files: VecDeque::new(),
150 }
151 }
152}
153
154impl From<(u64, u32)> for FileRotateConfig {
155 fn from(value: (u64, u32)) -> Self {
156 let (max_file_size, max_backup_count) = value;
157 Self {
158 max_file_size,
159 max_backup_count,
160 cur_file_size: 0,
161 cur_file_creation_date: Utc::now().date_naive(),
162 backup_files: VecDeque::new(),
163 }
164 }
165}
166
167#[cfg_attr(
168 feature = "python",
169 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.common", from_py_object)
170)]
171#[cfg_attr(
172 feature = "python",
173 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.common")
174)]
175#[derive(Debug, Clone, Default, PartialEq, Eq)]
176pub struct FileWriterConfig {
177 pub directory: Option<String>,
178 pub file_name: Option<String>,
179 pub file_format: Option<String>,
180 pub file_rotate: Option<FileRotateConfig>,
181}
182
183impl FileWriterConfig {
184 #[must_use]
186 pub fn new(
187 directory: Option<String>,
188 file_name: Option<String>,
189 file_format: Option<String>,
190 file_rotate: Option<(u64, u32)>,
191 ) -> Self {
192 let file_rotate = file_rotate.map(FileRotateConfig::from);
193 Self {
194 directory,
195 file_name,
196 file_format,
197 file_rotate,
198 }
199 }
200}
201
202#[derive(Debug)]
203pub struct FileWriter {
204 pub json_format: bool,
205 buf: BufWriter<File>,
206 path: PathBuf,
207 file_config: FileWriterConfig,
208 trader_id: String,
209 instance_id: String,
210 level: LevelFilter,
211 cur_file_date: NaiveDate,
212}
213
214impl FileWriter {
215 pub fn new(
217 trader_id: String,
218 instance_id: String,
219 file_config: FileWriterConfig,
220 fileout_level: LevelFilter,
221 ) -> Option<Self> {
222 let json_format = match file_config.file_format.as_ref().map(|s| s.to_lowercase()) {
224 Some(ref format) if format == "json" => true,
225 None => false,
226 Some(ref unrecognized) => {
227 eprintln!(
228 "{NAUTILUS_PREFIX} Unrecognized log file format: {unrecognized}. Using plain text format as default."
229 );
230 false
231 }
232 };
233
234 let file_path =
235 match Self::create_log_file_path(&file_config, &trader_id, &instance_id, json_format) {
236 Ok(path) => path,
237 Err(e) => {
238 eprintln!("{NAUTILUS_PREFIX} Error creating log directory: {e}");
239 return None;
240 }
241 };
242
243 match File::options()
244 .create(true)
245 .append(true)
246 .open(file_path.clone())
247 {
248 Ok(file) => {
249 let mut file_config = file_config;
251 if let Some(ref mut rotate_config) = file_config.file_rotate
252 && let Ok(metadata) = file.metadata()
253 {
254 rotate_config.cur_file_size = metadata.len();
255 }
256
257 Some(Self {
258 json_format,
259 buf: BufWriter::new(file),
260 path: file_path,
261 file_config,
262 trader_id,
263 instance_id,
264 level: fileout_level,
265 cur_file_date: Utc::now().date_naive(),
266 })
267 }
268 Err(e) => {
269 eprintln!("{NAUTILUS_PREFIX} Error creating log file: {e}");
270 None
271 }
272 }
273 }
274
275 fn create_log_file_path(
276 file_config: &FileWriterConfig,
277 trader_id: &str,
278 instance_id: &str,
279 is_json_format: bool,
280 ) -> Result<PathBuf, io::Error> {
281 let utc_now = Utc::now();
282
283 let basename = match file_config.file_name.as_ref() {
284 Some(file_name) => {
285 if file_config.file_rotate.is_some() {
286 let utc_datetime = utc_now.format("%Y-%m-%d_%H%M%S:%3f");
287 format!("{file_name}_{utc_datetime}")
288 } else {
289 file_name.clone()
290 }
291 }
292 None => {
293 let utc_component = if file_config.file_rotate.is_some() {
295 utc_now.format("%Y-%m-%d_%H%M%S:%3f")
296 } else {
297 utc_now.format("%Y-%m-%d")
298 };
299
300 format!("{trader_id}_{utc_component}_{instance_id}")
301 }
302 };
303
304 let suffix = if is_json_format { "json" } else { "log" };
305 let mut file_path = PathBuf::new();
306
307 if let Some(directory) = file_config.directory.as_ref() {
308 file_path.push(directory);
309 create_dir_all(&file_path)?;
310 }
311
312 file_path.push(basename);
313 file_path.set_extension(suffix);
314 Ok(file_path)
315 }
316
317 #[must_use]
318 fn should_rotate_file(&self, next_line_size: u64) -> bool {
319 if let Some(ref rotate_config) = self.file_config.file_rotate {
321 rotate_config.cur_file_size + next_line_size > rotate_config.max_file_size
322 } else if self.file_config.file_name.is_none() {
324 let today = Utc::now().date_naive();
325 self.cur_file_date != today
326 } else {
328 false
329 }
330 }
331
332 fn rotate_file(&mut self) {
333 self.flush();
334
335 let new_path = match Self::create_log_file_path(
336 &self.file_config,
337 &self.trader_id,
338 &self.instance_id,
339 self.json_format,
340 ) {
341 Ok(path) => path,
342 Err(e) => {
343 eprintln!("{NAUTILUS_PREFIX} Error creating log directory for rotation: {e}");
344 return;
345 }
346 };
347
348 match File::options().create(true).append(true).open(&new_path) {
349 Ok(new_file) => {
350 if let Some(rotate_config) = &mut self.file_config.file_rotate {
352 rotate_config.backup_files.push_back(self.path.clone());
354 rotate_config.cur_file_size = 0;
355 rotate_config.cur_file_creation_date = Utc::now().date_naive();
356 cleanup_backups(rotate_config);
357 } else {
358 self.cur_file_date = Utc::now().date_naive();
360 }
361
362 self.buf = BufWriter::new(new_file);
363 self.path = new_path.clone();
364 eprintln!(
365 "{NAUTILUS_PREFIX} Rotated log file, now logging to: {}",
366 new_path.display()
367 );
368 }
369 Err(e) => eprintln!("{NAUTILUS_PREFIX} Error creating log file: {e}"),
370 }
371 }
372}
373
374fn cleanup_backups(rotate_config: &mut FileRotateConfig) {
379 let excess = rotate_config
381 .backup_files
382 .len()
383 .saturating_sub(rotate_config.max_backup_count as usize);
384 for _ in 0..excess {
385 if let Some(path) = rotate_config.backup_files.pop_front() {
386 if path.exists()
387 && let Err(e) = std::fs::remove_file(&path)
388 {
389 eprintln!(
390 "{NAUTILUS_PREFIX} Failed to remove old log file {}: {e}",
391 path.display()
392 );
393 }
394 } else {
395 break;
396 }
397 }
398}
399
400impl LogWriter for FileWriter {
401 fn write(&mut self, line: &str) {
402 let line = strip_ansi_codes(line);
403 let line_size = line.len() as u64;
404
405 if self.should_rotate_file(line_size) {
407 self.rotate_file();
408 }
409
410 match self.buf.write_all(line.as_bytes()) {
411 Ok(()) => {
412 if let Some(rotate_config) = &mut self.file_config.file_rotate {
414 rotate_config.cur_file_size += line_size;
415 }
416 }
417 Err(e) => eprintln!("{NAUTILUS_PREFIX} Error writing to file: {e:?}"),
418 }
419 }
420
421 fn flush(&mut self) {
422 match self.buf.flush() {
423 Ok(()) => {}
424 Err(e) => eprintln!("{NAUTILUS_PREFIX} Error flushing file: {e:?}"),
425 }
426
427 match self.buf.get_ref().sync_all() {
428 Ok(()) => {}
429 Err(e) => eprintln!("{NAUTILUS_PREFIX} Error syncing file: {e:?}"),
430 }
431 }
432
433 fn enabled(&self, line: &LogLine) -> bool {
434 line.level <= self.level
435 }
436}
437
438fn strip_nonprinting_except_newline(s: &str) -> String {
439 s.chars()
440 .filter(|&c| c == '\n' || (!c.is_control() && c != '\u{7F}'))
441 .collect()
442}
443
444fn strip_ansi_codes(s: &str) -> String {
445 let re = ANSI_RE.get_or_init(|| Regex::new(r"\x1B\[[0-9;?=]*[A-Za-z]|\x1B\].*?\x07").unwrap());
446 let no_ansi = re.replace_all(s, "");
448 strip_nonprinting_except_newline(&no_ansi)
449}
450
451#[cfg(test)]
452mod tests {
453 use log::LevelFilter;
454 use rstest::rstest;
455 use tempfile::tempdir;
456
457 use super::*;
458
459 #[rstest]
460 fn test_file_writer_with_rotation_creates_new_timestamped_file() {
461 let temp_dir = tempdir().unwrap();
462
463 let config = FileWriterConfig {
464 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
465 file_name: Some("test".to_string()),
466 file_format: None,
467 file_rotate: Some(FileRotateConfig::from((2000, 5))),
468 };
469
470 let writer = FileWriter::new(
471 "TRADER-001".to_string(),
472 "instance-123".to_string(),
473 config,
474 LevelFilter::Info,
475 )
476 .unwrap();
477
478 assert_eq!(
479 writer
480 .file_config
481 .file_rotate
482 .as_ref()
483 .unwrap()
484 .cur_file_size,
485 0
486 );
487 assert!(writer.path.to_str().unwrap().contains("test_"));
488 }
489
490 #[rstest]
491 #[case("Hello, World!", "Hello, World!")]
492 #[case("Line1\nLine2", "Line1\nLine2")]
493 #[case("Tab\there", "Tabhere")]
494 #[case("Null\0char", "Nullchar")]
495 #[case("DEL\u{7F}char", "DELchar")]
496 #[case("Bell\u{07}sound", "Bellsound")]
497 #[case("Mix\t\0\u{7F}ed", "Mixed")]
498 fn test_strip_nonprinting_except_newline(#[case] input: &str, #[case] expected: &str) {
499 let result = strip_nonprinting_except_newline(input);
500 assert_eq!(result, expected);
501 }
502
503 #[rstest]
504 #[case("Plain text", "Plain text")]
505 #[case("\x1B[31mRed\x1B[0m", "Red")]
506 #[case("\x1B[1;32mBold Green\x1B[0m", "Bold Green")]
507 #[case("Before\x1B[0mAfter", "BeforeAfter")]
508 #[case("\x1B]0;Title\x07Content", "Content")]
509 #[case("Text\t\x1B[31mRed\x1B[0m", "TextRed")]
510 fn test_strip_ansi_codes(#[case] input: &str, #[case] expected: &str) {
511 let result = strip_ansi_codes(input);
512 assert_eq!(result, expected);
513 }
514
515 #[rstest]
516 fn test_file_writer_unwritable_directory_returns_none() {
517 let config = FileWriterConfig {
518 directory: Some("/nonexistent/path/that/should/not/exist".to_string()),
519 file_name: Some("test".to_string()),
520 file_format: None,
521 file_rotate: None,
522 };
523
524 let writer = FileWriter::new(
525 "TRADER-001".to_string(),
526 "instance-123".to_string(),
527 config,
528 LevelFilter::Info,
529 );
530
531 assert!(writer.is_none());
532 }
533
534 #[rstest]
535 fn test_file_writer_directory_is_file_returns_none() {
536 let temp_dir = tempdir().unwrap();
537 let file_path = temp_dir.path().join("not_a_directory");
538 std::fs::write(&file_path, "I am a file").unwrap();
539
540 let config = FileWriterConfig {
541 directory: Some(file_path.to_str().unwrap().to_string()),
542 file_name: Some("test".to_string()),
543 file_format: None,
544 file_rotate: None,
545 };
546
547 let writer = FileWriter::new(
548 "TRADER-001".to_string(),
549 "instance-123".to_string(),
550 config,
551 LevelFilter::Info,
552 );
553
554 assert!(writer.is_none());
555 }
556
557 #[rstest]
558 fn test_file_writer_unrecognized_format_defaults_to_text() {
559 let temp_dir = tempdir().unwrap();
560
561 let config = FileWriterConfig {
562 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
563 file_name: Some("test".to_string()),
564 file_format: Some("invalid_format".to_string()),
565 file_rotate: None,
566 };
567
568 let writer = FileWriter::new(
569 "TRADER-001".to_string(),
570 "instance-123".to_string(),
571 config,
572 LevelFilter::Info,
573 )
574 .unwrap();
575
576 assert!(!writer.json_format);
577 assert!(writer.path.extension().unwrap() == "log");
578 }
579
580 #[rstest]
581 fn test_file_writer_json_format() {
582 let temp_dir = tempdir().unwrap();
583
584 let config = FileWriterConfig {
585 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
586 file_name: Some("test".to_string()),
587 file_format: Some("json".to_string()),
588 file_rotate: None,
589 };
590
591 let writer = FileWriter::new(
592 "TRADER-001".to_string(),
593 "instance-123".to_string(),
594 config,
595 LevelFilter::Info,
596 )
597 .unwrap();
598
599 assert!(writer.json_format);
600 assert!(writer.path.extension().unwrap() == "json");
601 }
602
603 #[rstest]
604 fn test_stdout_writer_filters_error_level() {
605 let writer = StdoutWriter::new(LevelFilter::Info, true);
606
607 let error_line = LogLine {
609 timestamp: 0.into(),
610 level: log::Level::Error,
611 color: crate::enums::LogColor::Normal,
612 component: ustr::Ustr::from("Test"),
613 message: "error".to_string(),
614 };
615 assert!(!writer.enabled(&error_line));
616
617 let info_line = LogLine {
619 timestamp: 0.into(),
620 level: log::Level::Info,
621 color: crate::enums::LogColor::Normal,
622 component: ustr::Ustr::from("Test"),
623 message: "info".to_string(),
624 };
625 assert!(writer.enabled(&info_line));
626
627 let debug_line = LogLine {
629 timestamp: 0.into(),
630 level: log::Level::Debug,
631 color: crate::enums::LogColor::Normal,
632 component: ustr::Ustr::from("Test"),
633 message: "debug".to_string(),
634 };
635 assert!(!writer.enabled(&debug_line));
636 }
637
638 #[rstest]
639 fn test_stderr_writer_only_enables_error_level() {
640 let writer = StderrWriter::new(true);
641
642 let error_line = LogLine {
643 timestamp: 0.into(),
644 level: log::Level::Error,
645 color: crate::enums::LogColor::Normal,
646 component: ustr::Ustr::from("Test"),
647 message: "error".to_string(),
648 };
649 assert!(writer.enabled(&error_line));
650
651 let warn_line = LogLine {
652 timestamp: 0.into(),
653 level: log::Level::Warn,
654 color: crate::enums::LogColor::Normal,
655 component: ustr::Ustr::from("Test"),
656 message: "warn".to_string(),
657 };
658 assert!(!writer.enabled(&warn_line));
659 }
660}