tag2upload_service_manager/
tracing_logrotate.rs

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