log4rs/append/
file.rs

1//! The file appender.
2//!
3//! Requires the `file_appender` feature.
4
5use chrono::prelude::Local;
6use derive_more::Debug;
7use log::Record;
8use parking_lot::Mutex;
9use std::{
10    fs::{self, File, OpenOptions},
11    io::{self, BufWriter, Write},
12    path::{Path, PathBuf},
13};
14
15const TIME_PREFIX: &str = "$TIME{";
16const TIME_PREFIX_LEN: usize = TIME_PREFIX.len();
17const TIME_SUFFIX: char = '}';
18const TIME_SUFFIX_LEN: usize = 1;
19const MAX_REPLACEMENTS: usize = 5;
20
21#[cfg(feature = "config_parsing")]
22use crate::config::{Deserialize, Deserializers};
23#[cfg(feature = "config_parsing")]
24use crate::encode::EncoderConfig;
25
26use crate::{
27    append::{env_util::expand_env_vars, Append},
28    encode::{pattern::PatternEncoder, writer::simple::SimpleWriter, Encode},
29};
30
31/// The file appender's configuration.
32#[cfg(feature = "config_parsing")]
33#[derive(Clone, Eq, PartialEq, Hash, Debug, Default, serde::Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct FileAppenderConfig {
36    path: String,
37    encoder: Option<EncoderConfig>,
38    append: Option<bool>,
39}
40
41/// An appender which logs to a file.
42#[derive(Debug)]
43pub struct FileAppender {
44    #[allow(dead_code)] // reason = "debug purposes only"
45    path: PathBuf,
46    #[debug(skip)]
47    file: Mutex<SimpleWriter<BufWriter<File>>>,
48    encoder: Box<dyn Encode>,
49}
50
51impl Append for FileAppender {
52    fn append(&self, record: &Record) -> anyhow::Result<()> {
53        let mut file = self.file.lock();
54        self.encoder.encode(&mut *file, record)?;
55        file.flush()?;
56        Ok(())
57    }
58
59    fn flush(&self) {}
60}
61
62impl FileAppender {
63    /// Creates a new `FileAppender` builder.
64    pub fn builder() -> FileAppenderBuilder {
65        FileAppenderBuilder {
66            encoder: None,
67            append: true,
68        }
69    }
70}
71
72/// A builder for `FileAppender`s.
73pub struct FileAppenderBuilder {
74    encoder: Option<Box<dyn Encode>>,
75    append: bool,
76}
77
78impl FileAppenderBuilder {
79    /// Sets the output encoder for the `FileAppender`.
80    pub fn encoder(mut self, encoder: Box<dyn Encode>) -> FileAppenderBuilder {
81        self.encoder = Some(encoder);
82        self
83    }
84
85    /// Determines if the appender will append to or truncate the output file.
86    ///
87    /// Defaults to `true`.
88    pub fn append(mut self, append: bool) -> FileAppenderBuilder {
89        self.append = append;
90        self
91    }
92
93    /// Consumes the `FileAppenderBuilder`, producing a `FileAppender`.
94    /// The path argument can contain special patterns that will be resolved:
95    ///
96    /// - `$ENV{name_here}`: This pattern will be replaced by `name_here`.
97    ///   where 'name_here' will be the name of the environment variable that
98    ///   will be resolved. Note that if the variable fails to resolve,
99    ///   $ENV{name_here} will NOT be replaced in the path.
100    /// - `$TIME{chrono_format}`: This pattern will be replaced by `chrono_format`.
101    ///   where 'chrono_format' will be date/time format from chrono crate. Note
102    ///   that if the chrono_format fails to resolve, $TIME{chrono_format} will
103    ///   NOT be replaced in the path.
104    pub fn build<P: AsRef<Path>>(self, path: P) -> io::Result<FileAppender> {
105        let path_cow = path.as_ref().to_string_lossy();
106        // Expand environment variables in the path
107        let expanded_env_path: PathBuf = expand_env_vars(path_cow).as_ref().into();
108        // Apply the date/time format to the path
109        let final_path = self.date_time_format(expanded_env_path);
110
111        if let Some(parent) = final_path.parent() {
112            fs::create_dir_all(parent)?;
113        }
114        let file = OpenOptions::new()
115            .write(true)
116            .append(self.append)
117            .truncate(!self.append)
118            .create(true)
119            .open(&final_path)?;
120
121        Ok(FileAppender {
122            path: final_path,
123            file: Mutex::new(SimpleWriter(BufWriter::with_capacity(1024, file))),
124            encoder: self
125                .encoder
126                .unwrap_or_else(|| Box::<PatternEncoder>::default()),
127        })
128    }
129
130    fn date_time_format(&self, path: PathBuf) -> PathBuf {
131        let mut replacements = 0;
132        let mut date_time_path = path.to_str().unwrap().to_string();
133        // Locate the start and end of the placeholder
134        while let Some(start) = date_time_path.find(TIME_PREFIX) {
135            if replacements >= MAX_REPLACEMENTS {
136                break;
137            }
138            if let Some(end) = date_time_path[start..].find(TIME_SUFFIX) {
139                let end = start + end;
140                // Extract the date format string
141                let date_format = &date_time_path[start + TIME_PREFIX_LEN..end];
142
143                // Get the current date and time
144                let now = Local::now();
145
146                // Format the current date and time
147                let formatted_date = now.format(date_format).to_string();
148
149                // replacing the placeholder with the formatted date
150                date_time_path.replace_range(start..end + TIME_SUFFIX_LEN, &formatted_date);
151                replacements += 1;
152            } else {
153                // If there's no closing brace, we leave the placeholder as is
154                break;
155            }
156        }
157        PathBuf::from(date_time_path)
158    }
159}
160
161/// A deserializer for the `FileAppender`.
162///
163/// # Configuration
164///
165/// ```yaml
166/// kind: file
167///
168/// # The path of the log file. Required.
169/// # The path can contain environment variables of the form $ENV{name_here},
170/// # where 'name_here' will be the name of the environment variable that
171/// # will be resolved. Note that if the variable fails to resolve,
172/// # $ENV{name_here} will NOT be replaced in the path.
173/// path: log/foo.log
174///
175/// # Specifies if the appender should append to or truncate the log file if it
176/// # already exists. Defaults to `true`.
177/// append: true
178///
179/// # The encoder to use to format output. Defaults to `kind: pattern`.
180/// encoder:
181///   kind: pattern
182/// ```
183#[cfg(feature = "config_parsing")]
184#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Default)]
185pub struct FileAppenderDeserializer;
186
187#[cfg(feature = "config_parsing")]
188impl Deserialize for FileAppenderDeserializer {
189    type Trait = dyn Append;
190
191    type Config = FileAppenderConfig;
192
193    fn deserialize(
194        &self,
195        config: FileAppenderConfig,
196        deserializers: &Deserializers,
197    ) -> anyhow::Result<Box<Self::Trait>> {
198        let mut appender = FileAppender::builder();
199        if let Some(append) = config.append {
200            appender = appender.append(append);
201        }
202        if let Some(encoder) = config.encoder {
203            appender = appender.encoder(deserializers.deserialize(&encoder.kind, encoder.config)?);
204        }
205        Ok(Box::new(appender.build(&config.path)?))
206    }
207}
208
209#[cfg(test)]
210mod test {
211    use super::*;
212
213    #[test]
214    fn create_directories() {
215        let tempdir = tempfile::tempdir().unwrap();
216
217        FileAppender::builder()
218            .build(tempdir.path().join("foo").join("bar").join("baz.log"))
219            .unwrap();
220    }
221
222    #[test]
223    fn append_false() {
224        let tempdir = tempfile::tempdir().unwrap();
225        FileAppender::builder()
226            .append(false)
227            .build(tempdir.path().join("foo.log"))
228            .unwrap();
229    }
230
231    #[test]
232    fn test_date_time_format_with_valid_format() {
233        let current_time = Local::now().format("%Y-%m-%d").to_string();
234        let tempdir = tempfile::tempdir().unwrap();
235        let builder = FileAppender::builder()
236            .build(
237                tempdir
238                    .path()
239                    .join("foo")
240                    .join("bar")
241                    .join("logs/log-$TIME{%Y-%m-%d}.log"),
242            )
243            .unwrap();
244        let expected_path = tempdir
245            .path()
246            .join(format!("foo/bar/logs/log-{}.log", current_time));
247        assert_eq!(builder.path, expected_path);
248    }
249
250    #[test]
251    fn test_date_time_format_with_invalid_format() {
252        let tempdir = tempfile::tempdir().unwrap();
253        let builder = FileAppender::builder()
254            .build(
255                tempdir
256                    .path()
257                    .join("foo")
258                    .join("bar")
259                    .join("logs/log-$TIME{INVALID}.log"),
260            )
261            .unwrap();
262        let expected_path = tempdir.path().join("foo/bar/logs/log-INVALID.log");
263        assert_eq!(builder.path, expected_path);
264    }
265
266    #[test]
267    fn test_date_time_format_with_no_closing_brace() {
268        let tempdir = tempfile::tempdir().unwrap();
269        let current_time = Local::now().format("%Y-%m-%d").to_string();
270        let builder = FileAppender::builder()
271            .build(
272                tempdir
273                    .path()
274                    .join("foo")
275                    .join("bar")
276                    .join("logs/log-$TIME{%Y-%m-%d}-$TIME{no_closing_brace.log"),
277            )
278            .unwrap();
279        let expected_path = tempdir.path().join(format!(
280            "foo/bar/logs/log-{}-$TIME{{no_closing_brace.log",
281            current_time
282        ));
283        assert_eq!(builder.path, expected_path);
284    }
285
286    #[test]
287    fn test_date_time_format_with_max_replacements() {
288        let tempdir = tempfile::tempdir().unwrap();
289        let current_time = Local::now().format("%Y-%m-%d").to_string();
290        let builder = FileAppender::builder()
291            .build(
292                tempdir
293                    .path()
294                    .join("foo")
295                    .join("bar")
296                    .join("logs/log-$TIME{%Y-%m-%d}-$TIME{%Y-%m-%d}-$TIME{%Y-%m-%d}-$TIME{%Y-%m-%d}-$TIME{%Y-%m-%d}.log"),
297            )
298            .unwrap();
299        let expected_path = tempdir.path().join(format!(
300            "foo/bar/logs/log-{}-{}-{}-{}-{}.log",
301            current_time, current_time, current_time, current_time, current_time
302        ));
303        assert_eq!(builder.path, expected_path);
304    }
305
306    #[test]
307    fn test_date_time_format_over_max_replacements() {
308        let tempdir = tempfile::tempdir().unwrap();
309        let current_time = Local::now().format("%Y-%m-%d").to_string();
310
311        // Build a path with more than MAX_REPLACEMENTS ($TIME{...}) placeholders
312        let path_str = format!(
313            "foo/bar/logs/log-{}-{}-{}-{}-{}-{}-{}-{}-{}-{}.log",
314            "$TIME{%Y-%m-%d}",
315            "$TIME{%Y-%m-%d}",
316            "$TIME{%Y-%m-%d}",
317            "$TIME{%Y-%m-%d}",
318            "$TIME{%Y-%m-%d}",
319            "$TIME{%Y-%m-%d}",
320            "$TIME{%Y-%m-%d}",
321            "$TIME{%Y-%m-%d}",
322            "$TIME{%Y-%m-%d}",
323            "$TIME{%Y-%m-%d}"
324        );
325        let builder = FileAppender::builder()
326            .build(tempdir.path().join(path_str.clone()))
327            .unwrap();
328
329        // Only MAX_REPLACEMENTS should be replaced, the rest should remain as $TIME{...}
330        let mut expected_path = format!("foo/bar/logs/log");
331        for i in 0..10 {
332            if i < MAX_REPLACEMENTS {
333                expected_path.push_str(&format!("-{}", current_time));
334            } else {
335                expected_path.push_str("-$TIME{%Y-%m-%d}");
336            }
337        }
338        expected_path.push_str(".log");
339
340        let expected_path = tempdir.path().join(expected_path);
341        assert_eq!(builder.path, expected_path);
342    }
343
344    #[test]
345    fn test_date_time_format_without_placeholder() {
346        let tempdir = tempfile::tempdir().unwrap();
347        let builder = FileAppender::builder()
348            .build(tempdir.path().join("foo").join("bar").join("bar.log"))
349            .unwrap();
350        let expected_path = tempdir.path().join("foo/bar/bar.log");
351        assert_eq!(builder.path, expected_path);
352    }
353
354    #[test]
355    fn test_date_time_format_with_multiple_placeholders() {
356        let current_time = Local::now().format("%Y-%m-%d").to_string();
357        let tempdir = tempfile::tempdir().unwrap();
358        let builder = FileAppender::builder()
359            .build(
360                tempdir
361                    .path()
362                    .join("foo")
363                    .join("bar")
364                    .join("logs-$TIME{%Y-%m-%d}/log-$TIME{%Y-%m-%d}.log"),
365            )
366            .unwrap();
367        let expected_path = tempdir.path().join(format!(
368            "foo/bar/logs-{}/log-{}.log",
369            current_time, current_time
370        ));
371        assert_eq!(builder.path, expected_path);
372    }
373}