1use std::{
17 borrow::Cow,
18 collections::VecDeque,
19 fs::{File, create_dir_all},
20 io::{self, BufWriter, Stderr, Stdout, Write},
21 path::PathBuf,
22};
23
24use chrono::{NaiveDate, Utc};
25use log::LevelFilter;
26use nautilus_core::consts::NAUTILUS_PREFIX;
27use serde::{Deserialize, Serialize};
28
29use crate::{
30 config::{ConfigError, ConfigErrorCollector, ConfigResult},
31 logging::logger::LogLine,
32};
33
34pub trait LogWriter {
35 fn write(&mut self, line: &str);
37 fn flush(&mut self);
39 fn enabled(&self, line: &LogLine) -> bool;
41}
42
43#[derive(Debug)]
44pub struct StdoutWriter {
45 pub is_colored: bool,
46 io: StdoutSink,
47 level: LevelFilter,
48}
49
50#[derive(Debug)]
51enum StdoutSink {
52 Direct(Stdout),
53 Buffered(BufWriter<Stdout>),
54}
55
56impl StdoutWriter {
57 #[must_use]
59 pub fn new(level: LevelFilter, is_colored: bool, buffered: bool) -> Self {
60 let io = if buffered {
61 StdoutSink::Buffered(BufWriter::new(io::stdout()))
62 } else {
63 StdoutSink::Direct(io::stdout())
64 };
65
66 Self {
67 is_colored,
68 io,
69 level,
70 }
71 }
72}
73
74impl LogWriter for StdoutWriter {
75 fn write(&mut self, line: &str) {
76 let result = match &mut self.io {
77 StdoutSink::Direct(io) => io.write_all(line.as_bytes()),
78 StdoutSink::Buffered(io) => io.write_all(line.as_bytes()),
79 };
80
81 match result {
82 Ok(()) => {}
83 Err(e) => eprintln!("Error writing to stdout: {e:?}"),
84 }
85 }
86
87 fn flush(&mut self) {
88 let result = match &mut self.io {
89 StdoutSink::Direct(io) => io.flush(),
90 StdoutSink::Buffered(io) => io.flush(),
91 };
92
93 match result {
94 Ok(()) => {}
95 Err(e) => eprintln!("Error flushing stdout: {e:?}"),
96 }
97 }
98
99 fn enabled(&self, line: &LogLine) -> bool {
100 line.level > LevelFilter::Error && line.level <= self.level
102 }
103}
104
105#[derive(Debug)]
106pub struct StderrWriter {
107 pub is_colored: bool,
108 io: Stderr,
109}
110
111impl StderrWriter {
112 #[must_use]
114 pub fn new(is_colored: bool) -> Self {
115 Self {
116 io: io::stderr(),
117 is_colored,
118 }
119 }
120}
121
122impl LogWriter for StderrWriter {
123 fn write(&mut self, line: &str) {
124 match self.io.write_all(line.as_bytes()) {
125 Ok(()) => {}
126 Err(e) => eprintln!("Error writing to stderr: {e:?}"),
127 }
128 }
129
130 fn flush(&mut self) {
131 match self.io.flush() {
132 Ok(()) => {}
133 Err(e) => eprintln!("Error flushing stderr: {e:?}"),
134 }
135 }
136
137 fn enabled(&self, line: &LogLine) -> bool {
138 line.level == LevelFilter::Error
139 }
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
144#[serde(default, deny_unknown_fields)]
145pub struct FileRotateConfig {
146 pub max_file_size: u64,
148 pub max_backup_count: u32,
150 #[serde(skip)]
152 cur_file_size: u64,
153 #[serde(skip, default = "today_date")]
155 cur_file_creation_date: NaiveDate,
156 #[serde(skip)]
158 backup_files: VecDeque<PathBuf>,
159}
160
161fn today_date() -> NaiveDate {
162 Utc::now().date_naive()
163}
164
165impl PartialEq for FileRotateConfig {
166 fn eq(&self, other: &Self) -> bool {
167 self.max_file_size == other.max_file_size && self.max_backup_count == other.max_backup_count
168 }
169}
170
171impl Eq for FileRotateConfig {}
172
173impl Default for FileRotateConfig {
174 fn default() -> Self {
175 Self {
176 max_file_size: 100 * 1024 * 1024, max_backup_count: 5,
178 cur_file_size: 0,
179 cur_file_creation_date: Utc::now().date_naive(),
180 backup_files: VecDeque::new(),
181 }
182 }
183}
184
185impl From<(u64, u32)> for FileRotateConfig {
186 fn from(value: (u64, u32)) -> Self {
187 let (max_file_size, max_backup_count) = value;
188 Self {
189 max_file_size,
190 max_backup_count,
191 cur_file_size: 0,
192 cur_file_creation_date: Utc::now().date_naive(),
193 backup_files: VecDeque::new(),
194 }
195 }
196}
197
198#[cfg_attr(
199 feature = "python",
200 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.common", from_py_object)
201)]
202#[cfg_attr(
203 feature = "python",
204 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.common")
205)]
206#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(default, deny_unknown_fields)]
208pub struct FileWriterConfig {
209 pub directory: Option<String>,
210 pub file_name: Option<String>,
211 pub file_format: Option<String>,
212 pub file_rotate: Option<FileRotateConfig>,
213}
214
215impl FileWriterConfig {
216 #[must_use]
218 pub fn new(
219 directory: Option<String>,
220 file_name: Option<String>,
221 file_format: Option<String>,
222 file_rotate: Option<(u64, u32)>,
223 ) -> Self {
224 let file_rotate = file_rotate.map(FileRotateConfig::from);
225 Self {
226 directory,
227 file_name,
228 file_format,
229 file_rotate,
230 }
231 }
232
233 pub fn validate(&self) -> ConfigResult<()> {
240 let mut errors = ConfigErrorCollector::new();
241
242 for (field, value) in [
243 ("file_config.directory", &self.directory),
244 ("file_config.file_name", &self.file_name),
245 ] {
246 if let Some(value) = value {
247 errors.check(!value.trim().is_empty(), ConfigError::empty_field(field));
248 }
249 }
250
251 if let Some(rotate) = &self.file_rotate {
252 let max_file_size = rotate.max_file_size;
253 errors.check(
254 max_file_size > 0,
255 ConfigError::range(
256 "file_config.file_rotate.max_file_size",
257 format!("must be a positive number of bytes, was {max_file_size}"),
258 ),
259 );
260 }
261
262 errors.into_result()
263 }
264}
265
266#[derive(Debug)]
267pub struct FileWriter {
268 pub json_format: bool,
269 buf: BufWriter<File>,
270 path: PathBuf,
271 file_config: FileWriterConfig,
272 trader_id: String,
273 instance_id: String,
274 level: LevelFilter,
275 cur_file_date: NaiveDate,
276 sync_on_flush: bool,
277}
278
279impl FileWriter {
280 pub fn new(
282 trader_id: String,
283 instance_id: String,
284 file_config: FileWriterConfig,
285 fileout_level: LevelFilter,
286 clear_log_file: bool,
287 sync_on_flush: bool,
288 ) -> Option<Self> {
289 let json_format = match file_config.file_format.as_ref().map(|s| s.to_lowercase()) {
291 Some(ref format) if format == "json" => true,
292 None => false,
293 Some(ref unrecognized) => {
294 eprintln!(
295 "{NAUTILUS_PREFIX} Unrecognized log file format: {unrecognized}. Using plain text format as default."
296 );
297 false
298 }
299 };
300
301 let file_path =
302 match Self::create_log_file_path(&file_config, &trader_id, &instance_id, json_format) {
303 Ok(path) => path,
304 Err(e) => {
305 eprintln!("{NAUTILUS_PREFIX} Error creating log directory: {e}");
306 return None;
307 }
308 };
309
310 if clear_log_file
311 && file_path.exists()
312 && let Err(e) = File::create(&file_path)
313 {
314 eprintln!("{NAUTILUS_PREFIX} Error clearing log file: {e}");
315 }
316
317 match File::options()
318 .create(true)
319 .append(true)
320 .open(file_path.clone())
321 {
322 Ok(file) => {
323 let mut file_config = file_config;
325 if let Some(ref mut rotate_config) = file_config.file_rotate
326 && let Ok(metadata) = file.metadata()
327 {
328 rotate_config.cur_file_size = metadata.len();
329 }
330
331 Some(Self {
332 json_format,
333 buf: BufWriter::new(file),
334 path: file_path,
335 file_config,
336 trader_id,
337 instance_id,
338 level: fileout_level,
339 cur_file_date: Utc::now().date_naive(),
340 sync_on_flush,
341 })
342 }
343 Err(e) => {
344 eprintln!("{NAUTILUS_PREFIX} Error creating log file: {e}");
345 None
346 }
347 }
348 }
349
350 fn create_log_file_path(
351 file_config: &FileWriterConfig,
352 trader_id: &str,
353 instance_id: &str,
354 is_json_format: bool,
355 ) -> Result<PathBuf, io::Error> {
356 let utc_now = Utc::now();
357
358 let basename = if let Some(file_name) = file_config.file_name.as_ref() {
359 if file_config.file_rotate.is_some() {
360 let utc_datetime = utc_now.format("%Y-%m-%d_%H%M%S:%3f");
361 format!("{file_name}_{utc_datetime}")
362 } else {
363 file_name.clone()
364 }
365 } else {
366 let utc_component = if file_config.file_rotate.is_some() {
367 utc_now.format("%Y-%m-%d_%H%M%S:%3f")
368 } else {
369 utc_now.format("%Y-%m-%d")
370 };
371
372 format!("{trader_id}_{utc_component}_{instance_id}")
373 };
374
375 let suffix = if is_json_format { "jsonl" } else { "log" };
376 let mut file_path = PathBuf::new();
377
378 if let Some(directory) = file_config.directory.as_ref() {
379 file_path.push(directory);
380 create_dir_all(&file_path)?;
381 }
382
383 file_path.push(basename);
384 file_path.set_extension(suffix);
385 Ok(file_path)
386 }
387
388 #[must_use]
389 fn should_rotate_file(&self, next_line_size: u64) -> bool {
390 if let Some(ref rotate_config) = self.file_config.file_rotate {
392 rotate_config.cur_file_size + next_line_size > rotate_config.max_file_size
393 } else if self.file_config.file_name.is_none() {
395 let today = Utc::now().date_naive();
396 self.cur_file_date != today
397 } else {
399 false
400 }
401 }
402
403 fn rotate_file(&mut self) {
404 self.flush_and_sync_logged();
405
406 let new_path = match Self::create_log_file_path(
407 &self.file_config,
408 &self.trader_id,
409 &self.instance_id,
410 self.json_format,
411 ) {
412 Ok(path) => path,
413 Err(e) => {
414 eprintln!("{NAUTILUS_PREFIX} Error creating log directory for rotation: {e}");
415 return;
416 }
417 };
418
419 match File::options().create(true).append(true).open(&new_path) {
420 Ok(new_file) => {
421 if let Some(rotate_config) = &mut self.file_config.file_rotate {
423 rotate_config.backup_files.push_back(self.path.clone());
425 rotate_config.cur_file_size = 0;
426 rotate_config.cur_file_creation_date = Utc::now().date_naive();
427 cleanup_backups(rotate_config);
428 } else {
429 self.cur_file_date = Utc::now().date_naive();
431 }
432
433 self.buf = BufWriter::new(new_file);
434 self.path.clone_from(&new_path);
435 eprintln!(
436 "{NAUTILUS_PREFIX} Rotated log file, now logging to: {}",
437 new_path.display()
438 );
439 }
440 Err(e) => eprintln!("{NAUTILUS_PREFIX} Error creating log file: {e}"),
441 }
442 }
443
444 pub fn flush_buffer(&mut self) -> io::Result<()> {
450 self.buf.flush()
451 }
452
453 pub fn sync_to_disk(&mut self) -> io::Result<()> {
459 self.buf.get_ref().sync_all()
460 }
461
462 pub fn flush_and_sync(&mut self) -> io::Result<()> {
468 let flush_result = self.flush_buffer();
469 let sync_result = self.sync_to_disk();
470 flush_result.and(sync_result)
471 }
472
473 pub fn flush_and_sync_logged(&mut self) {
475 let flush_result = self.flush_buffer();
476 if let Err(e) = flush_result {
477 eprintln!("{NAUTILUS_PREFIX} Error flushing file: {e:?}");
478 }
479
480 let sync_result = self.sync_to_disk();
481 if let Err(e) = sync_result {
482 eprintln!("{NAUTILUS_PREFIX} Error syncing file: {e:?}");
483 }
484 }
485}
486
487fn cleanup_backups(rotate_config: &mut FileRotateConfig) {
492 let excess = rotate_config
494 .backup_files
495 .len()
496 .saturating_sub(rotate_config.max_backup_count as usize);
497 for _ in 0..excess {
498 if let Some(path) = rotate_config.backup_files.pop_front() {
499 if path.exists()
500 && let Err(e) = std::fs::remove_file(&path)
501 {
502 eprintln!(
503 "{NAUTILUS_PREFIX} Failed to remove old log file {}: {e}",
504 path.display()
505 );
506 }
507 } else {
508 break;
509 }
510 }
511}
512
513impl LogWriter for FileWriter {
514 fn write(&mut self, line: &str) {
515 let line = sanitize_file_line(line);
516 let line_size = line.len() as u64;
517
518 if self.should_rotate_file(line_size) {
520 self.rotate_file();
521 }
522
523 match self.buf.write_all(line.as_bytes()) {
524 Ok(()) => {
525 if let Some(rotate_config) = &mut self.file_config.file_rotate {
527 rotate_config.cur_file_size += line_size;
528 }
529 }
530 Err(e) => eprintln!("{NAUTILUS_PREFIX} Error writing to file: {e:?}"),
531 }
532 }
533
534 fn flush(&mut self) {
535 match self.flush_buffer() {
536 Ok(()) => {}
537 Err(e) => eprintln!("{NAUTILUS_PREFIX} Error flushing file: {e:?}"),
538 }
539
540 if self.sync_on_flush {
541 match self.sync_to_disk() {
542 Ok(()) => {}
543 Err(e) => eprintln!("{NAUTILUS_PREFIX} Error syncing file: {e:?}"),
544 }
545 }
546 }
547
548 fn enabled(&self, line: &LogLine) -> bool {
549 line.level <= self.level
550 }
551}
552
553fn contains_ansi_escape(s: &str) -> bool {
554 s.as_bytes().contains(&b'\x1b')
555}
556
557fn contains_nonprinting_except_newline(s: &str) -> bool {
558 if s.is_ascii() {
559 return s.bytes().any(|b| b != b'\n' && (b < b' ' || b == b'\x7f'));
560 }
561
562 s.chars()
563 .any(|c| c != '\n' && (c.is_control() || c == '\u{7F}'))
564}
565
566fn strip_nonprinting_except_newline(s: &str) -> Cow<'_, str> {
567 if !contains_nonprinting_except_newline(s) {
568 return Cow::Borrowed(s);
569 }
570
571 Cow::Owned(strip_nonprinting_to_string(s))
572}
573
574fn strip_nonprinting_to_string(s: &str) -> String {
575 s.chars()
576 .filter(|&c| c == '\n' || (!c.is_control() && c != '\u{7F}'))
577 .collect()
578}
579
580fn sanitize_file_line(s: &str) -> Cow<'_, str> {
581 if !contains_ansi_escape(s) {
582 return strip_nonprinting_except_newline(s);
583 }
584
585 Cow::Owned(strip_ansi_and_nonprinting_to_string(s))
586}
587
588fn strip_ansi_and_nonprinting_to_string(s: &str) -> String {
589 let bytes = s.as_bytes();
590 let mut out = String::with_capacity(s.len());
591 let mut i = 0;
592
593 while i < bytes.len() {
594 if bytes[i] == b'\x1b' {
595 if let Some(end) = ansi_escape_end(bytes, i) {
596 i = end;
597 } else {
598 i += 1;
599 }
600 continue;
601 }
602
603 if bytes[i].is_ascii() {
604 if bytes[i] == b'\n' || (bytes[i] >= b' ' && bytes[i] != b'\x7f') {
605 out.push(bytes[i] as char);
606 }
607 i += 1;
608 continue;
609 }
610
611 let ch = s[i..]
612 .chars()
613 .next()
614 .expect("valid UTF-8 char boundary expected");
615
616 if ch == '\n' || (!ch.is_control() && ch != '\u{7F}') {
617 out.push(ch);
618 }
619 i += ch.len_utf8();
620 }
621
622 out
623}
624
625fn ansi_escape_end(bytes: &[u8], start: usize) -> Option<usize> {
626 match bytes.get(start + 1).copied() {
627 Some(b'[') => csi_escape_end(bytes, start + 2),
628 Some(b']') => osc_escape_end(bytes, start + 2),
629 _ => None,
630 }
631}
632
633fn csi_escape_end(bytes: &[u8], mut i: usize) -> Option<usize> {
634 while let Some(byte) = bytes.get(i).copied() {
635 if byte.is_ascii_alphabetic() {
636 return Some(i + 1);
637 }
638
639 if !matches!(byte, b'0'..=b'9' | b';' | b'?' | b'=') {
640 return None;
641 }
642 i += 1;
643 }
644
645 None
646}
647
648fn osc_escape_end(bytes: &[u8], mut i: usize) -> Option<usize> {
649 while let Some(byte) = bytes.get(i).copied() {
650 if byte == b'\x07' {
651 return Some(i + 1);
652 }
653 i += 1;
654 }
655
656 None
657}
658
659#[cfg(test)]
660mod tests {
661 use log::LevelFilter;
662 use rstest::rstest;
663 use smallvec::SmallVec;
664 use tempfile::tempdir;
665
666 use super::*;
667
668 #[rstest]
669 fn test_validate_accepts_default() {
670 assert!(FileWriterConfig::default().validate().is_ok());
671 }
672
673 #[rstest]
674 fn test_validate_rejects_empty_directory() {
675 let config = FileWriterConfig::new(Some(String::new()), None, None, None);
676 assert!(
677 matches!(config.validate(), Err(ConfigError::EmptyField { field }) if field == "file_config.directory")
678 );
679 }
680
681 #[rstest]
682 fn test_validate_rejects_empty_file_name() {
683 let config = FileWriterConfig::new(None, Some(String::new()), None, None);
684 assert!(
685 matches!(config.validate(), Err(ConfigError::EmptyField { field }) if field == "file_config.file_name")
686 );
687 }
688
689 #[rstest]
690 fn test_validate_rejects_zero_rotation_size() {
691 let config = FileWriterConfig::new(None, None, None, Some((0, 5)));
692 assert!(
693 matches!(config.validate(), Err(ConfigError::Range { field, .. }) if field == "file_config.file_rotate.max_file_size")
694 );
695 }
696
697 #[rstest]
698 fn test_file_writer_with_rotation_creates_new_timestamped_file() {
699 let temp_dir = tempdir().unwrap();
700
701 let config = FileWriterConfig {
702 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
703 file_name: Some("test".to_string()),
704 file_format: None,
705 file_rotate: Some(FileRotateConfig::from((2000, 5))),
706 };
707
708 let writer = FileWriter::new(
709 "TRADER-001".to_string(),
710 "instance-123".to_string(),
711 config,
712 LevelFilter::Info,
713 false,
714 true,
715 )
716 .unwrap();
717
718 assert_eq!(
719 writer
720 .file_config
721 .file_rotate
722 .as_ref()
723 .unwrap()
724 .cur_file_size,
725 0
726 );
727 assert!(writer.path.to_str().unwrap().contains("test_"));
728 }
729
730 #[rstest]
731 #[case("Hello, World!", "Hello, World!")]
732 #[case("Line1\nLine2", "Line1\nLine2")]
733 #[case("Tab\there", "Tabhere")]
734 #[case("Null\0char", "Nullchar")]
735 #[case("DEL\u{7F}char", "DELchar")]
736 #[case("Bell\u{07}sound", "Bellsound")]
737 #[case("Mix\t\0\u{7F}ed", "Mixed")]
738 fn test_strip_nonprinting_except_newline(#[case] input: &str, #[case] expected: &str) {
739 let result = strip_nonprinting_except_newline(input);
740 assert_eq!(result, expected);
741 }
742
743 #[rstest]
744 #[case("Plain text", "Plain text")]
745 #[case("\x1B[31mRed\x1B[0m", "Red")]
746 #[case("\x1B[1;32mBold Green\x1B[0m", "Bold Green")]
747 #[case("Before\x1B[0mAfter", "BeforeAfter")]
748 #[case("\x1B]0;Title\x07Content", "Content")]
749 #[case("Text\t\x1B[31mRed\x1B[0m", "TextRed")]
750 #[case("Broken\x1B[31", "Broken[31")]
751 #[case("Broken\x1B]Title", "Broken]Title")]
752 fn test_sanitize_file_line(#[case] input: &str, #[case] expected: &str) {
753 let result = sanitize_file_line(input);
754 assert_eq!(result, expected);
755 }
756
757 #[rstest]
758 fn test_sanitize_file_line_borrows_clean_input() {
759 let result = sanitize_file_line("Plain text\n");
760
761 assert!(matches!(result, Cow::Borrowed(_)));
762 }
763
764 #[rstest]
765 fn test_file_writer_unwritable_directory_returns_none() {
766 let config = FileWriterConfig {
767 directory: Some("/nonexistent/path/that/should/not/exist".to_string()),
768 file_name: Some("test".to_string()),
769 file_format: None,
770 file_rotate: None,
771 };
772
773 let writer = FileWriter::new(
774 "TRADER-001".to_string(),
775 "instance-123".to_string(),
776 config,
777 LevelFilter::Info,
778 false,
779 true,
780 );
781
782 assert!(writer.is_none());
783 }
784
785 #[rstest]
786 fn test_file_writer_directory_is_file_returns_none() {
787 let temp_dir = tempdir().unwrap();
788 let file_path = temp_dir.path().join("not_a_directory");
789 std::fs::write(&file_path, "I am a file").unwrap();
790
791 let config = FileWriterConfig {
792 directory: Some(file_path.to_str().unwrap().to_string()),
793 file_name: Some("test".to_string()),
794 file_format: None,
795 file_rotate: None,
796 };
797
798 let writer = FileWriter::new(
799 "TRADER-001".to_string(),
800 "instance-123".to_string(),
801 config,
802 LevelFilter::Info,
803 false,
804 true,
805 );
806
807 assert!(writer.is_none());
808 }
809
810 #[rstest]
811 fn test_file_writer_unrecognized_format_defaults_to_text() {
812 let temp_dir = tempdir().unwrap();
813
814 let config = FileWriterConfig {
815 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
816 file_name: Some("test".to_string()),
817 file_format: Some("invalid_format".to_string()),
818 file_rotate: None,
819 };
820
821 let writer = FileWriter::new(
822 "TRADER-001".to_string(),
823 "instance-123".to_string(),
824 config,
825 LevelFilter::Info,
826 false,
827 true,
828 )
829 .unwrap();
830
831 assert!(!writer.json_format);
832 assert_eq!(writer.path.extension().unwrap(), "log");
833 }
834
835 #[rstest]
836 fn test_file_writer_json_format() {
837 let temp_dir = tempdir().unwrap();
838
839 let config = FileWriterConfig {
840 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
841 file_name: Some("test".to_string()),
842 file_format: Some("json".to_string()),
843 file_rotate: None,
844 };
845
846 let writer = FileWriter::new(
847 "TRADER-001".to_string(),
848 "instance-123".to_string(),
849 config,
850 LevelFilter::Info,
851 false,
852 true,
853 )
854 .unwrap();
855
856 assert!(writer.json_format);
857 assert_eq!(writer.path.extension().unwrap(), "jsonl");
858 }
859
860 #[rstest]
861 fn test_file_writer_clear_log_file_truncates_existing_file() {
862 let temp_dir = tempdir().unwrap();
863
864 let config = FileWriterConfig {
865 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
866 file_name: Some("test".to_string()),
867 file_format: None,
868 file_rotate: None,
869 };
870
871 let existing_path = temp_dir.path().join("test.log");
872 std::fs::write(&existing_path, "stale contents").unwrap();
873 assert_eq!(
874 std::fs::metadata(&existing_path).unwrap().len(),
875 "stale contents".len() as u64
876 );
877
878 let writer = FileWriter::new(
879 "TRADER-001".to_string(),
880 "instance-123".to_string(),
881 config,
882 LevelFilter::Info,
883 true,
884 true,
885 )
886 .unwrap();
887
888 assert_eq!(writer.path, existing_path);
889 assert_eq!(std::fs::metadata(&existing_path).unwrap().len(), 0);
890 }
891
892 #[rstest]
893 fn test_file_writer_clear_log_file_false_preserves_existing_file() {
894 let temp_dir = tempdir().unwrap();
895
896 let config = FileWriterConfig {
897 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
898 file_name: Some("test".to_string()),
899 file_format: None,
900 file_rotate: None,
901 };
902
903 let existing_path = temp_dir.path().join("test.log");
904 let existing_contents = "preserved contents";
905 std::fs::write(&existing_path, existing_contents).unwrap();
906
907 let writer = FileWriter::new(
908 "TRADER-001".to_string(),
909 "instance-123".to_string(),
910 config,
911 LevelFilter::Info,
912 false,
913 true,
914 )
915 .unwrap();
916
917 assert_eq!(writer.path, existing_path);
918 assert_eq!(
919 std::fs::read_to_string(&existing_path).unwrap(),
920 existing_contents
921 );
922 }
923
924 #[rstest]
925 fn test_file_writer_sync_on_flush_can_be_disabled() {
926 let temp_dir = tempdir().unwrap();
927
928 let config = FileWriterConfig {
929 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
930 file_name: Some("test".to_string()),
931 file_format: None,
932 file_rotate: None,
933 };
934
935 let mut writer = FileWriter::new(
936 "TRADER-001".to_string(),
937 "instance-123".to_string(),
938 config,
939 LevelFilter::Info,
940 false,
941 false,
942 )
943 .unwrap();
944
945 assert!(!writer.sync_on_flush);
946 writer.write("hello\n");
947 writer.flush();
948 writer.flush_and_sync().unwrap();
949 }
950
951 #[rstest]
952 fn test_stdout_writer_filters_error_level() {
953 let writer = StdoutWriter::new(LevelFilter::Info, true, false);
954
955 let error_line = LogLine {
957 timestamp: 0.into(),
958 level: log::Level::Error,
959 color: crate::enums::LogColor::Normal,
960 component: ustr::Ustr::from("Test"),
961 message: "error".to_string(),
962 fields: SmallVec::new(),
963 };
964 assert!(!writer.enabled(&error_line));
965
966 let info_line = LogLine {
968 timestamp: 0.into(),
969 level: log::Level::Info,
970 color: crate::enums::LogColor::Normal,
971 component: ustr::Ustr::from("Test"),
972 message: "info".to_string(),
973 fields: SmallVec::new(),
974 };
975 assert!(writer.enabled(&info_line));
976
977 let debug_line = LogLine {
979 timestamp: 0.into(),
980 level: log::Level::Debug,
981 color: crate::enums::LogColor::Normal,
982 component: ustr::Ustr::from("Test"),
983 message: "debug".to_string(),
984 fields: SmallVec::new(),
985 };
986 assert!(!writer.enabled(&debug_line));
987 }
988
989 #[rstest]
990 fn test_stderr_writer_only_enables_error_level() {
991 let writer = StderrWriter::new(true);
992
993 let error_line = LogLine {
994 timestamp: 0.into(),
995 level: log::Level::Error,
996 color: crate::enums::LogColor::Normal,
997 component: ustr::Ustr::from("Test"),
998 message: "error".to_string(),
999 fields: SmallVec::new(),
1000 };
1001 assert!(writer.enabled(&error_line));
1002
1003 let warn_line = LogLine {
1004 timestamp: 0.into(),
1005 level: log::Level::Warn,
1006 color: crate::enums::LogColor::Normal,
1007 component: ustr::Ustr::from("Test"),
1008 message: "warn".to_string(),
1009 fields: SmallVec::new(),
1010 };
1011 assert!(!writer.enabled(&warn_line));
1012 }
1013}