Skip to main content

rs_zero/core/logging/
writer.rs

1use std::{
2    fs::{self, File, OpenOptions},
3    io::{self, Write},
4    path::{Path, PathBuf},
5    sync::{Arc, Mutex},
6};
7
8use chrono::{Local, NaiveDate};
9use serde::Deserialize;
10
11use crate::core::logging::rotation::{cleanup_rotated_files, rotate_files};
12use crate::core::{CoreError, CoreResult};
13
14/// Log writer destination.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum LogWriterConfig {
17    /// Standard output.
18    Stdout,
19    /// Standard error.
20    Stderr,
21    /// Append to one file.
22    File(PathBuf),
23    /// Append to one file and rotate while the process is running.
24    RollingFile(RollingFileConfig),
25}
26
27/// Rolling policy for file-backed logs.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
29#[serde(rename_all = "snake_case")]
30pub enum RotationPolicy {
31    /// Rotate when local date changes.
32    #[default]
33    Daily,
34    /// Rotate when configured size boundary is exceeded.
35    Size,
36}
37
38/// Rolling file configuration.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct RollingFileConfig {
41    /// Active log file path.
42    pub path: PathBuf,
43    /// Rotation policy.
44    pub rotation: RotationPolicy,
45    /// Maximum bytes before the active file is rotated at runtime. Used by size rotation.
46    pub max_bytes: Option<u64>,
47    /// Number of rotated files to retain. `0` disables count-based cleanup.
48    pub max_files: usize,
49    /// Number of days to retain rotated files. `None` disables age-based cleanup.
50    pub keep_days: Option<u64>,
51    /// Compress rotated files as gzip.
52    pub compress: bool,
53}
54
55impl RollingFileConfig {
56    /// Creates a runtime rolling file config with a size boundary.
57    pub fn by_size(path: impl Into<PathBuf>, max_bytes: u64, max_files: usize) -> Self {
58        Self {
59            path: path.into(),
60            rotation: RotationPolicy::Size,
61            max_bytes: Some(max_bytes),
62            max_files,
63            keep_days: None,
64            compress: false,
65        }
66    }
67
68    /// Creates a runtime rolling file config with a daily boundary.
69    pub fn daily(path: impl Into<PathBuf>, max_files: usize) -> Self {
70        Self {
71            path: path.into(),
72            rotation: RotationPolicy::Daily,
73            max_bytes: None,
74            max_files,
75            keep_days: None,
76            compress: false,
77        }
78    }
79}
80
81/// Prepared writer information.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct PreparedLogWriter {
84    /// File path for file-backed writers.
85    pub path: Option<PathBuf>,
86    /// Whether rotation is configured.
87    pub rotation_enabled: bool,
88}
89
90impl LogWriterConfig {
91    /// Creates a file writer.
92    pub fn file(path: impl Into<PathBuf>) -> Self {
93        Self::File(path.into())
94    }
95}
96
97/// Validates the writer and prepares file paths when necessary.
98pub fn validate_writer(config: &LogWriterConfig) -> CoreResult<PreparedLogWriter> {
99    match config {
100        LogWriterConfig::Stdout | LogWriterConfig::Stderr => Ok(PreparedLogWriter {
101            path: None,
102            rotation_enabled: false,
103        }),
104        LogWriterConfig::File(path) => {
105            open_append_file(path)?;
106            Ok(PreparedLogWriter {
107                path: Some(path.clone()),
108                rotation_enabled: false,
109            })
110        }
111        LogWriterConfig::RollingFile(rolling) => {
112            open_append_file(&rolling.path)?;
113            Ok(PreparedLogWriter {
114                path: Some(rolling.path.clone()),
115                rotation_enabled: true,
116            })
117        }
118    }
119}
120
121/// Shared writer that rotates the active file while the process is running.
122#[derive(Clone)]
123pub struct RuntimeRollingFileWriter {
124    inner: Arc<Mutex<RuntimeRollingFileState>>,
125}
126
127impl RuntimeRollingFileWriter {
128    /// Opens a rolling writer from config.
129    pub fn new(config: RollingFileConfig) -> CoreResult<Self> {
130        if let Some(parent) = config.path.parent() {
131            fs::create_dir_all(parent).map_err(|error| CoreError::Logging(error.to_string()))?;
132        }
133        cleanup_rotated_files(&config).map_err(|error| CoreError::Logging(error.to_string()))?;
134        let file = open_append_file(&config.path)?;
135        let bytes_written = file
136            .metadata()
137            .map_err(|error| CoreError::Logging(error.to_string()))?
138            .len();
139        Ok(Self {
140            inner: Arc::new(Mutex::new(RuntimeRollingFileState {
141                current_day: Local::now().date_naive(),
142                config,
143                file,
144                bytes_written,
145            })),
146        })
147    }
148}
149
150impl Write for RuntimeRollingFileWriter {
151    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
152        let mut state = self
153            .inner
154            .lock()
155            .map_err(|_| io::Error::other("rolling log writer mutex poisoned"))?;
156        state.write(buf)
157    }
158
159    fn flush(&mut self) -> io::Result<()> {
160        let mut state = self
161            .inner
162            .lock()
163            .map_err(|_| io::Error::other("rolling log writer mutex poisoned"))?;
164        state.file.flush()
165    }
166}
167
168pub(crate) fn open_append_file(path: &Path) -> CoreResult<File> {
169    if let Some(parent) = path.parent() {
170        fs::create_dir_all(parent).map_err(|error| CoreError::Logging(error.to_string()))?;
171    }
172    OpenOptions::new()
173        .create(true)
174        .append(true)
175        .open(path)
176        .map_err(|error| CoreError::Logging(error.to_string()))
177}
178
179struct RuntimeRollingFileState {
180    config: RollingFileConfig,
181    file: File,
182    bytes_written: u64,
183    current_day: NaiveDate,
184}
185
186impl RuntimeRollingFileState {
187    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
188        self.rotate_if_needed(buf.len() as u64)?;
189        self.file.write_all(buf)?;
190        self.bytes_written = self.bytes_written.saturating_add(buf.len() as u64);
191        Ok(buf.len())
192    }
193
194    fn rotate_if_needed(&mut self, incoming_bytes: u64) -> io::Result<()> {
195        match self.config.rotation {
196            RotationPolicy::Size => self.rotate_by_size_if_needed(incoming_bytes),
197            RotationPolicy::Daily => self.rotate_by_day_if_needed(),
198        }
199    }
200
201    fn rotate_by_size_if_needed(&mut self, incoming_bytes: u64) -> io::Result<()> {
202        let Some(max_bytes) = self.config.max_bytes else {
203            return Ok(());
204        };
205        if max_bytes == 0 || self.bytes_written.saturating_add(incoming_bytes) <= max_bytes {
206            return Ok(());
207        }
208        self.rotate_active_file()
209    }
210
211    fn rotate_by_day_if_needed(&mut self) -> io::Result<()> {
212        let today = Local::now().date_naive();
213        if today <= self.current_day {
214            return Ok(());
215        }
216        self.current_day = today;
217        self.rotate_active_file()
218    }
219
220    fn rotate_active_file(&mut self) -> io::Result<()> {
221        self.file.flush()?;
222        rotate_files(&self.config)?;
223        self.file = OpenOptions::new()
224            .create(true)
225            .append(true)
226            .open(&self.config.path)?;
227        self.bytes_written = 0;
228        cleanup_rotated_files(&self.config)
229    }
230}