1use 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};
37use time::{format_description, Date, Duration, OffsetDateTime, Time};
38
39mod builder;
40pub use builder::{Builder, InitError};
41
42pub struct RollingFileAppender {
87 state: Inner,
88 writer: RwLock<File>,
89 #[cfg(test)]
90 now: Box<dyn Fn() -> OffsetDateTime + Send + Sync>,
91}
92
93#[derive(Debug)]
100pub struct RollingWriter<'a>(RwLockReadGuard<'a, File>);
101
102#[derive(Debug)]
103struct Inner {
104 log_directory: PathBuf,
105 log_filename_prefix: Option<String>,
106 log_filename_suffix: Option<String>,
107 date_format: Vec<format_description::FormatItem<'static>>,
108 rotation: Rotation,
109 next_date: AtomicUsize,
110 max_files: Option<usize>,
111}
112
113impl RollingFileAppender {
116 pub fn new(
142 rotation: Rotation,
143 directory: impl AsRef<Path>,
144 filename_prefix: impl AsRef<Path>,
145 ) -> RollingFileAppender {
146 let filename_prefix = filename_prefix
147 .as_ref()
148 .to_str()
149 .expect("filename prefix must be a valid UTF-8 string");
150 Self::builder()
151 .rotation(rotation)
152 .filename_prefix(filename_prefix)
153 .build(directory)
154 .expect("initializing rolling file appender failed")
155 }
156
157 #[must_use]
183 pub fn builder() -> Builder {
184 Builder::new()
185 }
186
187 fn from_builder(builder: &Builder, directory: impl AsRef<Path>) -> Result<Self, InitError> {
188 let Builder {
189 ref rotation,
190 ref prefix,
191 ref suffix,
192 ref max_files,
193 } = builder;
194 let directory = directory.as_ref().to_path_buf();
195 let now = OffsetDateTime::now_utc();
196 let (state, writer) = Inner::new(
197 now,
198 rotation.clone(),
199 directory,
200 prefix.clone(),
201 suffix.clone(),
202 *max_files,
203 )?;
204 Ok(Self {
205 state,
206 writer,
207 #[cfg(test)]
208 now: Box::new(OffsetDateTime::now_utc),
209 })
210 }
211
212 #[inline]
213 fn now(&self) -> OffsetDateTime {
214 #[cfg(test)]
215 return (self.now)();
216
217 #[cfg(not(test))]
218 OffsetDateTime::now_utc()
219 }
220}
221
222impl io::Write for RollingFileAppender {
223 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
224 let now = self.now();
225 let writer = self.writer.get_mut();
226 if let Some(current_time) = self.state.should_rollover(now) {
227 let _did_cas = self.state.advance_date(now, current_time);
228 debug_assert!(_did_cas, "if we have &mut access to the appender, no other thread can have advanced the timestamp...");
229 self.state.refresh_writer(now, writer);
230 }
231 writer.write(buf)
232 }
233
234 fn flush(&mut self) -> io::Result<()> {
235 self.writer.get_mut().flush()
236 }
237}
238
239impl<'a> tracing_subscriber::fmt::writer::MakeWriter<'a> for RollingFileAppender {
240 type Writer = RollingWriter<'a>;
241 fn make_writer(&'a self) -> Self::Writer {
242 let now = self.now();
243
244 if let Some(current_time) = self.state.should_rollover(now) {
246 if self.state.advance_date(now, current_time) {
249 self.state.refresh_writer(now, &mut self.writer.write());
250 }
251 }
252 RollingWriter(self.writer.read())
253 }
254}
255
256impl fmt::Debug for RollingFileAppender {
257 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260 f.debug_struct("RollingFileAppender")
261 .field("state", &self.state)
262 .field("writer", &self.writer)
263 .finish()
264 }
265}
266
267pub fn minutely(
296 directory: impl AsRef<Path>,
297 file_name_prefix: impl AsRef<Path>,
298) -> RollingFileAppender {
299 RollingFileAppender::new(Rotation::MINUTELY, directory, file_name_prefix)
300}
301
302pub fn hourly(
331 directory: impl AsRef<Path>,
332 file_name_prefix: impl AsRef<Path>,
333) -> RollingFileAppender {
334 RollingFileAppender::new(Rotation::HOURLY, directory, file_name_prefix)
335}
336
337pub fn daily(
367 directory: impl AsRef<Path>,
368 file_name_prefix: impl AsRef<Path>,
369) -> RollingFileAppender {
370 RollingFileAppender::new(Rotation::DAILY, directory, file_name_prefix)
371}
372
373pub fn never(directory: impl AsRef<Path>, file_name: impl AsRef<Path>) -> RollingFileAppender {
401 RollingFileAppender::new(Rotation::NEVER, directory, file_name)
402}
403
404#[derive(Clone, Eq, PartialEq, Debug)]
440pub struct Rotation(RotationKind);
441
442#[derive(Clone, Eq, PartialEq, Debug)]
443enum RotationKind {
444 Minutely,
445 Hourly,
446 Daily,
447 Never,
448}
449
450impl Rotation {
451 pub const MINUTELY: Self = Self(RotationKind::Minutely);
453 pub const HOURLY: Self = Self(RotationKind::Hourly);
455 pub const DAILY: Self = Self(RotationKind::Daily);
457 pub const NEVER: Self = Self(RotationKind::Never);
459
460 pub(crate) fn next_date(&self, current_date: &OffsetDateTime) -> Option<OffsetDateTime> {
461 let unrounded_next_date = match *self {
462 Rotation::MINUTELY => *current_date + Duration::minutes(1),
463 Rotation::HOURLY => *current_date + Duration::hours(1),
464 Rotation::DAILY => *current_date + Duration::days(1),
465 Rotation::NEVER => return None,
466 };
467 Some(self.round_date(&unrounded_next_date))
468 }
469
470 pub(crate) fn round_date(&self, date: &OffsetDateTime) -> OffsetDateTime {
472 match *self {
473 Rotation::MINUTELY => {
474 let time = Time::from_hms(date.hour(), date.minute(), 0)
475 .expect("Invalid time; this is a bug in tracing-appender");
476 date.replace_time(time)
477 }
478 Rotation::HOURLY => {
479 let time = Time::from_hms(date.hour(), 0, 0)
480 .expect("Invalid time; this is a bug in tracing-appender");
481 date.replace_time(time)
482 }
483 Rotation::DAILY => {
484 let time = Time::from_hms(0, 0, 0)
485 .expect("Invalid time; this is a bug in tracing-appender");
486 date.replace_time(time)
487 }
488 Rotation::NEVER => {
490 unreachable!("Rotation::NEVER is impossible to round.")
491 }
492 }
493 }
494
495 fn date_format(&self) -> Vec<format_description::FormatItem<'static>> {
496 match *self {
497 Rotation::MINUTELY => format_description::parse("[year]-[month]-[day]-[hour]-[minute]"),
498 Rotation::HOURLY => format_description::parse("[year]-[month]-[day]-[hour]"),
499 Rotation::DAILY => format_description::parse("[year]-[month]-[day]"),
500 Rotation::NEVER => format_description::parse("[year]-[month]-[day]"),
501 }
502 .expect("Unable to create a formatter; this is a bug in tracing-appender")
503 }
504}
505
506impl io::Write for RollingWriter<'_> {
509 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
510 (&*self.0).write(buf)
511 }
512
513 fn flush(&mut self) -> io::Result<()> {
514 (&*self.0).flush()
515 }
516}
517
518impl Inner {
521 fn new(
522 now: OffsetDateTime,
523 rotation: Rotation,
524 directory: impl AsRef<Path>,
525 log_filename_prefix: Option<String>,
526 log_filename_suffix: Option<String>,
527 max_files: Option<usize>,
528 ) -> Result<(Self, RwLock<File>), builder::InitError> {
529 let log_directory = directory.as_ref().to_path_buf();
530 let date_format = rotation.date_format();
531 let next_date = rotation.next_date(&now);
532
533 let inner = Inner {
534 log_directory,
535 log_filename_prefix,
536 log_filename_suffix,
537 date_format,
538 next_date: AtomicUsize::new(
539 next_date
540 .map(|date| date.unix_timestamp() as usize)
541 .unwrap_or(0),
542 ),
543 rotation,
544 max_files,
545 };
546 let filename = inner.join_date(&now);
547 let writer = RwLock::new(create_writer(inner.log_directory.as_ref(), &filename)?);
548 Ok((inner, writer))
549 }
550
551 pub(crate) fn join_date(&self, date: &OffsetDateTime) -> String {
552 let date = date
553 .format(&self.date_format)
554 .expect("Unable to format OffsetDateTime; this is a bug in tracing-appender");
555
556 match (
557 &self.rotation,
558 &self.log_filename_prefix,
559 &self.log_filename_suffix,
560 ) {
561 (&Rotation::NEVER, Some(filename), None) => filename.to_string(),
562 (&Rotation::NEVER, Some(filename), Some(suffix)) => format!("{}.{}", filename, suffix),
563 (&Rotation::NEVER, None, Some(suffix)) => suffix.to_string(),
564 (_, Some(filename), Some(suffix)) => format!("{}.{}.{}", filename, date, suffix),
565 (_, Some(filename), None) => format!("{}.{}", filename, date),
566 (_, None, Some(suffix)) => format!("{}.{}", date, suffix),
567 (_, None, None) => date,
568 }
569 }
570
571 fn prune_old_logs(&self, max_files: usize) {
572 let files = fs::read_dir(&self.log_directory).map(|dir| {
573 dir.filter_map(|entry| {
574 let entry = entry.ok()?;
575 let metadata = entry.metadata().ok()?;
576
577 if !metadata.is_file() {
580 return None;
581 }
582
583 let filename = entry.file_name();
584 let filename = filename.to_str()?;
586 if let Some(prefix) = &self.log_filename_prefix {
587 if !filename.starts_with(prefix) {
588 return None;
589 }
590 }
591
592 if let Some(suffix) = &self.log_filename_suffix {
593 if !filename.ends_with(suffix) {
594 return None;
595 }
596 }
597
598 if self.log_filename_prefix.is_none()
599 && self.log_filename_suffix.is_none()
600 && Date::parse(filename, &self.date_format).is_err()
601 {
602 return None;
603 }
604
605 let created = metadata.created().ok()?;
606 Some((entry, created))
607 })
608 .collect::<Vec<_>>()
609 });
610
611 let mut files = match files {
612 Ok(files) => files,
613 Err(error) => {
614 eprintln!("Error reading the log directory/files: {}", error);
615 return;
616 }
617 };
618 if files.len() < max_files {
619 return;
620 }
621
622 files.sort_by_key(|(_, created_at)| *created_at);
624
625 for (file, _) in files.iter().take(files.len() - (max_files - 1)) {
627 if let Err(error) = fs::remove_file(file.path()) {
628 eprintln!(
629 "Failed to remove old log file {}: {}",
630 file.path().display(),
631 error
632 );
633 }
634 }
635 }
636
637 fn refresh_writer(&self, now: OffsetDateTime, file: &mut File) {
638 let filename = self.join_date(&now);
639
640 if let Some(max_files) = self.max_files {
641 self.prune_old_logs(max_files);
642 }
643
644 match create_writer(&self.log_directory, &filename) {
645 Ok(new_file) => {
646 if let Err(err) = file.flush() {
647 eprintln!("Couldn't flush previous writer: {}", err);
648 }
649 *file = new_file;
650 }
651 Err(err) => eprintln!("Couldn't create writer for logs: {}", err),
652 }
653 }
654
655 fn should_rollover(&self, date: OffsetDateTime) -> Option<usize> {
664 let next_date = self.next_date.load(Ordering::Acquire);
665 if next_date == 0 {
667 return None;
668 }
669
670 if date.unix_timestamp() as usize >= next_date {
671 return Some(next_date);
672 }
673
674 None
675 }
676
677 fn advance_date(&self, now: OffsetDateTime, current: usize) -> bool {
678 let next_date = self
679 .rotation
680 .next_date(&now)
681 .map(|date| date.unix_timestamp() as usize)
682 .unwrap_or(0);
683 self.next_date
684 .compare_exchange(current, next_date, Ordering::AcqRel, Ordering::Acquire)
685 .is_ok()
686 }
687}
688
689fn create_writer(directory: &Path, filename: &str) -> Result<File, InitError> {
690 let path = directory.join(filename);
691 let mut open_options = OpenOptions::new();
692 open_options.append(true).create(true);
693
694 let new_file = open_options.open(path.as_path());
695 if new_file.is_err() {
696 if let Some(parent) = path.parent() {
697 fs::create_dir_all(parent).map_err(InitError::ctx("failed to create log directory"))?;
698 return open_options
699 .open(path)
700 .map_err(InitError::ctx("failed to create initial log file"));
701 }
702 }
703
704 new_file.map_err(InitError::ctx("failed to create initial log file"))
705}
706
707#[cfg(test)]
708mod test {
709 use super::*;
710 use std::fs;
711 use std::io::Write;
712
713 fn find_str_in_log(dir_path: &Path, expected_value: &str) -> bool {
714 let dir_contents = fs::read_dir(dir_path).expect("Failed to read directory");
715
716 for entry in dir_contents {
717 let path = entry.expect("Expected dir entry").path();
718 let file = fs::read_to_string(&path).expect("Failed to read file");
719 println!("path={}\nfile={:?}", path.display(), file);
720
721 if file.as_str() == expected_value {
722 return true;
723 }
724 }
725
726 false
727 }
728
729 fn write_to_log(appender: &mut RollingFileAppender, msg: &str) {
730 appender
731 .write_all(msg.as_bytes())
732 .expect("Failed to write to appender");
733 appender.flush().expect("Failed to flush!");
734 }
735
736 fn test_appender(rotation: Rotation, file_prefix: &str) {
737 let directory = tempfile::tempdir().expect("failed to create tempdir");
738 let mut appender = RollingFileAppender::new(rotation, directory.path(), file_prefix);
739
740 let expected_value = "Hello";
741 write_to_log(&mut appender, expected_value);
742 assert!(find_str_in_log(directory.path(), expected_value));
743
744 directory
745 .close()
746 .expect("Failed to explicitly close TempDir. TempDir should delete once out of scope.")
747 }
748
749 #[test]
750 fn write_minutely_log() {
751 test_appender(Rotation::HOURLY, "minutely.log");
752 }
753
754 #[test]
755 fn write_hourly_log() {
756 test_appender(Rotation::HOURLY, "hourly.log");
757 }
758
759 #[test]
760 fn write_daily_log() {
761 test_appender(Rotation::DAILY, "daily.log");
762 }
763
764 #[test]
765 fn write_never_log() {
766 test_appender(Rotation::NEVER, "never.log");
767 }
768
769 #[test]
770 fn test_rotations() {
771 let now = OffsetDateTime::now_utc();
773 let next = Rotation::MINUTELY.next_date(&now).unwrap();
774 assert_eq!((now + Duration::MINUTE).minute(), next.minute());
775
776 let now = OffsetDateTime::now_utc();
778 let next = Rotation::HOURLY.next_date(&now).unwrap();
779 assert_eq!((now + Duration::HOUR).hour(), next.hour());
780
781 let now = OffsetDateTime::now_utc();
783 let next = Rotation::DAILY.next_date(&now).unwrap();
784 assert_eq!((now + Duration::DAY).day(), next.day());
785
786 let now = OffsetDateTime::now_utc();
788 let next = Rotation::NEVER.next_date(&now);
789 assert!(next.is_none());
790 }
791
792 #[test]
793 #[should_panic(
794 expected = "internal error: entered unreachable code: Rotation::NEVER is impossible to round."
795 )]
796 fn test_never_date_rounding() {
797 let now = OffsetDateTime::now_utc();
798 let _ = Rotation::NEVER.round_date(&now);
799 }
800
801 #[test]
802 fn test_path_concatenation() {
803 let format = format_description::parse(
804 "[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour \
805 sign:mandatory]:[offset_minute]:[offset_second]",
806 )
807 .unwrap();
808 let directory = tempfile::tempdir().expect("failed to create tempdir");
809
810 let now = OffsetDateTime::parse("2020-02-01 10:01:00 +00:00:00", &format).unwrap();
811
812 struct TestCase {
813 expected: &'static str,
814 rotation: Rotation,
815 prefix: Option<&'static str>,
816 suffix: Option<&'static str>,
817 }
818
819 let test = |TestCase {
820 expected,
821 rotation,
822 prefix,
823 suffix,
824 }| {
825 let (inner, _) = Inner::new(
826 now,
827 rotation.clone(),
828 directory.path(),
829 prefix.map(ToString::to_string),
830 suffix.map(ToString::to_string),
831 None,
832 )
833 .unwrap();
834 let path = inner.join_date(&now);
835 assert_eq!(
836 expected, path,
837 "rotation = {:?}, prefix = {:?}, suffix = {:?}",
838 rotation, prefix, suffix
839 );
840 };
841
842 let test_cases = vec![
843 TestCase {
845 expected: "app.log.2020-02-01-10-01",
846 rotation: Rotation::MINUTELY,
847 prefix: Some("app.log"),
848 suffix: None,
849 },
850 TestCase {
851 expected: "app.log.2020-02-01-10",
852 rotation: Rotation::HOURLY,
853 prefix: Some("app.log"),
854 suffix: None,
855 },
856 TestCase {
857 expected: "app.log.2020-02-01",
858 rotation: Rotation::DAILY,
859 prefix: Some("app.log"),
860 suffix: None,
861 },
862 TestCase {
863 expected: "app.log",
864 rotation: Rotation::NEVER,
865 prefix: Some("app.log"),
866 suffix: None,
867 },
868 TestCase {
870 expected: "app.2020-02-01-10-01.log",
871 rotation: Rotation::MINUTELY,
872 prefix: Some("app"),
873 suffix: Some("log"),
874 },
875 TestCase {
876 expected: "app.2020-02-01-10.log",
877 rotation: Rotation::HOURLY,
878 prefix: Some("app"),
879 suffix: Some("log"),
880 },
881 TestCase {
882 expected: "app.2020-02-01.log",
883 rotation: Rotation::DAILY,
884 prefix: Some("app"),
885 suffix: Some("log"),
886 },
887 TestCase {
888 expected: "app.log",
889 rotation: Rotation::NEVER,
890 prefix: Some("app"),
891 suffix: Some("log"),
892 },
893 TestCase {
895 expected: "2020-02-01-10-01.log",
896 rotation: Rotation::MINUTELY,
897 prefix: None,
898 suffix: Some("log"),
899 },
900 TestCase {
901 expected: "2020-02-01-10.log",
902 rotation: Rotation::HOURLY,
903 prefix: None,
904 suffix: Some("log"),
905 },
906 TestCase {
907 expected: "2020-02-01.log",
908 rotation: Rotation::DAILY,
909 prefix: None,
910 suffix: Some("log"),
911 },
912 TestCase {
913 expected: "log",
914 rotation: Rotation::NEVER,
915 prefix: None,
916 suffix: Some("log"),
917 },
918 ];
919 for test_case in test_cases {
920 test(test_case)
921 }
922 }
923
924 #[test]
925 fn test_make_writer() {
926 use std::sync::{Arc, Mutex};
927 use tracing_subscriber::prelude::*;
928
929 let format = format_description::parse(
930 "[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour \
931 sign:mandatory]:[offset_minute]:[offset_second]",
932 )
933 .unwrap();
934
935 let now = OffsetDateTime::parse("2020-02-01 10:01:00 +00:00:00", &format).unwrap();
936 let directory = tempfile::tempdir().expect("failed to create tempdir");
937 let (state, writer) = Inner::new(
938 now,
939 Rotation::HOURLY,
940 directory.path(),
941 Some("test_make_writer".to_string()),
942 None,
943 None,
944 )
945 .unwrap();
946
947 let clock = Arc::new(Mutex::new(now));
948 let now = {
949 let clock = clock.clone();
950 Box::new(move || *clock.lock().unwrap())
951 };
952 let appender = RollingFileAppender { state, writer, now };
953 let default = tracing_subscriber::fmt()
954 .without_time()
955 .with_level(false)
956 .with_target(false)
957 .with_max_level(tracing_subscriber::filter::LevelFilter::TRACE)
958 .with_writer(appender)
959 .finish()
960 .set_default();
961
962 tracing::info!("file 1");
963
964 (*clock.lock().unwrap()) += Duration::seconds(1);
966
967 tracing::info!("file 1");
968
969 (*clock.lock().unwrap()) += Duration::hours(1);
971
972 tracing::info!("file 2");
973
974 (*clock.lock().unwrap()) += Duration::seconds(1);
976
977 tracing::info!("file 2");
978
979 drop(default);
980
981 let dir_contents = fs::read_dir(directory.path()).expect("Failed to read directory");
982 println!("dir={:?}", dir_contents);
983 for entry in dir_contents {
984 println!("entry={:?}", entry);
985 let path = entry.expect("Expected dir entry").path();
986 let file = fs::read_to_string(&path).expect("Failed to read file");
987 println!("path={}\nfile={:?}", path.display(), file);
988
989 match path
990 .extension()
991 .expect("found a file without a date!")
992 .to_str()
993 .expect("extension should be UTF8")
994 {
995 "2020-02-01-10" => {
996 assert_eq!("file 1\nfile 1\n", file);
997 }
998 "2020-02-01-11" => {
999 assert_eq!("file 2\nfile 2\n", file);
1000 }
1001 x => panic!("unexpected date {}", x),
1002 }
1003 }
1004 }
1005
1006 #[test]
1007 fn test_max_log_files() {
1008 use std::sync::{Arc, Mutex};
1009 use tracing_subscriber::prelude::*;
1010
1011 let format = format_description::parse(
1012 "[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour \
1013 sign:mandatory]:[offset_minute]:[offset_second]",
1014 )
1015 .unwrap();
1016
1017 let now = OffsetDateTime::parse("2020-02-01 10:01:00 +00:00:00", &format).unwrap();
1018 let directory = tempfile::tempdir().expect("failed to create tempdir");
1019 let (state, writer) = Inner::new(
1020 now,
1021 Rotation::HOURLY,
1022 directory.path(),
1023 Some("test_max_log_files".to_string()),
1024 None,
1025 Some(2),
1026 )
1027 .unwrap();
1028
1029 let clock = Arc::new(Mutex::new(now));
1030 let now = {
1031 let clock = clock.clone();
1032 Box::new(move || *clock.lock().unwrap())
1033 };
1034 let appender = RollingFileAppender { state, writer, now };
1035 let default = tracing_subscriber::fmt()
1036 .without_time()
1037 .with_level(false)
1038 .with_target(false)
1039 .with_max_level(tracing_subscriber::filter::LevelFilter::TRACE)
1040 .with_writer(appender)
1041 .finish()
1042 .set_default();
1043
1044 tracing::info!("file 1");
1045
1046 (*clock.lock().unwrap()) += Duration::seconds(1);
1048
1049 tracing::info!("file 1");
1050
1051 (*clock.lock().unwrap()) += Duration::hours(1);
1053
1054 std::thread::sleep(std::time::Duration::from_secs(1));
1058
1059 tracing::info!("file 2");
1060
1061 (*clock.lock().unwrap()) += Duration::seconds(1);
1063
1064 tracing::info!("file 2");
1065
1066 (*clock.lock().unwrap()) += Duration::hours(1);
1068
1069 std::thread::sleep(std::time::Duration::from_secs(1));
1071
1072 tracing::info!("file 3");
1073
1074 (*clock.lock().unwrap()) += Duration::seconds(1);
1076
1077 tracing::info!("file 3");
1078
1079 drop(default);
1080
1081 let dir_contents = fs::read_dir(directory.path()).expect("Failed to read directory");
1082 println!("dir={:?}", dir_contents);
1083
1084 for entry in dir_contents {
1085 println!("entry={:?}", entry);
1086 let path = entry.expect("Expected dir entry").path();
1087 let file = fs::read_to_string(&path).expect("Failed to read file");
1088 println!("path={}\nfile={:?}", path.display(), file);
1089
1090 match path
1091 .extension()
1092 .expect("found a file without a date!")
1093 .to_str()
1094 .expect("extension should be UTF8")
1095 {
1096 "2020-02-01-10" => {
1097 panic!("this file should have been pruned already!");
1098 }
1099 "2020-02-01-11" => {
1100 assert_eq!("file 2\nfile 2\n", file);
1101 }
1102 "2020-02-01-12" => {
1103 assert_eq!("file 3\nfile 3\n", file);
1104 }
1105 x => panic!("unexpected date {}", x),
1106 }
1107 }
1108 }
1109}