1use std::fs::{self, File, OpenOptions};
23use std::io::Write;
24use std::path::PathBuf;
25use std::ptr;
26use std::sync::Mutex;
27use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU8, Ordering};
28
29use log::{LevelFilter, Log, Metadata, Record};
30use time::OffsetDateTime;
31use time::format_description::BorrowedFormatItem;
32use time::macros::format_description;
33
34use crate::runtime::Runtime;
35
36const TIMESTAMP_FORMAT: &[BorrowedFormatItem<'_>] =
38 format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
39
40const DEFAULT_ROTATION_BYTES: u64 = 50 * 1024 * 1024;
42
43const DEFAULT_FILE_FORMAT: &str = "[{timestamp}] [{level}] {message}";
47
48const DEFAULT_SERVER_FORMAT: &str = "{prefix} {message}";
52
53pub struct LoggerConfig {
59 crate_name: String,
60 directory: PathBuf,
61 filename: Option<String>,
62 prefix: Option<String>,
63 level: LevelFilter,
64 also_to_server: bool,
65 banner: BannerMode,
66 rotation: Option<Rotation>,
67 file_format: String,
68 server_format: String,
69 #[cfg(feature = "compression")]
73 compress_archives: bool,
74 sinks: Vec<Box<dyn Sink>>,
77}
78
79pub trait Sink: Send + Sync {
103 fn emit(&self, record: &SinkRecord<'_>);
105}
106
107#[derive(Debug)]
111pub struct SinkRecord<'a> {
112 pub timestamp: &'a str,
114 pub level: log::Level,
116 pub target: &'a str,
119 pub message: &'a str,
121 pub prefix: &'a str,
123}
124
125pub type BannerBuilder = dyn Fn(&BannerMetadata) -> Vec<String> + Send + Sync;
128
129pub enum BannerMode {
131 Off,
133 Default,
136 Custom(Box<BannerBuilder>),
139}
140
141impl std::fmt::Debug for BannerMode {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 match self {
144 Self::Off => f.write_str("Off"),
145 Self::Default => f.write_str("Default"),
146 Self::Custom(_) => f.write_str("Custom(<fn>)"),
147 }
148 }
149}
150
151impl std::fmt::Debug for LoggerConfig {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 let mut s = f.debug_struct("LoggerConfig");
154 s.field("crate_name", &self.crate_name)
155 .field("directory", &self.directory)
156 .field("filename", &self.filename)
157 .field("prefix", &self.prefix)
158 .field("level", &self.level)
159 .field("also_to_server", &self.also_to_server)
160 .field("banner", &self.banner)
161 .field("rotation", &self.rotation)
162 .field("file_format", &self.file_format)
163 .field("server_format", &self.server_format)
164 .field("sinks", &format_args!("[{} sink(s)]", self.sinks.len()));
165 #[cfg(feature = "compression")]
166 s.field("compress_archives", &self.compress_archives);
167 s.finish()
168 }
169}
170
171#[derive(Debug, Clone, Copy)]
183struct Rotation {
184 max_bytes: u64,
185 keep: Option<u32>,
186}
187
188impl LoggerConfig {
189 #[must_use]
193 pub fn new(crate_name: impl Into<String>) -> Self {
194 Self {
195 crate_name: crate_name.into(),
196 directory: PathBuf::from("logs"),
197 filename: None,
198 prefix: None,
199 level: LevelFilter::Info,
200 also_to_server: true,
201 banner: BannerMode::Default,
202 rotation: Some(Rotation {
203 max_bytes: DEFAULT_ROTATION_BYTES,
204 keep: None,
207 }),
208 file_format: DEFAULT_FILE_FORMAT.to_owned(),
209 server_format: DEFAULT_SERVER_FORMAT.to_owned(),
210 #[cfg(feature = "compression")]
211 compress_archives: false,
212 sinks: Vec::new(),
213 }
214 }
215
216 #[must_use]
235 pub fn add_sink(mut self, sink: Box<dyn Sink>) -> Self {
236 self.sinks.push(sink);
237 self
238 }
239
240 #[cfg(feature = "compression")]
252 #[must_use]
253 pub fn compress_archives(mut self, yes: bool) -> Self {
254 self.compress_archives = yes;
255 self
256 }
257
258 #[must_use]
288 pub fn from_env(mut self) -> Self {
289 let prefix = env_var_prefix(&self.crate_name);
290 self.apply_env(&prefix);
291 self
292 }
293
294 fn apply_env(&mut self, prefix: &str) {
295 if let Some(raw) = read_env(prefix, "LEVEL") {
296 match parse_level(&raw) {
297 Some(l) => self.level = l,
298 None => warn_invalid(prefix, "LEVEL", &raw),
299 }
300 }
301 if let Some(raw) = read_env(prefix, "DIR") {
302 self.directory = PathBuf::from(raw);
303 }
304 if let Some(raw) = read_env(prefix, "FILE") {
305 self.filename = Some(raw);
306 }
307 if let Some(raw) = read_env(prefix, "ROTATION_MB") {
308 match raw.parse::<u64>() {
309 Ok(0) => self.rotation = None,
310 Ok(mb) => {
311 let max_bytes = mb.saturating_mul(1024 * 1024);
312 let keep = self.rotation.and_then(|r| r.keep);
313 self.rotation = Some(Rotation { max_bytes, keep });
314 }
315 Err(_) => warn_invalid(prefix, "ROTATION_MB", &raw),
316 }
317 }
318 if let Some(raw) = read_env(prefix, "ROTATION_KEEP") {
319 match raw.parse::<u32>() {
320 Ok(keep) => {
321 let max_bytes = self
322 .rotation
323 .map_or(DEFAULT_ROTATION_BYTES, |r| r.max_bytes);
324 self.rotation = Some(Rotation {
325 max_bytes,
326 keep: Some(keep),
327 });
328 }
329 Err(_) => warn_invalid(prefix, "ROTATION_KEEP", &raw),
330 }
331 }
332 if let Some(raw) = read_env(prefix, "NO_ROTATION")
333 && parse_bool(&raw)
334 {
335 self.rotation = None;
336 }
337 if let Some(raw) = read_env(prefix, "NO_BANNER")
338 && parse_bool(&raw)
339 {
340 self.banner = BannerMode::Off;
341 }
342 if let Some(raw) = read_env(prefix, "SERVER") {
343 self.also_to_server = parse_bool(&raw);
344 }
345 #[cfg(feature = "compression")]
346 if let Some(raw) = read_env(prefix, "COMPRESS") {
347 self.compress_archives = parse_bool(&raw);
348 }
349 }
350
351 #[must_use]
357 pub fn directory(mut self, path: impl Into<PathBuf>) -> Self {
358 self.directory = path.into();
359 self
360 }
361
362 #[must_use]
366 pub fn filename(mut self, name: impl Into<String>) -> Self {
367 self.filename = Some(name.into());
368 self
369 }
370
371 #[must_use]
375 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
376 self.prefix = Some(prefix.into());
377 self
378 }
379
380 #[must_use]
384 pub fn level(mut self, level: LevelFilter) -> Self {
385 self.level = level;
386 self
387 }
388
389 #[must_use]
393 pub fn also_to_server(mut self, enabled: bool) -> Self {
394 self.also_to_server = enabled;
395 self
396 }
397
398 #[must_use]
403 pub fn banner(mut self, mode: BannerMode) -> Self {
404 self.banner = mode;
405 self
406 }
407
408 #[must_use]
410 pub fn no_banner(mut self) -> Self {
411 self.banner = BannerMode::Off;
412 self
413 }
414
415 #[must_use]
420 pub fn banner_with<F>(mut self, builder: F) -> Self
421 where
422 F: Fn(&BannerMetadata) -> Vec<String> + Send + Sync + 'static,
423 {
424 self.banner = BannerMode::Custom(Box::new(builder));
425 self
426 }
427
428 #[must_use]
432 pub fn file_format(mut self, format: impl Into<String>) -> Self {
433 self.file_format = format.into();
434 self
435 }
436
437 #[must_use]
441 pub fn server_format(mut self, format: impl Into<String>) -> Self {
442 self.server_format = format.into();
443 self
444 }
445
446 #[must_use]
450 pub fn no_rotation(mut self) -> Self {
451 self.rotation = None;
452 self
453 }
454
455 #[must_use]
464 pub fn rotation_size_mb(mut self, mb: u64) -> Self {
465 if mb == 0 {
466 self.rotation = None;
467 } else {
468 let max_bytes = mb.saturating_mul(1024 * 1024);
469 let keep = self.rotation.and_then(|r| r.keep);
470 self.rotation = Some(Rotation { max_bytes, keep });
471 }
472 self
473 }
474
475 #[must_use]
482 pub fn rotation_keep(mut self, keep: u32) -> Self {
483 let max_bytes = self
484 .rotation
485 .map_or(DEFAULT_ROTATION_BYTES, |r| r.max_bytes);
486 self.rotation = Some(Rotation {
487 max_bytes,
488 keep: Some(keep),
489 });
490 self
491 }
492
493 #[must_use]
498 pub fn rotation_no_cleanup(mut self) -> Self {
499 let max_bytes = self
500 .rotation
501 .map_or(DEFAULT_ROTATION_BYTES, |r| r.max_bytes);
502 self.rotation = Some(Rotation {
503 max_bytes,
504 keep: None,
505 });
506 self
507 }
508
509 fn resolved_filename(&self) -> String {
510 self.filename
511 .clone()
512 .unwrap_or_else(|| format!("{}.log", self.crate_name))
513 }
514
515 fn resolved_prefix(&self) -> String {
516 self.prefix
517 .clone()
518 .unwrap_or_else(|| format!("[{}]", self.crate_name))
519 }
520
521 fn log_path(&self) -> PathBuf {
522 self.directory.join(self.resolved_filename())
523 }
524
525 fn resolved_archive_directory(&self) -> PathBuf {
526 self.directory.join("archive")
527 }
528}
529
530#[derive(Debug)]
532pub enum InstallError {
533 AlreadyInstalled,
536 Io(std::io::Error),
538}
539
540impl std::fmt::Display for InstallError {
541 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
542 match self {
543 Self::AlreadyInstalled => f.write_str("logger already installed"),
544 Self::Io(e) => write!(f, "i/o error: {e}"),
545 }
546 }
547}
548
549impl std::error::Error for InstallError {
550 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
551 match self {
552 Self::AlreadyInstalled => None,
553 Self::Io(e) => Some(e),
554 }
555 }
556}
557
558impl From<std::io::Error> for InstallError {
559 fn from(e: std::io::Error) -> Self {
560 Self::Io(e)
561 }
562}
563
564static INSTALLED: AtomicBool = AtomicBool::new(false);
570
571static INSTANCE: AtomicPtr<LoggerImpl> = AtomicPtr::new(ptr::null_mut());
578
579static LEVEL: AtomicU8 = AtomicU8::new(level_to_u8(LevelFilter::Info));
582
583const fn level_to_u8(l: LevelFilter) -> u8 {
584 match l {
585 LevelFilter::Off => 0,
586 LevelFilter::Error => 1,
587 LevelFilter::Warn => 2,
588 LevelFilter::Info => 3,
589 LevelFilter::Debug => 4,
590 LevelFilter::Trace => 5,
591 }
592}
593
594const fn u8_to_level(v: u8) -> LevelFilter {
595 match v {
596 0 => LevelFilter::Off,
597 1 => LevelFilter::Error,
598 2 => LevelFilter::Warn,
599 3 => LevelFilter::Info,
600 4 => LevelFilter::Debug,
601 _ => LevelFilter::Trace,
602 }
603}
604
605pub fn set_level(level: LevelFilter) {
609 LEVEL.store(level_to_u8(level), Ordering::Relaxed);
610 log::set_max_level(level);
611}
612
613#[must_use]
616pub fn level() -> LevelFilter {
617 u8_to_level(LEVEL.load(Ordering::Relaxed))
618}
619
620pub fn flush() {
631 let ptr = INSTANCE.load(Ordering::Acquire);
632 if ptr.is_null() {
633 return;
634 }
635 let logger = unsafe { &*ptr };
640 log::Log::flush(logger);
641}
642
643pub fn install(config: LoggerConfig) -> Result<(), InstallError> {
662 if INSTALLED.swap(true, Ordering::AcqRel) {
663 return Err(InstallError::AlreadyInstalled);
664 }
665
666 fs::create_dir_all(&config.directory)?;
667 let path = config.log_path();
668 let file = OpenOptions::new().create(true).append(true).open(&path)?;
669 let initial_size = file.metadata().map(|m| m.len()).unwrap_or(0);
670
671 let prefix = config.resolved_prefix();
672 let level = config.level;
673 let filename = config.resolved_filename();
674 let archive_directory = config.resolved_archive_directory();
675 let next_archive_index = find_next_archive_index(&archive_directory, &filename);
676 #[cfg(feature = "compression")]
677 let compress_archives = config.compress_archives;
678 let LoggerConfig {
679 also_to_server,
680 banner,
681 rotation,
682 file_format,
683 server_format,
684 sinks,
685 ..
686 } = config;
687
688 let logger = Box::new(LoggerImpl {
689 prefix,
690 also_to_server,
691 rotation,
692 path,
693 filename,
694 archive_directory,
695 file_format,
696 server_format,
697 #[cfg(feature = "compression")]
698 compress_archives,
699 sinks,
700 state: Mutex::new(LoggerState {
701 file: Some(file),
702 current_size: initial_size,
703 file_write_reported: false,
704 next_archive_index,
705 }),
706 });
707
708 set_level(level);
709
710 let logger_ptr: *const LoggerImpl = &raw const *logger;
711 INSTANCE.store(logger_ptr.cast_mut(), Ordering::Release);
712 log::set_boxed_logger(logger).map_err(|_| {
713 INSTANCE.store(ptr::null_mut(), Ordering::Release);
714 INSTALLED.store(false, Ordering::Release);
715 InstallError::AlreadyInstalled
716 })?;
717
718 print_banner_inner(&banner);
719
720 Ok(())
721}
722
723struct LoggerImpl {
728 prefix: String,
729 also_to_server: bool,
730 rotation: Option<Rotation>,
731 path: PathBuf,
732 filename: String,
733 archive_directory: PathBuf,
734 file_format: String,
735 server_format: String,
736 #[cfg(feature = "compression")]
737 compress_archives: bool,
738 sinks: Vec<Box<dyn Sink>>,
739 state: Mutex<LoggerState>,
740}
741
742struct LoggerState {
743 file: Option<File>,
744 current_size: u64,
745 file_write_reported: bool,
749 next_archive_index: u32,
753}
754
755impl Log for LoggerImpl {
756 fn enabled(&self, metadata: &Metadata<'_>) -> bool {
757 metadata.level() <= u8_to_level(LEVEL.load(Ordering::Relaxed))
758 }
759
760 fn log(&self, record: &Record<'_>) {
761 if !self.enabled(record.metadata()) {
762 return;
763 }
764
765 let message = format!("{}", record.args());
766 let level = record.level().as_str();
767
768 let timestamp = OffsetDateTime::now_local()
769 .unwrap_or_else(|_| OffsetDateTime::now_utc())
770 .format(TIMESTAMP_FORMAT)
771 .unwrap_or_else(|_| String::from("0000-00-00 00:00:00"));
772
773 if self.also_to_server {
775 let server_line = apply_format(
776 &self.server_format,
777 Some(&self.prefix),
778 ×tamp,
779 level,
780 &message,
781 );
782 Runtime::get().log(server_line);
783 }
784
785 let mut line = apply_format(&self.file_format, None, ×tamp, level, &message);
787 line.push('\n');
788
789 let mut state = match self.state.lock() {
790 Ok(s) => s,
791 Err(p) => p.into_inner(),
792 };
793
794 if let Some(rotation) = self.rotation
795 && state.current_size + line.len() as u64 > rotation.max_bytes
796 {
797 self.rotate(&mut state, rotation);
798 }
799
800 if let Some(file) = state.file.as_mut() {
801 match file.write_all(line.as_bytes()) {
802 Ok(()) => state.current_size += line.len() as u64,
803 Err(e) => {
804 if !state.file_write_reported {
805 state.file_write_reported = true;
806 Runtime::get().log(format!(
807 "{} failed to write {}: {}. Further file-write errors will be suppressed.",
808 self.prefix,
809 self.path.display(),
810 e,
811 ));
812 }
813 }
814 }
815 }
816
817 if !self.sinks.is_empty() {
820 let sink_record = SinkRecord {
821 timestamp: ×tamp,
822 level: record.level(),
823 target: record.target(),
824 message: &message,
825 prefix: &self.prefix,
826 };
827 for sink in &self.sinks {
828 sink.emit(&sink_record);
829 }
830 }
831 }
832
833 fn flush(&self) {
834 if let Ok(mut state) = self.state.lock()
835 && let Some(file) = state.file.as_mut()
836 {
837 let _ = file.flush();
838 }
839 }
840}
841
842impl LoggerImpl {
843 fn rotate(&self, state: &mut LoggerState, rotation: Rotation) {
852 state.file = None;
854
855 if let Err(e) = fs::create_dir_all(&self.archive_directory) {
857 self.report_file_error(state, "create archive directory", &e);
858 self.reopen_active(state);
861 return;
862 }
863
864 match rotation.keep {
865 Some(keep) if keep > 0 => self.rotate_shift(keep),
866 _ => {
868 let index = state.next_archive_index;
869 state.next_archive_index = state.next_archive_index.saturating_add(1);
870 let archived = self.archive_path(index);
871 if fs::rename(&self.path, &archived).is_ok() {
872 self.compress_archive(&archived);
873 }
874 }
875 }
876
877 self.reopen_active(state);
878 }
879
880 #[cfg(feature = "compression")]
888 fn compress_archive(&self, archived: &std::path::Path) {
889 if !self.compress_archives {
890 return;
891 }
892 let gz_path = {
893 let mut p = archived.as_os_str().to_owned();
894 p.push(".gz");
895 PathBuf::from(p)
896 };
897 let Ok(input) = fs::File::open(archived) else {
898 return;
899 };
900 let Ok(output) = fs::File::create(&gz_path) else {
901 return;
902 };
903 let mut encoder = flate2::write::GzEncoder::new(output, flate2::Compression::default());
904 let mut reader = std::io::BufReader::new(input);
905 if std::io::copy(&mut reader, &mut encoder).is_err() {
906 let _ = fs::remove_file(&gz_path);
907 return;
908 }
909 if encoder.finish().is_err() {
910 let _ = fs::remove_file(&gz_path);
911 return;
912 }
913 let _ = fs::remove_file(archived);
914 }
915
916 #[cfg(not(feature = "compression"))]
917 #[allow(clippy::unused_self)]
918 fn compress_archive(&self, _archived: &std::path::Path) {}
919
920 fn rotate_shift(&self, keep: u32) {
925 let _ = fs::remove_file(self.archive_path(keep));
926 #[cfg(feature = "compression")]
927 let _ = fs::remove_file(append_gz(&self.archive_path(keep)));
928 for index in (1..keep).rev() {
929 let src = self.archive_path(index);
930 let dst = self.archive_path(index + 1);
931 if src.exists() {
932 let _ = fs::rename(&src, &dst);
933 }
934 #[cfg(feature = "compression")]
935 {
936 let src_gz = append_gz(&src);
937 let dst_gz = append_gz(&dst);
938 if src_gz.exists() {
939 let _ = fs::rename(&src_gz, &dst_gz);
940 }
941 }
942 }
943 let archived = self.archive_path(1);
944 if fs::rename(&self.path, &archived).is_ok() {
945 self.compress_archive(&archived);
946 }
947 }
948
949 fn reopen_active(&self, state: &mut LoggerState) {
950 match OpenOptions::new()
951 .create(true)
952 .append(true)
953 .open(&self.path)
954 {
955 Ok(file) => {
956 state.file = Some(file);
957 state.current_size = 0;
958 }
959 Err(e) => self.report_file_error(state, "reopen", &e),
960 }
961 }
962
963 fn report_file_error(&self, state: &mut LoggerState, action: &str, e: &std::io::Error) {
964 if !state.file_write_reported {
965 state.file_write_reported = true;
966 Runtime::get().log(format!(
967 "{} failed to {} {}: {}. Further file-write errors will be suppressed.",
968 self.prefix,
969 action,
970 self.path.display(),
971 e,
972 ));
973 }
974 }
975
976 fn archive_path(&self, index: u32) -> PathBuf {
977 self.archive_directory
978 .join(format!("{}.{}", self.filename, index))
979 }
980}
981
982fn env_var_prefix(crate_name: &str) -> String {
985 crate_name
986 .chars()
987 .map(|c| {
988 if c.is_ascii_alphanumeric() {
989 c.to_ascii_uppercase()
990 } else {
991 '_'
992 }
993 })
994 .collect()
995}
996
997fn read_env(prefix: &str, key: &str) -> Option<String> {
998 let name = format!("{prefix}_LOG_{key}");
999 std::env::var(&name).ok().filter(|s| !s.is_empty())
1000}
1001
1002fn parse_level(raw: &str) -> Option<LevelFilter> {
1003 match raw.trim().to_ascii_lowercase().as_str() {
1004 "off" => Some(LevelFilter::Off),
1005 "error" => Some(LevelFilter::Error),
1006 "warn" | "warning" => Some(LevelFilter::Warn),
1007 "info" => Some(LevelFilter::Info),
1008 "debug" => Some(LevelFilter::Debug),
1009 "trace" => Some(LevelFilter::Trace),
1010 _ => None,
1011 }
1012}
1013
1014fn parse_bool(raw: &str) -> bool {
1015 matches!(
1016 raw.trim().to_ascii_lowercase().as_str(),
1017 "1" | "true" | "yes" | "on"
1018 )
1019}
1020
1021fn warn_invalid(prefix: &str, key: &str, raw: &str) {
1027 let msg = format!(
1028 "[rust-samp] ignoring invalid env var {prefix}_LOG_{key}={raw:?} — keeping previous value",
1029 );
1030 if let Some(rt) = Runtime::try_get() {
1031 rt.log(msg);
1032 } else {
1033 eprintln!("{msg}");
1034 }
1035}
1036
1037#[cfg(feature = "compression")]
1038fn append_gz(path: &std::path::Path) -> PathBuf {
1039 let mut s = path.as_os_str().to_owned();
1040 s.push(".gz");
1041 PathBuf::from(s)
1042}
1043
1044fn find_next_archive_index(archive_dir: &std::path::Path, filename: &str) -> u32 {
1049 let prefix = format!("{filename}.");
1050 let mut max = 0u32;
1051 if let Ok(entries) = fs::read_dir(archive_dir) {
1052 for entry in entries.flatten() {
1053 if let Some(name) = entry.file_name().to_str()
1054 && let Some(rest) = name.strip_prefix(&prefix)
1055 {
1056 let idx_str = rest.strip_suffix(".gz").unwrap_or(rest);
1059 if let Ok(index) = idx_str.parse::<u32>() {
1060 max = max.max(index);
1061 }
1062 }
1063 }
1064 }
1065 max.saturating_add(1)
1066}
1067
1068thread_local! {
1073 static BANNER_METADATA: std::cell::RefCell<Option<BannerMetadata>> =
1078 const { std::cell::RefCell::new(None) };
1079}
1080
1081#[doc(hidden)]
1085pub fn __set_banner_metadata(metadata: BannerMetadata) {
1086 BANNER_METADATA.with(|cell| {
1087 *cell.borrow_mut() = Some(metadata);
1088 });
1089}
1090
1091#[derive(Debug, Clone)]
1093pub struct BannerMetadata {
1094 pub name: &'static str,
1095 pub version: &'static str,
1096 pub authors: &'static str,
1097 pub repository: &'static str,
1098}
1099
1100impl BannerMetadata {
1101 #[must_use]
1104 pub fn new(
1105 name: &'static str,
1106 version: &'static str,
1107 authors: &'static str,
1108 repository: &'static str,
1109 ) -> Self {
1110 Self {
1111 name,
1112 version,
1113 authors,
1114 repository,
1115 }
1116 }
1117}
1118
1119fn apply_format(
1130 template: &str,
1131 prefix: Option<&str>,
1132 timestamp: &str,
1133 level: &str,
1134 message: &str,
1135) -> String {
1136 let mut out = String::with_capacity(template.len());
1137 let bytes = template.as_bytes();
1138 let mut i = 0;
1139
1140 while i < bytes.len() {
1141 if bytes[i] == b'{'
1142 && let Some(close) = template[i + 1..].find('}')
1143 {
1144 let end = i + 1 + close;
1145 let spec = &template[i + 1..end];
1146 if let Some(rendered) = render_placeholder(spec, prefix, timestamp, level, message) {
1147 out.push_str(&rendered);
1148 } else {
1149 out.push_str(&template[i..=end]);
1151 }
1152 i = end + 1;
1153 } else {
1154 out.push(bytes[i] as char);
1155 i += 1;
1156 }
1157 }
1158
1159 out
1160}
1161
1162fn render_placeholder(
1165 spec: &str,
1166 prefix: Option<&str>,
1167 timestamp: &str,
1168 level: &str,
1169 message: &str,
1170) -> Option<String> {
1171 let (name, format_spec) = spec.split_once(':').unwrap_or((spec, ""));
1172 let value: &str = match name {
1173 "timestamp" => timestamp,
1174 "level" => level,
1175 "message" => message,
1176 "prefix" => prefix.unwrap_or(""),
1177 _ => return None,
1178 };
1179
1180 if format_spec.is_empty() {
1181 return Some(value.to_owned());
1182 }
1183
1184 let (alignment, width_str) = match format_spec.chars().next() {
1185 Some('<') => (Alignment::Left, &format_spec[1..]),
1186 Some('>') => (Alignment::Right, &format_spec[1..]),
1187 Some('^') => (Alignment::Center, &format_spec[1..]),
1188 _ => return Some(value.to_owned()),
1189 };
1190
1191 let Ok(width) = width_str.parse::<usize>() else {
1192 return Some(value.to_owned());
1193 };
1194
1195 Some(match alignment {
1196 Alignment::Left => format!("{value:<width$}"),
1197 Alignment::Right => format!("{value:>width$}"),
1198 Alignment::Center => format!("{value:^width$}"),
1199 })
1200}
1201
1202enum Alignment {
1203 Left,
1204 Right,
1205 Center,
1206}
1207
1208fn print_banner_inner(mode: &BannerMode) {
1209 let metadata = BANNER_METADATA.with(|cell| cell.borrow().clone());
1210 let Some(meta) = metadata else {
1211 return;
1214 };
1215
1216 let lines = match mode {
1217 BannerMode::Off => return,
1218 BannerMode::Default => default_banner_lines(&meta),
1219 BannerMode::Custom(builder) => builder(&meta),
1220 };
1221
1222 for line in lines {
1223 log::info!("{line}");
1224 }
1225}
1226
1227fn default_banner_lines(meta: &BannerMetadata) -> Vec<String> {
1228 let authors = if meta.authors.trim().is_empty() {
1229 "Unknown"
1230 } else {
1231 meta.authors
1232 };
1233 let repository = if meta.repository.trim().is_empty() {
1234 "N/A"
1235 } else {
1236 meta.repository
1237 };
1238
1239 vec![
1240 String::new(),
1241 format!(" | {} {}", meta.name, meta.version),
1242 String::from(" |-------------------------------"),
1243 format!(" | Author: {}", authors),
1244 format!(" | Repository: {}", repository),
1245 String::new(),
1246 ]
1247}
1248
1249pub fn print_banner() {
1256 print_banner_inner(&BannerMode::Default);
1260}
1261
1262#[cfg(test)]
1267mod tests {
1268 use super::*;
1269 use std::path::Path;
1270
1271 #[test]
1272 fn config_resolves_defaults() {
1273 let cfg = LoggerConfig::new("my-plugin");
1274 assert_eq!(cfg.resolved_filename(), "my-plugin.log");
1275 assert_eq!(cfg.resolved_prefix(), "[my-plugin]");
1276 assert_eq!(cfg.log_path(), Path::new("logs/my-plugin.log"));
1277 assert_eq!(cfg.resolved_archive_directory(), Path::new("logs/archive"));
1278 assert_eq!(cfg.level, LevelFilter::Info);
1279 assert!(cfg.also_to_server);
1280 assert!(matches!(cfg.banner, BannerMode::Default));
1281 assert_eq!(cfg.file_format, DEFAULT_FILE_FORMAT);
1282 assert_eq!(cfg.server_format, DEFAULT_SERVER_FORMAT);
1283 let rotation = cfg.rotation.expect("default rotation enabled");
1284 assert_eq!(rotation.max_bytes, 50 * 1024 * 1024);
1285 assert_eq!(rotation.keep, None);
1287 }
1288
1289 #[test]
1290 fn config_overrides_apply() {
1291 let cfg = LoggerConfig::new("foo")
1292 .directory("custom")
1293 .filename("custom.log")
1294 .prefix("[Custom]")
1295 .level(LevelFilter::Warn)
1296 .also_to_server(false)
1297 .no_banner()
1298 .rotation_size_mb(10)
1299 .rotation_keep(3)
1300 .file_format("{level}: {message}")
1301 .server_format("<{prefix}> {message}");
1302 assert_eq!(cfg.directory, Path::new("custom"));
1303 assert_eq!(
1306 cfg.resolved_archive_directory(),
1307 Path::new("custom/archive")
1308 );
1309 assert_eq!(cfg.resolved_filename(), "custom.log");
1310 assert_eq!(cfg.resolved_prefix(), "[Custom]");
1311 assert_eq!(cfg.level, LevelFilter::Warn);
1312 assert!(!cfg.also_to_server);
1313 assert!(matches!(cfg.banner, BannerMode::Off));
1314 assert_eq!(cfg.file_format, "{level}: {message}");
1315 assert_eq!(cfg.server_format, "<{prefix}> {message}");
1316 let rotation = cfg.rotation.expect("explicit rotation kept");
1317 assert_eq!(rotation.max_bytes, 10 * 1024 * 1024);
1318 assert_eq!(rotation.keep, Some(3));
1319 }
1320
1321 #[test]
1322 fn rotation_no_cleanup_resets_keep_to_none() {
1323 let cfg = LoggerConfig::new("foo")
1324 .rotation_keep(5)
1325 .rotation_no_cleanup();
1326 let rotation = cfg.rotation.expect("rotation still active");
1327 assert_eq!(rotation.keep, None);
1328 }
1329
1330 #[test]
1331 fn apply_format_substitutes_placeholders() {
1332 let line = apply_format(
1333 "[{timestamp}] [{level}] {message}",
1334 None,
1335 "2026-06-08 12:30:45",
1336 "INFO",
1337 "ready",
1338 );
1339 assert_eq!(line, "[2026-06-08 12:30:45] [INFO] ready");
1340
1341 let server = apply_format(
1342 "{prefix} {message}",
1343 Some("[my-plugin]"),
1344 "2026-06-08 12:30:45",
1345 "WARN",
1346 "stalled",
1347 );
1348 assert_eq!(server, "[my-plugin] stalled");
1349 }
1350
1351 #[test]
1352 fn apply_format_supports_width_specifiers() {
1353 let right = apply_format("[{level:>5}] {message}", None, "ts", "INFO", "msg");
1354 assert_eq!(right, "[ INFO] msg");
1355
1356 let left = apply_format("[{level:<5}] {message}", None, "ts", "INFO", "msg");
1357 assert_eq!(left, "[INFO ] msg");
1358
1359 let center = apply_format("[{level:^6}] {message}", None, "ts", "INFO", "msg");
1360 assert_eq!(center, "[ INFO ] msg");
1361 }
1362
1363 #[test]
1364 fn apply_format_width_smaller_than_value_does_not_truncate() {
1365 let line = apply_format("[{level:>2}] {message}", None, "ts", "INFO", "msg");
1366 assert_eq!(line, "[INFO] msg");
1368 }
1369
1370 #[test]
1371 fn apply_format_leaves_unknown_placeholders_untouched() {
1372 let line = apply_format(
1373 "{foo} {message}",
1374 None,
1375 "2026-06-08 12:30:45",
1376 "INFO",
1377 "ready",
1378 );
1379 assert_eq!(line, "{foo} ready");
1380 }
1381
1382 #[test]
1383 fn custom_banner_lines_emit_in_order() {
1384 let cfg = LoggerConfig::new("foo").banner_with(|meta| {
1385 vec![
1386 String::from("=== plugin start ==="),
1387 format!("hello {}!", meta.name),
1388 ]
1389 });
1390 let lines = match &cfg.banner {
1391 BannerMode::Custom(builder) => builder(&BannerMetadata::new(
1392 "foo",
1393 "1.0",
1394 "ZOTTCE",
1395 "https://example.com",
1396 )),
1397 _ => unreachable!(),
1398 };
1399 assert_eq!(lines.len(), 2);
1400 assert_eq!(lines[0], "=== plugin start ===");
1401 assert_eq!(lines[1], "hello foo!");
1402 }
1403
1404 #[test]
1405 fn no_rotation_disables_archives() {
1406 let cfg = LoggerConfig::new("foo").rotation_size_mb(20).no_rotation();
1407 assert!(cfg.rotation.is_none());
1408 }
1409
1410 #[test]
1411 fn rotation_size_mb_zero_disables() {
1412 let cfg = LoggerConfig::new("foo").rotation_size_mb(0);
1413 assert!(cfg.rotation.is_none());
1414 }
1415
1416 #[test]
1417 fn level_round_trip() {
1418 for level in [
1419 LevelFilter::Off,
1420 LevelFilter::Error,
1421 LevelFilter::Warn,
1422 LevelFilter::Info,
1423 LevelFilter::Debug,
1424 LevelFilter::Trace,
1425 ] {
1426 assert_eq!(u8_to_level(level_to_u8(level)), level);
1427 }
1428 }
1429
1430 #[test]
1431 fn set_and_read_level() {
1432 set_level(LevelFilter::Warn);
1433 assert_eq!(level(), LevelFilter::Warn);
1434 set_level(LevelFilter::Trace);
1435 assert_eq!(level(), LevelFilter::Trace);
1436 }
1437
1438 #[cfg(feature = "compression")]
1439 #[test]
1440 fn compress_archives_builder_sets_flag() {
1441 let cfg = LoggerConfig::new("foo").compress_archives(true);
1442 assert!(cfg.compress_archives);
1443 let cfg = LoggerConfig::new("foo").compress_archives(false);
1444 assert!(!cfg.compress_archives);
1445 let cfg = LoggerConfig::new("foo");
1446 assert!(
1447 !cfg.compress_archives,
1448 "compression must stay opt-in when the builder is not called"
1449 );
1450 }
1451
1452 #[cfg(feature = "compression")]
1453 #[test]
1454 fn find_next_archive_index_counts_gz_variants() {
1455 let tmp = std::env::temp_dir().join(format!(
1456 "rust-samp-test-archive-{}-{}",
1457 std::process::id(),
1458 std::time::SystemTime::now()
1459 .duration_since(std::time::UNIX_EPOCH)
1460 .unwrap()
1461 .as_nanos()
1462 ));
1463 std::fs::create_dir_all(&tmp).unwrap();
1464 std::fs::write(tmp.join("foo.log.2.gz"), b"compressed").unwrap();
1466 std::fs::write(tmp.join("foo.log.5"), b"plain").unwrap();
1467
1468 let next = super::find_next_archive_index(&tmp, "foo.log");
1471 assert_eq!(next, 6);
1472
1473 let _ = std::fs::remove_dir_all(&tmp);
1474 }
1475
1476 #[test]
1477 fn env_var_prefix_uppercases_and_sanitises() {
1478 assert_eq!(super::env_var_prefix("memcached"), "MEMCACHED");
1479 assert_eq!(super::env_var_prefix("streamer-rs"), "STREAMER_RS");
1480 assert_eq!(super::env_var_prefix("my.plugin"), "MY_PLUGIN");
1481 assert_eq!(super::env_var_prefix("plugin_v2"), "PLUGIN_V2");
1482 }
1483
1484 #[test]
1485 fn parse_level_accepts_known_names() {
1486 assert_eq!(super::parse_level("off"), Some(LevelFilter::Off));
1487 assert_eq!(super::parse_level("ERROR"), Some(LevelFilter::Error));
1488 assert_eq!(super::parse_level("warn"), Some(LevelFilter::Warn));
1489 assert_eq!(super::parse_level("warning"), Some(LevelFilter::Warn));
1490 assert_eq!(super::parse_level(" info "), Some(LevelFilter::Info));
1491 assert_eq!(super::parse_level("debug"), Some(LevelFilter::Debug));
1492 assert_eq!(super::parse_level("trace"), Some(LevelFilter::Trace));
1493 assert_eq!(super::parse_level("nope"), None);
1494 assert_eq!(super::parse_level(""), None);
1495 }
1496
1497 #[test]
1498 fn parse_bool_accepts_common_truthy() {
1499 for s in ["1", "true", "TRUE", " yes ", "on"] {
1500 assert!(super::parse_bool(s), "{s:?} should parse as true");
1501 }
1502 for s in ["0", "false", "no", "off", "", "anything"] {
1503 assert!(!super::parse_bool(s), "{s:?} should parse as false");
1504 }
1505 }
1506
1507 #[test]
1508 fn from_env_applies_overrides_and_ignores_garbage() {
1509 let crate_name = format!("rust_samp_test_{}", std::process::id());
1512 let prefix = super::env_var_prefix(&crate_name);
1513
1514 unsafe {
1519 std::env::set_var(format!("{prefix}_LOG_LEVEL"), "debug");
1520 std::env::set_var(format!("{prefix}_LOG_DIR"), "/tmp/rust-samp-from-env");
1521 std::env::set_var(format!("{prefix}_LOG_ROTATION_MB"), "not-a-number");
1522 std::env::set_var(format!("{prefix}_LOG_NO_BANNER"), "1");
1523 std::env::set_var(format!("{prefix}_LOG_SERVER"), "false");
1524 }
1525
1526 let cfg = LoggerConfig::new(crate_name).from_env();
1527
1528 assert_eq!(cfg.level, LevelFilter::Debug);
1529 assert_eq!(cfg.directory, PathBuf::from("/tmp/rust-samp-from-env"));
1530 assert!(matches!(cfg.banner, BannerMode::Off));
1531 assert!(!cfg.also_to_server);
1532 assert!(matches!(
1534 cfg.rotation,
1535 Some(Rotation {
1536 max_bytes: DEFAULT_ROTATION_BYTES,
1537 ..
1538 })
1539 ));
1540
1541 unsafe {
1543 std::env::remove_var(format!("{prefix}_LOG_LEVEL"));
1544 std::env::remove_var(format!("{prefix}_LOG_DIR"));
1545 std::env::remove_var(format!("{prefix}_LOG_ROTATION_MB"));
1546 std::env::remove_var(format!("{prefix}_LOG_NO_BANNER"));
1547 std::env::remove_var(format!("{prefix}_LOG_SERVER"));
1548 }
1549 }
1550
1551 #[test]
1552 fn add_sink_appends_in_call_order() {
1553 struct Counter(std::sync::atomic::AtomicUsize);
1554 impl super::Sink for Counter {
1555 fn emit(&self, _record: &super::SinkRecord<'_>) {
1556 self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1557 }
1558 }
1559
1560 let cfg = LoggerConfig::new("foo");
1561 assert_eq!(cfg.sinks.len(), 0, "no sinks by default");
1562
1563 let cfg = cfg
1564 .add_sink(Box::new(Counter(0.into())))
1565 .add_sink(Box::new(Counter(0.into())));
1566 assert_eq!(cfg.sinks.len(), 2);
1567 }
1568
1569 #[test]
1570 fn flush_without_install_is_noop() {
1571 super::flush();
1576 super::flush();
1577 }
1578}