1use std::fs::{self, File, OpenOptions};
23use std::io::Write;
24use std::path::PathBuf;
25use std::sync::Mutex;
26use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
27
28use log::{LevelFilter, Log, Metadata, Record};
29use time::OffsetDateTime;
30use time::format_description::BorrowedFormatItem;
31use time::macros::format_description;
32
33use crate::runtime::Runtime;
34
35const TIMESTAMP_FORMAT: &[BorrowedFormatItem<'_>] =
37 format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
38
39const DEFAULT_ROTATION_BYTES: u64 = 50 * 1024 * 1024;
41
42const DEFAULT_FILE_FORMAT: &str = "[{timestamp}] [{level}] {message}";
46
47const DEFAULT_SERVER_FORMAT: &str = "{prefix} {message}";
51
52pub struct LoggerConfig {
58 crate_name: String,
59 directory: PathBuf,
60 filename: Option<String>,
61 prefix: Option<String>,
62 level: LevelFilter,
63 also_to_server: bool,
64 banner: BannerMode,
65 rotation: Option<Rotation>,
66 file_format: String,
67 server_format: String,
68}
69
70pub type BannerBuilder = dyn Fn(&BannerMetadata) -> Vec<String> + Send + Sync;
73
74pub enum BannerMode {
76 Off,
78 Default,
81 Custom(Box<BannerBuilder>),
84}
85
86impl std::fmt::Debug for BannerMode {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 match self {
89 Self::Off => f.write_str("Off"),
90 Self::Default => f.write_str("Default"),
91 Self::Custom(_) => f.write_str("Custom(<fn>)"),
92 }
93 }
94}
95
96impl std::fmt::Debug for LoggerConfig {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 f.debug_struct("LoggerConfig")
99 .field("crate_name", &self.crate_name)
100 .field("directory", &self.directory)
101 .field("filename", &self.filename)
102 .field("prefix", &self.prefix)
103 .field("level", &self.level)
104 .field("also_to_server", &self.also_to_server)
105 .field("banner", &self.banner)
106 .field("rotation", &self.rotation)
107 .field("file_format", &self.file_format)
108 .field("server_format", &self.server_format)
109 .finish()
110 }
111}
112
113#[derive(Debug, Clone, Copy)]
125struct Rotation {
126 max_bytes: u64,
127 keep: Option<u32>,
128}
129
130impl LoggerConfig {
131 #[must_use]
135 pub fn new(crate_name: impl Into<String>) -> Self {
136 Self {
137 crate_name: crate_name.into(),
138 directory: PathBuf::from("logs"),
139 filename: None,
140 prefix: None,
141 level: LevelFilter::Info,
142 also_to_server: true,
143 banner: BannerMode::Default,
144 rotation: Some(Rotation {
145 max_bytes: DEFAULT_ROTATION_BYTES,
146 keep: None,
149 }),
150 file_format: DEFAULT_FILE_FORMAT.to_owned(),
151 server_format: DEFAULT_SERVER_FORMAT.to_owned(),
152 }
153 }
154
155 #[must_use]
161 pub fn directory(mut self, path: impl Into<PathBuf>) -> Self {
162 self.directory = path.into();
163 self
164 }
165
166 #[must_use]
170 pub fn filename(mut self, name: impl Into<String>) -> Self {
171 self.filename = Some(name.into());
172 self
173 }
174
175 #[must_use]
179 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
180 self.prefix = Some(prefix.into());
181 self
182 }
183
184 #[must_use]
188 pub fn level(mut self, level: LevelFilter) -> Self {
189 self.level = level;
190 self
191 }
192
193 #[must_use]
197 pub fn also_to_server(mut self, enabled: bool) -> Self {
198 self.also_to_server = enabled;
199 self
200 }
201
202 #[must_use]
207 pub fn banner(mut self, mode: BannerMode) -> Self {
208 self.banner = mode;
209 self
210 }
211
212 #[must_use]
214 pub fn no_banner(mut self) -> Self {
215 self.banner = BannerMode::Off;
216 self
217 }
218
219 #[must_use]
224 pub fn banner_with<F>(mut self, builder: F) -> Self
225 where
226 F: Fn(&BannerMetadata) -> Vec<String> + Send + Sync + 'static,
227 {
228 self.banner = BannerMode::Custom(Box::new(builder));
229 self
230 }
231
232 #[must_use]
236 pub fn file_format(mut self, format: impl Into<String>) -> Self {
237 self.file_format = format.into();
238 self
239 }
240
241 #[must_use]
245 pub fn server_format(mut self, format: impl Into<String>) -> Self {
246 self.server_format = format.into();
247 self
248 }
249
250 #[must_use]
254 pub fn no_rotation(mut self) -> Self {
255 self.rotation = None;
256 self
257 }
258
259 #[must_use]
268 pub fn rotation_size_mb(mut self, mb: u64) -> Self {
269 if mb == 0 {
270 self.rotation = None;
271 } else {
272 let max_bytes = mb.saturating_mul(1024 * 1024);
273 let keep = self.rotation.and_then(|r| r.keep);
274 self.rotation = Some(Rotation { max_bytes, keep });
275 }
276 self
277 }
278
279 #[must_use]
286 pub fn rotation_keep(mut self, keep: u32) -> Self {
287 let max_bytes = self
288 .rotation
289 .map_or(DEFAULT_ROTATION_BYTES, |r| r.max_bytes);
290 self.rotation = Some(Rotation {
291 max_bytes,
292 keep: Some(keep),
293 });
294 self
295 }
296
297 #[must_use]
302 pub fn rotation_no_cleanup(mut self) -> Self {
303 let max_bytes = self
304 .rotation
305 .map_or(DEFAULT_ROTATION_BYTES, |r| r.max_bytes);
306 self.rotation = Some(Rotation {
307 max_bytes,
308 keep: None,
309 });
310 self
311 }
312
313 fn resolved_filename(&self) -> String {
314 self.filename
315 .clone()
316 .unwrap_or_else(|| format!("{}.log", self.crate_name))
317 }
318
319 fn resolved_prefix(&self) -> String {
320 self.prefix
321 .clone()
322 .unwrap_or_else(|| format!("[{}]", self.crate_name))
323 }
324
325 fn log_path(&self) -> PathBuf {
326 self.directory.join(self.resolved_filename())
327 }
328
329 fn resolved_archive_directory(&self) -> PathBuf {
330 self.directory.join("archive")
331 }
332}
333
334#[derive(Debug)]
336pub enum InstallError {
337 AlreadyInstalled,
340 Io(std::io::Error),
342}
343
344impl std::fmt::Display for InstallError {
345 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
346 match self {
347 Self::AlreadyInstalled => f.write_str("logger already installed"),
348 Self::Io(e) => write!(f, "i/o error: {e}"),
349 }
350 }
351}
352
353impl std::error::Error for InstallError {
354 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
355 match self {
356 Self::AlreadyInstalled => None,
357 Self::Io(e) => Some(e),
358 }
359 }
360}
361
362impl From<std::io::Error> for InstallError {
363 fn from(e: std::io::Error) -> Self {
364 Self::Io(e)
365 }
366}
367
368static INSTALLED: AtomicBool = AtomicBool::new(false);
374
375static LEVEL: AtomicU8 = AtomicU8::new(level_to_u8(LevelFilter::Info));
378
379const fn level_to_u8(l: LevelFilter) -> u8 {
380 match l {
381 LevelFilter::Off => 0,
382 LevelFilter::Error => 1,
383 LevelFilter::Warn => 2,
384 LevelFilter::Info => 3,
385 LevelFilter::Debug => 4,
386 LevelFilter::Trace => 5,
387 }
388}
389
390const fn u8_to_level(v: u8) -> LevelFilter {
391 match v {
392 0 => LevelFilter::Off,
393 1 => LevelFilter::Error,
394 2 => LevelFilter::Warn,
395 3 => LevelFilter::Info,
396 4 => LevelFilter::Debug,
397 _ => LevelFilter::Trace,
398 }
399}
400
401pub fn set_level(level: LevelFilter) {
405 LEVEL.store(level_to_u8(level), Ordering::Relaxed);
406 log::set_max_level(level);
407}
408
409#[must_use]
412pub fn level() -> LevelFilter {
413 u8_to_level(LEVEL.load(Ordering::Relaxed))
414}
415
416pub fn install(config: LoggerConfig) -> Result<(), InstallError> {
435 if INSTALLED.swap(true, Ordering::AcqRel) {
436 return Err(InstallError::AlreadyInstalled);
437 }
438
439 fs::create_dir_all(&config.directory)?;
440 let path = config.log_path();
441 let file = OpenOptions::new().create(true).append(true).open(&path)?;
442 let initial_size = file.metadata().map(|m| m.len()).unwrap_or(0);
443
444 let prefix = config.resolved_prefix();
445 let level = config.level;
446 let filename = config.resolved_filename();
447 let archive_directory = config.resolved_archive_directory();
448 let next_archive_index = find_next_archive_index(&archive_directory, &filename);
449 let LoggerConfig {
450 also_to_server,
451 banner,
452 rotation,
453 file_format,
454 server_format,
455 ..
456 } = config;
457
458 let logger = Box::new(LoggerImpl {
459 prefix,
460 also_to_server,
461 rotation,
462 path,
463 filename,
464 archive_directory,
465 file_format,
466 server_format,
467 state: Mutex::new(LoggerState {
468 file: Some(file),
469 current_size: initial_size,
470 file_write_reported: false,
471 next_archive_index,
472 }),
473 });
474
475 set_level(level);
476
477 log::set_boxed_logger(logger).map_err(|_| {
478 INSTALLED.store(false, Ordering::Release);
479 InstallError::AlreadyInstalled
480 })?;
481
482 print_banner_inner(&banner);
483
484 Ok(())
485}
486
487struct LoggerImpl {
492 prefix: String,
493 also_to_server: bool,
494 rotation: Option<Rotation>,
495 path: PathBuf,
496 filename: String,
497 archive_directory: PathBuf,
498 file_format: String,
499 server_format: String,
500 state: Mutex<LoggerState>,
501}
502
503struct LoggerState {
504 file: Option<File>,
505 current_size: u64,
506 file_write_reported: bool,
510 next_archive_index: u32,
514}
515
516impl Log for LoggerImpl {
517 fn enabled(&self, metadata: &Metadata<'_>) -> bool {
518 metadata.level() <= u8_to_level(LEVEL.load(Ordering::Relaxed))
519 }
520
521 fn log(&self, record: &Record<'_>) {
522 if !self.enabled(record.metadata()) {
523 return;
524 }
525
526 let message = format!("{}", record.args());
527 let level = record.level().as_str();
528
529 let timestamp = OffsetDateTime::now_local()
530 .unwrap_or_else(|_| OffsetDateTime::now_utc())
531 .format(TIMESTAMP_FORMAT)
532 .unwrap_or_else(|_| String::from("0000-00-00 00:00:00"));
533
534 if self.also_to_server {
536 let server_line = apply_format(
537 &self.server_format,
538 Some(&self.prefix),
539 ×tamp,
540 level,
541 &message,
542 );
543 Runtime::get().log(server_line);
544 }
545
546 let mut line = apply_format(&self.file_format, None, ×tamp, level, &message);
548 line.push('\n');
549
550 let mut state = match self.state.lock() {
551 Ok(s) => s,
552 Err(p) => p.into_inner(),
553 };
554
555 if let Some(rotation) = self.rotation
556 && state.current_size + line.len() as u64 > rotation.max_bytes
557 {
558 self.rotate(&mut state, rotation);
559 }
560
561 if let Some(file) = state.file.as_mut() {
562 match file.write_all(line.as_bytes()) {
563 Ok(()) => state.current_size += line.len() as u64,
564 Err(e) => {
565 if !state.file_write_reported {
566 state.file_write_reported = true;
567 Runtime::get().log(format!(
568 "{} failed to write {}: {}. Further file-write errors will be suppressed.",
569 self.prefix,
570 self.path.display(),
571 e,
572 ));
573 }
574 }
575 }
576 }
577 }
578
579 fn flush(&self) {
580 if let Ok(mut state) = self.state.lock()
581 && let Some(file) = state.file.as_mut()
582 {
583 let _ = file.flush();
584 }
585 }
586}
587
588impl LoggerImpl {
589 fn rotate(&self, state: &mut LoggerState, rotation: Rotation) {
598 state.file = None;
600
601 if let Err(e) = fs::create_dir_all(&self.archive_directory) {
603 self.report_file_error(state, "create archive directory", &e);
604 self.reopen_active(state);
607 return;
608 }
609
610 match rotation.keep {
611 Some(keep) if keep > 0 => self.rotate_shift(keep),
612 _ => {
614 let index = state.next_archive_index;
615 state.next_archive_index = state.next_archive_index.saturating_add(1);
616 let _ = fs::rename(&self.path, self.archive_path(index));
617 }
618 }
619
620 self.reopen_active(state);
621 }
622
623 fn rotate_shift(&self, keep: u32) {
626 let _ = fs::remove_file(self.archive_path(keep));
627 for index in (1..keep).rev() {
628 let src = self.archive_path(index);
629 let dst = self.archive_path(index + 1);
630 if src.exists() {
631 let _ = fs::rename(&src, &dst);
632 }
633 }
634 let _ = fs::rename(&self.path, self.archive_path(1));
635 }
636
637 fn reopen_active(&self, state: &mut LoggerState) {
638 match OpenOptions::new()
639 .create(true)
640 .append(true)
641 .open(&self.path)
642 {
643 Ok(file) => {
644 state.file = Some(file);
645 state.current_size = 0;
646 }
647 Err(e) => self.report_file_error(state, "reopen", &e),
648 }
649 }
650
651 fn report_file_error(&self, state: &mut LoggerState, action: &str, e: &std::io::Error) {
652 if !state.file_write_reported {
653 state.file_write_reported = true;
654 Runtime::get().log(format!(
655 "{} failed to {} {}: {}. Further file-write errors will be suppressed.",
656 self.prefix,
657 action,
658 self.path.display(),
659 e,
660 ));
661 }
662 }
663
664 fn archive_path(&self, index: u32) -> PathBuf {
665 self.archive_directory
666 .join(format!("{}.{}", self.filename, index))
667 }
668}
669
670fn find_next_archive_index(archive_dir: &std::path::Path, filename: &str) -> u32 {
675 let prefix = format!("{filename}.");
676 let mut max = 0u32;
677 if let Ok(entries) = fs::read_dir(archive_dir) {
678 for entry in entries.flatten() {
679 if let Some(name) = entry.file_name().to_str()
680 && let Some(rest) = name.strip_prefix(&prefix)
681 && let Ok(index) = rest.parse::<u32>()
682 {
683 max = max.max(index);
684 }
685 }
686 }
687 max.saturating_add(1)
688}
689
690thread_local! {
695 static BANNER_METADATA: std::cell::RefCell<Option<BannerMetadata>> =
700 const { std::cell::RefCell::new(None) };
701}
702
703#[doc(hidden)]
707pub fn __set_banner_metadata(metadata: BannerMetadata) {
708 BANNER_METADATA.with(|cell| {
709 *cell.borrow_mut() = Some(metadata);
710 });
711}
712
713#[derive(Debug, Clone)]
715pub struct BannerMetadata {
716 pub name: &'static str,
717 pub version: &'static str,
718 pub authors: &'static str,
719 pub repository: &'static str,
720}
721
722impl BannerMetadata {
723 #[must_use]
726 pub fn new(
727 name: &'static str,
728 version: &'static str,
729 authors: &'static str,
730 repository: &'static str,
731 ) -> Self {
732 Self {
733 name,
734 version,
735 authors,
736 repository,
737 }
738 }
739}
740
741fn apply_format(
752 template: &str,
753 prefix: Option<&str>,
754 timestamp: &str,
755 level: &str,
756 message: &str,
757) -> String {
758 let mut out = String::with_capacity(template.len());
759 let bytes = template.as_bytes();
760 let mut i = 0;
761
762 while i < bytes.len() {
763 if bytes[i] == b'{'
764 && let Some(close) = template[i + 1..].find('}')
765 {
766 let end = i + 1 + close;
767 let spec = &template[i + 1..end];
768 if let Some(rendered) = render_placeholder(spec, prefix, timestamp, level, message) {
769 out.push_str(&rendered);
770 } else {
771 out.push_str(&template[i..=end]);
773 }
774 i = end + 1;
775 } else {
776 out.push(bytes[i] as char);
777 i += 1;
778 }
779 }
780
781 out
782}
783
784fn render_placeholder(
787 spec: &str,
788 prefix: Option<&str>,
789 timestamp: &str,
790 level: &str,
791 message: &str,
792) -> Option<String> {
793 let (name, format_spec) = spec.split_once(':').unwrap_or((spec, ""));
794 let value: &str = match name {
795 "timestamp" => timestamp,
796 "level" => level,
797 "message" => message,
798 "prefix" => prefix.unwrap_or(""),
799 _ => return None,
800 };
801
802 if format_spec.is_empty() {
803 return Some(value.to_owned());
804 }
805
806 let (alignment, width_str) = match format_spec.chars().next() {
807 Some('<') => (Alignment::Left, &format_spec[1..]),
808 Some('>') => (Alignment::Right, &format_spec[1..]),
809 Some('^') => (Alignment::Center, &format_spec[1..]),
810 _ => return Some(value.to_owned()),
811 };
812
813 let Ok(width) = width_str.parse::<usize>() else {
814 return Some(value.to_owned());
815 };
816
817 Some(match alignment {
818 Alignment::Left => format!("{value:<width$}"),
819 Alignment::Right => format!("{value:>width$}"),
820 Alignment::Center => format!("{value:^width$}"),
821 })
822}
823
824enum Alignment {
825 Left,
826 Right,
827 Center,
828}
829
830fn print_banner_inner(mode: &BannerMode) {
831 let metadata = BANNER_METADATA.with(|cell| cell.borrow().clone());
832 let Some(meta) = metadata else {
833 return;
836 };
837
838 let lines = match mode {
839 BannerMode::Off => return,
840 BannerMode::Default => default_banner_lines(&meta),
841 BannerMode::Custom(builder) => builder(&meta),
842 };
843
844 for line in lines {
845 log::info!("{line}");
846 }
847}
848
849fn default_banner_lines(meta: &BannerMetadata) -> Vec<String> {
850 let authors = if meta.authors.trim().is_empty() {
851 "Unknown"
852 } else {
853 meta.authors
854 };
855 let repository = if meta.repository.trim().is_empty() {
856 "N/A"
857 } else {
858 meta.repository
859 };
860
861 vec![
862 String::new(),
863 format!(" | {} {}", meta.name, meta.version),
864 String::from(" |-------------------------------"),
865 format!(" | Author: {}", authors),
866 format!(" | Repository: {}", repository),
867 String::new(),
868 ]
869}
870
871pub fn print_banner() {
878 print_banner_inner(&BannerMode::Default);
882}
883
884#[cfg(test)]
889mod tests {
890 use super::*;
891 use std::path::Path;
892
893 #[test]
894 fn config_resolves_defaults() {
895 let cfg = LoggerConfig::new("my-plugin");
896 assert_eq!(cfg.resolved_filename(), "my-plugin.log");
897 assert_eq!(cfg.resolved_prefix(), "[my-plugin]");
898 assert_eq!(cfg.log_path(), Path::new("logs/my-plugin.log"));
899 assert_eq!(cfg.resolved_archive_directory(), Path::new("logs/archive"));
900 assert_eq!(cfg.level, LevelFilter::Info);
901 assert!(cfg.also_to_server);
902 assert!(matches!(cfg.banner, BannerMode::Default));
903 assert_eq!(cfg.file_format, DEFAULT_FILE_FORMAT);
904 assert_eq!(cfg.server_format, DEFAULT_SERVER_FORMAT);
905 let rotation = cfg.rotation.expect("default rotation enabled");
906 assert_eq!(rotation.max_bytes, 50 * 1024 * 1024);
907 assert_eq!(rotation.keep, None);
909 }
910
911 #[test]
912 fn config_overrides_apply() {
913 let cfg = LoggerConfig::new("foo")
914 .directory("custom")
915 .filename("custom.log")
916 .prefix("[Custom]")
917 .level(LevelFilter::Warn)
918 .also_to_server(false)
919 .no_banner()
920 .rotation_size_mb(10)
921 .rotation_keep(3)
922 .file_format("{level}: {message}")
923 .server_format("<{prefix}> {message}");
924 assert_eq!(cfg.directory, Path::new("custom"));
925 assert_eq!(
928 cfg.resolved_archive_directory(),
929 Path::new("custom/archive")
930 );
931 assert_eq!(cfg.resolved_filename(), "custom.log");
932 assert_eq!(cfg.resolved_prefix(), "[Custom]");
933 assert_eq!(cfg.level, LevelFilter::Warn);
934 assert!(!cfg.also_to_server);
935 assert!(matches!(cfg.banner, BannerMode::Off));
936 assert_eq!(cfg.file_format, "{level}: {message}");
937 assert_eq!(cfg.server_format, "<{prefix}> {message}");
938 let rotation = cfg.rotation.expect("explicit rotation kept");
939 assert_eq!(rotation.max_bytes, 10 * 1024 * 1024);
940 assert_eq!(rotation.keep, Some(3));
941 }
942
943 #[test]
944 fn rotation_no_cleanup_resets_keep_to_none() {
945 let cfg = LoggerConfig::new("foo")
946 .rotation_keep(5)
947 .rotation_no_cleanup();
948 let rotation = cfg.rotation.expect("rotation still active");
949 assert_eq!(rotation.keep, None);
950 }
951
952 #[test]
953 fn apply_format_substitutes_placeholders() {
954 let line = apply_format(
955 "[{timestamp}] [{level}] {message}",
956 None,
957 "2026-06-08 12:30:45",
958 "INFO",
959 "ready",
960 );
961 assert_eq!(line, "[2026-06-08 12:30:45] [INFO] ready");
962
963 let server = apply_format(
964 "{prefix} {message}",
965 Some("[my-plugin]"),
966 "2026-06-08 12:30:45",
967 "WARN",
968 "stalled",
969 );
970 assert_eq!(server, "[my-plugin] stalled");
971 }
972
973 #[test]
974 fn apply_format_supports_width_specifiers() {
975 let right = apply_format("[{level:>5}] {message}", None, "ts", "INFO", "msg");
976 assert_eq!(right, "[ INFO] msg");
977
978 let left = apply_format("[{level:<5}] {message}", None, "ts", "INFO", "msg");
979 assert_eq!(left, "[INFO ] msg");
980
981 let center = apply_format("[{level:^6}] {message}", None, "ts", "INFO", "msg");
982 assert_eq!(center, "[ INFO ] msg");
983 }
984
985 #[test]
986 fn apply_format_width_smaller_than_value_does_not_truncate() {
987 let line = apply_format("[{level:>2}] {message}", None, "ts", "INFO", "msg");
988 assert_eq!(line, "[INFO] msg");
990 }
991
992 #[test]
993 fn apply_format_leaves_unknown_placeholders_untouched() {
994 let line = apply_format(
995 "{foo} {message}",
996 None,
997 "2026-06-08 12:30:45",
998 "INFO",
999 "ready",
1000 );
1001 assert_eq!(line, "{foo} ready");
1002 }
1003
1004 #[test]
1005 fn custom_banner_lines_emit_in_order() {
1006 let cfg = LoggerConfig::new("foo").banner_with(|meta| {
1007 vec![
1008 String::from("=== plugin start ==="),
1009 format!("hello {}!", meta.name),
1010 ]
1011 });
1012 let lines = match &cfg.banner {
1013 BannerMode::Custom(builder) => builder(&BannerMetadata::new(
1014 "foo",
1015 "1.0",
1016 "ZOTTCE",
1017 "https://example.com",
1018 )),
1019 _ => unreachable!(),
1020 };
1021 assert_eq!(lines.len(), 2);
1022 assert_eq!(lines[0], "=== plugin start ===");
1023 assert_eq!(lines[1], "hello foo!");
1024 }
1025
1026 #[test]
1027 fn no_rotation_disables_archives() {
1028 let cfg = LoggerConfig::new("foo").rotation_size_mb(20).no_rotation();
1029 assert!(cfg.rotation.is_none());
1030 }
1031
1032 #[test]
1033 fn rotation_size_mb_zero_disables() {
1034 let cfg = LoggerConfig::new("foo").rotation_size_mb(0);
1035 assert!(cfg.rotation.is_none());
1036 }
1037
1038 #[test]
1039 fn level_round_trip() {
1040 for level in [
1041 LevelFilter::Off,
1042 LevelFilter::Error,
1043 LevelFilter::Warn,
1044 LevelFilter::Info,
1045 LevelFilter::Debug,
1046 LevelFilter::Trace,
1047 ] {
1048 assert_eq!(u8_to_level(level_to_u8(level)), level);
1049 }
1050 }
1051
1052 #[test]
1053 fn set_and_read_level() {
1054 set_level(LevelFilter::Warn);
1055 assert_eq!(level(), LevelFilter::Warn);
1056 set_level(LevelFilter::Trace);
1057 assert_eq!(level(), LevelFilter::Trace);
1058 }
1059}