1#[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#[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#[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#[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
157impl 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 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 { 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
299impl 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 { 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 .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
356impl<'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 )
387 }
388 fn make_writer_for(&'a self, meta: &Metadata<'_>) -> Writer<'a> {
389 self.make_writer_for_level(*meta.level())
390 }
391}
392
393impl LevelConfig {
396 fn max_age_backstop(&self) -> Duration {
401 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
462impl 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
523impl 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
577impl 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#[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 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 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 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]
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) } 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}