1use std::{
17 collections::VecDeque,
18 fs::{File, create_dir_all},
19 io::{self, BufWriter, Stderr, Stdout, Write},
20 path::PathBuf,
21 sync::OnceLock,
22};
23
24use chrono::{NaiveDate, Utc};
25use log::LevelFilter;
26use regex::Regex;
27
28use crate::logging::logger::LogLine;
29
30static ANSI_RE: OnceLock<Regex> = OnceLock::new();
31
32pub trait LogWriter {
33 fn write(&mut self, line: &str);
35 fn flush(&mut self);
37 fn enabled(&self, line: &LogLine) -> bool;
39}
40
41#[derive(Debug)]
42pub struct StdoutWriter {
43 pub is_colored: bool,
44 io: Stdout,
45 level: LevelFilter,
46}
47
48impl StdoutWriter {
49 #[must_use]
51 pub fn new(level: LevelFilter, is_colored: bool) -> Self {
52 Self {
53 io: io::stdout(),
54 level,
55 is_colored,
56 }
57 }
58}
59
60impl LogWriter for StdoutWriter {
61 fn write(&mut self, line: &str) {
62 match self.io.write_all(line.as_bytes()) {
63 Ok(()) => {}
64 Err(e) => eprintln!("Error writing to stdout: {e:?}"),
65 }
66 }
67
68 fn flush(&mut self) {
69 match self.io.flush() {
70 Ok(()) => {}
71 Err(e) => eprintln!("Error flushing stdout: {e:?}"),
72 }
73 }
74
75 fn enabled(&self, line: &LogLine) -> bool {
76 line.level > LevelFilter::Error && line.level <= self.level
78 }
79}
80
81#[derive(Debug)]
82pub struct StderrWriter {
83 pub is_colored: bool,
84 io: Stderr,
85}
86
87impl StderrWriter {
88 #[must_use]
90 pub fn new(is_colored: bool) -> Self {
91 Self {
92 io: io::stderr(),
93 is_colored,
94 }
95 }
96}
97
98impl LogWriter for StderrWriter {
99 fn write(&mut self, line: &str) {
100 match self.io.write_all(line.as_bytes()) {
101 Ok(()) => {}
102 Err(e) => eprintln!("Error writing to stderr: {e:?}"),
103 }
104 }
105
106 fn flush(&mut self) {
107 match self.io.flush() {
108 Ok(()) => {}
109 Err(e) => eprintln!("Error flushing stderr: {e:?}"),
110 }
111 }
112
113 fn enabled(&self, line: &LogLine) -> bool {
114 line.level == LevelFilter::Error
115 }
116}
117
118#[derive(Debug, Clone)]
120pub struct FileRotateConfig {
121 pub max_file_size: u64,
123 pub max_backup_count: u32,
125 cur_file_size: u64,
127 cur_file_creation_date: NaiveDate,
129 backup_files: VecDeque<PathBuf>,
131}
132
133impl Default for FileRotateConfig {
134 fn default() -> Self {
135 Self {
136 max_file_size: 100 * 1024 * 1024, max_backup_count: 5,
138 cur_file_size: 0,
139 cur_file_creation_date: Utc::now().date_naive(),
140 backup_files: VecDeque::new(),
141 }
142 }
143}
144
145impl From<(u64, u32)> for FileRotateConfig {
146 fn from(value: (u64, u32)) -> Self {
147 let (max_file_size, max_backup_count) = value;
148 Self {
149 max_file_size,
150 max_backup_count,
151 cur_file_size: 0,
152 cur_file_creation_date: Utc::now().date_naive(),
153 backup_files: VecDeque::new(),
154 }
155 }
156}
157
158#[cfg_attr(
159 feature = "python",
160 pyo3::pyclass(module = "posei_trader.core.nautilus_pyo3.common")
161)]
162#[derive(Debug, Clone, Default)]
163pub struct FileWriterConfig {
164 pub directory: Option<String>,
165 pub file_name: Option<String>,
166 pub file_format: Option<String>,
167 pub file_rotate: Option<FileRotateConfig>,
168}
169
170impl FileWriterConfig {
171 #[must_use]
173 pub fn new(
174 directory: Option<String>,
175 file_name: Option<String>,
176 file_format: Option<String>,
177 file_rotate: Option<(u64, u32)>,
178 ) -> Self {
179 let file_rotate = file_rotate.map(FileRotateConfig::from);
180 Self {
181 directory,
182 file_name,
183 file_format,
184 file_rotate,
185 }
186 }
187}
188
189#[derive(Debug)]
190pub struct FileWriter {
191 pub json_format: bool,
192 buf: BufWriter<File>,
193 path: PathBuf,
194 file_config: FileWriterConfig,
195 trader_id: String,
196 instance_id: String,
197 level: LevelFilter,
198 cur_file_date: NaiveDate,
199}
200
201impl FileWriter {
202 pub fn new(
204 trader_id: String,
205 instance_id: String,
206 file_config: FileWriterConfig,
207 fileout_level: LevelFilter,
208 ) -> Option<Self> {
209 let json_format = match file_config.file_format.as_ref().map(|s| s.to_lowercase()) {
211 Some(ref format) if format == "json" => true,
212 None => false,
213 Some(ref unrecognized) => {
214 tracing::error!(
215 "Unrecognized log file format: {unrecognized}. Using plain text format as default."
216 );
217 false
218 }
219 };
220
221 let file_path =
222 Self::create_log_file_path(&file_config, &trader_id, &instance_id, json_format);
223
224 match File::options()
225 .create(true)
226 .append(true)
227 .open(file_path.clone())
228 {
229 Ok(file) => Some(Self {
230 json_format,
231 buf: BufWriter::new(file),
232 path: file_path,
233 file_config,
234 trader_id,
235 instance_id,
236 level: fileout_level,
237 cur_file_date: Utc::now().date_naive(),
238 }),
239 Err(e) => {
240 tracing::error!("Error creating log file: {e}");
241 None
242 }
243 }
244 }
245
246 fn create_log_file_path(
247 file_config: &FileWriterConfig,
248 trader_id: &str,
249 instance_id: &str,
250 is_json_format: bool,
251 ) -> PathBuf {
252 let utc_now = Utc::now();
253
254 let basename = match file_config.file_name.as_ref() {
255 Some(file_name) => {
256 if file_config.file_rotate.is_some() {
257 let utc_datetime = utc_now.format("%Y-%m-%d_%H%M%S:%3f");
258 format!("{file_name}_{utc_datetime}")
259 } else {
260 file_name.clone()
261 }
262 }
263 None => {
264 let utc_component = if file_config.file_rotate.is_some() {
266 utc_now.format("%Y-%m-%d_%H%M%S:%3f")
267 } else {
268 utc_now.format("%Y-%m-%d")
269 };
270
271 format!("{trader_id}_{utc_component}_{instance_id}")
272 }
273 };
274
275 let suffix = if is_json_format { "json" } else { "log" };
276 let mut file_path = PathBuf::new();
277
278 if let Some(directory) = file_config.directory.as_ref() {
279 file_path.push(directory);
280 create_dir_all(&file_path).expect("Failed to create directories for log file");
281 }
282
283 file_path.push(basename);
284 file_path.set_extension(suffix);
285 file_path
286 }
287
288 #[must_use]
289 fn should_rotate_file(&self, next_line_size: u64) -> bool {
290 if let Some(ref rotate_config) = self.file_config.file_rotate {
292 rotate_config.cur_file_size + next_line_size > rotate_config.max_file_size
293 } else if self.file_config.file_name.is_none() {
295 let today = Utc::now().date_naive();
296 self.cur_file_date != today
297 } else {
299 false
300 }
301 }
302
303 fn rotate_file(&mut self) {
304 self.flush();
306
307 let new_path = Self::create_log_file_path(
309 &self.file_config,
310 &self.trader_id,
311 &self.instance_id,
312 self.json_format,
313 );
314 match File::options().create(true).append(true).open(&new_path) {
315 Ok(new_file) => {
316 if let Some(rotate_config) = &mut self.file_config.file_rotate {
318 rotate_config.backup_files.push_back(self.path.clone());
320 rotate_config.cur_file_size = 0;
321 rotate_config.cur_file_creation_date = Utc::now().date_naive();
322 cleanup_backups(rotate_config);
323 } else {
324 self.cur_file_date = Utc::now().date_naive();
326 }
327
328 self.buf = BufWriter::new(new_file);
329 self.path = new_path;
330 }
331 Err(e) => tracing::error!("Error creating log file: {e}"),
332 }
333
334 tracing::info!("Rotated log file, now logging to: {}", self.path.display());
335 }
336}
337
338fn cleanup_backups(rotate_config: &mut FileRotateConfig) {
343 let excess = rotate_config
345 .backup_files
346 .len()
347 .saturating_sub(rotate_config.max_backup_count as usize);
348 for _ in 0..excess {
349 if let Some(path) = rotate_config.backup_files.pop_front() {
350 if path.exists() {
351 match std::fs::remove_file(&path) {
352 Ok(_) => tracing::debug!("Removed old log file: {}", path.display()),
353 Err(e) => {
354 tracing::error!("Failed to remove old log file {}: {e}", path.display())
355 }
356 }
357 }
358 } else {
359 break;
360 }
361 }
362}
363
364impl LogWriter for FileWriter {
365 fn write(&mut self, line: &str) {
366 let line = strip_ansi_codes(line);
367 let line_size = line.len() as u64;
368
369 if self.should_rotate_file(line_size) {
371 self.rotate_file();
372 }
373
374 match self.buf.write_all(line.as_bytes()) {
375 Ok(()) => {
376 if let Some(rotate_config) = &mut self.file_config.file_rotate {
378 rotate_config.cur_file_size += line_size;
379 }
380 }
381 Err(e) => tracing::error!("Error writing to file: {e:?}"),
382 }
383 }
384
385 fn flush(&mut self) {
386 match self.buf.flush() {
387 Ok(()) => {}
388 Err(e) => tracing::error!("Error flushing file: {e:?}"),
389 }
390
391 match self.buf.get_ref().sync_all() {
392 Ok(()) => {}
393 Err(e) => tracing::error!("Error syncing file: {e:?}"),
394 }
395 }
396
397 fn enabled(&self, line: &LogLine) -> bool {
398 line.level <= self.level
399 }
400}
401
402fn strip_nonprinting_except_newline(s: &str) -> String {
403 s.chars()
404 .filter(|&c| c == '\n' || (!c.is_control() && c != '\u{7F}'))
405 .collect()
406}
407
408fn strip_ansi_codes(s: &str) -> String {
409 let re = ANSI_RE.get_or_init(|| Regex::new(r"\x1B\[[0-9;?=]*[A-Za-z]|\x1B\].*?\x07").unwrap());
410 let no_controls = strip_nonprinting_except_newline(s);
411 re.replace_all(&no_controls, "").to_string()
412}