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