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