1use std::fs::{self, File, OpenOptions};
23use std::io::Write;
24use std::path::PathBuf;
25use std::ptr;
26use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU8, AtomicUsize, Ordering};
27use std::sync::{Mutex, PoisonError};
28
29use log::{LevelFilter, Log, Metadata, Record};
30use time::OffsetDateTime;
31use time::format_description::BorrowedFormatItem;
32use time::macros::format_description;
33
34use crate::macros::SDK_LOG_PREFIX;
35use crate::runtime::Runtime;
36
37const TIMESTAMP_FORMAT: &[BorrowedFormatItem<'_>] =
39 format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
40
41const DEFAULT_ROTATION_BYTES: u64 = 50 * 1024 * 1024;
43
44const DEFAULT_FILE_FORMAT: &str = "[{timestamp}] [{level}] {message}";
48
49const DEFAULT_SERVER_FORMAT: &str = "{prefix} {message}";
53
54pub struct LoggerConfig {
60 crate_name: String,
61 directory: PathBuf,
62 filename: Option<String>,
63 prefix: Option<String>,
64 level: LevelFilter,
65 also_to_server: bool,
66 banner: BannerMode,
67 rotation: Option<Rotation>,
68 file_format: String,
69 server_format: String,
70 #[cfg(feature = "compression")]
74 compress_archives: bool,
75 sinks: Vec<Box<dyn Sink>>,
78}
79
80pub trait Sink: Send + Sync {
104 fn emit(&self, record: &SinkRecord<'_>);
106}
107
108#[derive(Debug)]
112pub struct SinkRecord<'a> {
113 pub timestamp: &'a str,
115 pub level: log::Level,
117 pub target: &'a str,
120 pub message: &'a str,
122 pub prefix: &'a str,
124}
125
126pub type BannerBuilder = dyn Fn(&BannerMetadata) -> Vec<String> + Send + Sync;
129
130pub enum BannerMode {
132 Off,
134 Default,
137 Custom(Box<BannerBuilder>),
140}
141
142impl std::fmt::Debug for BannerMode {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 match self {
145 Self::Off => f.write_str("Off"),
146 Self::Default => f.write_str("Default"),
147 Self::Custom(_) => f.write_str("Custom(<fn>)"),
148 }
149 }
150}
151
152impl std::fmt::Debug for LoggerConfig {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 let mut s = f.debug_struct("LoggerConfig");
155 s.field("crate_name", &self.crate_name)
156 .field("directory", &self.directory)
157 .field("filename", &self.filename)
158 .field("prefix", &self.prefix)
159 .field("level", &self.level)
160 .field("also_to_server", &self.also_to_server)
161 .field("banner", &self.banner)
162 .field("rotation", &self.rotation)
163 .field("file_format", &self.file_format)
164 .field("server_format", &self.server_format)
165 .field("sinks", &format_args!("[{} sink(s)]", self.sinks.len()));
166 #[cfg(feature = "compression")]
167 s.field("compress_archives", &self.compress_archives);
168 s.finish()
169 }
170}
171
172#[derive(Debug, Clone, Copy)]
184struct Rotation {
185 max_bytes: u64,
186 keep: Option<u32>,
187}
188
189impl LoggerConfig {
190 #[must_use]
194 pub fn new(crate_name: impl Into<String>) -> Self {
195 Self {
196 crate_name: crate_name.into(),
197 directory: PathBuf::from("logs"),
198 filename: None,
199 prefix: None,
200 level: LevelFilter::Info,
201 also_to_server: true,
202 banner: BannerMode::Default,
203 rotation: Some(Rotation {
204 max_bytes: DEFAULT_ROTATION_BYTES,
205 keep: None,
208 }),
209 file_format: DEFAULT_FILE_FORMAT.to_owned(),
210 server_format: DEFAULT_SERVER_FORMAT.to_owned(),
211 #[cfg(feature = "compression")]
212 compress_archives: false,
213 sinks: Vec::new(),
214 }
215 }
216
217 #[must_use]
236 pub fn add_sink(mut self, sink: Box<dyn Sink>) -> Self {
237 self.sinks.push(sink);
238 self
239 }
240
241 #[cfg(feature = "compression")]
253 #[must_use]
254 pub fn compress_archives(mut self, yes: bool) -> Self {
255 self.compress_archives = yes;
256 self
257 }
258
259 #[must_use]
289 pub fn from_env(mut self) -> Self {
290 let prefix = env_var_prefix(&self.crate_name);
291 self.apply_env(&prefix);
292 self
293 }
294
295 fn apply_env(&mut self, prefix: &str) {
296 if let Some(raw) = read_env(prefix, "LEVEL") {
297 match parse_level(&raw) {
298 Some(l) => self.level = l,
299 None => warn_invalid(prefix, "LEVEL", &raw),
300 }
301 }
302 if let Some(raw) = read_env(prefix, "DIR") {
303 self.directory = PathBuf::from(raw);
304 }
305 if let Some(raw) = read_env(prefix, "FILE") {
306 self.filename = Some(raw);
307 }
308 if let Some(raw) = read_env(prefix, "ROTATION_MB") {
309 match raw.parse::<u64>() {
310 Ok(0) => self.rotation = None,
311 Ok(mb) => {
312 let max_bytes = mb.saturating_mul(1024 * 1024);
313 let keep = self.rotation.and_then(|r| r.keep);
314 self.rotation = Some(Rotation { max_bytes, keep });
315 }
316 Err(_) => warn_invalid(prefix, "ROTATION_MB", &raw),
317 }
318 }
319 if let Some(raw) = read_env(prefix, "ROTATION_KEEP") {
320 match raw.parse::<u32>() {
321 Ok(keep) => {
322 let max_bytes = self
323 .rotation
324 .map_or(DEFAULT_ROTATION_BYTES, |r| r.max_bytes);
325 self.rotation = Some(Rotation {
326 max_bytes,
327 keep: Some(keep),
328 });
329 }
330 Err(_) => warn_invalid(prefix, "ROTATION_KEEP", &raw),
331 }
332 }
333 if let Some(raw) = read_env(prefix, "NO_ROTATION")
334 && parse_bool(&raw)
335 {
336 self.rotation = None;
337 }
338 if let Some(raw) = read_env(prefix, "NO_BANNER")
339 && parse_bool(&raw)
340 {
341 self.banner = BannerMode::Off;
342 }
343 if let Some(raw) = read_env(prefix, "SERVER") {
344 self.also_to_server = parse_bool(&raw);
345 }
346 #[cfg(feature = "compression")]
347 if let Some(raw) = read_env(prefix, "COMPRESS") {
348 self.compress_archives = parse_bool(&raw);
349 }
350 }
351
352 #[must_use]
358 pub fn directory(mut self, path: impl Into<PathBuf>) -> Self {
359 self.directory = path.into();
360 self
361 }
362
363 #[must_use]
367 pub fn filename(mut self, name: impl Into<String>) -> Self {
368 self.filename = Some(name.into());
369 self
370 }
371
372 #[must_use]
376 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
377 self.prefix = Some(prefix.into());
378 self
379 }
380
381 #[must_use]
385 pub fn level(mut self, level: LevelFilter) -> Self {
386 self.level = level;
387 self
388 }
389
390 #[must_use]
394 pub fn also_to_server(mut self, enabled: bool) -> Self {
395 self.also_to_server = enabled;
396 self
397 }
398
399 #[must_use]
404 pub fn banner(mut self, mode: BannerMode) -> Self {
405 self.banner = mode;
406 self
407 }
408
409 #[must_use]
411 pub fn no_banner(mut self) -> Self {
412 self.banner = BannerMode::Off;
413 self
414 }
415
416 #[must_use]
421 pub fn banner_with<F>(mut self, builder: F) -> Self
422 where
423 F: Fn(&BannerMetadata) -> Vec<String> + Send + Sync + 'static,
424 {
425 self.banner = BannerMode::Custom(Box::new(builder));
426 self
427 }
428
429 #[must_use]
433 pub fn file_format(mut self, format: impl Into<String>) -> Self {
434 self.file_format = format.into();
435 self
436 }
437
438 #[must_use]
442 pub fn server_format(mut self, format: impl Into<String>) -> Self {
443 self.server_format = format.into();
444 self
445 }
446
447 #[must_use]
451 pub fn no_rotation(mut self) -> Self {
452 self.rotation = None;
453 self
454 }
455
456 #[must_use]
465 pub fn rotation_size_mb(mut self, mb: u64) -> Self {
466 if mb == 0 {
467 self.rotation = None;
468 } else {
469 let max_bytes = mb.saturating_mul(1024 * 1024);
470 let keep = self.rotation.and_then(|r| r.keep);
471 self.rotation = Some(Rotation { max_bytes, keep });
472 }
473 self
474 }
475
476 #[must_use]
483 pub fn rotation_keep(mut self, keep: u32) -> Self {
484 let max_bytes = self
485 .rotation
486 .map_or(DEFAULT_ROTATION_BYTES, |r| r.max_bytes);
487 self.rotation = Some(Rotation {
488 max_bytes,
489 keep: Some(keep),
490 });
491 self
492 }
493
494 #[must_use]
499 pub fn rotation_no_cleanup(mut self) -> Self {
500 let max_bytes = self
501 .rotation
502 .map_or(DEFAULT_ROTATION_BYTES, |r| r.max_bytes);
503 self.rotation = Some(Rotation {
504 max_bytes,
505 keep: None,
506 });
507 self
508 }
509
510 fn resolved_filename(&self) -> String {
511 self.filename
512 .clone()
513 .unwrap_or_else(|| format!("{}.log", self.crate_name))
514 }
515
516 fn resolved_prefix(&self) -> String {
517 self.prefix
518 .clone()
519 .unwrap_or_else(|| format!("[{}]", self.crate_name))
520 }
521
522 fn log_path(&self) -> PathBuf {
523 self.directory.join(self.resolved_filename())
524 }
525
526 fn resolved_archive_directory(&self) -> PathBuf {
527 self.directory.join("archive")
528 }
529}
530
531#[derive(Debug)]
533pub enum InstallError {
534 AlreadyInstalled,
537 Io(std::io::Error),
539}
540
541impl std::fmt::Display for InstallError {
542 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
543 match self {
544 Self::AlreadyInstalled => f.write_str("logger already installed"),
545 Self::Io(e) => write!(f, "i/o error: {e}"),
546 }
547 }
548}
549
550impl std::error::Error for InstallError {
551 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
552 match self {
553 Self::AlreadyInstalled => None,
554 Self::Io(e) => Some(e),
555 }
556 }
557}
558
559impl From<std::io::Error> for InstallError {
560 fn from(e: std::io::Error) -> Self {
561 Self::Io(e)
562 }
563}
564
565static INSTALLED: AtomicBool = AtomicBool::new(false);
571
572static INSTANCE: AtomicPtr<LoggerImpl> = AtomicPtr::new(ptr::null_mut());
579
580static LEVEL: AtomicU8 = AtomicU8::new(level_to_u8(LevelFilter::Info));
583
584const fn level_to_u8(l: LevelFilter) -> u8 {
585 match l {
586 LevelFilter::Off => 0,
587 LevelFilter::Error => 1,
588 LevelFilter::Warn => 2,
589 LevelFilter::Info => 3,
590 LevelFilter::Debug => 4,
591 LevelFilter::Trace => 5,
592 }
593}
594
595const fn u8_to_level(v: u8) -> LevelFilter {
596 match v {
597 0 => LevelFilter::Off,
598 1 => LevelFilter::Error,
599 2 => LevelFilter::Warn,
600 3 => LevelFilter::Info,
601 4 => LevelFilter::Debug,
602 _ => LevelFilter::Trace,
603 }
604}
605
606pub fn set_level(level: LevelFilter) {
610 LEVEL.store(level_to_u8(level), Ordering::Relaxed);
611 log::set_max_level(level);
612}
613
614#[must_use]
617pub fn level() -> LevelFilter {
618 u8_to_level(LEVEL.load(Ordering::Relaxed))
619}
620
621pub fn flush() {
632 let ptr = INSTANCE.load(Ordering::Acquire);
633 if ptr.is_null() {
634 return;
635 }
636 let logger = unsafe { &*ptr };
641 log::Log::flush(logger);
642}
643
644pub fn install(config: LoggerConfig) -> Result<(), InstallError> {
663 if INSTALLED.swap(true, Ordering::AcqRel) {
664 return Err(InstallError::AlreadyInstalled);
665 }
666
667 let path = config.log_path();
668 let opened = fs::create_dir_all(&config.directory)
669 .and_then(|()| OpenOptions::new().create(true).append(true).open(&path));
670 let file = match opened {
671 Ok(file) => file,
672 Err(e) => {
673 INSTALLED.store(false, Ordering::Release);
674 report_to_server(format!(
677 "{SDK_LOG_PREFIX} cannot open {}: {e}; this plugin's log file is disabled",
678 path.display()
679 ));
680 return Err(e.into());
681 }
682 };
683 let initial_size = file.metadata().map(|m| m.len()).unwrap_or(0);
684
685 let prefix = config.resolved_prefix();
686 let level = config.level;
687 let filename = config.resolved_filename();
688 let archive_directory = config.resolved_archive_directory();
689 let next_archive_index = find_next_archive_index(&archive_directory, &filename);
690 #[cfg(feature = "compression")]
691 let compress_archives = config.compress_archives;
692 let LoggerConfig {
693 also_to_server,
694 banner,
695 rotation,
696 file_format,
697 server_format,
698 sinks,
699 ..
700 } = config;
701
702 let logger = Box::new(LoggerImpl {
703 prefix,
704 also_to_server,
705 rotation,
706 path,
707 filename,
708 archive_directory,
709 file_format,
710 server_format,
711 #[cfg(feature = "compression")]
712 compress_archives,
713 sinks,
714 state: Mutex::new(LoggerState {
715 file: Some(file),
716 current_size: initial_size,
717 file_write_reported: false,
718 next_archive_index,
719 }),
720 clock: Clock::new(),
721 });
722
723 set_level(level);
724
725 let logger_ptr: *const LoggerImpl = &raw const *logger;
726 INSTANCE.store(logger_ptr.cast_mut(), Ordering::Release);
727 log::set_boxed_logger(logger).map_err(|_| {
728 INSTANCE.store(ptr::null_mut(), Ordering::Release);
729 INSTALLED.store(false, Ordering::Release);
730 InstallError::AlreadyInstalled
731 })?;
732
733 print_banner_inner(&banner);
734
735 Ok(())
736}
737
738struct LoggerImpl {
743 prefix: String,
744 also_to_server: bool,
745 rotation: Option<Rotation>,
746 path: PathBuf,
747 filename: String,
748 archive_directory: PathBuf,
749 file_format: String,
750 server_format: String,
751 #[cfg(feature = "compression")]
752 compress_archives: bool,
753 sinks: Vec<Box<dyn Sink>>,
754 state: Mutex<LoggerState>,
755 clock: Clock,
756}
757
758struct Clock {
766 cache: Mutex<ClockCache>,
767}
768
769struct ClockCache {
770 second: i64,
772 offset_minute: i64,
774 offset: time::UtcOffset,
775 text: String,
776}
777
778impl Clock {
779 fn new() -> Self {
780 Clock {
781 cache: Mutex::new(ClockCache {
782 second: i64::MIN,
783 offset_minute: i64::MIN,
784 offset: time::UtcOffset::UTC,
785 text: String::new(),
786 }),
787 }
788 }
789
790 fn timestamp(&self) -> String {
791 let now = OffsetDateTime::now_utc();
792 let second = now.unix_timestamp();
793 let mut cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
794 if cache.second != second {
795 let minute = second.div_euclid(60);
796 if cache.offset_minute != minute {
797 if let Ok(offset) = time::UtcOffset::current_local_offset() {
800 cache.offset = offset;
801 }
802 cache.offset_minute = minute;
803 }
804 cache.text = now
805 .to_offset(cache.offset)
806 .format(TIMESTAMP_FORMAT)
807 .unwrap_or_else(|_| String::from("0000-00-00 00:00:00"));
808 cache.second = second;
809 }
810 cache.text.clone()
811 }
812}
813
814struct LoggerState {
815 file: Option<File>,
816 current_size: u64,
817 file_write_reported: bool,
821 next_archive_index: u32,
825}
826
827static SERVER_BACKLOG: Mutex<Vec<(log::Level, String)>> = Mutex::new(Vec::new());
831
832const SERVER_BACKLOG_LIMIT: usize = 10_000;
836static SERVER_BACKLOG_DROPPED: AtomicUsize = AtomicUsize::new(0);
837static SERVER_BACKLOG_WAITING: AtomicBool = AtomicBool::new(false);
840
841pub(crate) fn to_server(level: log::Level, line: String) {
844 queue_for_server(level, line, false);
845}
846
847fn report_to_server(line: String) {
850 queue_for_server(log::Level::Error, line, true);
851}
852
853fn queue_for_server(level: log::Level, line: String, always: bool) {
854 if crate::runtime::on_main_thread() && server_log_ready() {
858 flush_server_backlog();
859 send_to_server(level, line);
860 return;
861 }
862 let mut backlog = SERVER_BACKLOG
863 .lock()
864 .unwrap_or_else(PoisonError::into_inner);
865 if always || backlog.len() < SERVER_BACKLOG_LIMIT {
866 backlog.push((level, line));
867 } else {
868 SERVER_BACKLOG_DROPPED.fetch_add(1, Ordering::Relaxed);
869 }
870 SERVER_BACKLOG_WAITING.store(true, Ordering::Release);
871}
872
873fn server_log_ready() -> bool {
874 Runtime::try_get().is_some_and(Runtime::has_server_log)
875}
876
877pub(crate) fn flush_server_backlog() {
881 if !SERVER_BACKLOG_WAITING.load(Ordering::Acquire) || !server_log_ready() {
882 return;
883 }
884 let lines = {
885 let mut backlog = SERVER_BACKLOG
886 .lock()
887 .unwrap_or_else(PoisonError::into_inner);
888 SERVER_BACKLOG_WAITING.store(false, Ordering::Release);
889 std::mem::take(&mut *backlog)
890 };
891 for (level, line) in lines {
892 send_to_server(level, line);
893 }
894 let dropped = SERVER_BACKLOG_DROPPED.swap(0, Ordering::Relaxed);
895 if dropped > 0 {
896 send_to_server(
897 log::Level::Warn,
898 format!(
899 "{SDK_LOG_PREFIX} {dropped} log lines from other threads were not copied to the server log \
900 (they came faster than the main thread drained them)"
901 ),
902 );
903 }
904}
905
906fn send_to_server(level: log::Level, line: String) {
908 let Some(rt) = Runtime::try_get() else {
909 return;
910 };
911 #[cfg(not(feature = "samp-only"))]
912 rt.log_level(server_log_level(level), line);
913 #[cfg(feature = "samp-only")]
914 {
915 let _ = level;
916 rt.log(line);
917 }
918}
919
920#[cfg(not(feature = "samp-only"))]
922fn server_log_level(level: log::Level) -> samp_sdk::omp::LogLevel {
923 match level {
924 log::Level::Error => samp_sdk::omp::LogLevel::Error,
925 log::Level::Warn => samp_sdk::omp::LogLevel::Warning,
926 log::Level::Info => samp_sdk::omp::LogLevel::Message,
927 log::Level::Debug | log::Level::Trace => samp_sdk::omp::LogLevel::Debug,
928 }
929}
930
931impl Log for LoggerImpl {
932 fn enabled(&self, metadata: &Metadata<'_>) -> bool {
933 metadata.level() <= u8_to_level(LEVEL.load(Ordering::Relaxed))
934 }
935
936 fn log(&self, record: &Record<'_>) {
937 if !self.enabled(record.metadata()) {
938 return;
939 }
940
941 let message = format!("{}", record.args());
942 let level = record.level().as_str();
943
944 let timestamp = self.clock.timestamp();
945
946 if self.also_to_server {
953 let server_line = apply_format(
954 &self.server_format,
955 Some(&self.prefix),
956 ×tamp,
957 level,
958 &message,
959 );
960 to_server(record.level(), server_line);
961 }
962
963 let mut line = apply_format(&self.file_format, None, ×tamp, level, &message);
965 line.push('\n');
966
967 let mut state = match self.state.lock() {
968 Ok(s) => s,
969 Err(p) => p.into_inner(),
970 };
971
972 if let Some(rotation) = self.rotation
973 && state.current_size + line.len() as u64 > rotation.max_bytes
974 {
975 self.rotate(&mut state, rotation);
976 }
977
978 if let Some(file) = state.file.as_mut() {
979 match file.write_all(line.as_bytes()) {
980 Ok(()) => state.current_size += line.len() as u64,
981 Err(e) => {
982 if !state.file_write_reported {
983 state.file_write_reported = true;
984 report_to_server(format!(
985 "{} failed to write {}: {}. Further file-write errors will be suppressed.",
986 self.prefix,
987 self.path.display(),
988 e,
989 ));
990 }
991 }
992 }
993 }
994
995 if !self.sinks.is_empty() {
998 let sink_record = SinkRecord {
999 timestamp: ×tamp,
1000 level: record.level(),
1001 target: record.target(),
1002 message: &message,
1003 prefix: &self.prefix,
1004 };
1005 for sink in &self.sinks {
1006 sink.emit(&sink_record);
1007 }
1008 }
1009 }
1010
1011 fn flush(&self) {
1012 if let Ok(mut state) = self.state.lock()
1013 && let Some(file) = state.file.as_mut()
1014 {
1015 let _ = file.flush();
1016 }
1017 }
1018}
1019
1020impl LoggerImpl {
1021 fn rotate(&self, state: &mut LoggerState, rotation: Rotation) {
1030 state.file = None;
1032
1033 if let Err(e) = fs::create_dir_all(&self.archive_directory) {
1035 self.report_file_error(state, "create archive directory", &e);
1036 self.reopen_active(state);
1039 return;
1040 }
1041
1042 match rotation.keep {
1043 Some(keep) if keep > 0 => self.rotate_shift(keep),
1044 _ => {
1046 let index = state.next_archive_index;
1047 state.next_archive_index = state.next_archive_index.saturating_add(1);
1048 let archived = self.archive_path(index);
1049 if fs::rename(&self.path, &archived).is_ok() {
1050 self.compress_archive(&archived);
1051 }
1052 }
1053 }
1054
1055 self.reopen_active(state);
1056 }
1057
1058 #[cfg(feature = "compression")]
1066 fn compress_archive(&self, archived: &std::path::Path) {
1067 if !self.compress_archives {
1068 return;
1069 }
1070 let gz_path = {
1071 let mut p = archived.as_os_str().to_owned();
1072 p.push(".gz");
1073 PathBuf::from(p)
1074 };
1075 let Ok(input) = fs::File::open(archived) else {
1076 return;
1077 };
1078 let Ok(output) = fs::File::create(&gz_path) else {
1079 return;
1080 };
1081 let mut encoder = flate2::write::GzEncoder::new(output, flate2::Compression::default());
1082 let mut reader = std::io::BufReader::new(input);
1083 if std::io::copy(&mut reader, &mut encoder).is_err() {
1084 let _ = fs::remove_file(&gz_path);
1085 return;
1086 }
1087 if encoder.finish().is_err() {
1088 let _ = fs::remove_file(&gz_path);
1089 return;
1090 }
1091 let _ = fs::remove_file(archived);
1092 }
1093
1094 #[cfg(not(feature = "compression"))]
1095 #[allow(clippy::unused_self)]
1096 fn compress_archive(&self, _archived: &std::path::Path) {}
1097
1098 fn rotate_shift(&self, keep: u32) {
1103 let _ = fs::remove_file(self.archive_path(keep));
1104 #[cfg(feature = "compression")]
1105 let _ = fs::remove_file(append_gz(&self.archive_path(keep)));
1106 for index in (1..keep).rev() {
1107 let src = self.archive_path(index);
1108 let dst = self.archive_path(index + 1);
1109 if src.exists() {
1110 let _ = fs::rename(&src, &dst);
1111 }
1112 #[cfg(feature = "compression")]
1113 {
1114 let src_gz = append_gz(&src);
1115 let dst_gz = append_gz(&dst);
1116 if src_gz.exists() {
1117 let _ = fs::rename(&src_gz, &dst_gz);
1118 }
1119 }
1120 }
1121 let archived = self.archive_path(1);
1122 if fs::rename(&self.path, &archived).is_ok() {
1123 self.compress_archive(&archived);
1124 }
1125 }
1126
1127 fn reopen_active(&self, state: &mut LoggerState) {
1128 match OpenOptions::new()
1129 .create(true)
1130 .append(true)
1131 .open(&self.path)
1132 {
1133 Ok(file) => {
1134 state.file = Some(file);
1135 state.current_size = 0;
1136 }
1137 Err(e) => self.report_file_error(state, "reopen", &e),
1138 }
1139 }
1140
1141 fn report_file_error(&self, state: &mut LoggerState, action: &str, e: &std::io::Error) {
1142 if !state.file_write_reported {
1143 state.file_write_reported = true;
1144 report_to_server(format!(
1145 "{} failed to {} {}: {}. Further file-write errors will be suppressed.",
1146 self.prefix,
1147 action,
1148 self.path.display(),
1149 e,
1150 ));
1151 }
1152 }
1153
1154 fn archive_path(&self, index: u32) -> PathBuf {
1155 self.archive_directory
1156 .join(format!("{}.{}", self.filename, index))
1157 }
1158}
1159
1160fn env_var_prefix(crate_name: &str) -> String {
1163 crate_name
1164 .chars()
1165 .map(|c| {
1166 if c.is_ascii_alphanumeric() {
1167 c.to_ascii_uppercase()
1168 } else {
1169 '_'
1170 }
1171 })
1172 .collect()
1173}
1174
1175fn read_env(prefix: &str, key: &str) -> Option<String> {
1176 let name = format!("{prefix}_LOG_{key}");
1177 std::env::var(&name).ok().filter(|s| !s.is_empty())
1178}
1179
1180fn parse_level(raw: &str) -> Option<LevelFilter> {
1181 match raw.trim().to_ascii_lowercase().as_str() {
1182 "off" => Some(LevelFilter::Off),
1183 "error" => Some(LevelFilter::Error),
1184 "warn" | "warning" => Some(LevelFilter::Warn),
1185 "info" => Some(LevelFilter::Info),
1186 "debug" => Some(LevelFilter::Debug),
1187 "trace" => Some(LevelFilter::Trace),
1188 _ => None,
1189 }
1190}
1191
1192fn parse_bool(raw: &str) -> bool {
1193 matches!(
1194 raw.trim().to_ascii_lowercase().as_str(),
1195 "1" | "true" | "yes" | "on"
1196 )
1197}
1198
1199fn warn_invalid(prefix: &str, key: &str, raw: &str) {
1205 let msg = format!(
1206 "[rust-samp] ignoring invalid env var {prefix}_LOG_{key}={raw:?} — keeping previous value",
1207 );
1208 if let Some(rt) = Runtime::try_get() {
1209 rt.log(msg);
1210 } else {
1211 eprintln!("{msg}");
1212 }
1213}
1214
1215#[cfg(feature = "compression")]
1216fn append_gz(path: &std::path::Path) -> PathBuf {
1217 let mut s = path.as_os_str().to_owned();
1218 s.push(".gz");
1219 PathBuf::from(s)
1220}
1221
1222fn find_next_archive_index(archive_dir: &std::path::Path, filename: &str) -> u32 {
1227 let prefix = format!("{filename}.");
1228 let mut max = 0u32;
1229 if let Ok(entries) = fs::read_dir(archive_dir) {
1230 for entry in entries.flatten() {
1231 if let Some(name) = entry.file_name().to_str()
1232 && let Some(rest) = name.strip_prefix(&prefix)
1233 {
1234 let idx_str = rest.strip_suffix(".gz").unwrap_or(rest);
1237 if let Ok(index) = idx_str.parse::<u32>() {
1238 max = max.max(index);
1239 }
1240 }
1241 }
1242 }
1243 max.saturating_add(1)
1244}
1245
1246thread_local! {
1251 static BANNER_METADATA: std::cell::RefCell<Option<BannerMetadata>> =
1256 const { std::cell::RefCell::new(None) };
1257}
1258
1259#[doc(hidden)]
1263pub fn __set_banner_metadata(metadata: BannerMetadata) {
1264 BANNER_METADATA.with(|cell| {
1265 *cell.borrow_mut() = Some(metadata);
1266 });
1267}
1268
1269#[derive(Debug, Clone)]
1271pub struct BannerMetadata {
1272 pub name: &'static str,
1273 pub version: &'static str,
1274 pub authors: &'static str,
1275 pub repository: &'static str,
1276}
1277
1278impl BannerMetadata {
1279 #[must_use]
1282 pub fn new(
1283 name: &'static str,
1284 version: &'static str,
1285 authors: &'static str,
1286 repository: &'static str,
1287 ) -> Self {
1288 Self {
1289 name,
1290 version,
1291 authors,
1292 repository,
1293 }
1294 }
1295}
1296
1297fn apply_format(
1308 template: &str,
1309 prefix: Option<&str>,
1310 timestamp: &str,
1311 level: &str,
1312 message: &str,
1313) -> String {
1314 let mut out = String::with_capacity(template.len());
1315 let bytes = template.as_bytes();
1316 let mut i = 0;
1317
1318 while i < bytes.len() {
1319 if bytes[i] == b'{'
1320 && let Some(close) = template[i + 1..].find('}')
1321 {
1322 let end = i + 1 + close;
1323 let spec = &template[i + 1..end];
1324 if let Some(rendered) = render_placeholder(spec, prefix, timestamp, level, message) {
1325 out.push_str(&rendered);
1326 } else {
1327 out.push_str(&template[i..=end]);
1329 }
1330 i = end + 1;
1331 } else {
1332 out.push(bytes[i] as char);
1333 i += 1;
1334 }
1335 }
1336
1337 out
1338}
1339
1340fn render_placeholder(
1343 spec: &str,
1344 prefix: Option<&str>,
1345 timestamp: &str,
1346 level: &str,
1347 message: &str,
1348) -> Option<String> {
1349 let (name, format_spec) = spec.split_once(':').unwrap_or((spec, ""));
1350 let value: &str = match name {
1351 "timestamp" => timestamp,
1352 "level" => level,
1353 "message" => message,
1354 "prefix" => prefix.unwrap_or(""),
1355 _ => return None,
1356 };
1357
1358 if format_spec.is_empty() {
1359 return Some(value.to_owned());
1360 }
1361
1362 let (alignment, width_str) = match format_spec.chars().next() {
1363 Some('<') => (Alignment::Left, &format_spec[1..]),
1364 Some('>') => (Alignment::Right, &format_spec[1..]),
1365 Some('^') => (Alignment::Center, &format_spec[1..]),
1366 _ => return Some(value.to_owned()),
1367 };
1368
1369 let Ok(width) = width_str.parse::<usize>() else {
1370 return Some(value.to_owned());
1371 };
1372
1373 Some(match alignment {
1374 Alignment::Left => format!("{value:<width$}"),
1375 Alignment::Right => format!("{value:>width$}"),
1376 Alignment::Center => format!("{value:^width$}"),
1377 })
1378}
1379
1380enum Alignment {
1381 Left,
1382 Right,
1383 Center,
1384}
1385
1386fn print_banner_inner(mode: &BannerMode) {
1387 let metadata = BANNER_METADATA.with(|cell| cell.borrow().clone());
1388 let Some(meta) = metadata else {
1389 return;
1392 };
1393
1394 let lines = match mode {
1395 BannerMode::Off => return,
1396 BannerMode::Default => default_banner_lines(&meta),
1397 BannerMode::Custom(builder) => builder(&meta),
1398 };
1399
1400 for line in lines {
1401 log::info!("{line}");
1402 }
1403}
1404
1405fn default_banner_lines(meta: &BannerMetadata) -> Vec<String> {
1406 let authors = if meta.authors.trim().is_empty() {
1407 "Unknown"
1408 } else {
1409 meta.authors
1410 };
1411 let repository = if meta.repository.trim().is_empty() {
1412 "N/A"
1413 } else {
1414 meta.repository
1415 };
1416
1417 vec![
1418 String::new(),
1419 format!(" | {} {}", meta.name, meta.version),
1420 String::from(" |-------------------------------"),
1421 format!(" | Author: {}", authors),
1422 format!(" | Repository: {}", repository),
1423 String::new(),
1424 ]
1425}
1426
1427pub fn print_banner() {
1434 print_banner_inner(&BannerMode::Default);
1438}
1439
1440#[cfg(test)]
1445mod tests {
1446 use super::*;
1447 use std::path::Path;
1448
1449 fn clear_backlog() {
1453 SERVER_BACKLOG.lock().unwrap().clear();
1454 SERVER_BACKLOG_DROPPED.store(0, Ordering::Relaxed);
1455 }
1456
1457 #[test]
1458 fn server_lines_wait_until_the_server_has_a_log() {
1459 let _g = crate::test_support::exclusive();
1460 clear_backlog();
1461
1462 to_server(log::Level::Info, "before the server log".into());
1463 std::thread::spawn(|| to_server(log::Level::Info, "from a worker".into()))
1465 .join()
1466 .unwrap();
1467
1468 flush_server_backlog();
1469 let backlog = SERVER_BACKLOG.lock().unwrap();
1470 assert!(backlog.contains(&(log::Level::Info, "before the server log".to_owned())));
1471 assert!(backlog.contains(&(log::Level::Info, "from a worker".to_owned())));
1472 drop(backlog);
1473 clear_backlog();
1474 }
1475
1476 #[test]
1477 fn the_server_backlog_is_bounded_but_failure_reports_are_kept() {
1478 let _g = crate::test_support::exclusive();
1479 clear_backlog();
1480
1481 std::thread::spawn(|| {
1482 for _ in 0..SERVER_BACKLOG_LIMIT + 5 {
1483 to_server(log::Level::Info, String::new());
1484 }
1485 report_to_server("the log file failed".into());
1486 })
1487 .join()
1488 .unwrap();
1489 let backlog = SERVER_BACKLOG.lock().unwrap();
1490 assert!(backlog.len() <= SERVER_BACKLOG_LIMIT + 1);
1491 assert_eq!(
1492 backlog.last(),
1493 Some(&(log::Level::Error, "the log file failed".to_owned()))
1494 );
1495 drop(backlog);
1496 assert!(SERVER_BACKLOG_DROPPED.load(Ordering::Relaxed) >= 5);
1497 clear_backlog();
1498 }
1499
1500 #[test]
1501 #[cfg_attr(miri, ignore = "reads the real-time clock, which Miri isolates")]
1502 fn the_clock_formats_local_time_and_keeps_it_for_the_second() {
1503 let clock = Clock::new();
1504 let first = clock.timestamp();
1505 assert_eq!(first.len(), 19, "{first}");
1507 assert!(first.bytes().enumerate().all(|(i, b)| match i {
1508 4 | 7 => b == b'-',
1509 10 => b == b' ',
1510 13 | 16 => b == b':',
1511 _ => b.is_ascii_digit(),
1512 }));
1513 let cache = clock.cache.lock().unwrap();
1514 assert_eq!(cache.text, first);
1515 assert_ne!(cache.second, i64::MIN);
1516 }
1517
1518 #[test]
1519 fn config_resolves_defaults() {
1520 let cfg = LoggerConfig::new("my-plugin");
1521 assert_eq!(cfg.resolved_filename(), "my-plugin.log");
1522 assert_eq!(cfg.resolved_prefix(), "[my-plugin]");
1523 assert_eq!(cfg.log_path(), Path::new("logs/my-plugin.log"));
1524 assert_eq!(cfg.resolved_archive_directory(), Path::new("logs/archive"));
1525 assert_eq!(cfg.level, LevelFilter::Info);
1526 assert!(cfg.also_to_server);
1527 assert!(matches!(cfg.banner, BannerMode::Default));
1528 assert_eq!(cfg.file_format, DEFAULT_FILE_FORMAT);
1529 assert_eq!(cfg.server_format, DEFAULT_SERVER_FORMAT);
1530 let rotation = cfg.rotation.expect("default rotation enabled");
1531 assert_eq!(rotation.max_bytes, 50 * 1024 * 1024);
1532 assert_eq!(rotation.keep, None);
1534 }
1535
1536 #[test]
1537 fn config_overrides_apply() {
1538 let cfg = LoggerConfig::new("foo")
1539 .directory("custom")
1540 .filename("custom.log")
1541 .prefix("[Custom]")
1542 .level(LevelFilter::Warn)
1543 .also_to_server(false)
1544 .no_banner()
1545 .rotation_size_mb(10)
1546 .rotation_keep(3)
1547 .file_format("{level}: {message}")
1548 .server_format("<{prefix}> {message}");
1549 assert_eq!(cfg.directory, Path::new("custom"));
1550 assert_eq!(
1553 cfg.resolved_archive_directory(),
1554 Path::new("custom/archive")
1555 );
1556 assert_eq!(cfg.resolved_filename(), "custom.log");
1557 assert_eq!(cfg.resolved_prefix(), "[Custom]");
1558 assert_eq!(cfg.level, LevelFilter::Warn);
1559 assert!(!cfg.also_to_server);
1560 assert!(matches!(cfg.banner, BannerMode::Off));
1561 assert_eq!(cfg.file_format, "{level}: {message}");
1562 assert_eq!(cfg.server_format, "<{prefix}> {message}");
1563 let rotation = cfg.rotation.expect("explicit rotation kept");
1564 assert_eq!(rotation.max_bytes, 10 * 1024 * 1024);
1565 assert_eq!(rotation.keep, Some(3));
1566 }
1567
1568 #[test]
1569 fn rotation_no_cleanup_resets_keep_to_none() {
1570 let cfg = LoggerConfig::new("foo")
1571 .rotation_keep(5)
1572 .rotation_no_cleanup();
1573 let rotation = cfg.rotation.expect("rotation still active");
1574 assert_eq!(rotation.keep, None);
1575 }
1576
1577 #[test]
1578 fn apply_format_substitutes_placeholders() {
1579 let line = apply_format(
1580 "[{timestamp}] [{level}] {message}",
1581 None,
1582 "2026-06-08 12:30:45",
1583 "INFO",
1584 "ready",
1585 );
1586 assert_eq!(line, "[2026-06-08 12:30:45] [INFO] ready");
1587
1588 let server = apply_format(
1589 "{prefix} {message}",
1590 Some("[my-plugin]"),
1591 "2026-06-08 12:30:45",
1592 "WARN",
1593 "stalled",
1594 );
1595 assert_eq!(server, "[my-plugin] stalled");
1596 }
1597
1598 #[test]
1599 fn apply_format_supports_width_specifiers() {
1600 let right = apply_format("[{level:>5}] {message}", None, "ts", "INFO", "msg");
1601 assert_eq!(right, "[ INFO] msg");
1602
1603 let left = apply_format("[{level:<5}] {message}", None, "ts", "INFO", "msg");
1604 assert_eq!(left, "[INFO ] msg");
1605
1606 let center = apply_format("[{level:^6}] {message}", None, "ts", "INFO", "msg");
1607 assert_eq!(center, "[ INFO ] msg");
1608 }
1609
1610 #[test]
1611 fn apply_format_width_smaller_than_value_does_not_truncate() {
1612 let line = apply_format("[{level:>2}] {message}", None, "ts", "INFO", "msg");
1613 assert_eq!(line, "[INFO] msg");
1615 }
1616
1617 #[test]
1618 fn apply_format_leaves_unknown_placeholders_untouched() {
1619 let line = apply_format(
1620 "{foo} {message}",
1621 None,
1622 "2026-06-08 12:30:45",
1623 "INFO",
1624 "ready",
1625 );
1626 assert_eq!(line, "{foo} ready");
1627 }
1628
1629 #[test]
1630 fn custom_banner_lines_emit_in_order() {
1631 let cfg = LoggerConfig::new("foo").banner_with(|meta| {
1632 vec![
1633 String::from("=== plugin start ==="),
1634 format!("hello {}!", meta.name),
1635 ]
1636 });
1637 let lines = match &cfg.banner {
1638 BannerMode::Custom(builder) => builder(&BannerMetadata::new(
1639 "foo",
1640 "1.0",
1641 "ZOTTCE",
1642 "https://example.com",
1643 )),
1644 _ => unreachable!(),
1645 };
1646 assert_eq!(lines.len(), 2);
1647 assert_eq!(lines[0], "=== plugin start ===");
1648 assert_eq!(lines[1], "hello foo!");
1649 }
1650
1651 #[test]
1652 fn no_rotation_disables_archives() {
1653 let cfg = LoggerConfig::new("foo").rotation_size_mb(20).no_rotation();
1654 assert!(cfg.rotation.is_none());
1655 }
1656
1657 #[test]
1658 fn rotation_size_mb_zero_disables() {
1659 let cfg = LoggerConfig::new("foo").rotation_size_mb(0);
1660 assert!(cfg.rotation.is_none());
1661 }
1662
1663 #[test]
1664 fn level_round_trip() {
1665 for level in [
1666 LevelFilter::Off,
1667 LevelFilter::Error,
1668 LevelFilter::Warn,
1669 LevelFilter::Info,
1670 LevelFilter::Debug,
1671 LevelFilter::Trace,
1672 ] {
1673 assert_eq!(u8_to_level(level_to_u8(level)), level);
1674 }
1675 }
1676
1677 #[test]
1678 fn set_and_read_level() {
1679 set_level(LevelFilter::Warn);
1680 assert_eq!(level(), LevelFilter::Warn);
1681 set_level(LevelFilter::Trace);
1682 assert_eq!(level(), LevelFilter::Trace);
1683 }
1684
1685 #[cfg(feature = "compression")]
1686 #[test]
1687 fn compress_archives_builder_sets_flag() {
1688 let cfg = LoggerConfig::new("foo").compress_archives(true);
1689 assert!(cfg.compress_archives);
1690 let cfg = LoggerConfig::new("foo").compress_archives(false);
1691 assert!(!cfg.compress_archives);
1692 let cfg = LoggerConfig::new("foo");
1693 assert!(
1694 !cfg.compress_archives,
1695 "compression must stay opt-in when the builder is not called"
1696 );
1697 }
1698
1699 #[cfg(feature = "compression")]
1700 #[test]
1701 fn find_next_archive_index_counts_gz_variants() {
1702 let tmp = std::env::temp_dir().join(format!(
1703 "rust-samp-test-archive-{}-{}",
1704 std::process::id(),
1705 std::time::SystemTime::now()
1706 .duration_since(std::time::UNIX_EPOCH)
1707 .unwrap()
1708 .as_nanos()
1709 ));
1710 std::fs::create_dir_all(&tmp).unwrap();
1711 std::fs::write(tmp.join("foo.log.2.gz"), b"compressed").unwrap();
1713 std::fs::write(tmp.join("foo.log.5"), b"plain").unwrap();
1714
1715 let next = super::find_next_archive_index(&tmp, "foo.log");
1718 assert_eq!(next, 6);
1719
1720 let _ = std::fs::remove_dir_all(&tmp);
1721 }
1722
1723 #[test]
1724 fn env_var_prefix_uppercases_and_sanitises() {
1725 assert_eq!(super::env_var_prefix("memcached"), "MEMCACHED");
1726 assert_eq!(super::env_var_prefix("streamer-rs"), "STREAMER_RS");
1727 assert_eq!(super::env_var_prefix("my.plugin"), "MY_PLUGIN");
1728 assert_eq!(super::env_var_prefix("plugin_v2"), "PLUGIN_V2");
1729 }
1730
1731 #[test]
1732 fn parse_level_accepts_known_names() {
1733 assert_eq!(super::parse_level("off"), Some(LevelFilter::Off));
1734 assert_eq!(super::parse_level("ERROR"), Some(LevelFilter::Error));
1735 assert_eq!(super::parse_level("warn"), Some(LevelFilter::Warn));
1736 assert_eq!(super::parse_level("warning"), Some(LevelFilter::Warn));
1737 assert_eq!(super::parse_level(" info "), Some(LevelFilter::Info));
1738 assert_eq!(super::parse_level("debug"), Some(LevelFilter::Debug));
1739 assert_eq!(super::parse_level("trace"), Some(LevelFilter::Trace));
1740 assert_eq!(super::parse_level("nope"), None);
1741 assert_eq!(super::parse_level(""), None);
1742 }
1743
1744 #[test]
1745 fn parse_bool_accepts_common_truthy() {
1746 for s in ["1", "true", "TRUE", " yes ", "on"] {
1747 assert!(super::parse_bool(s), "{s:?} should parse as true");
1748 }
1749 for s in ["0", "false", "no", "off", "", "anything"] {
1750 assert!(!super::parse_bool(s), "{s:?} should parse as false");
1751 }
1752 }
1753
1754 #[test]
1755 fn from_env_applies_overrides_and_ignores_garbage() {
1756 let crate_name = format!("rust_samp_test_{}", std::process::id());
1759 let prefix = super::env_var_prefix(&crate_name);
1760
1761 unsafe {
1766 std::env::set_var(format!("{prefix}_LOG_LEVEL"), "debug");
1767 std::env::set_var(format!("{prefix}_LOG_DIR"), "/tmp/rust-samp-from-env");
1768 std::env::set_var(format!("{prefix}_LOG_ROTATION_MB"), "not-a-number");
1769 std::env::set_var(format!("{prefix}_LOG_NO_BANNER"), "1");
1770 std::env::set_var(format!("{prefix}_LOG_SERVER"), "false");
1771 }
1772
1773 let cfg = LoggerConfig::new(crate_name).from_env();
1774
1775 assert_eq!(cfg.level, LevelFilter::Debug);
1776 assert_eq!(cfg.directory, PathBuf::from("/tmp/rust-samp-from-env"));
1777 assert!(matches!(cfg.banner, BannerMode::Off));
1778 assert!(!cfg.also_to_server);
1779 assert!(matches!(
1781 cfg.rotation,
1782 Some(Rotation {
1783 max_bytes: DEFAULT_ROTATION_BYTES,
1784 ..
1785 })
1786 ));
1787
1788 unsafe {
1790 std::env::remove_var(format!("{prefix}_LOG_LEVEL"));
1791 std::env::remove_var(format!("{prefix}_LOG_DIR"));
1792 std::env::remove_var(format!("{prefix}_LOG_ROTATION_MB"));
1793 std::env::remove_var(format!("{prefix}_LOG_NO_BANNER"));
1794 std::env::remove_var(format!("{prefix}_LOG_SERVER"));
1795 }
1796 }
1797
1798 #[test]
1799 fn add_sink_appends_in_call_order() {
1800 struct Counter(std::sync::atomic::AtomicUsize);
1801 impl super::Sink for Counter {
1802 fn emit(&self, _record: &super::SinkRecord<'_>) {
1803 self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1804 }
1805 }
1806
1807 let cfg = LoggerConfig::new("foo");
1808 assert_eq!(cfg.sinks.len(), 0, "no sinks by default");
1809
1810 let cfg = cfg
1811 .add_sink(Box::new(Counter(0.into())))
1812 .add_sink(Box::new(Counter(0.into())));
1813 assert_eq!(cfg.sinks.len(), 2);
1814 }
1815
1816 #[test]
1817 fn flush_without_install_is_noop() {
1818 super::flush();
1823 super::flush();
1824 }
1825}