tracing_logrotate/
lib.rs

1#![deny(missing_docs)]
2#![doc=include_str!("../README.md")]
3
4#[cfg(test)]
5use std::eprintln as tprintln;
6
7#[cfg(not(test))]
8macro_rules! tprintln { {$($x:tt)* } => {} }
9
10use std::collections::{HashMap, BTreeMap};
11use std::fmt::{self, Debug, Display};
12use std::fs::{self, File};
13use std::io::{self, LineWriter, Write as _};
14use std::ops::Add;
15use std::path::{Path, PathBuf};
16use std::sync::{Mutex, MutexGuard};
17use std::time::{Duration, SystemTime as RawSystemTime};
18
19use chrono::{NaiveDateTime, NaiveTime, Weekday, Utc};
20use chrono::{Datelike as _, Timelike as _};
21use derive_more::{From, Into};
22use itertools::Itertools;
23use serde::{Deserialize, Deserializer, Serialize};
24use thiserror::Error;
25use tracing::{Level, Metadata};
26use tracing::metadata::LevelFilter;
27use tracing_subscriber::fmt::MakeWriter;
28
29type DateTimeUtc = chrono::DateTime<Utc>;
30
31#[cfg(test)]
32use humantime_serde::re::humantime::format_rfc3339_nanos as f3339;
33
34const SUFFIX: &str = ".log";
35
36//---------- public API types ----------
37
38/// Logfile rotation period
39#[derive(Copy, Clone, PartialEq, Eq, Debug)]
40#[derive(strum::EnumString, strum::Display)]
41#[derive(Serialize)]
42#[cfg_attr(test, derive(strum::EnumIter))]
43#[strum(serialize_all = "snake_case")]
44#[serde(into = "String")]
45#[allow(missing_docs)]
46pub enum Interval {
47    Hour,
48    Day,
49    Week,
50    Month,
51}
52
53/// `Writer` for `Appender as tracing_subscriber::fmt::MakeWriter`
54pub struct Writer<'a> {
55    level: Level,
56    appender: &'a Appender,
57}
58
59/// Configuration for a `tracing_logrotate::Appender`
60///
61/// Conceptually, the configuration is a map from
62/// (a subset of) the log levels, to a `LevelConfig`
63/// specifying the rotation interval and number of files to keep.
64///
65/// It can be (de)serialised as a map from `String` to `LevelConfig`.
66#[derive(From, Into, Deserialize, Serialize, Debug, Default, Clone)]
67#[serde(try_from = "ConfigSerde", into="ConfigSerde")]
68#[cfg_attr(test, derive(Eq, PartialEq))]
69pub struct Config(BTreeMap<LevelFilter, LevelConfig>);
70
71/// **Tracing Appender that writes to files, with rotation and expiry**
72pub struct Appender {
73    inner: Mutex<Inner>,
74}
75
76/// Rotation configuration for a set of logfiles at a particular level
77///
78/// How *long* the files are kept for is the product
79/// of `interval` and `max_files`.
80#[derive(Clone, Debug, Deserialize, Serialize)]
81#[cfg_attr(test, derive(Eq, PartialEq))]
82pub struct LevelConfig {
83    /// Rotation interval.
84    pub interval: Interval,
85
86    /// How many files to keep.
87    ///
88    /// Defaults to 15 when deserialising.
89    #[serde(default = "max_files_default")]
90    pub max_files: u32,
91}
92
93fn max_files_default() -> u32 { 15 }
94
95//---------- principal internal types ----------
96
97#[derive(PartialEq, Eq, Debug)]
98struct Period {
99    start: NaiveDateTime,
100    interval: Interval,
101}
102
103struct Inner {
104    common: Common,
105    outputs: Vec<Output>,
106}
107
108struct Common {
109    dir: PathBuf,
110    tprov: TimeProvider,
111    max_max_age_backstop: Duration,
112}
113
114struct Output {
115    level: LevelFilter,
116    config: LevelConfig,
117    period: Period,
118    file: OutputFile,
119}
120
121struct OutputFile {
122    file: LineWriter<File>,
123    path: PathBuf,
124}
125
126#[derive(Error, Debug)]
127#[error("{}: {op}: {ioe}", path.display())]
128struct Error {
129    #[source]
130    ioe: io::Error,
131    path: PathBuf,
132    op: String,
133}
134
135type ConfigSerde = HashMap<String, LevelConfig>;
136
137//---------- time mocking/testing support types ----------
138
139#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
140struct SystemTime(RawSystemTime);
141
142enum TimeProvider {
143    System,
144
145    #[cfg(test)]
146    Test(test::TestTimeProvider),
147}
148
149//==================== implementations ====================
150
151//---------- principal impls ----------
152
153impl Appender {
154    /// Create a new `Appender`
155    ///
156    /// You must supply the configuration, and the target directory.
157    ///
158    /// Files ending in `*.log` in this directory will be managed
159    /// by`tracing-logrotate`, rotating and expiring as necessary.
160    pub fn new(dir: PathBuf, levels: &Config) -> io::Result<Self> {
161        Self::new_with_time_provider(dir, levels, TimeProvider::System)
162    }
163
164    fn new_with_time_provider(
165        dir: PathBuf,
166        levels: &Config,
167        tprov: TimeProvider,
168    ) -> io::Result<Self> {
169        let now = tprov.now();
170        let max_max_age_backstop = levels.0.iter().map(|(_level, config)| {
171            config.max_age_backstop()
172        })
173            .max()
174            .unwrap_or(Duration::MAX);
175
176        let common = Common { dir, tprov, max_max_age_backstop };
177        let outputs = levels.0.iter()
178            .filter(|(_, config)| config.max_files > 0)
179            .map(|(&level, config)| {
180                let period = Period::containing(now, config.interval);
181                let file = common.open_file(now, level, &config, &period)?;
182                let config = config.clone();
183                Ok::<_, Error>(Output { level, config, period, file })
184            }).try_collect()?;
185        let inner = Mutex::new(Inner { common, outputs });
186        Ok(Appender { inner })
187    }
188
189    fn lock(&self) -> MutexGuard<'_, Inner> {
190        self.inner.lock().expect("lock poisoned!")
191    }
192
193    fn make_writer_for_level<'a>(&'a self, level: Level) -> Writer<'a> {
194        Writer {
195            level,
196            appender: &self,
197        }
198    }
199}
200
201impl Common {
202    fn open_file(
203        &self,
204        now: SystemTime,
205        level: LevelFilter,
206        config: &LevelConfig,
207        period: &Period,
208    ) -> Result<OutputFile, Error> {
209        let prefix = {
210            let mut l = format!("{}_", level);
211            l.make_ascii_lowercase();
212            l
213        };
214
215        let this_leaf = format!("{prefix}{period}{SUFFIX}");
216        let this_path = self.leaf2path(&this_leaf);
217
218        tprintln!("XX R{} OPENING {}", f3339(RawSystemTime::now()), this_leaf);
219
220        let this_file = File::options()
221            .append(true)
222            .create(true)
223            .read(false)
224            .truncate(false)
225            .open(&this_path)
226            .map_err(Error::err_mapper("open", &this_path))?;
227
228        let mut del_candidates = vec![];
229        let max_age_backstop = config.max_age_backstop();
230
231        self.scan_for_expire(
232            now,
233            |del_leaf, age| {
234                if age > self.max_max_age_backstop {
235                    return self.delete_leaf(del_leaf, ">max_backstop");
236                }
237                if ! del_leaf.starts_with(&prefix) {
238                    return Ok(())
239                }
240                if age > max_age_backstop && *del_leaf < *this_leaf {
241                    self.delete_leaf(del_leaf, ">backstop")?;
242                } else {
243                    del_candidates.push(del_leaf.to_owned());
244                }
245                Ok(())
246            },
247        )?;
248
249        // lexical, latest first
250        del_candidates.sort_by(|a, b| b.cmp(a));
251
252        while {
253            del_candidates.len()
254                >
255            usize::try_from(config.max_files).unwrap_or(usize::MAX)
256        } {
257            let del_leaf = del_candidates.pop().expect("empty!");
258            if del_leaf >= this_leaf { break; }
259            self.delete_leaf(&del_leaf, "max_files")?;
260        }
261
262        Ok(OutputFile {
263            file: LineWriter::new(this_file),
264            path: this_path,
265        })
266    }
267
268    fn leaf2path(&self, leaf: &str) -> PathBuf {
269        let mut path = self.dir.clone();
270        path.push(leaf);
271        path
272    }
273}
274
275impl Output {
276    fn if_level(&mut self, event: Level) -> Option<&mut Self> {
277        if event <= self.level { // Level <=> LevelFilter is backwards!!
278            Some(self)
279        } else {
280            None
281        }
282    }
283
284    fn consider_reopen(&mut self, common: &Common) -> Result<(), Error> {
285        let now = common.tprov.now();
286        let period = Period::containing(now, self.config.interval);
287        if self.period == period { return Ok(()) }
288
289        self.file.file.flush().map_err(self.err_mapper("flush"))?;
290        let file = common.open_file(now, self.level, &self.config, &period)?;
291        self.file = file;
292        self.period = period;
293        Ok(())
294    }
295}
296
297//---------- expiry ----------
298
299impl Common {
300    fn scan_for_expire(
301        &self,
302        now: SystemTime,
303        mut each_leaf: impl FnMut(&str, Duration) -> Result<(), Error>,
304    ) -> Result<(), Error> {
305        let err_mapper_readdir = || Error::err_mapper("read dir", &self.dir);
306
307        for ent in fs::read_dir(&self.dir)
308            .map_err(err_mapper_readdir())?
309        {
310            let ent = ent.map_err(err_mapper_readdir())?;
311            let del_leaf = ent.file_name();
312            let Ok(del_leaf) = <&str>::try_from(&*del_leaf)
313            else { /* non-utf8 crap in this directory, ignore */ continue };
314
315            tprintln!("{:37} SCAN {}", "", del_leaf);
316
317            if !del_leaf.ends_with(&SUFFIX) { continue }
318
319            let ent_path = ent.file_name();
320            let ent_path = ent_path.as_ref();
321
322            let age = (|| {
323                let modified = ent
324                    .metadata()
325                    .map_err(Error::err_mapper("stat existing/old logfile",
326                                               ent_path))?
327                    .modified()
328                    .map_err(Error::err_mapper("convert mtime", ent_path))?;
329                let age = now.duration_since(&self.tprov, modified)
330                    // Sometimes, Linux shows a file as having a modification
331                    // time in the future.  WTF.  We use this only for
332                    // calculating the age for expiry, and in that case
333                    // zero will do nicely..  If the clock is badly broken,
334                    // then zero will avoid us expiring things wrongly.
335                    .unwrap_or_default();
336                Ok(age)
337            })()?;
338
339            each_leaf(del_leaf, age)?;
340        }
341        Ok(())
342    }
343
344    fn delete_leaf(&self, del_leaf: &str, _why: &str) -> Result<(), Error> {
345        tprintln!("    {_why:31} DELETE {}", del_leaf);
346
347        let del_path = self.leaf2path(del_leaf);
348        fs::remove_file(&del_path)
349            .map_err(Error::err_mapper("remove old file", &del_path))?;
350        Ok(())
351    }
352}
353
354//---------- writer plumbing ----------
355
356impl<'a> io::Write for Writer<'a> {
357    fn write(&mut self, s: &[u8]) -> io::Result<usize> {
358        let mut inner = self.appender.lock();
359        let inner = &mut *inner;
360        for o in &mut inner.outputs {
361            if let Some(o) = o.if_level(self.level) {
362                o.consider_reopen(&inner.common)?;
363                o.file.file.write_all(s).map_err(o.err_mapper("write"))?;
364            }
365        }
366        Ok(s.len())
367    }
368    fn flush(&mut self) -> io::Result<()> {
369        let mut inner = self.appender.lock();
370        let inner = &mut *inner;
371        for o in &mut inner.outputs {
372            if let Some(o) = o.if_level(self.level) {
373                o.file.file.flush().map_err(o.err_mapper("write"))?;
374            }
375        }
376        Ok(())
377    }
378}
379
380impl<'a> MakeWriter<'a> for Appender {
381    type Writer = Writer<'a>;
382
383    fn make_writer(&'a self) -> Writer<'a> {
384        self.make_writer_for_level(Level::ERROR /* sadly no ::MAX */)
385    }
386    fn make_writer_for(&'a self, meta: &Metadata<'_>) -> Writer<'a> {
387        self.make_writer_for_level(*meta.level())
388    }
389}
390
391//---------- Interval/Period calculations etc. ----------
392
393impl LevelConfig {
394    /// Backstop maximum age for any file (for this `LevelConfig`)
395    ///
396    /// In case we haven't actually be writing files, so there
397    /// might not be enough to cause rotation.  Cap the data age.
398    fn max_age_backstop(&self) -> Duration {
399        // The oldest file might legitimately have an age of n+1 periods,
400        // since maybe it was started right at the start of the period.
401        self.interval.duration() * (self.max_files + 1)
402    }
403}
404
405impl Period {
406    pub fn containing(now: SystemTime, interval: Interval) -> Self {
407        use Interval as I;
408        let from_date = |d| NaiveDateTime::new(d, NaiveTime::MIN);
409        let now = now.chrono_naive_utc();
410        let start = match interval {
411            I::Hour => {
412                let hour = now.hour();
413                NaiveDateTime::new(
414                    now.date(),
415                    NaiveTime::from_hms_opt(hour, 0, 0)
416                        .expect("hour out of range?!")
417                )
418            }   
419            I::Day => {
420                from_date(now.date())
421            }
422            I::Week => {
423                let week = now.date().week(Weekday::Sun);
424                from_date(week.first_day())
425            }
426            I::Month => {
427                let start = now.date().with_day0(0).expect("month no start!");
428                from_date(start)
429            }
430        };
431        Period { start, interval }
432    }
433}
434
435impl Interval {
436    /// The (longest) duration that this interval might last.
437    ///
438    /// (So `Interval::Month.duration()` is 31 days.
439    pub fn duration(&self) -> Duration {
440        use Interval as I;
441        Duration::from_secs(match self {
442            I::Hour => 3600,
443            I::Day => 86400,
444            I::Week => 86400 * 7,
445            I::Month => 86400 * 31,
446        })
447    }
448}
449
450impl Display for Period {
451    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
452        use Interval as I;
453        let i = match self.interval {
454            I::Hour => "%Y-%m-%dT%H",
455            I::Day => "%Y-%m-%d",
456            I::Week => "%Y-%m-%d+",
457            I::Month => "%Y-%m",
458        };
459        write!(f, "{}", self.start.format(i))
460    }
461}
462
463//---------- deserialisation and config support ----------
464
465impl Config {
466    /// The most verbose log level specified in this configuration
467    pub fn most_verbose_level(&self) -> LevelFilter {
468        self.0.keys().max().copied().unwrap_or(LevelFilter::OFF)
469    }
470}
471
472impl TryFrom<ConfigSerde> for Config {
473    type Error = tracing::level_filters::ParseLevelFilterError;
474
475    fn try_from(hm: ConfigSerde) -> Result<Self, Self::Error> {
476        let bm = hm.into_iter().map(|(k, v)| {
477            let k: LevelFilter = k.parse()?;
478            Ok::<_, Self::Error>((k, v))
479        }).try_collect()?;
480        Ok(Config(bm))
481    }
482}
483
484impl From<Config> for ConfigSerde {
485    fn from(sc: Config) -> Self {
486        sc.0.into_iter().map(|(k, v)| {
487            let mut k: String = k.to_string();
488            k.make_ascii_lowercase();
489            (k, v)
490        }).collect()
491    }
492}
493
494impl<'de> Deserialize<'de> for Interval {
495    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
496        struct V;
497        
498        const EXP: &str = "log rotation interval";
499
500        impl serde::de::Visitor<'_> for V {
501            type Value = Interval;
502            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
503            where E: serde::de::Error
504            {
505                v.parse().map_err(
506                    |_| E::invalid_value(
507                        serde::de::Unexpected::Str(v),
508                        &EXP,
509                    )
510                )
511            }
512            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
513                write!(f, "{EXP}")
514            }
515        }
516
517        d.deserialize_str(V)
518    }
519}
520
521impl From<Interval> for String {
522    fn from(i: Interval) -> String { i.to_string() }
523}
524
525//---------- time mocking/testing ----------
526
527// contains the cfg(test) impls, for convenience
528
529impl TimeProvider {
530    fn now(&self) -> SystemTime {
531        use TimeProvider as TP;
532        let now = RawSystemTime::now();
533        match self {
534            TP::System => SystemTime(now),
535
536            #[cfg(test)]
537            TP::Test(t) => {
538                let mut t = TimeProvider::lock(t);
539                tprintln!("TP R{} now", f3339(now));
540                assert!(now >= t.last_seen);
541                t.raw2pretend(now)
542            }
543        }
544    }
545}
546
547impl SystemTime {
548    fn duration_since(
549        &self,
550        t: &TimeProvider,
551        file_age: RawSystemTime
552    ) -> Result<Duration, std::time::SystemTimeError> {
553        use TimeProvider as TP;
554        let file_age = match t {
555            TP::System => SystemTime(file_age),
556
557            #[cfg(test)]
558            TP::Test(t) => {
559                let mut t = TimeProvider::lock(t);
560                tprintln!("TP R{} duration_since", f3339(file_age));
561                t.raw2pretend(file_age)
562            },
563        };
564        self.0.duration_since(file_age.0)
565    }
566
567    fn chrono_naive_utc(self) -> NaiveDateTime {
568        DateTimeUtc::from(self.0).naive_utc()
569    }
570}
571
572impl Add<Duration> for SystemTime {
573    type Output = SystemTime;
574    fn add(self, rhs: Duration) -> SystemTime {
575        SystemTime(self.0 + rhs)
576    }
577}
578
579//---------- Error handling ----------
580
581impl From<Error> for io::Error {
582    fn from(e: Error) -> io::Error {
583        io::Error::new(e.ioe.kind(), e)
584    }
585}
586
587impl Output {
588    fn err_mapper<'o>(&'o self, op: &'o str)
589                      -> impl FnOnce(io::Error) -> Error + 'o
590    {
591        Error::err_mapper(op, &self.file.path)
592    }
593}
594
595impl Error {
596    fn err_mapper<'o>(op: &'o str, path: &'o Path)
597                      -> impl FnOnce(io::Error) -> Error + 'o
598    {
599        |ioe| Error {
600            ioe,
601            path: path.to_owned(),
602            op: op.to_owned(),
603        }
604    }
605}
606
607//==================== tests ====================
608
609#[cfg(test)]
610mod test {
611    use super::*;
612    use std::collections::BTreeSet;
613    use std::cmp;
614    use std::process::Command;
615    use std::str::FromStr;
616    use std::sync::Arc;
617
618    use anyhow::Context;
619    use humantime_serde::re::humantime;
620    use itertools::izip;
621    use serde_json::json;
622    use strum::IntoEnumIterator;
623    use test_temp_dir::test_temp_dir;
624    use testresult::{TestError, TestResult};
625
626    //---------- time mocking/testing ----------
627
628    /// Guess at the filesystem timestamp resolution
629    ///
630    /// [SuS 2024](https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/sys_stat.h.html):
631    /// 
632    /// > Upon assignment, file timestamps are immediately converted to the resolution of the file system by truncation \[...]
633    ///
634    /// So when the actual logging code creates a file,
635    /// its mtime may be *before* a previous `RawSystemTime::now()`,
636    /// by up to this value.
637    /// Our approach is to try to segment [`RawSystemTime`] into ranges,
638    /// which are treated as giving different simulated [`SystemTime`].
639    /// We must sometimes sleep for this long, therefore,
640    /// to make sure that file timestamps agree with the clock.
641    ///
642    /// On Linux the fs uses a cached value of the current time.
643    /// [Austin Phillips on StackOverflow in 2013](https://stackoverflow.com/questions/14392975/timestamp-accuracy-on-ext4-sub-millsecond/14393315#14393315):
644    /// > Internally, the ext4 filesystem code calls current_fs_time() which is the current cached kernel time \[...]
645    ///
646    /// There is, it seems, no way to find out the resolution.
647    /// [Mike in 2011](https://unix.stackexchange.com/questions/11599/determine-file-system-timestamp-precision/11605#11605):
648    /// > \[... ] but they don't seem to support any way to find out that piece of information.
649    ///
650    /// Current [pathconf(3)](https://manpages.debian.org/trixie/manpages-dev/pathconf.3.en.html)
651    /// indeed doesn't show anything useful.
652    ///
653    /// So we hardcode this guess which, empirically, seems to work here.
654    const FS_TIMESTAMP_RESOLUTION: Duration = Duration::from_millis(10);
655
656    pub type TestTimeProvider = Arc<Mutex<TimeProviderInner>>;
657
658    const TEST_START_3339: &str = "2024-10-01T19:39:45Z";
659
660    pub struct TimeProviderInner {
661        /// After `.0`. report `.1`
662        pub since: Vec<(RawSystemTime, SystemTime)>,
663        pub last_seen: RawSystemTime,
664    }
665
666    impl TimeProvider {
667        pub fn lock(t: &TestTimeProvider)
668                    -> MutexGuard<'_, TimeProviderInner>
669        {
670            t.lock().expect("tprov poison!")
671        }
672    }
673
674    impl FromStr for SystemTime {
675        type Err = humantime::TimestampError;
676        fn from_str(s: &str) -> Result<Self, Self::Err> {
677            humantime::parse_rfc3339(s).map(SystemTime)
678        }
679    }
680
681    pub fn raw_system_time_start_period() -> RawSystemTime {
682        let now = RawSystemTime::now();
683        std::thread::sleep(FS_TIMESTAMP_RESOLUTION);
684        now
685    }
686
687    impl TimeProviderInner {
688        pub fn new_for_test() -> TestTimeProvider {
689            TimeProviderInner::new(
690                TEST_START_3339.parse().expect("bad constant")
691            )
692        }
693
694        pub fn new(start: SystemTime) -> TestTimeProvider {
695            let now = raw_system_time_start_period();
696            tprintln!("TP R{} start       S{}", f3339(now), f3339(start.0));
697            let tprov = TimeProviderInner {
698                since: vec![(now, start)],
699                last_seen: now,
700            };
701            Arc::new(Mutex::new(tprov))
702        }
703
704        pub fn raw2pretend(&mut self, raw: RawSystemTime) -> SystemTime {
705            tprintln!("TP R{} raw2pretend", f3339(raw));
706            self.last_seen = cmp::max(self.last_seen, raw);
707            assert!(raw >= self.since[0].0, "{raw:?} {:?}", self.since);
708            let ent_pos = self.since
709                .binary_search_by(|ent| ent.0.cmp(&raw))
710                .unwrap_or_else(|insert| insert - 1);
711            let r = self.since[ent_pos].1;
712            tprintln!("TP R{} raw2pretend S{}", f3339(raw), f3339(r.0));
713            r
714        }
715
716        pub fn advance(&mut self, by_secs: u64) {
717            let pstart = raw_system_time_start_period();
718            assert!(pstart > self.last_seen);
719            let last = self.since.last_mut().expect("empty!");
720            assert!(pstart > last.0);
721            let new_period = (pstart, last.1 + Duration::from_secs(by_secs));
722            self.since.push(new_period);
723        }
724    }
725
726    //---------- Ctx and common setup functions ----------
727
728    struct Ctx {
729        a: Appender,
730        dir: PathBuf,
731        tprov: TestTimeProvider,
732    }
733
734    impl Ctx {
735        fn advance(&self, secs: u64) {
736            TimeProvider::lock(&self.tprov)
737                .advance(secs)
738        }
739
740        fn expect_files(&self, shell: &str) -> TestResult<()> {
741            let shell = shell.chars()
742                .filter(|c| !c.is_ascii_whitespace())
743                .collect::<String>();
744
745            let output = Command::new("bash")
746                .args(["-ec", &format!("echo {shell}")])
747                .output()?;
748            assert!(output.status.success(), "{}", output.status);
749            assert!(output.stderr.is_empty());
750            let output = String::from_utf8(output.stdout)?;
751            let expect = output
752                .trim()
753                .split_ascii_whitespace()
754                .map(str::to_owned)
755                .collect::<BTreeSet<_>>();
756
757            let actual = fs::read_dir(&self.dir)?
758                .map(|ent| {
759                    let leaf = PathBuf::from(ent?.file_name())
760                        .to_str().expect("non-utf8").to_owned();
761                    Ok::<_, TestError>(leaf)
762                })
763                .filter_ok(|f| !(f == "." || f == ".."))
764                .collect::<Result<BTreeSet<_>, _>>()?;
765            assert_eq!(actual, expect,
766                       "actual={actual:#?} expect={expect:#?}");
767            Ok(())
768        }
769
770        fn shutdown(self) -> TestTimeProvider {
771            let Ctx { tprov, .. } = self;
772            tprov
773        }
774    }
775
776    fn usual_levels() -> Config {
777        use LevelFilter as L;
778        use Interval as I;
779        [
780            (L::DEBUG, I::Hour),
781            (L::INFO,  I::Day),
782        ]
783            .into_iter().map(|(level, interval)| {
784                (level, LevelConfig { max_files: 5, interval })
785            })
786            .collect::<BTreeMap<_, _>>()
787            .into()
788    }
789
790    fn setup(dir: &Path) -> TestResult<Ctx> {
791        let tprov = TimeProviderInner::new_for_test();
792        Ctx::new(dir, tprov)
793    }
794
795    impl Ctx {
796        fn new(
797            dir: &Path,
798            tprov: TestTimeProvider,
799        ) -> TestResult<Self> {
800            Ctx::new_with_levels(dir, usual_levels(), tprov)
801        }
802
803        fn new_with_levels(
804            dir: &Path,
805            levels: Config,
806            tprov: TestTimeProvider,
807        ) -> TestResult<Self> {
808            let a = Appender::new_with_time_provider(
809                dir.to_owned(),
810                &levels,
811                TimeProvider::Test(tprov.clone()),
812            )?;
813
814            Ok(Ctx { a, tprov, dir: dir.to_owned() })
815        }
816
817        fn log(&self, m: impl Display) -> TestResult<()> {
818            let mut w = self.a.make_writer();
819            writeln!(w, "{m}")?;
820            w.flush()?;
821            Ok(())
822        }
823    }
824
825    //---------- test cases ----------
826
827    #[test]
828    fn rotate() -> TestResult<()> { test_temp_dir!().used_by(|dir| {
829        let c = setup(dir)?;
830        c.log("started")?;
831        for h in 0..7 {
832            c.advance(3600);
833            c.log(format!("h={h}"))?;
834        }
835        c.expect_files(
836            r"{ debug_2024-10-{ 02T{ 02, 01, 00 }, 01T{ 23, 22 }},
837                info_2024-10-{ 02, 01 } }.log"
838        )?;
839        Ok(())
840    }).into_untracked() }
841
842    #[test]
843    fn level() -> TestResult<()> { test_temp_dir!().used_by(|dir| {
844        let msg_error = "this message is an error";
845        let msg_debug = "this message is debugging";
846
847        let c = setup(dir)?;
848        c.log(msg_error)?;
849
850        {
851            let mut w = c.a.make_writer_for_level(Level::DEBUG);
852            writeln!(w, "{msg_debug}")?;
853            w.flush()?;
854        }
855
856        let read = |leaf: &str| {
857            let mut path = dir.to_owned();
858            path.push(leaf);
859            let s = fs::read_to_string(path)
860                .context(leaf.to_owned())?;
861            Ok::<_, anyhow::Error>(s)
862        };
863
864        assert_eq!(
865            read("debug_2024-10-01T19.log")?,
866            format!("{msg_error}\n{msg_debug}\n"),
867        );
868        assert_eq!(
869            read("info_2024-10-01.log")?,
870            format!("{msg_error}\n"),
871        );
872
873        Ok(())
874    }).into_untracked() }
875
876    #[test]
877    fn restart() -> TestResult<()> { test_temp_dir!().used_by(|dir| {
878        let c = setup(dir)?;
879        c.log("earlier")?;
880
881        let tprov = c.shutdown();
882
883        TimeProvider::lock(&tprov).advance(86400);
884
885        let c = Ctx::new(&dir, tprov)?;
886
887        c.expect_files(
888            r"{ debug_2024-10-02T19,
889                info_2024-10-{ 02, 01 } }.log"
890        )?;
891        Ok(())
892    }).into_untracked() }
893
894    #[test]
895    fn reconfig() -> TestResult<()> { test_temp_dir!().used_by(|dir| {
896        use LevelFilter as L;
897        let levels = [
898            L::TRACE,
899            L::DEBUG,
900            L::INFO,
901            L::WARN,
902            L::ERROR,
903        ];
904
905        let c = Ctx::new_with_levels(
906            &dir,
907            izip!(
908                levels.into_iter().cycle(),
909                Interval::iter(),
910            ).map(|(level, interval)| {
911                (level, LevelConfig { max_files: 5, interval })
912            }).collect::<BTreeMap<_, _>>().into(),
913            TimeProviderInner::new_for_test(),
914        )?;
915
916        c.log("started")?;
917        c.advance(86400 * 3);
918        c.log("3 days later")?;
919
920        c.expect_files(
921            r"{ trace_2024-10-04T19,
922                debug_2024-10-01,
923                debug_2024-10-04,
924                info_2024-09-29+,
925                warn_2024-10 }.log"
926        )?;
927
928        let tprov = c.shutdown();
929
930        TimeProvider::lock(&tprov).advance(86400 * 4);
931
932        let c = Ctx::new(&dir, tprov)?;
933
934        c.log("a further 4 days later")?;
935
936        c.expect_files(
937            r"{ trace_2024-10-04T19,
938                debug_2024-10-08T19,
939                info_2024-09-29+,
940                info_2024-10-08,
941                warn_2024-10 }.log"
942        )?;
943
944        c.advance(86400 * 3);
945        c.log("surely that trace log from 2024-10-04 goes away")?;
946
947        c.expect_files(
948            r"{ debug_2024-10-11T19,
949                info_2024-10-08,
950                info_2024-10-11 }.log"
951        )?;
952
953        Ok(())
954    }).into_untracked() }
955
956    impl Period {
957        fn start_system_time(&self) -> SystemTime {
958            SystemTime(self.start.and_utc().into())
959        }
960    }
961
962    #[test]
963    fn intervals() -> TestResult<()> {
964        let start: SystemTime = TEST_START_3339.parse()?;
965        for i in Interval::iter() {
966            tprintln!("{i:?}");
967            let containing = |t| Period::containing(t, i);
968            let mut t = start;
969            for n in 0..30 {
970                tprintln!("{i:?} {n} {}", f3339(t.0));
971                let period = containing(t);
972                let start = period.start_system_time();
973                assert_eq!(period, containing(start));
974
975                let d = if i != Interval::Month {
976                    i.duration()
977                } else {
978                    let days = |days: u64| Duration::from_secs(days * 86400);
979                    assert_eq!(i.duration(), days(31));
980
981                    let month = period.start.month();
982                    if month == 2 {
983                        days(28) // next year, 2025, is not leap
984                    } else if [4,6,9,11].contains(&month) {
985                        days(30)
986                    } else {
987                        days(31)
988                    }
989                };
990                let d_minus = d - Duration::from_secs(1);
991
992                assert_eq!(period, containing(start + d_minus));
993                let next_period = containing(start + d);
994                assert_ne!(period, next_period);
995                assert_ne!(period.to_string(), next_period.to_string());
996                t = next_period.start_system_time();
997            }
998        }
999        Ok(())
1000    }
1001
1002    #[test]
1003    fn serde() -> TestResult<()> {
1004        let j = json!{ {
1005            "debug": {
1006                "interval": "hour",
1007                "max_files": 5,
1008            },
1009            "info": {
1010                "interval": "day",
1011                "max_files": 5,
1012            }
1013        } };
1014
1015        let fj: Config = serde_json::from_value(j.clone())?;
1016
1017        assert_eq!(fj, usual_levels());
1018        assert_eq!(serde_json::to_value(&fj)?, j);
1019
1020        assert_eq!(fj.most_verbose_level(), LevelFilter::DEBUG);
1021        assert_eq!(
1022            Config(BTreeMap::new()).most_verbose_level(),
1023            LevelFilter::OFF,
1024        );
1025
1026        Ok(())
1027    }
1028}