Skip to main content

tracing_appender/
rolling.rs

1//! A rolling file appender.
2//!
3//! Creates a new log file at a fixed frequency as defined by [`Rotation`].
4//! Logs will be written to this file for the duration of the period and will automatically roll over
5//! to the newly created log file once the time period has elapsed.
6//!
7//! The log file is created at the specified directory and file name prefix which *may* be appended with
8//! the date and time.
9//!
10//! The following helpers are available for creating a rolling file appender.
11//!
12//! - [`Rotation::minutely()`][minutely]: A new log file in the format of `some_directory/log_file_name_prefix.yyyy-MM-dd-HH-mm`
13//!   will be created minutely (once per minute)
14//! - [`Rotation::hourly()`][hourly]: A new log file in the format of `some_directory/log_file_name_prefix.yyyy-MM-dd-HH`
15//!   will be created hourly
16//! - [`Rotation::daily()`][daily]: A new log file in the format of `some_directory/log_file_name_prefix.yyyy-MM-dd`
17//!   will be created daily
18//! - [`Rotation::never()`][never()]: This will result in log file located at `some_directory/log_file_name`
19//!
20//!
21//! # Examples
22//!
23//! ```rust
24//! # fn docs() {
25//! use tracing_appender::rolling::{RollingFileAppender, Rotation};
26//! let file_appender = RollingFileAppender::new(Rotation::HOURLY, "/some/directory", "prefix.log");
27//! # }
28//! ```
29use crate::sync::{RwLock, RwLockReadGuard};
30use std::{
31    fmt::{self, Debug},
32    fs::{self, File, OpenOptions},
33    io::{self, Write},
34    path::{Path, PathBuf},
35    sync::atomic::{AtomicUsize, Ordering},
36    time::SystemTime,
37};
38use time::{format_description, Date, Duration, OffsetDateTime, PrimitiveDateTime, Time};
39
40mod builder;
41pub use builder::{Builder, InitError};
42
43/// A file appender with the ability to rotate log files at a fixed schedule.
44///
45/// `RollingFileAppender` implements the [`std:io::Write` trait][write] and will
46/// block on write operations. It may be used with [`NonBlocking`] to perform
47/// writes without blocking the current thread.
48///
49/// Additionally, `RollingFileAppender` also implements the [`MakeWriter`]
50/// trait from `tracing-subscriber`, so it may also be used
51/// directly, without [`NonBlocking`].
52///
53/// [write]: std::io::Write
54/// [`NonBlocking`]: super::non_blocking::NonBlocking
55///
56/// # Examples
57///
58/// Rolling a log file once every hour:
59///
60/// ```rust
61/// # fn docs() {
62/// let file_appender = tracing_appender::rolling::hourly("/some/directory", "prefix");
63/// # }
64/// ```
65///
66/// Combining a `RollingFileAppender` with another [`MakeWriter`] implementation:
67///
68/// ```rust
69/// # fn docs() {
70/// use tracing_subscriber::fmt::writer::MakeWriterExt;
71///
72/// // Log all events to a rolling log file.
73/// let logfile = tracing_appender::rolling::hourly("/logs", "myapp-logs");
74///
75/// // Log `INFO` and above to stdout.
76/// let stdout = std::io::stdout.with_max_level(tracing::Level::INFO);
77///
78/// tracing_subscriber::fmt()
79///     // Combine the stdout and log file `MakeWriter`s into one
80///     // `MakeWriter` that writes to both
81///     .with_writer(stdout.and(logfile))
82///     .init();
83/// # }
84/// ```
85///
86/// [`MakeWriter`]: tracing_subscriber::fmt::writer::MakeWriter
87pub struct RollingFileAppender {
88    state: Inner,
89    writer: RwLock<File>,
90    #[cfg(test)]
91    now: Box<dyn Fn() -> OffsetDateTime + Send + Sync>,
92}
93
94/// A [writer] that writes to a rolling log file.
95///
96/// This is returned by the [`MakeWriter`] implementation for [`RollingFileAppender`].
97///
98/// [writer]: std::io::Write
99/// [`MakeWriter`]: tracing_subscriber::fmt::writer::MakeWriter
100#[derive(Debug)]
101pub struct RollingWriter<'a>(RwLockReadGuard<'a, File>);
102
103#[derive(Debug)]
104struct Inner {
105    log_directory: PathBuf,
106    log_filename_prefix: Option<String>,
107    log_filename_suffix: Option<String>,
108    log_latest_symlink_name: Option<String>,
109    date_format: Vec<format_description::FormatItem<'static>>,
110    rotation: Rotation,
111    next_date: AtomicUsize,
112    max_files: Option<usize>,
113}
114
115// === impl RollingFileAppender ===
116
117impl RollingFileAppender {
118    /// Creates a new `RollingFileAppender`.
119    ///
120    /// A `RollingFileAppender` will have a fixed rotation whose frequency is
121    /// defined by [`Rotation`]. The `directory` and `file_name_prefix`
122    /// arguments determine the location and file name's _prefix_ of the log
123    /// file. `RollingFileAppender` will automatically append the current date
124    /// and hour (UTC format) to the file name.
125    ///
126    /// Alternatively, a `RollingFileAppender` can be constructed using one of the following helpers:
127    ///
128    /// - [`Rotation::minutely()`][minutely],
129    /// - [`Rotation::hourly()`][hourly],
130    /// - [`Rotation::daily()`][daily],
131    /// - [`Rotation::never()`][never()]
132    ///
133    /// Additional parameters can be configured using [`RollingFileAppender::builder`].
134    ///
135    /// # Examples
136    ///
137    /// ```rust
138    /// # fn docs() {
139    /// use tracing_appender::rolling::{RollingFileAppender, Rotation};
140    /// let file_appender = RollingFileAppender::new(Rotation::HOURLY, "/some/directory", "prefix.log");
141    /// # }
142    /// ```
143    pub fn new(
144        rotation: Rotation,
145        directory: impl AsRef<Path>,
146        filename_prefix: impl AsRef<Path>,
147    ) -> RollingFileAppender {
148        let filename_prefix = filename_prefix
149            .as_ref()
150            .to_str()
151            .expect("filename prefix must be a valid UTF-8 string");
152        Self::builder()
153            .rotation(rotation)
154            .filename_prefix(filename_prefix)
155            .build(directory)
156            .expect("initializing rolling file appender failed")
157    }
158
159    /// Returns a new [`Builder`] for configuring a `RollingFileAppender`.
160    ///
161    /// The builder interface can be used to set additional configuration
162    /// parameters when constructing a new appender.
163    ///
164    /// Unlike [`RollingFileAppender::new`], the [`Builder::build`] method
165    /// returns a `Result` rather than panicking when the appender cannot be
166    /// initialized. Therefore, the builder interface can also be used when
167    /// appender initialization errors should be handled gracefully.
168    ///
169    /// # Examples
170    ///
171    /// ```rust
172    /// # fn docs() {
173    /// use tracing_appender::rolling::{RollingFileAppender, Rotation};
174    ///
175    /// let file_appender = RollingFileAppender::builder()
176    ///     .rotation(Rotation::HOURLY) // rotate log files once every hour
177    ///     .filename_prefix("myapp") // log file names will be prefixed with `myapp.`
178    ///     .filename_suffix("log") // log file names will be suffixed with `.log`
179    ///     .build("/var/log") // try to build an appender that stores log files in `/var/log`
180    ///     .expect("initializing rolling file appender failed");
181    /// # drop(file_appender);
182    /// # }
183    /// ```
184    #[must_use]
185    pub fn builder() -> Builder {
186        Builder::new()
187    }
188
189    fn from_builder(builder: &Builder, directory: impl AsRef<Path>) -> Result<Self, InitError> {
190        let Builder {
191            ref rotation,
192            ref prefix,
193            ref suffix,
194            ref latest_symlink,
195            ref max_files,
196        } = builder;
197        let directory = directory.as_ref().to_path_buf();
198        let now = OffsetDateTime::now_utc();
199        let (state, writer) = Inner::new(
200            now,
201            rotation.clone(),
202            directory,
203            prefix.clone(),
204            suffix.clone(),
205            latest_symlink.clone(),
206            *max_files,
207        )?;
208        Ok(Self {
209            state,
210            writer,
211            #[cfg(test)]
212            now: Box::new(OffsetDateTime::now_utc),
213        })
214    }
215
216    #[inline]
217    fn now(&self) -> OffsetDateTime {
218        #[cfg(test)]
219        return (self.now)();
220
221        #[cfg(not(test))]
222        OffsetDateTime::now_utc()
223    }
224}
225
226impl io::Write for RollingFileAppender {
227    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
228        let now = self.now();
229        let writer = self.writer.get_mut();
230        if let Some(current_time) = self.state.should_rollover(now) {
231            let _did_cas = self.state.advance_date(now, current_time);
232            debug_assert!(_did_cas, "if we have &mut access to the appender, no other thread can have advanced the timestamp...");
233            self.state.refresh_writer(now, writer);
234        }
235        writer.write(buf)
236    }
237
238    fn flush(&mut self) -> io::Result<()> {
239        self.writer.get_mut().flush()
240    }
241}
242
243impl<'a> tracing_subscriber::fmt::writer::MakeWriter<'a> for RollingFileAppender {
244    type Writer = RollingWriter<'a>;
245    fn make_writer(&'a self) -> Self::Writer {
246        let now = self.now();
247
248        // Should we try to roll over the log file?
249        if let Some(current_time) = self.state.should_rollover(now) {
250            // Did we get the right to lock the file? If not, another thread
251            // did it and we can just make a writer.
252            if self.state.advance_date(now, current_time) {
253                self.state.refresh_writer(now, &mut self.writer.write());
254            }
255        }
256        RollingWriter(self.writer.read())
257    }
258}
259
260impl fmt::Debug for RollingFileAppender {
261    // This manual impl is required because of the `now` field (only present
262    // with `cfg(test)`), which is not `Debug`...
263    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264        f.debug_struct("RollingFileAppender")
265            .field("state", &self.state)
266            .field("writer", &self.writer)
267            .finish()
268    }
269}
270
271/// Creates a minutely-rotating file appender. This will rotate the log file once per minute.
272///
273/// The appender returned by `rolling::minutely` can be used with `non_blocking` to create
274/// a non-blocking, minutely file appender.
275///
276/// The directory of the log file is specified with the `directory` argument.
277/// `file_name_prefix` specifies the _prefix_ of the log file. `RollingFileAppender`
278/// adds the current date, hour, and minute to the log file in UTC.
279///
280/// # Examples
281///
282/// ``` rust
283/// # #[clippy::allow(needless_doctest_main)]
284/// fn main () {
285/// # fn doc() {
286///     let appender = tracing_appender::rolling::minutely("/some/path", "rolling.log");
287///     let (non_blocking_appender, _guard) = tracing_appender::non_blocking(appender);
288///
289///     let subscriber = tracing_subscriber::fmt().with_writer(non_blocking_appender);
290///
291///     tracing::subscriber::with_default(subscriber.finish(), || {
292///         tracing::event!(tracing::Level::INFO, "Hello");
293///     });
294/// # }
295/// }
296/// ```
297///
298/// This will result in a log file located at `/some/path/rolling.log.yyyy-MM-dd-HH-mm`.
299pub fn minutely(
300    directory: impl AsRef<Path>,
301    file_name_prefix: impl AsRef<Path>,
302) -> RollingFileAppender {
303    RollingFileAppender::new(Rotation::MINUTELY, directory, file_name_prefix)
304}
305
306/// Creates an hourly-rotating file appender.
307///
308/// The appender returned by `rolling::hourly` can be used with `non_blocking` to create
309/// a non-blocking, hourly file appender.
310///
311/// The directory of the log file is specified with the `directory` argument.
312/// `file_name_prefix` specifies the _prefix_ of the log file. `RollingFileAppender`
313/// adds the current date and hour to the log file in UTC.
314///
315/// # Examples
316///
317/// ``` rust
318/// # #[clippy::allow(needless_doctest_main)]
319/// fn main () {
320/// # fn doc() {
321///     let appender = tracing_appender::rolling::hourly("/some/path", "rolling.log");
322///     let (non_blocking_appender, _guard) = tracing_appender::non_blocking(appender);
323///
324///     let subscriber = tracing_subscriber::fmt().with_writer(non_blocking_appender);
325///
326///     tracing::subscriber::with_default(subscriber.finish(), || {
327///         tracing::event!(tracing::Level::INFO, "Hello");
328///     });
329/// # }
330/// }
331/// ```
332///
333/// This will result in a log file located at `/some/path/rolling.log.yyyy-MM-dd-HH`.
334pub fn hourly(
335    directory: impl AsRef<Path>,
336    file_name_prefix: impl AsRef<Path>,
337) -> RollingFileAppender {
338    RollingFileAppender::new(Rotation::HOURLY, directory, file_name_prefix)
339}
340
341/// Creates a daily-rotating file appender.
342///
343/// The appender returned by `rolling::daily` can be used with `non_blocking` to create
344/// a non-blocking, daily file appender.
345///
346/// A `RollingFileAppender` has a fixed rotation whose frequency is
347/// defined by [`Rotation`]. The `directory` and `file_name_prefix`
348/// arguments determine the location and file name's _prefix_ of the log file.
349/// `RollingFileAppender` automatically appends the current date in UTC.
350///
351/// # Examples
352///
353/// ``` rust
354/// # #[clippy::allow(needless_doctest_main)]
355/// fn main () {
356/// # fn doc() {
357///     let appender = tracing_appender::rolling::daily("/some/path", "rolling.log");
358///     let (non_blocking_appender, _guard) = tracing_appender::non_blocking(appender);
359///
360///     let subscriber = tracing_subscriber::fmt().with_writer(non_blocking_appender);
361///
362///     tracing::subscriber::with_default(subscriber.finish(), || {
363///         tracing::event!(tracing::Level::INFO, "Hello");
364///     });
365/// # }
366/// }
367/// ```
368///
369/// This will result in a log file located at `/some/path/rolling.log.yyyy-MM-dd`.
370pub fn daily(
371    directory: impl AsRef<Path>,
372    file_name_prefix: impl AsRef<Path>,
373) -> RollingFileAppender {
374    RollingFileAppender::new(Rotation::DAILY, directory, file_name_prefix)
375}
376
377/// Creates a weekly-rotating file appender. The logs will rotate every Sunday at midnight UTC.
378///
379/// The appender returned by `rolling::weekly` can be used with `non_blocking` to create
380/// a non-blocking, weekly file appender.
381///
382/// A `RollingFileAppender` has a fixed rotation whose frequency is
383/// defined by [`Rotation`]. The `directory` and `file_name_prefix` arguments
384/// determine the location and file name's _prefix_ of the log file.
385/// `RollingFileAppender` automatically appends the current date in UTC.
386///
387/// # Examples
388///
389/// ``` rust
390/// # #[clippy::allow(needless_doctest_main)]
391/// fn main () {
392/// # fn doc() {
393///     let appender = tracing_appender::rolling::weekly("/some/path", "rolling.log");
394///     let (non_blocking_appender, _guard) = tracing_appender::non_blocking(appender);
395///
396///     let subscriber = tracing_subscriber::fmt().with_writer(non_blocking_appender);
397///
398///     tracing::subscriber::with_default(subscriber.finish(), || {
399///         tracing::event!(tracing::Level::INFO, "Hello");
400///     });
401/// # }
402/// }
403/// ```
404///
405/// This will result in a log file located at `/some/path/rolling.log.yyyy-MM-dd`.
406pub fn weekly(
407    directory: impl AsRef<Path>,
408    file_name_prefix: impl AsRef<Path>,
409) -> RollingFileAppender {
410    RollingFileAppender::new(Rotation::WEEKLY, directory, file_name_prefix)
411}
412
413/// Creates a non-rolling file appender.
414///
415/// The appender returned by `rolling::never` can be used with `non_blocking` to create
416/// a non-blocking, non-rotating appender.
417///
418/// The location of the log file will be specified the `directory` passed in.
419/// `file_name` specifies the complete name of the log file (no date or time is appended).
420///
421/// # Examples
422///
423/// ``` rust
424/// # #[clippy::allow(needless_doctest_main)]
425/// fn main () {
426/// # fn doc() {
427///     let appender = tracing_appender::rolling::never("/some/path", "non-rolling.log");
428///     let (non_blocking_appender, _guard) = tracing_appender::non_blocking(appender);
429///
430///     let subscriber = tracing_subscriber::fmt().with_writer(non_blocking_appender);
431///
432///     tracing::subscriber::with_default(subscriber.finish(), || {
433///         tracing::event!(tracing::Level::INFO, "Hello");
434///     });
435/// # }
436/// }
437/// ```
438///
439/// This will result in a log file located at `/some/path/non-rolling.log`.
440pub fn never(directory: impl AsRef<Path>, file_name: impl AsRef<Path>) -> RollingFileAppender {
441    RollingFileAppender::new(Rotation::NEVER, directory, file_name)
442}
443
444/// Defines a fixed period for rolling of a log file.
445///
446/// To use a `Rotation`, pick one of the following options:
447///
448/// ### Minutely Rotation
449/// ```rust
450/// # fn docs() {
451/// use tracing_appender::rolling::Rotation;
452/// let rotation = tracing_appender::rolling::Rotation::MINUTELY;
453/// # }
454/// ```
455///
456/// ### Hourly Rotation
457/// ```rust
458/// # fn docs() {
459/// use tracing_appender::rolling::Rotation;
460/// let rotation = tracing_appender::rolling::Rotation::HOURLY;
461/// # }
462/// ```
463///
464/// ### Daily Rotation
465/// ```rust
466/// # fn docs() {
467/// use tracing_appender::rolling::Rotation;
468/// let rotation = tracing_appender::rolling::Rotation::DAILY;
469/// # }
470/// ```
471///
472/// ### Weekly Rotation
473/// ```rust
474/// # fn docs() {
475/// use tracing_appender::rolling::Rotation;
476/// let rotation = tracing_appender::rolling::Rotation::WEEKLY;
477/// # }
478/// ```
479///
480/// ### No Rotation
481/// ```rust
482/// # fn docs() {
483/// use tracing_appender::rolling::Rotation;
484/// let rotation = tracing_appender::rolling::Rotation::NEVER;
485/// # }
486/// ```
487#[derive(Clone, Eq, PartialEq, Debug)]
488pub struct Rotation(RotationKind);
489
490#[derive(Clone, Eq, PartialEq, Debug)]
491enum RotationKind {
492    Minutely,
493    Hourly,
494    Daily,
495    Weekly,
496    Never,
497}
498
499impl Rotation {
500    /// Provides a minutely rotation.
501    pub const MINUTELY: Self = Self(RotationKind::Minutely);
502    /// Provides an hourly rotation.
503    pub const HOURLY: Self = Self(RotationKind::Hourly);
504    /// Provides a daily rotation.
505    pub const DAILY: Self = Self(RotationKind::Daily);
506    /// Provides a weekly rotation that rotates every Sunday at midnight UTC.
507    pub const WEEKLY: Self = Self(RotationKind::Weekly);
508    /// Provides a rotation that never rotates.
509    pub const NEVER: Self = Self(RotationKind::Never);
510
511    /// Determines the next date that we should round to or `None` if `self` uses [`Rotation::NEVER`].
512    pub(crate) fn next_date(&self, current_date: &OffsetDateTime) -> Option<OffsetDateTime> {
513        let unrounded_next_date = match *self {
514            Rotation::MINUTELY => *current_date + Duration::minutes(1),
515            Rotation::HOURLY => *current_date + Duration::hours(1),
516            Rotation::DAILY => *current_date + Duration::days(1),
517            Rotation::WEEKLY => *current_date + Duration::weeks(1),
518            Rotation::NEVER => return None,
519        };
520        Some(self.round_date(unrounded_next_date))
521    }
522
523    /// Rounds the date towards the past using the [`Rotation`] interval.
524    ///
525    /// # Panics
526    ///
527    /// This method will panic if `self`` uses [`Rotation::NEVER`].
528    pub(crate) fn round_date(&self, date: OffsetDateTime) -> OffsetDateTime {
529        match *self {
530            Rotation::MINUTELY => {
531                let time = Time::from_hms(date.hour(), date.minute(), 0)
532                    .expect("Invalid time; this is a bug in tracing-appender");
533                date.replace_time(time)
534            }
535            Rotation::HOURLY => {
536                let time = Time::from_hms(date.hour(), 0, 0)
537                    .expect("Invalid time; this is a bug in tracing-appender");
538                date.replace_time(time)
539            }
540            Rotation::DAILY => {
541                let time = Time::from_hms(0, 0, 0)
542                    .expect("Invalid time; this is a bug in tracing-appender");
543                date.replace_time(time)
544            }
545            Rotation::WEEKLY => {
546                let zero_time = Time::from_hms(0, 0, 0)
547                    .expect("Invalid time; this is a bug in tracing-appender");
548
549                let days_since_sunday = date.weekday().number_days_from_sunday();
550                let date = date - Duration::days(days_since_sunday.into());
551                date.replace_time(zero_time)
552            }
553            // Rotation::NEVER is impossible to round.
554            Rotation::NEVER => {
555                unreachable!("Rotation::NEVER is impossible to round.")
556            }
557        }
558    }
559
560    fn date_format(&self) -> Vec<format_description::FormatItem<'static>> {
561        match *self {
562            Rotation::MINUTELY => format_description::parse("[year]-[month]-[day]-[hour]-[minute]"),
563            Rotation::HOURLY => format_description::parse("[year]-[month]-[day]-[hour]"),
564            Rotation::DAILY => format_description::parse("[year]-[month]-[day]"),
565            Rotation::WEEKLY => format_description::parse("[year]-[month]-[day]"),
566            Rotation::NEVER => format_description::parse("[year]-[month]-[day]"),
567        }
568        .expect("Unable to create a formatter; this is a bug in tracing-appender")
569    }
570}
571
572// === impl RollingWriter ===
573
574impl io::Write for RollingWriter<'_> {
575    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
576        (&*self.0).write(buf)
577    }
578
579    fn flush(&mut self) -> io::Result<()> {
580        (&*self.0).flush()
581    }
582}
583
584// === impl Inner ===
585
586impl Inner {
587    fn new(
588        now: OffsetDateTime,
589        rotation: Rotation,
590        directory: impl AsRef<Path>,
591        log_filename_prefix: Option<String>,
592        log_filename_suffix: Option<String>,
593        log_latest_symlink_name: Option<String>,
594        max_files: Option<usize>,
595    ) -> Result<(Self, RwLock<File>), builder::InitError> {
596        let log_directory = directory.as_ref().to_path_buf();
597        let date_format = rotation.date_format();
598        let next_date = rotation.next_date(&now);
599
600        let inner = Inner {
601            log_directory,
602            log_filename_prefix,
603            log_filename_suffix,
604            log_latest_symlink_name,
605            date_format,
606            next_date: AtomicUsize::new(
607                next_date
608                    .map(|date| date.unix_timestamp() as usize)
609                    .unwrap_or(0),
610            ),
611            rotation,
612            max_files,
613        };
614
615        if let Some(max_files) = max_files {
616            inner.prune_old_logs(max_files);
617        }
618
619        let filename = inner.join_date(&now);
620        let writer = RwLock::new(create_writer(
621            inner.log_directory.as_ref(),
622            &filename,
623            inner.log_latest_symlink_name.as_deref(),
624        )?);
625        Ok((inner, writer))
626    }
627
628    /// Returns the full filename for the provided date, using [`Rotation`] to round accordingly.
629    pub(crate) fn join_date(&self, date: &OffsetDateTime) -> String {
630        let date = if let Rotation::NEVER = self.rotation {
631            date.format(&self.date_format)
632                .expect("Unable to format OffsetDateTime; this is a bug in tracing-appender")
633        } else {
634            self.rotation
635                .round_date(*date)
636                .format(&self.date_format)
637                .expect("Unable to format OffsetDateTime; this is a bug in tracing-appender")
638        };
639
640        match (
641            &self.rotation,
642            &self.log_filename_prefix,
643            &self.log_filename_suffix,
644        ) {
645            (&Rotation::NEVER, Some(filename), None) => filename.to_string(),
646            (&Rotation::NEVER, Some(filename), Some(suffix)) => format!("{}.{}", filename, suffix),
647            (&Rotation::NEVER, None, Some(suffix)) => suffix.to_string(),
648            (_, Some(filename), Some(suffix)) => format!("{}.{}.{}", filename, date, suffix),
649            (_, Some(filename), None) => format!("{}.{}", filename, date),
650            (_, None, Some(suffix)) => format!("{}.{}", date, suffix),
651            (_, None, None) => date,
652        }
653    }
654
655    fn prune_old_logs(&self, max_files: usize) {
656        let files = fs::read_dir(&self.log_directory).map(|dir| {
657            dir.filter_map(|entry| {
658                let entry = entry.ok()?;
659                let metadata = entry.metadata().ok()?;
660
661                // the appender only creates files, not directories or symlinks,
662                // so we should never delete a dir or symlink.
663                if !metadata.is_file() {
664                    return None;
665                }
666
667                let filename = entry.file_name();
668                // if the filename is not a UTF-8 string, skip it.
669                let filename = filename.to_str()?;
670                if let Some(prefix) = &self.log_filename_prefix {
671                    if !filename.starts_with(prefix) {
672                        return None;
673                    }
674                }
675
676                if let Some(suffix) = &self.log_filename_suffix {
677                    if !filename.ends_with(suffix) {
678                        return None;
679                    }
680                }
681
682                if self.log_filename_prefix.is_none()
683                    && self.log_filename_suffix.is_none()
684                    && Date::parse(filename, &self.date_format).is_err()
685                {
686                    return None;
687                }
688
689                let created = metadata.created().ok().or_else(|| {
690                    parse_date_from_filename(
691                        filename,
692                        &self.date_format,
693                        self.log_filename_prefix.as_deref(),
694                        self.log_filename_suffix.as_deref(),
695                    )
696                })?;
697                Some((entry, created))
698            })
699            .collect::<Vec<_>>()
700        });
701
702        let mut files = match files {
703            Ok(files) => files,
704            Err(error) => {
705                eprintln!("Error reading the log directory/files: {}", error);
706                return;
707            }
708        };
709        if files.len() < max_files {
710            return;
711        }
712
713        // sort the files by their creation timestamps.
714        files.sort_by_key(|(_, created_at)| *created_at);
715
716        // delete files, so that (n-1) files remain, because we will create another log file
717        for (file, _) in files.iter().take(files.len() - (max_files - 1)) {
718            if let Err(error) = fs::remove_file(file.path()) {
719                eprintln!(
720                    "Failed to remove old log file {}: {}",
721                    file.path().display(),
722                    error
723                );
724            }
725        }
726    }
727
728    fn refresh_writer(&self, now: OffsetDateTime, file: &mut File) {
729        let filename = self.join_date(&now);
730
731        if let Some(max_files) = self.max_files {
732            self.prune_old_logs(max_files);
733        }
734
735        match create_writer(
736            &self.log_directory,
737            &filename,
738            self.log_latest_symlink_name.as_deref(),
739        ) {
740            Ok(new_file) => {
741                if let Err(err) = file.flush() {
742                    eprintln!("Couldn't flush previous writer: {}", err);
743                }
744                *file = new_file;
745            }
746            Err(err) => eprintln!("Couldn't create writer for logs: {}", err),
747        }
748    }
749
750    /// Checks whether or not it's time to roll over the log file.
751    ///
752    /// Rather than returning a `bool`, this returns the current value of
753    /// `next_date` so that we can perform a `compare_exchange` operation with
754    /// that value when setting the next rollover time.
755    ///
756    /// If this method returns `Some`, we should roll to a new log file.
757    /// Otherwise, if this returns we should not rotate the log file.
758    fn should_rollover(&self, date: OffsetDateTime) -> Option<usize> {
759        let next_date = self.next_date.load(Ordering::Acquire);
760        // if the next date is 0, this appender *never* rotates log files.
761        if next_date == 0 {
762            return None;
763        }
764
765        if date.unix_timestamp() as usize >= next_date {
766            return Some(next_date);
767        }
768
769        None
770    }
771
772    fn advance_date(&self, now: OffsetDateTime, current: usize) -> bool {
773        let next_date = self
774            .rotation
775            .next_date(&now)
776            .map(|date| date.unix_timestamp() as usize)
777            .unwrap_or(0);
778        self.next_date
779            .compare_exchange(current, next_date, Ordering::AcqRel, Ordering::Acquire)
780            .is_ok()
781    }
782}
783
784fn create_writer(
785    directory: &Path,
786    filename: &str,
787    latest_symlink_name: Option<&str>,
788) -> Result<File, InitError> {
789    let path = directory.join(filename);
790    let mut open_options = OpenOptions::new();
791    open_options.append(true).create(true);
792
793    let new_file = open_options.open(&path).or_else(|_| {
794        if let Some(parent) = path.parent() {
795            fs::create_dir_all(parent).map_err(InitError::ctx("failed to create log directory"))?;
796        }
797        open_options
798            .open(&path)
799            .map_err(InitError::ctx("failed to create log file"))
800    })?;
801
802    if let Some(symlink_name) = latest_symlink_name {
803        let symlink_path = directory.join(symlink_name);
804        let _ = symlink::remove_symlink_file(&symlink_path);
805        symlink::symlink_file(path, symlink_path).map_err(InitError::ctx(
806            "failed to create symlink to latest log file",
807        ))?;
808    }
809
810    Ok(new_file)
811}
812
813fn parse_date_from_filename(
814    filename: &str,
815    date_format: &Vec<format_description::FormatItem<'static>>,
816    prefix: Option<&str>,
817    suffix: Option<&str>,
818) -> Option<SystemTime> {
819    let mut datetime = filename;
820    if let Some(prefix) = prefix {
821        datetime = datetime.strip_prefix(prefix)?;
822        datetime = datetime.strip_prefix('.')?;
823    }
824    if let Some(suffix) = suffix {
825        datetime = datetime.strip_suffix(suffix)?;
826        datetime = datetime.strip_suffix('.')?;
827    }
828
829    PrimitiveDateTime::parse(datetime, date_format)
830        .or_else(|_| Date::parse(datetime, date_format).map(|d| d.with_time(Time::MIDNIGHT)))
831        .ok()
832        .map(|dt| dt.assume_utc().into())
833}
834
835#[cfg(test)]
836mod test {
837    use super::*;
838    use std::fs;
839    use std::io::Write;
840
841    fn find_str_in_log(dir_path: &Path, expected_value: &str) -> bool {
842        let dir_contents = fs::read_dir(dir_path).expect("Failed to read directory");
843
844        for entry in dir_contents {
845            let path = entry.expect("Expected dir entry").path();
846            let file = fs::read_to_string(&path).expect("Failed to read file");
847            println!("path={}\nfile={:?}", path.display(), file);
848
849            if file.as_str() == expected_value {
850                return true;
851            }
852        }
853
854        false
855    }
856
857    fn write_to_log(appender: &mut RollingFileAppender, msg: &str) {
858        appender
859            .write_all(msg.as_bytes())
860            .expect("Failed to write to appender");
861        appender.flush().expect("Failed to flush!");
862    }
863
864    fn test_appender(rotation: Rotation, file_prefix: &str) {
865        let directory = tempfile::tempdir().expect("failed to create tempdir");
866        let mut appender = RollingFileAppender::new(rotation, directory.path(), file_prefix);
867
868        let expected_value = "Hello";
869        write_to_log(&mut appender, expected_value);
870        assert!(find_str_in_log(directory.path(), expected_value));
871
872        directory
873            .close()
874            .expect("Failed to explicitly close TempDir. TempDir should delete once out of scope.")
875    }
876
877    #[test]
878    fn write_minutely_log() {
879        test_appender(Rotation::MINUTELY, "minutely.log");
880    }
881
882    #[test]
883    fn write_hourly_log() {
884        test_appender(Rotation::HOURLY, "hourly.log");
885    }
886
887    #[test]
888    fn write_daily_log() {
889        test_appender(Rotation::DAILY, "daily.log");
890    }
891
892    #[test]
893    fn write_weekly_log() {
894        test_appender(Rotation::WEEKLY, "weekly.log");
895    }
896
897    #[test]
898    fn write_never_log() {
899        test_appender(Rotation::NEVER, "never.log");
900    }
901
902    #[test]
903    fn test_rotations() {
904        // per-minute basis
905        let now = OffsetDateTime::now_utc();
906        let next = Rotation::MINUTELY.next_date(&now).unwrap();
907        assert_eq!((now + Duration::MINUTE).minute(), next.minute());
908
909        // per-hour basis
910        let now = OffsetDateTime::now_utc();
911        let next = Rotation::HOURLY.next_date(&now).unwrap();
912        assert_eq!((now + Duration::HOUR).hour(), next.hour());
913
914        // per-day basis
915        let now = OffsetDateTime::now_utc();
916        let next = Rotation::DAILY.next_date(&now).unwrap();
917        assert_eq!((now + Duration::DAY).day(), next.day());
918
919        // per-week basis
920        let now = OffsetDateTime::now_utc();
921        let now_rounded = Rotation::WEEKLY.round_date(now);
922        let next = Rotation::WEEKLY.next_date(&now).unwrap();
923        assert!(now_rounded < next);
924
925        // never
926        let now = OffsetDateTime::now_utc();
927        let next = Rotation::NEVER.next_date(&now);
928        assert!(next.is_none());
929    }
930
931    #[test]
932    fn test_join_date() {
933        struct TestCase {
934            expected: &'static str,
935            rotation: Rotation,
936            prefix: Option<&'static str>,
937            suffix: Option<&'static str>,
938            now: OffsetDateTime,
939        }
940
941        let format = format_description::parse(
942            "[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour \
943         sign:mandatory]:[offset_minute]:[offset_second]",
944        )
945        .unwrap();
946        let directory = tempfile::tempdir().expect("failed to create tempdir");
947
948        let test_cases = vec![
949            TestCase {
950                expected: "my_prefix.2025-02-16.log",
951                rotation: Rotation::WEEKLY,
952                prefix: Some("my_prefix"),
953                suffix: Some("log"),
954                now: OffsetDateTime::parse("2025-02-17 10:01:00 +00:00:00", &format).unwrap(),
955            },
956            // Make sure weekly rotation rounds to the preceding year when appropriate
957            TestCase {
958                expected: "my_prefix.2024-12-29.log",
959                rotation: Rotation::WEEKLY,
960                prefix: Some("my_prefix"),
961                suffix: Some("log"),
962                now: OffsetDateTime::parse("2025-01-01 10:01:00 +00:00:00", &format).unwrap(),
963            },
964            TestCase {
965                expected: "my_prefix.2025-02-17.log",
966                rotation: Rotation::DAILY,
967                prefix: Some("my_prefix"),
968                suffix: Some("log"),
969                now: OffsetDateTime::parse("2025-02-17 10:01:00 +00:00:00", &format).unwrap(),
970            },
971            TestCase {
972                expected: "my_prefix.2025-02-17-10.log",
973                rotation: Rotation::HOURLY,
974                prefix: Some("my_prefix"),
975                suffix: Some("log"),
976                now: OffsetDateTime::parse("2025-02-17 10:01:00 +00:00:00", &format).unwrap(),
977            },
978            TestCase {
979                expected: "my_prefix.2025-02-17-10-01.log",
980                rotation: Rotation::MINUTELY,
981                prefix: Some("my_prefix"),
982                suffix: Some("log"),
983                now: OffsetDateTime::parse("2025-02-17 10:01:00 +00:00:00", &format).unwrap(),
984            },
985            TestCase {
986                expected: "my_prefix.log",
987                rotation: Rotation::NEVER,
988                prefix: Some("my_prefix"),
989                suffix: Some("log"),
990                now: OffsetDateTime::parse("2025-02-17 10:01:00 +00:00:00", &format).unwrap(),
991            },
992        ];
993
994        for test_case in test_cases {
995            let (inner, _) = Inner::new(
996                test_case.now,
997                test_case.rotation.clone(),
998                directory.path(),
999                test_case.prefix.map(ToString::to_string),
1000                test_case.suffix.map(ToString::to_string),
1001                None,
1002                None,
1003            )
1004            .unwrap();
1005            let path = inner.join_date(&test_case.now);
1006
1007            assert_eq!(path, test_case.expected);
1008        }
1009    }
1010
1011    #[test]
1012    #[should_panic(
1013        expected = "internal error: entered unreachable code: Rotation::NEVER is impossible to round."
1014    )]
1015    fn test_never_date_rounding() {
1016        let now = OffsetDateTime::now_utc();
1017        let _ = Rotation::NEVER.round_date(now);
1018    }
1019
1020    #[test]
1021    fn test_path_concatenation() {
1022        let format = format_description::parse(
1023            "[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour \
1024         sign:mandatory]:[offset_minute]:[offset_second]",
1025        )
1026        .unwrap();
1027        let directory = tempfile::tempdir().expect("failed to create tempdir");
1028
1029        let now = OffsetDateTime::parse("2020-02-01 10:01:00 +00:00:00", &format).unwrap();
1030
1031        struct TestCase {
1032            expected: &'static str,
1033            rotation: Rotation,
1034            prefix: Option<&'static str>,
1035            suffix: Option<&'static str>,
1036        }
1037
1038        let test = |TestCase {
1039                        expected,
1040                        rotation,
1041                        prefix,
1042                        suffix,
1043                    }| {
1044            let (inner, _) = Inner::new(
1045                now,
1046                rotation.clone(),
1047                directory.path(),
1048                prefix.map(ToString::to_string),
1049                suffix.map(ToString::to_string),
1050                None,
1051                None,
1052            )
1053            .unwrap();
1054            let path = inner.join_date(&now);
1055            assert_eq!(
1056                expected, path,
1057                "rotation = {:?}, prefix = {:?}, suffix = {:?}",
1058                rotation, prefix, suffix
1059            );
1060        };
1061
1062        let test_cases = vec![
1063            // prefix only
1064            TestCase {
1065                expected: "app.log.2020-02-01-10-01",
1066                rotation: Rotation::MINUTELY,
1067                prefix: Some("app.log"),
1068                suffix: None,
1069            },
1070            TestCase {
1071                expected: "app.log.2020-02-01-10",
1072                rotation: Rotation::HOURLY,
1073                prefix: Some("app.log"),
1074                suffix: None,
1075            },
1076            TestCase {
1077                expected: "app.log.2020-02-01",
1078                rotation: Rotation::DAILY,
1079                prefix: Some("app.log"),
1080                suffix: None,
1081            },
1082            TestCase {
1083                expected: "app.log",
1084                rotation: Rotation::NEVER,
1085                prefix: Some("app.log"),
1086                suffix: None,
1087            },
1088            // prefix and suffix
1089            TestCase {
1090                expected: "app.2020-02-01-10-01.log",
1091                rotation: Rotation::MINUTELY,
1092                prefix: Some("app"),
1093                suffix: Some("log"),
1094            },
1095            TestCase {
1096                expected: "app.2020-02-01-10.log",
1097                rotation: Rotation::HOURLY,
1098                prefix: Some("app"),
1099                suffix: Some("log"),
1100            },
1101            TestCase {
1102                expected: "app.2020-02-01.log",
1103                rotation: Rotation::DAILY,
1104                prefix: Some("app"),
1105                suffix: Some("log"),
1106            },
1107            TestCase {
1108                expected: "app.log",
1109                rotation: Rotation::NEVER,
1110                prefix: Some("app"),
1111                suffix: Some("log"),
1112            },
1113            // suffix only
1114            TestCase {
1115                expected: "2020-02-01-10-01.log",
1116                rotation: Rotation::MINUTELY,
1117                prefix: None,
1118                suffix: Some("log"),
1119            },
1120            TestCase {
1121                expected: "2020-02-01-10.log",
1122                rotation: Rotation::HOURLY,
1123                prefix: None,
1124                suffix: Some("log"),
1125            },
1126            TestCase {
1127                expected: "2020-02-01.log",
1128                rotation: Rotation::DAILY,
1129                prefix: None,
1130                suffix: Some("log"),
1131            },
1132            TestCase {
1133                expected: "log",
1134                rotation: Rotation::NEVER,
1135                prefix: None,
1136                suffix: Some("log"),
1137            },
1138        ];
1139        for test_case in test_cases {
1140            test(test_case)
1141        }
1142    }
1143
1144    #[test]
1145    fn test_make_writer() {
1146        use std::sync::{Arc, Mutex};
1147        use tracing_subscriber::prelude::*;
1148
1149        let format = format_description::parse(
1150            "[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour \
1151         sign:mandatory]:[offset_minute]:[offset_second]",
1152        )
1153        .unwrap();
1154
1155        let now = OffsetDateTime::parse("2020-02-01 10:01:00 +00:00:00", &format).unwrap();
1156        let directory = tempfile::tempdir().expect("failed to create tempdir");
1157        let (state, writer) = Inner::new(
1158            now,
1159            Rotation::HOURLY,
1160            directory.path(),
1161            Some("test_make_writer".to_string()),
1162            None,
1163            None,
1164            None,
1165        )
1166        .unwrap();
1167
1168        let clock = Arc::new(Mutex::new(now));
1169        let now = {
1170            let clock = clock.clone();
1171            Box::new(move || *clock.lock().unwrap())
1172        };
1173        let appender = RollingFileAppender { state, writer, now };
1174        let default = tracing_subscriber::fmt()
1175            .without_time()
1176            .with_level(false)
1177            .with_target(false)
1178            .with_max_level(tracing_subscriber::filter::LevelFilter::TRACE)
1179            .with_writer(appender)
1180            .finish()
1181            .set_default();
1182
1183        tracing::info!("file 1");
1184
1185        // advance time by one second
1186        (*clock.lock().unwrap()) += Duration::seconds(1);
1187
1188        tracing::info!("file 1");
1189
1190        // advance time by one hour
1191        (*clock.lock().unwrap()) += Duration::hours(1);
1192
1193        tracing::info!("file 2");
1194
1195        // advance time by one second
1196        (*clock.lock().unwrap()) += Duration::seconds(1);
1197
1198        tracing::info!("file 2");
1199
1200        drop(default);
1201
1202        let dir_contents = fs::read_dir(directory.path()).expect("Failed to read directory");
1203        println!("dir={:?}", dir_contents);
1204        for entry in dir_contents {
1205            println!("entry={:?}", entry);
1206            let path = entry.expect("Expected dir entry").path();
1207            let file = fs::read_to_string(&path).expect("Failed to read file");
1208            println!("path={}\nfile={:?}", path.display(), file);
1209
1210            match path
1211                .extension()
1212                .expect("found a file without a date!")
1213                .to_str()
1214                .expect("extension should be UTF8")
1215            {
1216                "2020-02-01-10" => {
1217                    assert_eq!("file 1\nfile 1\n", file);
1218                }
1219                "2020-02-01-11" => {
1220                    assert_eq!("file 2\nfile 2\n", file);
1221                }
1222                x => panic!("unexpected date {}", x),
1223            }
1224        }
1225    }
1226
1227    #[test]
1228    fn test_max_log_files() {
1229        use std::sync::{Arc, Mutex};
1230        use tracing_subscriber::prelude::*;
1231
1232        let format = format_description::parse(
1233            "[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour \
1234         sign:mandatory]:[offset_minute]:[offset_second]",
1235        )
1236        .unwrap();
1237
1238        let now = OffsetDateTime::parse("2020-02-01 10:01:00 +00:00:00", &format).unwrap();
1239        let directory = tempfile::tempdir().expect("failed to create tempdir");
1240        let (state, writer) = Inner::new(
1241            now,
1242            Rotation::HOURLY,
1243            directory.path(),
1244            Some("test_max_log_files".to_string()),
1245            None,
1246            None,
1247            Some(2),
1248        )
1249        .unwrap();
1250
1251        let clock = Arc::new(Mutex::new(now));
1252        let now = {
1253            let clock = clock.clone();
1254            Box::new(move || *clock.lock().unwrap())
1255        };
1256        let appender = RollingFileAppender { state, writer, now };
1257        let default = tracing_subscriber::fmt()
1258            .without_time()
1259            .with_level(false)
1260            .with_target(false)
1261            .with_max_level(tracing_subscriber::filter::LevelFilter::TRACE)
1262            .with_writer(appender)
1263            .finish()
1264            .set_default();
1265
1266        tracing::info!("file 1");
1267
1268        // advance time by one second
1269        (*clock.lock().unwrap()) += Duration::seconds(1);
1270
1271        tracing::info!("file 1");
1272
1273        // advance time by one hour
1274        (*clock.lock().unwrap()) += Duration::hours(1);
1275
1276        // depending on the filesystem, the creation timestamp's resolution may
1277        // be as coarse as one second, so we need to wait a bit here to ensure
1278        // that the next file actually is newer than the old one.
1279        std::thread::sleep(std::time::Duration::from_secs(1));
1280
1281        tracing::info!("file 2");
1282
1283        // advance time by one second
1284        (*clock.lock().unwrap()) += Duration::seconds(1);
1285
1286        tracing::info!("file 2");
1287
1288        // advance time by one hour
1289        (*clock.lock().unwrap()) += Duration::hours(1);
1290
1291        // again, sleep to ensure that the creation timestamps actually differ.
1292        std::thread::sleep(std::time::Duration::from_secs(1));
1293
1294        tracing::info!("file 3");
1295
1296        // advance time by one second
1297        (*clock.lock().unwrap()) += Duration::seconds(1);
1298
1299        tracing::info!("file 3");
1300
1301        drop(default);
1302
1303        let dir_contents = fs::read_dir(directory.path()).expect("Failed to read directory");
1304        println!("dir={:?}", dir_contents);
1305
1306        for entry in dir_contents {
1307            println!("entry={:?}", entry);
1308            let path = entry.expect("Expected dir entry").path();
1309            let file = fs::read_to_string(&path).expect("Failed to read file");
1310            println!("path={}\nfile={:?}", path.display(), file);
1311
1312            match path
1313                .extension()
1314                .expect("found a file without a date!")
1315                .to_str()
1316                .expect("extension should be UTF8")
1317            {
1318                "2020-02-01-10" => {
1319                    panic!("this file should have been pruned already!");
1320                }
1321                "2020-02-01-11" => {
1322                    assert_eq!("file 2\nfile 2\n", file);
1323                }
1324                "2020-02-01-12" => {
1325                    assert_eq!("file 3\nfile 3\n", file);
1326                }
1327                x => panic!("unexpected date {}", x),
1328            }
1329        }
1330    }
1331
1332    #[test]
1333    fn test_parse_date_from_filename_daily() {
1334        let date_format = Rotation::DAILY.date_format();
1335        let filename = "app.2020-02-01.log";
1336        let created = parse_date_from_filename(filename, &date_format, Some("app"), Some("log"));
1337        assert_eq!(
1338            created,
1339            Some(SystemTime::UNIX_EPOCH + Duration::seconds(1580515200))
1340        );
1341    }
1342
1343    #[test]
1344    fn test_parse_date_from_filename_hourly() {
1345        let date_format = Rotation::HOURLY.date_format();
1346        let filename = "app.2020-02-01-10.log";
1347        let created = parse_date_from_filename(filename, &date_format, Some("app"), Some("log"));
1348        assert_eq!(
1349            created,
1350            Some(SystemTime::UNIX_EPOCH + Duration::seconds(1580551200))
1351        );
1352    }
1353
1354    #[test]
1355    fn test_parse_date_from_filename_minutely() {
1356        let date_format = Rotation::MINUTELY.date_format();
1357        let filename = "app.2020-02-01-10-01.log";
1358        let created = parse_date_from_filename(filename, &date_format, Some("app"), Some("log"));
1359        assert_eq!(
1360            created,
1361            Some(SystemTime::UNIX_EPOCH + Duration::seconds(1580551260))
1362        );
1363    }
1364
1365    #[test]
1366    fn test_latest_symlink() {
1367        use std::sync::{Arc, Mutex};
1368
1369        let format = format_description::parse(
1370            "[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour \
1371         sign:mandatory]:[offset_minute]:[offset_second]",
1372        )
1373        .unwrap();
1374
1375        let now = OffsetDateTime::parse("2020-02-01 10:01:00 +00:00:00", &format).unwrap();
1376        let directory = tempfile::tempdir().expect("failed to create tempdir");
1377        let (state, writer) = Inner::new(
1378            now,
1379            Rotation::HOURLY,
1380            directory.path(),
1381            Some("test_latest_symlink".to_string()),
1382            None,
1383            Some("latest.log".to_string()),
1384            None,
1385        )
1386        .unwrap();
1387
1388        // Verify symlink was created pointing to the initial log file
1389        let symlink_path = directory.path().join("latest.log");
1390        assert!(symlink_path.is_symlink(), "latest.log should be a symlink");
1391        let target = fs::read_link(&symlink_path).expect("failed to read symlink");
1392        assert!(
1393            target.to_string_lossy().contains("2020-02-01-10"),
1394            "symlink should point to file with date 2020-02-01-10, but points to {:?}",
1395            target
1396        );
1397
1398        // Set up appender with mock clock to test rotation
1399        let clock = Arc::new(Mutex::new(now));
1400        let now_fn = {
1401            let clock = clock.clone();
1402            Box::new(move || *clock.lock().unwrap())
1403        };
1404        let mut appender = RollingFileAppender {
1405            state,
1406            writer,
1407            now: now_fn,
1408        };
1409
1410        // Advance time by one hour and write to trigger rotation
1411        *clock.lock().unwrap() += Duration::hours(1);
1412        appender.write_all(b"test\n").expect("failed to write");
1413        appender.flush().expect("failed to flush");
1414
1415        // Verify symlink now points to the new log file
1416        let target = fs::read_link(&symlink_path).expect("failed to read symlink");
1417        assert!(
1418            target.to_string_lossy().contains("2020-02-01-11"),
1419            "symlink should point to file with date 2020-02-01-11, but points to {:?}",
1420            target
1421        );
1422
1423        // Verify the symlink is functional
1424        let content = fs::read_to_string(&symlink_path).expect("failed to read through symlink");
1425        assert_eq!("test\n", content);
1426    }
1427}