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 Default for FileRotateConfig {
135 fn default() -> Self {
136 Self {
137 max_file_size: 100 * 1024 * 1024, 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 #[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 pub fn new(
205 trader_id: String,
206 instance_id: String,
207 file_config: FileWriterConfig,
208 fileout_level: LevelFilter,
209 ) -> Option<Self> {
210 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 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 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 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 } else if self.file_config.file_name.is_none() {
312 let today = Utc::now().date_naive();
313 self.cur_file_date != today
314 } 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 if let Some(rotate_config) = &mut self.file_config.file_rotate {
340 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 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
362fn cleanup_backups(rotate_config: &mut FileRotateConfig) {
367 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 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 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 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 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 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 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}