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 regex::Regex;
27
28use crate::logging::logger::LogLine;
29
30static ANSI_RE: OnceLock<Regex> = OnceLock::new();
31
32pub trait LogWriter {
33 fn write(&mut self, line: &str);
35 fn flush(&mut self);
37 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 #[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 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 #[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#[derive(Debug, Clone)]
120pub struct FileRotateConfig {
121 pub max_file_size: u64,
123 pub max_backup_count: u32,
125 cur_file_size: u64,
127 cur_file_creation_date: NaiveDate,
129 backup_files: VecDeque<PathBuf>,
131}
132
133impl Default for FileRotateConfig {
134 fn default() -> Self {
135 Self {
136 max_file_size: 100 * 1024 * 1024, 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 #[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 pub fn new(
204 trader_id: String,
205 instance_id: String,
206 file_config: FileWriterConfig,
207 fileout_level: LevelFilter,
208 ) -> Option<Self> {
209 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 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 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 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 } else if self.file_config.file_name.is_none() {
311 let today = Utc::now().date_naive();
312 self.cur_file_date != today
313 } 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 if let Some(rotate_config) = &mut self.file_config.file_rotate {
339 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 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
358fn cleanup_backups(rotate_config: &mut FileRotateConfig) {
363 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 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 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 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 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 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 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}