Skip to main content

ferrilog_core/
output.rs

1use std::{
2    fs::{self, File, OpenOptions},
3    io::{self, BufWriter, Write},
4    path::PathBuf,
5};
6
7use crate::{
8    Level,
9    header::{
10        DEFAULT_HEADER_PATTERN, DefaultHeader, LEVEL_THREAD_LOCATION_HEADER_PATTERN,
11        LevelThreadLocationHeader, StaticHeader, StaticHeaderWriteFn, TokenizedHeader,
12    },
13};
14
15/// Configuration for automatic log file rotation.
16pub struct RotationConfig {
17    /// Maximum file size in bytes before rotating. Must be > 0.
18    pub max_size: u64,
19    /// Maximum number of rotated files to keep. Oldest files beyond this count are deleted.
20    pub max_files: u32,
21}
22
23impl RotationConfig {
24    /// Create a new rotation config with defaults (100MB max, 5 files).
25    pub fn new() -> Self {
26        Self { max_size: 100 * 1024 * 1024, max_files: 5 }
27    }
28
29    /// Create a rotation config: rotate when file exceeds `max_size` bytes,
30    /// keeping at most `max_files` old rotated files.
31    pub fn by_size(max_size: u64, max_files: u32) -> Self {
32        Self { max_size, max_files }
33    }
34
35    /// Set the maximum file size before rotation.
36    pub fn max_size(mut self, bytes: u64) -> Self {
37        self.max_size = bytes;
38        self
39    }
40
41    /// Set the maximum number of rotated files to keep.
42    pub fn max_files(mut self, count: u32) -> Self {
43        self.max_files = count;
44        self
45    }
46
47    /// Set the maximum file size in megabytes before rotation.
48    pub fn max_size_mb(mut self, megabytes: u64) -> Self {
49        self.max_size = megabytes * 1024 * 1024;
50        self
51    }
52}
53
54impl Default for RotationConfig {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60struct FileRotation {
61    base_path: PathBuf,
62    config: RotationConfig,
63    current_size: u64,
64}
65
66enum HeaderMode {
67    Empty,
68    Default(DefaultHeader),
69    LevelThreadLocation(LevelThreadLocationHeader),
70    Static(StaticHeader),
71    Dynamic(TokenizedHeader),
72}
73
74impl HeaderMode {
75    #[inline]
76    fn from_pattern(pattern: &str) -> io::Result<Self> {
77        if pattern.is_empty() {
78            Ok(Self::Empty)
79        } else if pattern == DEFAULT_HEADER_PATTERN {
80            Ok(Self::Default(DefaultHeader::new()))
81        } else if pattern == LEVEL_THREAD_LOCATION_HEADER_PATTERN {
82            Ok(Self::LevelThreadLocation(LevelThreadLocationHeader::new()))
83        } else {
84            TokenizedHeader::parse_pattern(pattern).map(Self::Dynamic)
85        }
86    }
87
88    #[inline]
89    fn write_header(
90        &mut self,
91        output: &mut Vec<u8>,
92        nanoseconds: i64,
93        level: Level,
94        thread_name: &[u8],
95        location: &str,
96    ) {
97        match self {
98            Self::Empty => {}
99            Self::Default(header) => {
100                header.write_header(output, nanoseconds, level, thread_name, location);
101            }
102            Self::LevelThreadLocation(header) => {
103                header.write_header(output, nanoseconds, level, thread_name, location);
104            }
105            Self::Static(header) => {
106                header.write_header(output, nanoseconds, level, thread_name, location);
107            }
108            Self::Dynamic(header) => {
109                header.write_header(output, nanoseconds, level, thread_name, location);
110            }
111        }
112    }
113}
114
115/// Buffered log output that formats records through a [`TokenizedHeader`] and
116/// writes them to a configurable [`Write`] destination.
117pub struct OutputBuffer {
118    pending: Vec<u8>,
119    writer: Box<dyn Write + Send>,
120    header: HeaderMode,
121    rotation: Option<FileRotation>,
122}
123
124impl Default for OutputBuffer {
125    fn default() -> Self {
126        Self::new()
127    }
128}
129
130impl OutputBuffer {
131    /// Creates a new `OutputBuffer` that writes to stderr with the default header pattern.
132    pub fn new() -> Self {
133        Self {
134            pending: Vec::with_capacity(8 * 1024),
135            writer: Box::new(io::stderr()),
136            header: HeaderMode::Default(DefaultHeader::new()),
137            rotation: None,
138        }
139    }
140
141    /// Replaces the underlying writer with the given one.
142    pub fn set_writer(&mut self, writer: Box<dyn Write + Send>) {
143        self.writer = writer;
144    }
145
146    /// Opens (or creates) a log file at `path` in append mode and redirects output to it.
147    /// Any previously configured rotation is cleared.
148    pub fn set_log_file(&mut self, path: &str) -> io::Result<()> {
149        self.flush()?;
150        let file = open_log_file(path)?;
151        self.writer = Box::new(BufWriter::new(file));
152        self.rotation = None;
153        Ok(())
154    }
155
156    /// Opens (or creates) a log file at `path` with automatic size-based rotation.
157    /// When the file exceeds `config.max_size` bytes, it is rotated according to
158    /// the rotation configuration.
159    pub fn set_log_file_with_rotation(
160        &mut self,
161        path: &str,
162        config: RotationConfig,
163    ) -> io::Result<()> {
164        self.flush()?;
165        let file = open_log_file(path)?;
166        let current_size = file.metadata()?.len();
167        self.writer = Box::new(BufWriter::new(file));
168        self.rotation = Some(FileRotation { base_path: PathBuf::from(path), config, current_size });
169        Ok(())
170    }
171
172    /// Sets a custom header pattern for log record formatting.
173    pub fn set_header_pattern(&mut self, pattern: &str) -> io::Result<()> {
174        self.header = HeaderMode::from_pattern(pattern)?;
175        Ok(())
176    }
177
178    pub fn set_static_header(&mut self, write: StaticHeaderWriteFn) {
179        self.header = HeaderMode::Static(StaticHeader::new(write));
180    }
181
182    /// Formats and buffers a single log record directly into the pending output buffer.
183    pub fn write_record<F>(
184        &mut self,
185        timestamp_nanoseconds: i64,
186        level: Level,
187        thread_name: &[u8],
188        location: &str,
189        append_body: F,
190    ) -> io::Result<()>
191    where
192        F: FnOnce(&mut Vec<u8>),
193    {
194        self.header.write_header(
195            &mut self.pending,
196            timestamp_nanoseconds,
197            level,
198            thread_name,
199            location,
200        );
201        append_body(&mut self.pending);
202        self.pending.push(b'\n');
203        Ok(())
204    }
205
206    /// Returns the number of bytes currently buffered and awaiting flush.
207    #[inline]
208    pub fn buffered_len(&self) -> usize {
209        self.pending.len()
210    }
211
212    /// Returns `true` if there are buffered bytes that have not been flushed yet.
213    #[inline]
214    pub fn has_pending(&self) -> bool {
215        !self.pending.is_empty()
216    }
217
218    /// Flushes all pending bytes to the underlying writer.
219    /// If rotation is configured and the file size exceeds the threshold,
220    /// the log file is rotated after flushing.
221    pub fn flush(&mut self) -> io::Result<()> {
222        if self.pending.is_empty() {
223            return Ok(());
224        }
225        let written = self.pending.len() as u64;
226        self.writer.write_all(&self.pending)?;
227        self.writer.flush()?;
228        self.pending.clear();
229
230        let needs_rotation = if let Some(ref mut rotation) = self.rotation {
231            rotation.current_size += written;
232            rotation.current_size >= rotation.config.max_size
233        } else {
234            false
235        };
236
237        if needs_rotation {
238            self.rotate()?;
239        }
240
241        Ok(())
242    }
243
244    /// Rotates the current log file by renaming it through a numbered
245    /// suffix chain (base -> base.1 -> base.2 -> ...) and opening a
246    /// fresh file at the base path.
247    fn rotate(&mut self) -> io::Result<()> {
248        let rotation = self.rotation.as_mut().expect("rotate called without rotation config");
249        let base_path = &rotation.base_path;
250        let max_files = rotation.config.max_files;
251
252        // Drop the current writer so the file handle is released.
253        self.writer = Box::new(io::sink());
254
255        // Delete the oldest file if it exceeds max_files.
256        let oldest = format!("{}.{}", base_path.display(), max_files);
257        let _ = fs::remove_file(&oldest);
258
259        // Rename base.N-1 -> base.N, ..., base.1 -> base.2
260        for i in (1..max_files).rev() {
261            let from = format!("{}.{}", base_path.display(), i);
262            let to = format!("{}.{}", base_path.display(), i + 1);
263            let _ = fs::rename(&from, &to);
264        }
265
266        // Rename base -> base.1
267        let first_rotated = format!("{}.1", base_path.display());
268        let _ = fs::rename(base_path, &first_rotated);
269
270        // Open a new file at the base path
271        let file = open_log_file(base_path.to_str().unwrap_or(""))?;
272        self.writer = Box::new(BufWriter::new(file));
273
274        rotation.current_size = 0;
275        Ok(())
276    }
277}
278
279fn open_log_file(path: &str) -> io::Result<File> {
280    OpenOptions::new().create(true).append(true).open(path)
281}