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    time::Duration,
7};
8
9use crate::core::{CoreError, CoreResult};
10
11/// Log writer destination.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum LogWriterConfig {
14    /// Standard output.
15    Stdout,
16    /// Standard error.
17    Stderr,
18    /// Append to one file.
19    File(PathBuf),
20    /// Append to one file and rotate before startup when configured boundaries are exceeded.
21    RollingFile(RollingFileConfig),
22}
23
24/// Rolling file configuration.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct RollingFileConfig {
27    /// Active log file path.
28    pub path: PathBuf,
29    /// Maximum bytes before the active file is rotated at runtime.
30    pub max_bytes: Option<u64>,
31    /// Number of rotated files to retain.
32    pub max_files: usize,
33    /// Maximum age boundary documented for runtime rotation.
34    pub max_age: Option<Duration>,
35}
36
37impl RollingFileConfig {
38    /// Creates a runtime rolling file config with a size boundary.
39    pub fn by_size(path: impl Into<PathBuf>, max_bytes: u64, max_files: usize) -> Self {
40        Self {
41            path: path.into(),
42            max_bytes: Some(max_bytes),
43            max_files,
44            max_age: None,
45        }
46    }
47}
48
49/// Prepared writer information.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct PreparedLogWriter {
52    /// File path for file-backed writers.
53    pub path: Option<PathBuf>,
54    /// Whether rotation is configured.
55    pub rotation_enabled: bool,
56}
57
58impl LogWriterConfig {
59    /// Creates a file writer.
60    pub fn file(path: impl Into<PathBuf>) -> Self {
61        Self::File(path.into())
62    }
63}
64
65/// Validates the writer and prepares file paths when necessary.
66pub fn validate_writer(config: &LogWriterConfig) -> CoreResult<PreparedLogWriter> {
67    match config {
68        LogWriterConfig::Stdout | LogWriterConfig::Stderr => Ok(PreparedLogWriter {
69            path: None,
70            rotation_enabled: false,
71        }),
72        LogWriterConfig::File(path) => {
73            open_append_file(path)?;
74            Ok(PreparedLogWriter {
75                path: Some(path.clone()),
76                rotation_enabled: false,
77            })
78        }
79        LogWriterConfig::RollingFile(rolling) => {
80            open_append_file(&rolling.path)?;
81            Ok(PreparedLogWriter {
82                path: Some(rolling.path.clone()),
83                rotation_enabled: rolling.max_bytes.is_some() || rolling.max_age.is_some(),
84            })
85        }
86    }
87}
88
89/// Shared writer that rotates the active file while the process is running.
90#[derive(Clone)]
91pub struct RuntimeRollingFileWriter {
92    inner: Arc<Mutex<RuntimeRollingFileState>>,
93}
94
95impl RuntimeRollingFileWriter {
96    /// Opens a rolling writer from config.
97    pub fn new(config: RollingFileConfig) -> CoreResult<Self> {
98        if let Some(parent) = config.path.parent() {
99            fs::create_dir_all(parent).map_err(|error| CoreError::Logging(error.to_string()))?;
100        }
101        let file = open_append_file(&config.path)?;
102        let bytes_written = file
103            .metadata()
104            .map_err(|error| CoreError::Logging(error.to_string()))?
105            .len();
106        Ok(Self {
107            inner: Arc::new(Mutex::new(RuntimeRollingFileState {
108                config,
109                file,
110                bytes_written,
111            })),
112        })
113    }
114}
115
116impl Write for RuntimeRollingFileWriter {
117    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
118        let mut state = self
119            .inner
120            .lock()
121            .map_err(|_| io::Error::other("rolling log writer mutex poisoned"))?;
122        state.write(buf)
123    }
124
125    fn flush(&mut self) -> io::Result<()> {
126        let mut state = self
127            .inner
128            .lock()
129            .map_err(|_| io::Error::other("rolling log writer mutex poisoned"))?;
130        state.file.flush()
131    }
132}
133
134pub(crate) fn open_append_file(path: &Path) -> CoreResult<File> {
135    if let Some(parent) = path.parent() {
136        fs::create_dir_all(parent).map_err(|error| CoreError::Logging(error.to_string()))?;
137    }
138    OpenOptions::new()
139        .create(true)
140        .append(true)
141        .open(path)
142        .map_err(|error| CoreError::Logging(error.to_string()))
143}
144
145struct RuntimeRollingFileState {
146    config: RollingFileConfig,
147    file: File,
148    bytes_written: u64,
149}
150
151impl RuntimeRollingFileState {
152    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
153        self.rotate_if_needed(buf.len() as u64)?;
154        self.file.write_all(buf)?;
155        self.bytes_written = self.bytes_written.saturating_add(buf.len() as u64);
156        Ok(buf.len())
157    }
158
159    fn rotate_if_needed(&mut self, incoming_bytes: u64) -> io::Result<()> {
160        let Some(max_bytes) = self.config.max_bytes else {
161            return Ok(());
162        };
163        if max_bytes == 0 || self.bytes_written.saturating_add(incoming_bytes) <= max_bytes {
164            return Ok(());
165        }
166
167        self.file.flush()?;
168        self.rotate_files()?;
169        self.file = OpenOptions::new()
170            .create(true)
171            .append(true)
172            .open(&self.config.path)?;
173        self.bytes_written = 0;
174        Ok(())
175    }
176
177    fn rotate_files(&self) -> io::Result<()> {
178        let max_files = self.config.max_files.max(1);
179        let oldest = rotated_path(&self.config.path, max_files);
180        if oldest.exists() {
181            fs::remove_file(&oldest)?;
182        }
183        for index in (1..max_files).rev() {
184            let source = rotated_path(&self.config.path, index);
185            if source.exists() {
186                fs::rename(source, rotated_path(&self.config.path, index + 1))?;
187            }
188        }
189        if self.config.path.exists() {
190            fs::rename(&self.config.path, rotated_path(&self.config.path, 1))?;
191        }
192        Ok(())
193    }
194}
195
196fn rotated_path(path: &Path, index: usize) -> PathBuf {
197    path.with_file_name(format!(
198        "{}.{index}",
199        path.file_name()
200            .and_then(|name| name.to_str())
201            .unwrap_or("service.log")
202    ))
203}