1use std::{
19 borrow::Cow,
20 collections::VecDeque,
21 fs::{File, create_dir_all},
22 io::{self, BufWriter, Stderr, Stdout, Write},
23 path::PathBuf,
24};
25
26use jiff::{Timestamp, civil::Date, tz::Offset};
27use log::LevelFilter;
28use nautilus_core::consts::NAUTILUS_PREFIX;
29use serde::{Deserialize, Serialize};
30
31use crate::{
32 config::{ConfigError, ConfigErrorCollector, ConfigResult},
33 logging::logger::LogLine,
34};
35
36pub trait LogWriter {
37 fn write(&mut self, line: &str);
39 fn flush(&mut self);
41 fn enabled(&self, line: &LogLine) -> bool;
43}
44
45#[derive(Debug)]
47pub struct StdoutWriter {
48 pub is_colored: bool,
49 io: StdoutSink,
50 level: LevelFilter,
51}
52
53#[derive(Debug)]
54enum StdoutSink {
55 Direct(Stdout),
56 Buffered(BufWriter<Stdout>),
57}
58
59impl StdoutWriter {
60 #[must_use]
62 pub fn new(level: LevelFilter, is_colored: bool, buffered: bool) -> Self {
63 let io = if buffered {
64 StdoutSink::Buffered(BufWriter::new(io::stdout()))
65 } else {
66 StdoutSink::Direct(io::stdout())
67 };
68
69 Self {
70 is_colored,
71 io,
72 level,
73 }
74 }
75}
76
77impl LogWriter for StdoutWriter {
78 fn write(&mut self, line: &str) {
79 let result = match &mut self.io {
80 StdoutSink::Direct(io) => io.write_all(line.as_bytes()),
81 StdoutSink::Buffered(io) => io.write_all(line.as_bytes()),
82 };
83
84 if let Err(e) = result {
85 eprintln!("Error writing to stdout: {e:?}");
86 }
87 }
88
89 fn flush(&mut self) {
90 let result = match &mut self.io {
91 StdoutSink::Direct(io) => io.flush(),
92 StdoutSink::Buffered(io) => io.flush(),
93 };
94
95 if let Err(e) = result {
96 eprintln!("Error flushing stdout: {e:?}");
97 }
98 }
99
100 fn enabled(&self, line: &LogLine) -> bool {
101 line.level > LevelFilter::Error && line.level <= self.level
103 }
104}
105
106#[derive(Debug)]
108pub struct StderrWriter {
109 pub is_colored: bool,
110 io: Stderr,
111}
112
113impl StderrWriter {
114 #[must_use]
116 pub fn new(is_colored: bool) -> Self {
117 Self {
118 io: io::stderr(),
119 is_colored,
120 }
121 }
122}
123
124impl LogWriter for StderrWriter {
125 fn write(&mut self, line: &str) {
126 if let Err(e) = self.io.write_all(line.as_bytes()) {
127 eprintln!("Error writing to stderr: {e:?}");
128 }
129 }
130
131 fn flush(&mut self) {
132 if let Err(e) = self.io.flush() {
133 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: Date,
156 #[serde(skip)]
158 backup_files: VecDeque<PathBuf>,
159}
160
161fn utc_date(timestamp: Timestamp) -> Date {
162 Offset::UTC.to_datetime(timestamp).date()
163}
164
165fn today_date() -> Date {
166 utc_date(Timestamp::now())
167}
168
169impl PartialEq for FileRotateConfig {
170 fn eq(&self, other: &Self) -> bool {
171 self.max_file_size == other.max_file_size && self.max_backup_count == other.max_backup_count
172 }
173}
174
175impl Eq for FileRotateConfig {}
176
177impl Default for FileRotateConfig {
178 fn default() -> Self {
179 Self {
180 max_file_size: 100 * 1024 * 1024, max_backup_count: 5,
182 cur_file_size: 0,
183 cur_file_creation_date: today_date(),
184 backup_files: VecDeque::new(),
185 }
186 }
187}
188
189impl From<(u64, u32)> for FileRotateConfig {
190 fn from(value: (u64, u32)) -> Self {
191 let (max_file_size, max_backup_count) = value;
192 Self {
193 max_file_size,
194 max_backup_count,
195 cur_file_size: 0,
196 cur_file_creation_date: today_date(),
197 backup_files: VecDeque::new(),
198 }
199 }
200}
201
202#[cfg_attr(
204 feature = "python",
205 pyo3::pyclass(module = "nautilus_trader.common", from_py_object)
206)]
207#[cfg_attr(
208 feature = "python",
209 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.common")
210)]
211#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(default, deny_unknown_fields)]
213pub struct FileWriterConfig {
214 pub directory: Option<String>,
215 pub file_name: Option<String>,
216 pub file_format: Option<String>,
217 pub file_rotate: Option<FileRotateConfig>,
218}
219
220impl FileWriterConfig {
221 #[must_use]
223 pub fn new(
224 directory: Option<String>,
225 file_name: Option<String>,
226 file_format: Option<String>,
227 file_rotate: Option<(u64, u32)>,
228 ) -> Self {
229 let file_rotate = file_rotate.map(FileRotateConfig::from);
230 Self {
231 directory,
232 file_name,
233 file_format,
234 file_rotate,
235 }
236 }
237
238 pub fn validate(&self) -> ConfigResult<()> {
245 let mut errors = ConfigErrorCollector::new();
246
247 for (field, value) in [
248 ("file_config.directory", &self.directory),
249 ("file_config.file_name", &self.file_name),
250 ] {
251 if let Some(value) = value {
252 errors.check(!value.trim().is_empty(), ConfigError::empty_field(field));
253 }
254 }
255
256 if let Some(rotate) = &self.file_rotate {
257 let max_file_size = rotate.max_file_size;
258 errors.check(
259 max_file_size > 0,
260 ConfigError::range(
261 "file_config.file_rotate.max_file_size",
262 format!("must be a positive number of bytes, was {max_file_size}"),
263 ),
264 );
265 }
266
267 errors.into_result()
268 }
269}
270
271#[derive(Debug)]
273pub struct FileWriter {
274 pub json_format: bool,
275 buf: BufWriter<File>,
276 path: PathBuf,
277 file_config: FileWriterConfig,
278 trader_id: String,
279 instance_id: String,
280 level: LevelFilter,
281 cur_file_date: Date,
282 sync_on_flush: bool,
283}
284
285const ROTATION_TIMESTAMP_FORMAT: &str = "%Y-%m-%d_%H%M%S-%3f";
287
288impl FileWriter {
289 pub fn new(
291 trader_id: String,
292 instance_id: String,
293 file_config: FileWriterConfig,
294 fileout_level: LevelFilter,
295 clear_log_file: bool,
296 sync_on_flush: bool,
297 ) -> Option<Self> {
298 let json_format = match file_config.file_format.as_ref().map(|s| s.to_lowercase()) {
300 Some(ref format) if format == "json" => true,
301 None => false,
302 Some(ref unrecognized) => {
303 eprintln!(
304 "{NAUTILUS_PREFIX} Unrecognized log file format: {unrecognized}. Using plain text format as default."
305 );
306 false
307 }
308 };
309
310 let file_path = match Self::create_log_file_path(
311 &file_config,
312 &trader_id,
313 &instance_id,
314 json_format,
315 Timestamp::now(),
316 ) {
317 Ok(path) => path,
318 Err(e) => {
319 eprintln!("{NAUTILUS_PREFIX} Error creating log directory: {e}");
320 return None;
321 }
322 };
323
324 if clear_log_file
325 && file_path.exists()
326 && let Err(e) = File::create(&file_path)
327 {
328 eprintln!("{NAUTILUS_PREFIX} Error clearing log file: {e}");
329 }
330
331 let file = match File::options().create(true).append(true).open(&file_path) {
332 Ok(file) => file,
333 Err(e) => {
334 eprintln!("{NAUTILUS_PREFIX} Error creating log file: {e}");
335 return None;
336 }
337 };
338
339 let mut file_config = file_config;
341 if let Some(ref mut rotate_config) = file_config.file_rotate
342 && let Ok(metadata) = file.metadata()
343 {
344 rotate_config.cur_file_size = metadata.len();
345 }
346
347 Some(Self {
348 json_format,
349 buf: BufWriter::new(file),
350 path: file_path,
351 file_config,
352 trader_id,
353 instance_id,
354 level: fileout_level,
355 cur_file_date: today_date(),
356 sync_on_flush,
357 })
358 }
359
360 fn create_log_file_path(
361 file_config: &FileWriterConfig,
362 trader_id: &str,
363 instance_id: &str,
364 is_json_format: bool,
365 utc_now: Timestamp,
366 ) -> Result<PathBuf, io::Error> {
367 let basename = if let Some(file_name) = file_config.file_name.as_ref() {
368 if file_config.file_rotate.is_some() {
369 let utc_datetime = utc_now.strftime(ROTATION_TIMESTAMP_FORMAT);
370 format!("{file_name}_{utc_datetime}")
371 } else {
372 file_name.clone()
373 }
374 } else {
375 let utc_component = if file_config.file_rotate.is_some() {
376 utc_now.strftime(ROTATION_TIMESTAMP_FORMAT)
377 } else {
378 utc_now.strftime("%Y-%m-%d")
379 };
380
381 format!("{trader_id}_{utc_component}_{instance_id}")
382 };
383
384 let suffix = if is_json_format { "jsonl" } else { "log" };
385 let mut file_path = PathBuf::new();
386
387 if let Some(directory) = file_config.directory.as_ref() {
388 file_path.push(directory);
389 create_dir_all(&file_path)?;
390 }
391
392 file_path.push(basename);
393 file_path.set_extension(suffix);
394 Ok(file_path)
395 }
396
397 #[must_use]
398 fn should_rotate_file(&self, next_line_size: u64) -> bool {
399 if let Some(ref rotate_config) = self.file_config.file_rotate {
401 rotate_config.cur_file_size + next_line_size > rotate_config.max_file_size
402 } else if self.file_config.file_name.is_none() {
404 let today = today_date();
405 self.cur_file_date != today
406 } else {
408 false
409 }
410 }
411
412 fn rotate_file(&mut self) {
413 self.rotate_file_at(Timestamp::now());
414 }
415
416 fn rotate_file_at(&mut self, utc_now: Timestamp) {
417 self.flush_and_sync_logged();
418
419 let new_path = match Self::create_log_file_path(
420 &self.file_config,
421 &self.trader_id,
422 &self.instance_id,
423 self.json_format,
424 utc_now,
425 ) {
426 Ok(path) => path,
427 Err(e) => {
428 eprintln!("{NAUTILUS_PREFIX} Error creating log directory for rotation: {e}");
429 return;
430 }
431 };
432
433 if new_path == self.path {
434 return;
438 }
439
440 let new_file = match File::options().create(true).append(true).open(&new_path) {
441 Ok(file) => file,
442 Err(e) => {
443 eprintln!("{NAUTILUS_PREFIX} Error creating log file: {e}");
444 return;
445 }
446 };
447
448 if let Some(rotate_config) = &mut self.file_config.file_rotate {
450 rotate_config.backup_files.push_back(self.path.clone());
452 rotate_config.cur_file_size = 0;
453 rotate_config.cur_file_creation_date = utc_date(utc_now);
454 cleanup_backups(rotate_config);
455 } else {
456 self.cur_file_date = utc_date(utc_now);
458 }
459
460 self.buf = BufWriter::new(new_file);
461 self.path.clone_from(&new_path);
462 eprintln!(
463 "{NAUTILUS_PREFIX} Rotated log file, now logging to: {}",
464 new_path.display()
465 );
466 }
467
468 pub fn flush_buffer(&mut self) -> io::Result<()> {
474 self.buf.flush()
475 }
476
477 pub fn sync_to_disk(&mut self) -> io::Result<()> {
483 self.buf.get_ref().sync_all()
484 }
485
486 pub fn flush_and_sync(&mut self) -> io::Result<()> {
492 let flush_result = self.flush_buffer();
493 let sync_result = self.sync_to_disk();
494 flush_result.and(sync_result)
495 }
496
497 pub fn flush_and_sync_logged(&mut self) {
499 let flush_result = self.flush_buffer();
500 if let Err(e) = flush_result {
501 eprintln!("{NAUTILUS_PREFIX} Error flushing file: {e:?}");
502 }
503
504 let sync_result = self.sync_to_disk();
505 if let Err(e) = sync_result {
506 eprintln!("{NAUTILUS_PREFIX} Error syncing file: {e:?}");
507 }
508 }
509}
510
511fn cleanup_backups(rotate_config: &mut FileRotateConfig) {
516 let excess = rotate_config
518 .backup_files
519 .len()
520 .saturating_sub(rotate_config.max_backup_count as usize);
521 for _ in 0..excess {
522 let Some(path) = rotate_config.backup_files.pop_front() else {
523 break;
524 };
525
526 if path.exists()
527 && let Err(e) = std::fs::remove_file(&path)
528 {
529 eprintln!(
530 "{NAUTILUS_PREFIX} Failed to remove old log file {}: {e}",
531 path.display()
532 );
533 }
534 }
535}
536
537impl LogWriter for FileWriter {
538 fn write(&mut self, line: &str) {
539 let line = sanitize_file_line(line);
540 let line_size = line.len() as u64;
541
542 if self.should_rotate_file(line_size) {
544 self.rotate_file();
545 }
546
547 if let Err(e) = self.buf.write_all(line.as_bytes()) {
548 eprintln!("{NAUTILUS_PREFIX} Error writing to file: {e:?}");
549 return;
550 }
551
552 if let Some(rotate_config) = &mut self.file_config.file_rotate {
554 rotate_config.cur_file_size += line_size;
555 }
556 }
557
558 fn flush(&mut self) {
559 if let Err(e) = self.flush_buffer() {
560 eprintln!("{NAUTILUS_PREFIX} Error flushing file: {e:?}");
561 }
562
563 if self.sync_on_flush
564 && let Err(e) = self.sync_to_disk()
565 {
566 eprintln!("{NAUTILUS_PREFIX} Error syncing file: {e:?}");
567 }
568 }
569
570 fn enabled(&self, line: &LogLine) -> bool {
571 line.level <= self.level
572 }
573}
574
575fn contains_ansi_escape(s: &str) -> bool {
576 s.as_bytes().contains(&b'\x1b')
577}
578
579fn contains_nonprinting_except_newline(s: &str) -> bool {
580 if s.is_ascii() {
581 return s.bytes().any(|b| b != b'\n' && (b < b' ' || b == b'\x7f'));
582 }
583
584 s.chars()
585 .any(|c| c != '\n' && (c.is_control() || c == '\u{7F}'))
586}
587
588fn strip_nonprinting_except_newline(s: &str) -> Cow<'_, str> {
589 if !contains_nonprinting_except_newline(s) {
590 return Cow::Borrowed(s);
591 }
592
593 Cow::Owned(strip_nonprinting_to_string(s))
594}
595
596fn strip_nonprinting_to_string(s: &str) -> String {
597 s.chars()
598 .filter(|&c| c == '\n' || (!c.is_control() && c != '\u{7F}'))
599 .collect()
600}
601
602fn sanitize_file_line(s: &str) -> Cow<'_, str> {
603 if !contains_ansi_escape(s) {
604 return strip_nonprinting_except_newline(s);
605 }
606
607 Cow::Owned(strip_ansi_and_nonprinting_to_string(s))
608}
609
610fn strip_ansi_and_nonprinting_to_string(s: &str) -> String {
611 let bytes = s.as_bytes();
612 let mut out = String::with_capacity(s.len());
613 let mut i = 0;
614
615 while i < bytes.len() {
616 if bytes[i] == b'\x1b' {
617 if let Some(end) = ansi_escape_end(bytes, i) {
618 i = end;
619 } else {
620 i += 1;
621 }
622 continue;
623 }
624
625 if bytes[i].is_ascii() {
626 if bytes[i] == b'\n' || (bytes[i] >= b' ' && bytes[i] != b'\x7f') {
627 out.push(bytes[i] as char);
628 }
629 i += 1;
630 continue;
631 }
632
633 let ch = s[i..]
634 .chars()
635 .next()
636 .expect("valid UTF-8 char boundary expected");
637
638 if ch == '\n' || (!ch.is_control() && ch != '\u{7F}') {
639 out.push(ch);
640 }
641 i += ch.len_utf8();
642 }
643
644 out
645}
646
647fn ansi_escape_end(bytes: &[u8], start: usize) -> Option<usize> {
648 match bytes.get(start + 1).copied() {
649 Some(b'[') => csi_escape_end(bytes, start + 2),
650 Some(b']') => osc_escape_end(bytes, start + 2),
651 _ => None,
652 }
653}
654
655fn csi_escape_end(bytes: &[u8], mut i: usize) -> Option<usize> {
656 while let Some(byte) = bytes.get(i).copied() {
657 if byte.is_ascii_alphabetic() {
658 return Some(i + 1);
659 }
660
661 if !matches!(byte, b'0'..=b'9' | b';' | b'?' | b'=') {
662 return None;
663 }
664 i += 1;
665 }
666
667 None
668}
669
670fn osc_escape_end(bytes: &[u8], mut i: usize) -> Option<usize> {
671 while let Some(byte) = bytes.get(i).copied() {
672 if byte == b'\x07' {
673 return Some(i + 1);
674 }
675 i += 1;
676 }
677
678 None
679}
680
681#[cfg(test)]
682mod tests {
683 use log::LevelFilter;
684 use rstest::rstest;
685 use smallvec::SmallVec;
686 use tempfile::tempdir;
687
688 use super::*;
689
690 #[rstest]
691 fn test_validate_accepts_default() {
692 assert!(FileWriterConfig::default().validate().is_ok());
693 }
694
695 #[rstest]
696 fn test_validate_rejects_empty_directory() {
697 let config = FileWriterConfig::new(Some(String::new()), None, None, None);
698 assert!(
699 matches!(config.validate(), Err(ConfigError::EmptyField { field }) if field == "file_config.directory")
700 );
701 }
702
703 #[rstest]
704 fn test_validate_rejects_empty_file_name() {
705 let config = FileWriterConfig::new(None, Some(String::new()), None, None);
706 assert!(
707 matches!(config.validate(), Err(ConfigError::EmptyField { field }) if field == "file_config.file_name")
708 );
709 }
710
711 #[rstest]
712 fn test_validate_rejects_zero_rotation_size() {
713 let config = FileWriterConfig::new(None, None, None, Some((0, 5)));
714 assert!(
715 matches!(config.validate(), Err(ConfigError::Range { field, .. }) if field == "file_config.file_rotate.max_file_size")
716 );
717 }
718
719 #[rstest]
720 fn test_file_writer_with_rotation_creates_new_timestamped_file() {
721 let temp_dir = tempdir().unwrap();
722
723 let config = FileWriterConfig {
724 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
725 file_name: Some("test".to_string()),
726 file_format: None,
727 file_rotate: Some(FileRotateConfig::from((2000, 5))),
728 };
729
730 let writer = FileWriter::new(
731 "TRADER-001".to_string(),
732 "instance-123".to_string(),
733 config,
734 LevelFilter::Info,
735 false,
736 true,
737 )
738 .unwrap();
739
740 assert_eq!(
741 writer
742 .file_config
743 .file_rotate
744 .as_ref()
745 .unwrap()
746 .cur_file_size,
747 0
748 );
749 assert!(writer.path.to_str().unwrap().contains("test_"));
750 }
751
752 fn fixed_rotation_time(millis: u32) -> Timestamp {
753 Offset::UTC
754 .to_timestamp(Date::new(2024, 1, 15).unwrap().at(
755 10,
756 30,
757 45,
758 i32::try_from(millis).unwrap() * 1_000_000,
759 ))
760 .unwrap()
761 }
762
763 #[rstest]
764 fn test_create_log_file_path_with_rotation_uses_portable_separator() {
765 let temp_dir = tempdir().unwrap();
766
767 let config = FileWriterConfig {
768 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
769 file_name: Some("test".to_string()),
770 file_format: None,
771 file_rotate: Some(FileRotateConfig::from((2000, 5))),
772 };
773
774 let path = FileWriter::create_log_file_path(
775 &config,
776 "TRADER-001",
777 "instance-123",
778 false,
779 fixed_rotation_time(123),
780 )
781 .unwrap();
782
783 let file_name = path.file_name().unwrap().to_str().unwrap();
784 assert_eq!(file_name, "test_2024-01-15_103045-123.log");
785 }
786
787 #[rstest]
788 fn test_create_log_file_path_with_rotation_default_name_uses_portable_separator() {
789 let config = FileWriterConfig {
790 directory: None,
791 file_name: None,
792 file_format: None,
793 file_rotate: Some(FileRotateConfig::from((2000, 5))),
794 };
795
796 let path = FileWriter::create_log_file_path(
797 &config,
798 "TRADER-001",
799 "instance-123",
800 false,
801 fixed_rotation_time(123),
802 )
803 .unwrap();
804
805 let file_name = path.file_name().unwrap().to_str().unwrap();
806 assert_eq!(
807 file_name,
808 "TRADER-001_2024-01-15_103045-123_instance-123.log"
809 );
810 }
811
812 #[rstest]
813 fn test_rotate_file_same_millisecond_preserves_active_file() {
814 let temp_dir = tempdir().unwrap();
815
816 let config = FileWriterConfig {
817 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
818 file_name: Some("test".to_string()),
819 file_format: None,
820 file_rotate: Some(FileRotateConfig::from((2000, 0))),
821 };
822
823 let mut writer = FileWriter::new(
824 "TRADER-001".to_string(),
825 "instance-123".to_string(),
826 config,
827 LevelFilter::Info,
828 false,
829 true,
830 )
831 .unwrap();
832
833 let fixed = fixed_rotation_time(123);
834 writer.rotate_file_at(fixed);
835
836 let active_path = writer.path.clone();
837 assert!(active_path.exists());
838
839 writer.rotate_file_at(fixed);
842
843 assert_eq!(writer.path, active_path);
844 assert!(active_path.exists());
845
846 writer.write("still logging\n");
847 writer.flush_and_sync().unwrap();
848
849 assert!(active_path.exists());
850 let contents = std::fs::read_to_string(&active_path).unwrap();
851 assert!(contents.contains("still logging"));
852 }
853
854 #[rstest]
855 fn test_rotate_file_date_based_updates_file_and_creation_date() {
856 let temp_dir = tempdir().unwrap();
857
858 let config = FileWriterConfig {
859 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
860 file_name: None,
861 file_format: None,
862 file_rotate: None,
863 };
864
865 let mut writer = FileWriter::new(
866 "TRADER-001".to_string(),
867 "instance-123".to_string(),
868 config,
869 LevelFilter::Info,
870 false,
871 true,
872 )
873 .unwrap();
874
875 writer.rotate_file_at(fixed_rotation_time(123));
876
877 assert_eq!(writer.cur_file_date, utc_date(fixed_rotation_time(123)));
878 let file_name = writer.path.file_name().unwrap().to_str().unwrap();
879 assert_eq!(file_name, "TRADER-001_2024-01-15_instance-123.log");
880 }
881
882 #[rstest]
883 fn test_rotate_file_removes_previous_file_when_backup_count_zero() {
884 let temp_dir = tempdir().unwrap();
885
886 let config = FileWriterConfig {
887 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
888 file_name: Some("test".to_string()),
889 file_format: None,
890 file_rotate: Some(FileRotateConfig::from((2000, 0))),
891 };
892
893 let mut writer = FileWriter::new(
894 "TRADER-001".to_string(),
895 "instance-123".to_string(),
896 config,
897 LevelFilter::Info,
898 false,
899 true,
900 )
901 .unwrap();
902
903 writer.rotate_file_at(fixed_rotation_time(123));
904 let first_path = writer.path.clone();
905 assert!(first_path.exists());
906
907 writer.rotate_file_at(fixed_rotation_time(124));
908
909 assert_ne!(writer.path, first_path);
910 assert!(
911 !first_path.exists(),
912 "previous rotated file should be removed with zero backup count"
913 );
914 assert!(writer.path.exists());
915 }
916
917 #[rstest]
918 #[case("Hello, World!", "Hello, World!")]
919 #[case("Line1\nLine2", "Line1\nLine2")]
920 #[case("Tab\there", "Tabhere")]
921 #[case("Null\0char", "Nullchar")]
922 #[case("DEL\u{7F}char", "DELchar")]
923 #[case("Bell\u{07}sound", "Bellsound")]
924 #[case("Mix\t\0\u{7F}ed", "Mixed")]
925 fn test_strip_nonprinting_except_newline(#[case] input: &str, #[case] expected: &str) {
926 let result = strip_nonprinting_except_newline(input);
927 assert_eq!(result, expected);
928 }
929
930 #[rstest]
931 #[case("Plain text", "Plain text")]
932 #[case("\x1B[31mRed\x1B[0m", "Red")]
933 #[case("\x1B[1;32mBold Green\x1B[0m", "Bold Green")]
934 #[case("Before\x1B[0mAfter", "BeforeAfter")]
935 #[case("\x1B]0;Title\x07Content", "Content")]
936 #[case("Text\t\x1B[31mRed\x1B[0m", "TextRed")]
937 #[case("Broken\x1B[31", "Broken[31")]
938 #[case("Broken\x1B]Title", "Broken]Title")]
939 fn test_sanitize_file_line(#[case] input: &str, #[case] expected: &str) {
940 let result = sanitize_file_line(input);
941 assert_eq!(result, expected);
942 }
943
944 #[rstest]
945 fn test_sanitize_file_line_borrows_clean_input() {
946 let result = sanitize_file_line("Plain text\n");
947
948 assert!(matches!(result, Cow::Borrowed(_)));
949 }
950
951 #[rstest]
952 fn test_file_writer_unwritable_directory_returns_none() {
953 let config = FileWriterConfig {
954 directory: Some("/nonexistent/path/that/should/not/exist".to_string()),
955 file_name: Some("test".to_string()),
956 file_format: None,
957 file_rotate: None,
958 };
959
960 let writer = FileWriter::new(
961 "TRADER-001".to_string(),
962 "instance-123".to_string(),
963 config,
964 LevelFilter::Info,
965 false,
966 true,
967 );
968
969 assert!(writer.is_none());
970 }
971
972 #[rstest]
973 fn test_file_writer_directory_is_file_returns_none() {
974 let temp_dir = tempdir().unwrap();
975 let file_path = temp_dir.path().join("not_a_directory");
976 std::fs::write(&file_path, "I am a file").unwrap();
977
978 let config = FileWriterConfig {
979 directory: Some(file_path.to_str().unwrap().to_string()),
980 file_name: Some("test".to_string()),
981 file_format: None,
982 file_rotate: None,
983 };
984
985 let writer = FileWriter::new(
986 "TRADER-001".to_string(),
987 "instance-123".to_string(),
988 config,
989 LevelFilter::Info,
990 false,
991 true,
992 );
993
994 assert!(writer.is_none());
995 }
996
997 #[rstest]
998 fn test_file_writer_unrecognized_format_defaults_to_text() {
999 let temp_dir = tempdir().unwrap();
1000
1001 let config = FileWriterConfig {
1002 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
1003 file_name: Some("test".to_string()),
1004 file_format: Some("invalid_format".to_string()),
1005 file_rotate: None,
1006 };
1007
1008 let writer = FileWriter::new(
1009 "TRADER-001".to_string(),
1010 "instance-123".to_string(),
1011 config,
1012 LevelFilter::Info,
1013 false,
1014 true,
1015 )
1016 .unwrap();
1017
1018 assert!(!writer.json_format);
1019 assert_eq!(writer.path.extension().unwrap(), "log");
1020 }
1021
1022 #[rstest]
1023 fn test_file_writer_json_format() {
1024 let temp_dir = tempdir().unwrap();
1025
1026 let config = FileWriterConfig {
1027 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
1028 file_name: Some("test".to_string()),
1029 file_format: Some("json".to_string()),
1030 file_rotate: None,
1031 };
1032
1033 let writer = FileWriter::new(
1034 "TRADER-001".to_string(),
1035 "instance-123".to_string(),
1036 config,
1037 LevelFilter::Info,
1038 false,
1039 true,
1040 )
1041 .unwrap();
1042
1043 assert!(writer.json_format);
1044 assert_eq!(writer.path.extension().unwrap(), "jsonl");
1045 }
1046
1047 #[rstest]
1048 fn test_file_writer_clear_log_file_truncates_existing_file() {
1049 let temp_dir = tempdir().unwrap();
1050
1051 let config = FileWriterConfig {
1052 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
1053 file_name: Some("test".to_string()),
1054 file_format: None,
1055 file_rotate: None,
1056 };
1057
1058 let existing_path = temp_dir.path().join("test.log");
1059 std::fs::write(&existing_path, "stale contents").unwrap();
1060 assert_eq!(
1061 std::fs::metadata(&existing_path).unwrap().len(),
1062 "stale contents".len() as u64
1063 );
1064
1065 let writer = FileWriter::new(
1066 "TRADER-001".to_string(),
1067 "instance-123".to_string(),
1068 config,
1069 LevelFilter::Info,
1070 true,
1071 true,
1072 )
1073 .unwrap();
1074
1075 assert_eq!(writer.path, existing_path);
1076 assert_eq!(std::fs::metadata(&existing_path).unwrap().len(), 0);
1077 }
1078
1079 #[rstest]
1080 fn test_file_writer_clear_log_file_false_preserves_existing_file() {
1081 let temp_dir = tempdir().unwrap();
1082
1083 let config = FileWriterConfig {
1084 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
1085 file_name: Some("test".to_string()),
1086 file_format: None,
1087 file_rotate: None,
1088 };
1089
1090 let existing_path = temp_dir.path().join("test.log");
1091 let existing_contents = "preserved contents";
1092 std::fs::write(&existing_path, existing_contents).unwrap();
1093
1094 let writer = FileWriter::new(
1095 "TRADER-001".to_string(),
1096 "instance-123".to_string(),
1097 config,
1098 LevelFilter::Info,
1099 false,
1100 true,
1101 )
1102 .unwrap();
1103
1104 assert_eq!(writer.path, existing_path);
1105 assert_eq!(
1106 std::fs::read_to_string(&existing_path).unwrap(),
1107 existing_contents
1108 );
1109 }
1110
1111 #[rstest]
1112 fn test_file_writer_sync_on_flush_can_be_disabled() {
1113 let temp_dir = tempdir().unwrap();
1114
1115 let config = FileWriterConfig {
1116 directory: Some(temp_dir.path().to_str().unwrap().to_string()),
1117 file_name: Some("test".to_string()),
1118 file_format: None,
1119 file_rotate: None,
1120 };
1121
1122 let mut writer = FileWriter::new(
1123 "TRADER-001".to_string(),
1124 "instance-123".to_string(),
1125 config,
1126 LevelFilter::Info,
1127 false,
1128 false,
1129 )
1130 .unwrap();
1131
1132 assert!(!writer.sync_on_flush);
1133 writer.write("hello\n");
1134 writer.flush();
1135 writer.flush_and_sync().unwrap();
1136 }
1137
1138 #[rstest]
1139 fn test_stdout_writer_filters_error_level() {
1140 let writer = StdoutWriter::new(LevelFilter::Info, true, false);
1141
1142 let error_line = LogLine {
1144 timestamp: 0.into(),
1145 level: log::Level::Error,
1146 color: crate::enums::LogColor::Normal,
1147 component: ustr::Ustr::from("Test"),
1148 message: "error".to_string(),
1149 fields: SmallVec::new(),
1150 };
1151 assert!(!writer.enabled(&error_line));
1152
1153 let info_line = LogLine {
1155 timestamp: 0.into(),
1156 level: log::Level::Info,
1157 color: crate::enums::LogColor::Normal,
1158 component: ustr::Ustr::from("Test"),
1159 message: "info".to_string(),
1160 fields: SmallVec::new(),
1161 };
1162 assert!(writer.enabled(&info_line));
1163
1164 let debug_line = LogLine {
1166 timestamp: 0.into(),
1167 level: log::Level::Debug,
1168 color: crate::enums::LogColor::Normal,
1169 component: ustr::Ustr::from("Test"),
1170 message: "debug".to_string(),
1171 fields: SmallVec::new(),
1172 };
1173 assert!(!writer.enabled(&debug_line));
1174 }
1175
1176 #[rstest]
1177 fn test_stderr_writer_only_enables_error_level() {
1178 let writer = StderrWriter::new(true);
1179
1180 let error_line = LogLine {
1181 timestamp: 0.into(),
1182 level: log::Level::Error,
1183 color: crate::enums::LogColor::Normal,
1184 component: ustr::Ustr::from("Test"),
1185 message: "error".to_string(),
1186 fields: SmallVec::new(),
1187 };
1188 assert!(writer.enabled(&error_line));
1189
1190 let warn_line = LogLine {
1191 timestamp: 0.into(),
1192 level: log::Level::Warn,
1193 color: crate::enums::LogColor::Normal,
1194 component: ustr::Ustr::from("Test"),
1195 message: "warn".to_string(),
1196 fields: SmallVec::new(),
1197 };
1198 assert!(!writer.enabled(&warn_line));
1199 }
1200}