Skip to main content

nautilus_common/logging/
writer.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Stdout, stderr, and rotating file log writers.
17
18use 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    /// Writes a log line.
38    fn write(&mut self, line: &str);
39    /// Flushes buffered logs.
40    fn flush(&mut self);
41    /// Checks if a line needs to be written to the writer or not.
42    fn enabled(&self, line: &LogLine) -> bool;
43}
44
45/// Writes eligible log lines to stdout with optional buffering and ANSI colors.
46#[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    /// Creates a new [`StdoutWriter`] instance.
61    #[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        // Prevent error logs also writing to stdout (they go to stderr)
102        line.level > LevelFilter::Error && line.level <= self.level
103    }
104}
105
106/// Writes error log lines to stderr.
107#[derive(Debug)]
108pub struct StderrWriter {
109    pub is_colored: bool,
110    io: Stderr,
111}
112
113impl StderrWriter {
114    /// Creates a new [`StderrWriter`] instance.
115    #[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/// Configures size-based log file rotation.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144#[serde(default, deny_unknown_fields)]
145pub struct FileRotateConfig {
146    /// Maximum file size in bytes before rotating.
147    pub max_file_size: u64,
148    /// Maximum number of backup files to keep.
149    pub max_backup_count: u32,
150    /// Current file size tracking.
151    #[serde(skip)]
152    cur_file_size: u64,
153    /// Current file creation date.
154    #[serde(skip, default = "today_date")]
155    cur_file_creation_date: Date,
156    /// Queue of backup file paths (oldest first).
157    #[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, // 100MB default
181            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/// Configures file log output.
203#[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    /// Creates a new [`FileWriterConfig`] instance.
222    #[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    /// Validates the file writer configuration, collecting every field violation.
239    ///
240    /// # Errors
241    ///
242    /// Returns a [`ConfigError`] (a [`ConfigError::Multiple`] when more than one field is
243    /// invalid) if any field fails validation.
244    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/// Writes sanitized plain-text or JSON log lines with optional file rotation.
272#[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
285// Rotated log file names avoid ':' which is reserved in Windows file names
286const ROTATION_TIMESTAMP_FORMAT: &str = "%Y-%m-%d_%H%M%S-%3f";
287
288impl FileWriter {
289    /// Creates a new [`FileWriter`] instance.
290    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        // Set up log file
299        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        // Seed cur_file_size from existing file length if rotation is enabled
340        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        // Size-based rotation takes priority when configured
400        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        // Otherwise, for default-named logs, rotate on UTC date change
403        } else if self.file_config.file_name.is_none() {
404            let today = today_date();
405            self.cur_file_date != today
406        // No rotation for custom-named logs without size-based rotation
407        } 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            // Rotation names have millisecond resolution: a second rotation within the same
435            // millisecond resolves to the active path. Keep writing to it; rotating would
436            // enqueue the active file as a backup where cleanup could delete it.
437            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        // Rotate existing file
449        if let Some(rotate_config) = &mut self.file_config.file_rotate {
450            // Add current file to backup queue
451            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            // Update creation date for date-based rotation
457            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    /// Flushes the userspace file buffer to the OS.
469    ///
470    /// # Errors
471    ///
472    /// Returns an error if the underlying file buffer cannot be flushed.
473    pub fn flush_buffer(&mut self) -> io::Result<()> {
474        self.buf.flush()
475    }
476
477    /// Requests that flushed file data is synchronized to durable storage.
478    ///
479    /// # Errors
480    ///
481    /// Returns an error if the operating system cannot sync the file to disk.
482    pub fn sync_to_disk(&mut self) -> io::Result<()> {
483        self.buf.get_ref().sync_all()
484    }
485
486    /// Flushes buffered file data and then syncs it to disk.
487    ///
488    /// # Errors
489    ///
490    /// Returns an error if either flushing the file buffer or syncing the file to disk fails.
491    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    /// Flushes and syncs while preserving the existing logging-on-error behavior.
498    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
511/// Clean up old backup files if we exceed the max backup count.
512///
513/// TODO: Minor consider using a more specific version to pop a single file
514/// since normal execution will not create more than 1 excess file
515fn cleanup_backups(rotate_config: &mut FileRotateConfig) {
516    // Remove oldest backup files until we are at or below max_backup_count
517    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        // Rotate file if needed (size-based or date-based depending on configuration)
543        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        // Update current file size
553        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        // A second rotation within the same millisecond resolves to the same path and
840        // must not replace, enqueue, or delete the active file.
841        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        // Error level should NOT be enabled for stdout (goes to stderr)
1143        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        // Info level should be enabled
1154        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        // Debug should NOT be enabled when stdout level is Info
1165        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}