rs_zero/core/logging/
writer.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum LogWriterConfig {
14 Stdout,
16 Stderr,
18 File(PathBuf),
20 RollingFile(RollingFileConfig),
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct RollingFileConfig {
27 pub path: PathBuf,
29 pub max_bytes: Option<u64>,
31 pub max_files: usize,
33 pub max_age: Option<Duration>,
35}
36
37impl RollingFileConfig {
38 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#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct PreparedLogWriter {
52 pub path: Option<PathBuf>,
54 pub rotation_enabled: bool,
56}
57
58impl LogWriterConfig {
59 pub fn file(path: impl Into<PathBuf>) -> Self {
61 Self::File(path.into())
62 }
63}
64
65pub 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#[derive(Clone)]
91pub struct RuntimeRollingFileWriter {
92 inner: Arc<Mutex<RuntimeRollingFileState>>,
93}
94
95impl RuntimeRollingFileWriter {
96 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}