use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use std::ptr;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU8, Ordering};
use log::{LevelFilter, Log, Metadata, Record};
use time::OffsetDateTime;
use time::format_description::BorrowedFormatItem;
use time::macros::format_description;
use crate::runtime::Runtime;
const TIMESTAMP_FORMAT: &[BorrowedFormatItem<'_>] =
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
const DEFAULT_ROTATION_BYTES: u64 = 50 * 1024 * 1024;
const DEFAULT_FILE_FORMAT: &str = "[{timestamp}] [{level}] {message}";
const DEFAULT_SERVER_FORMAT: &str = "{prefix} {message}";
pub struct LoggerConfig {
crate_name: String,
directory: PathBuf,
filename: Option<String>,
prefix: Option<String>,
level: LevelFilter,
also_to_server: bool,
banner: BannerMode,
rotation: Option<Rotation>,
file_format: String,
server_format: String,
#[cfg(feature = "compression")]
compress_archives: bool,
sinks: Vec<Box<dyn Sink>>,
}
pub trait Sink: Send + Sync {
fn emit(&self, record: &SinkRecord<'_>);
}
#[derive(Debug)]
pub struct SinkRecord<'a> {
pub timestamp: &'a str,
pub level: log::Level,
pub target: &'a str,
pub message: &'a str,
pub prefix: &'a str,
}
pub type BannerBuilder = dyn Fn(&BannerMetadata) -> Vec<String> + Send + Sync;
pub enum BannerMode {
Off,
Default,
Custom(Box<BannerBuilder>),
}
impl std::fmt::Debug for BannerMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Off => f.write_str("Off"),
Self::Default => f.write_str("Default"),
Self::Custom(_) => f.write_str("Custom(<fn>)"),
}
}
}
impl std::fmt::Debug for LoggerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut s = f.debug_struct("LoggerConfig");
s.field("crate_name", &self.crate_name)
.field("directory", &self.directory)
.field("filename", &self.filename)
.field("prefix", &self.prefix)
.field("level", &self.level)
.field("also_to_server", &self.also_to_server)
.field("banner", &self.banner)
.field("rotation", &self.rotation)
.field("file_format", &self.file_format)
.field("server_format", &self.server_format)
.field("sinks", &format_args!("[{} sink(s)]", self.sinks.len()));
#[cfg(feature = "compression")]
s.field("compress_archives", &self.compress_archives);
s.finish()
}
}
#[derive(Debug, Clone, Copy)]
struct Rotation {
max_bytes: u64,
keep: Option<u32>,
}
impl LoggerConfig {
#[must_use]
pub fn new(crate_name: impl Into<String>) -> Self {
Self {
crate_name: crate_name.into(),
directory: PathBuf::from("logs"),
filename: None,
prefix: None,
level: LevelFilter::Info,
also_to_server: true,
banner: BannerMode::Default,
rotation: Some(Rotation {
max_bytes: DEFAULT_ROTATION_BYTES,
keep: None,
}),
file_format: DEFAULT_FILE_FORMAT.to_owned(),
server_format: DEFAULT_SERVER_FORMAT.to_owned(),
#[cfg(feature = "compression")]
compress_archives: false,
sinks: Vec::new(),
}
}
#[must_use]
pub fn add_sink(mut self, sink: Box<dyn Sink>) -> Self {
self.sinks.push(sink);
self
}
#[cfg(feature = "compression")]
#[must_use]
pub fn compress_archives(mut self, yes: bool) -> Self {
self.compress_archives = yes;
self
}
#[must_use]
pub fn from_env(mut self) -> Self {
let prefix = env_var_prefix(&self.crate_name);
self.apply_env(&prefix);
self
}
fn apply_env(&mut self, prefix: &str) {
if let Some(raw) = read_env(prefix, "LEVEL") {
match parse_level(&raw) {
Some(l) => self.level = l,
None => warn_invalid(prefix, "LEVEL", &raw),
}
}
if let Some(raw) = read_env(prefix, "DIR") {
self.directory = PathBuf::from(raw);
}
if let Some(raw) = read_env(prefix, "FILE") {
self.filename = Some(raw);
}
if let Some(raw) = read_env(prefix, "ROTATION_MB") {
match raw.parse::<u64>() {
Ok(0) => self.rotation = None,
Ok(mb) => {
let max_bytes = mb.saturating_mul(1024 * 1024);
let keep = self.rotation.and_then(|r| r.keep);
self.rotation = Some(Rotation { max_bytes, keep });
}
Err(_) => warn_invalid(prefix, "ROTATION_MB", &raw),
}
}
if let Some(raw) = read_env(prefix, "ROTATION_KEEP") {
match raw.parse::<u32>() {
Ok(keep) => {
let max_bytes = self
.rotation
.map_or(DEFAULT_ROTATION_BYTES, |r| r.max_bytes);
self.rotation = Some(Rotation {
max_bytes,
keep: Some(keep),
});
}
Err(_) => warn_invalid(prefix, "ROTATION_KEEP", &raw),
}
}
if let Some(raw) = read_env(prefix, "NO_ROTATION")
&& parse_bool(&raw)
{
self.rotation = None;
}
if let Some(raw) = read_env(prefix, "NO_BANNER")
&& parse_bool(&raw)
{
self.banner = BannerMode::Off;
}
if let Some(raw) = read_env(prefix, "SERVER") {
self.also_to_server = parse_bool(&raw);
}
#[cfg(feature = "compression")]
if let Some(raw) = read_env(prefix, "COMPRESS") {
self.compress_archives = parse_bool(&raw);
}
}
#[must_use]
pub fn directory(mut self, path: impl Into<PathBuf>) -> Self {
self.directory = path.into();
self
}
#[must_use]
pub fn filename(mut self, name: impl Into<String>) -> Self {
self.filename = Some(name.into());
self
}
#[must_use]
pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = Some(prefix.into());
self
}
#[must_use]
pub fn level(mut self, level: LevelFilter) -> Self {
self.level = level;
self
}
#[must_use]
pub fn also_to_server(mut self, enabled: bool) -> Self {
self.also_to_server = enabled;
self
}
#[must_use]
pub fn banner(mut self, mode: BannerMode) -> Self {
self.banner = mode;
self
}
#[must_use]
pub fn no_banner(mut self) -> Self {
self.banner = BannerMode::Off;
self
}
#[must_use]
pub fn banner_with<F>(mut self, builder: F) -> Self
where
F: Fn(&BannerMetadata) -> Vec<String> + Send + Sync + 'static,
{
self.banner = BannerMode::Custom(Box::new(builder));
self
}
#[must_use]
pub fn file_format(mut self, format: impl Into<String>) -> Self {
self.file_format = format.into();
self
}
#[must_use]
pub fn server_format(mut self, format: impl Into<String>) -> Self {
self.server_format = format.into();
self
}
#[must_use]
pub fn no_rotation(mut self) -> Self {
self.rotation = None;
self
}
#[must_use]
pub fn rotation_size_mb(mut self, mb: u64) -> Self {
if mb == 0 {
self.rotation = None;
} else {
let max_bytes = mb.saturating_mul(1024 * 1024);
let keep = self.rotation.and_then(|r| r.keep);
self.rotation = Some(Rotation { max_bytes, keep });
}
self
}
#[must_use]
pub fn rotation_keep(mut self, keep: u32) -> Self {
let max_bytes = self
.rotation
.map_or(DEFAULT_ROTATION_BYTES, |r| r.max_bytes);
self.rotation = Some(Rotation {
max_bytes,
keep: Some(keep),
});
self
}
#[must_use]
pub fn rotation_no_cleanup(mut self) -> Self {
let max_bytes = self
.rotation
.map_or(DEFAULT_ROTATION_BYTES, |r| r.max_bytes);
self.rotation = Some(Rotation {
max_bytes,
keep: None,
});
self
}
fn resolved_filename(&self) -> String {
self.filename
.clone()
.unwrap_or_else(|| format!("{}.log", self.crate_name))
}
fn resolved_prefix(&self) -> String {
self.prefix
.clone()
.unwrap_or_else(|| format!("[{}]", self.crate_name))
}
fn log_path(&self) -> PathBuf {
self.directory.join(self.resolved_filename())
}
fn resolved_archive_directory(&self) -> PathBuf {
self.directory.join("archive")
}
}
#[derive(Debug)]
pub enum InstallError {
AlreadyInstalled,
Io(std::io::Error),
}
impl std::fmt::Display for InstallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AlreadyInstalled => f.write_str("logger already installed"),
Self::Io(e) => write!(f, "i/o error: {e}"),
}
}
}
impl std::error::Error for InstallError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::AlreadyInstalled => None,
Self::Io(e) => Some(e),
}
}
}
impl From<std::io::Error> for InstallError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
static INSTALLED: AtomicBool = AtomicBool::new(false);
static INSTANCE: AtomicPtr<LoggerImpl> = AtomicPtr::new(ptr::null_mut());
static LEVEL: AtomicU8 = AtomicU8::new(level_to_u8(LevelFilter::Info));
const fn level_to_u8(l: LevelFilter) -> u8 {
match l {
LevelFilter::Off => 0,
LevelFilter::Error => 1,
LevelFilter::Warn => 2,
LevelFilter::Info => 3,
LevelFilter::Debug => 4,
LevelFilter::Trace => 5,
}
}
const fn u8_to_level(v: u8) -> LevelFilter {
match v {
0 => LevelFilter::Off,
1 => LevelFilter::Error,
2 => LevelFilter::Warn,
3 => LevelFilter::Info,
4 => LevelFilter::Debug,
_ => LevelFilter::Trace,
}
}
pub fn set_level(level: LevelFilter) {
LEVEL.store(level_to_u8(level), Ordering::Relaxed);
log::set_max_level(level);
}
#[must_use]
pub fn level() -> LevelFilter {
u8_to_level(LEVEL.load(Ordering::Relaxed))
}
pub fn flush() {
let ptr = INSTANCE.load(Ordering::Acquire);
if ptr.is_null() {
return;
}
let logger = unsafe { &*ptr };
log::Log::flush(logger);
}
pub fn install(config: LoggerConfig) -> Result<(), InstallError> {
if INSTALLED.swap(true, Ordering::AcqRel) {
return Err(InstallError::AlreadyInstalled);
}
fs::create_dir_all(&config.directory)?;
let path = config.log_path();
let file = OpenOptions::new().create(true).append(true).open(&path)?;
let initial_size = file.metadata().map(|m| m.len()).unwrap_or(0);
let prefix = config.resolved_prefix();
let level = config.level;
let filename = config.resolved_filename();
let archive_directory = config.resolved_archive_directory();
let next_archive_index = find_next_archive_index(&archive_directory, &filename);
#[cfg(feature = "compression")]
let compress_archives = config.compress_archives;
let LoggerConfig {
also_to_server,
banner,
rotation,
file_format,
server_format,
sinks,
..
} = config;
let logger = Box::new(LoggerImpl {
prefix,
also_to_server,
rotation,
path,
filename,
archive_directory,
file_format,
server_format,
#[cfg(feature = "compression")]
compress_archives,
sinks,
state: Mutex::new(LoggerState {
file: Some(file),
current_size: initial_size,
file_write_reported: false,
next_archive_index,
}),
});
set_level(level);
let logger_ptr: *const LoggerImpl = &raw const *logger;
INSTANCE.store(logger_ptr.cast_mut(), Ordering::Release);
log::set_boxed_logger(logger).map_err(|_| {
INSTANCE.store(ptr::null_mut(), Ordering::Release);
INSTALLED.store(false, Ordering::Release);
InstallError::AlreadyInstalled
})?;
print_banner_inner(&banner);
Ok(())
}
struct LoggerImpl {
prefix: String,
also_to_server: bool,
rotation: Option<Rotation>,
path: PathBuf,
filename: String,
archive_directory: PathBuf,
file_format: String,
server_format: String,
#[cfg(feature = "compression")]
compress_archives: bool,
sinks: Vec<Box<dyn Sink>>,
state: Mutex<LoggerState>,
}
struct LoggerState {
file: Option<File>,
current_size: u64,
file_write_reported: bool,
next_archive_index: u32,
}
impl Log for LoggerImpl {
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
metadata.level() <= u8_to_level(LEVEL.load(Ordering::Relaxed))
}
fn log(&self, record: &Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
let message = format!("{}", record.args());
let level = record.level().as_str();
let timestamp = OffsetDateTime::now_local()
.unwrap_or_else(|_| OffsetDateTime::now_utc())
.format(TIMESTAMP_FORMAT)
.unwrap_or_else(|_| String::from("0000-00-00 00:00:00"));
if self.also_to_server {
let server_line = apply_format(
&self.server_format,
Some(&self.prefix),
×tamp,
level,
&message,
);
Runtime::get().log(server_line);
}
let mut line = apply_format(&self.file_format, None, ×tamp, level, &message);
line.push('\n');
let mut state = match self.state.lock() {
Ok(s) => s,
Err(p) => p.into_inner(),
};
if let Some(rotation) = self.rotation
&& state.current_size + line.len() as u64 > rotation.max_bytes
{
self.rotate(&mut state, rotation);
}
if let Some(file) = state.file.as_mut() {
match file.write_all(line.as_bytes()) {
Ok(()) => state.current_size += line.len() as u64,
Err(e) => {
if !state.file_write_reported {
state.file_write_reported = true;
Runtime::get().log(format!(
"{} failed to write {}: {}. Further file-write errors will be suppressed.",
self.prefix,
self.path.display(),
e,
));
}
}
}
}
if !self.sinks.is_empty() {
let sink_record = SinkRecord {
timestamp: ×tamp,
level: record.level(),
target: record.target(),
message: &message,
prefix: &self.prefix,
};
for sink in &self.sinks {
sink.emit(&sink_record);
}
}
}
fn flush(&self) {
if let Ok(mut state) = self.state.lock()
&& let Some(file) = state.file.as_mut()
{
let _ = file.flush();
}
}
}
impl LoggerImpl {
fn rotate(&self, state: &mut LoggerState, rotation: Rotation) {
state.file = None;
if let Err(e) = fs::create_dir_all(&self.archive_directory) {
self.report_file_error(state, "create archive directory", &e);
self.reopen_active(state);
return;
}
match rotation.keep {
Some(keep) if keep > 0 => self.rotate_shift(keep),
_ => {
let index = state.next_archive_index;
state.next_archive_index = state.next_archive_index.saturating_add(1);
let archived = self.archive_path(index);
if fs::rename(&self.path, &archived).is_ok() {
self.compress_archive(&archived);
}
}
}
self.reopen_active(state);
}
#[cfg(feature = "compression")]
fn compress_archive(&self, archived: &std::path::Path) {
if !self.compress_archives {
return;
}
let gz_path = {
let mut p = archived.as_os_str().to_owned();
p.push(".gz");
PathBuf::from(p)
};
let Ok(input) = fs::File::open(archived) else {
return;
};
let Ok(output) = fs::File::create(&gz_path) else {
return;
};
let mut encoder = flate2::write::GzEncoder::new(output, flate2::Compression::default());
let mut reader = std::io::BufReader::new(input);
if std::io::copy(&mut reader, &mut encoder).is_err() {
let _ = fs::remove_file(&gz_path);
return;
}
if encoder.finish().is_err() {
let _ = fs::remove_file(&gz_path);
return;
}
let _ = fs::remove_file(archived);
}
#[cfg(not(feature = "compression"))]
#[allow(clippy::unused_self)]
fn compress_archive(&self, _archived: &std::path::Path) {}
fn rotate_shift(&self, keep: u32) {
let _ = fs::remove_file(self.archive_path(keep));
#[cfg(feature = "compression")]
let _ = fs::remove_file(append_gz(&self.archive_path(keep)));
for index in (1..keep).rev() {
let src = self.archive_path(index);
let dst = self.archive_path(index + 1);
if src.exists() {
let _ = fs::rename(&src, &dst);
}
#[cfg(feature = "compression")]
{
let src_gz = append_gz(&src);
let dst_gz = append_gz(&dst);
if src_gz.exists() {
let _ = fs::rename(&src_gz, &dst_gz);
}
}
}
let archived = self.archive_path(1);
if fs::rename(&self.path, &archived).is_ok() {
self.compress_archive(&archived);
}
}
fn reopen_active(&self, state: &mut LoggerState) {
match OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
{
Ok(file) => {
state.file = Some(file);
state.current_size = 0;
}
Err(e) => self.report_file_error(state, "reopen", &e),
}
}
fn report_file_error(&self, state: &mut LoggerState, action: &str, e: &std::io::Error) {
if !state.file_write_reported {
state.file_write_reported = true;
Runtime::get().log(format!(
"{} failed to {} {}: {}. Further file-write errors will be suppressed.",
self.prefix,
action,
self.path.display(),
e,
));
}
}
fn archive_path(&self, index: u32) -> PathBuf {
self.archive_directory
.join(format!("{}.{}", self.filename, index))
}
}
fn env_var_prefix(crate_name: &str) -> String {
crate_name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_uppercase()
} else {
'_'
}
})
.collect()
}
fn read_env(prefix: &str, key: &str) -> Option<String> {
let name = format!("{prefix}_LOG_{key}");
std::env::var(&name).ok().filter(|s| !s.is_empty())
}
fn parse_level(raw: &str) -> Option<LevelFilter> {
match raw.trim().to_ascii_lowercase().as_str() {
"off" => Some(LevelFilter::Off),
"error" => Some(LevelFilter::Error),
"warn" | "warning" => Some(LevelFilter::Warn),
"info" => Some(LevelFilter::Info),
"debug" => Some(LevelFilter::Debug),
"trace" => Some(LevelFilter::Trace),
_ => None,
}
}
fn parse_bool(raw: &str) -> bool {
matches!(
raw.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
fn warn_invalid(prefix: &str, key: &str, raw: &str) {
let msg = format!(
"[rust-samp] ignoring invalid env var {prefix}_LOG_{key}={raw:?} — keeping previous value",
);
if let Some(rt) = Runtime::try_get() {
rt.log(msg);
} else {
eprintln!("{msg}");
}
}
#[cfg(feature = "compression")]
fn append_gz(path: &std::path::Path) -> PathBuf {
let mut s = path.as_os_str().to_owned();
s.push(".gz");
PathBuf::from(s)
}
fn find_next_archive_index(archive_dir: &std::path::Path, filename: &str) -> u32 {
let prefix = format!("{filename}.");
let mut max = 0u32;
if let Ok(entries) = fs::read_dir(archive_dir) {
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str()
&& let Some(rest) = name.strip_prefix(&prefix)
{
let idx_str = rest.strip_suffix(".gz").unwrap_or(rest);
if let Ok(index) = idx_str.parse::<u32>() {
max = max.max(index);
}
}
}
}
max.saturating_add(1)
}
thread_local! {
static BANNER_METADATA: std::cell::RefCell<Option<BannerMetadata>> =
const { std::cell::RefCell::new(None) };
}
#[doc(hidden)]
pub fn __set_banner_metadata(metadata: BannerMetadata) {
BANNER_METADATA.with(|cell| {
*cell.borrow_mut() = Some(metadata);
});
}
#[derive(Debug, Clone)]
pub struct BannerMetadata {
pub name: &'static str,
pub version: &'static str,
pub authors: &'static str,
pub repository: &'static str,
}
impl BannerMetadata {
#[must_use]
pub fn new(
name: &'static str,
version: &'static str,
authors: &'static str,
repository: &'static str,
) -> Self {
Self {
name,
version,
authors,
repository,
}
}
}
fn apply_format(
template: &str,
prefix: Option<&str>,
timestamp: &str,
level: &str,
message: &str,
) -> String {
let mut out = String::with_capacity(template.len());
let bytes = template.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'{'
&& let Some(close) = template[i + 1..].find('}')
{
let end = i + 1 + close;
let spec = &template[i + 1..end];
if let Some(rendered) = render_placeholder(spec, prefix, timestamp, level, message) {
out.push_str(&rendered);
} else {
out.push_str(&template[i..=end]);
}
i = end + 1;
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
fn render_placeholder(
spec: &str,
prefix: Option<&str>,
timestamp: &str,
level: &str,
message: &str,
) -> Option<String> {
let (name, format_spec) = spec.split_once(':').unwrap_or((spec, ""));
let value: &str = match name {
"timestamp" => timestamp,
"level" => level,
"message" => message,
"prefix" => prefix.unwrap_or(""),
_ => return None,
};
if format_spec.is_empty() {
return Some(value.to_owned());
}
let (alignment, width_str) = match format_spec.chars().next() {
Some('<') => (Alignment::Left, &format_spec[1..]),
Some('>') => (Alignment::Right, &format_spec[1..]),
Some('^') => (Alignment::Center, &format_spec[1..]),
_ => return Some(value.to_owned()),
};
let Ok(width) = width_str.parse::<usize>() else {
return Some(value.to_owned());
};
Some(match alignment {
Alignment::Left => format!("{value:<width$}"),
Alignment::Right => format!("{value:>width$}"),
Alignment::Center => format!("{value:^width$}"),
})
}
enum Alignment {
Left,
Right,
Center,
}
fn print_banner_inner(mode: &BannerMode) {
let metadata = BANNER_METADATA.with(|cell| cell.borrow().clone());
let Some(meta) = metadata else {
return;
};
let lines = match mode {
BannerMode::Off => return,
BannerMode::Default => default_banner_lines(&meta),
BannerMode::Custom(builder) => builder(&meta),
};
for line in lines {
log::info!("{line}");
}
}
fn default_banner_lines(meta: &BannerMetadata) -> Vec<String> {
let authors = if meta.authors.trim().is_empty() {
"Unknown"
} else {
meta.authors
};
let repository = if meta.repository.trim().is_empty() {
"N/A"
} else {
meta.repository
};
vec![
String::new(),
format!(" | {} {}", meta.name, meta.version),
String::from(" |-------------------------------"),
format!(" | Author: {}", authors),
format!(" | Repository: {}", repository),
String::new(),
]
}
pub fn print_banner() {
print_banner_inner(&BannerMode::Default);
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn config_resolves_defaults() {
let cfg = LoggerConfig::new("my-plugin");
assert_eq!(cfg.resolved_filename(), "my-plugin.log");
assert_eq!(cfg.resolved_prefix(), "[my-plugin]");
assert_eq!(cfg.log_path(), Path::new("logs/my-plugin.log"));
assert_eq!(cfg.resolved_archive_directory(), Path::new("logs/archive"));
assert_eq!(cfg.level, LevelFilter::Info);
assert!(cfg.also_to_server);
assert!(matches!(cfg.banner, BannerMode::Default));
assert_eq!(cfg.file_format, DEFAULT_FILE_FORMAT);
assert_eq!(cfg.server_format, DEFAULT_SERVER_FORMAT);
let rotation = cfg.rotation.expect("default rotation enabled");
assert_eq!(rotation.max_bytes, 50 * 1024 * 1024);
assert_eq!(rotation.keep, None);
}
#[test]
fn config_overrides_apply() {
let cfg = LoggerConfig::new("foo")
.directory("custom")
.filename("custom.log")
.prefix("[Custom]")
.level(LevelFilter::Warn)
.also_to_server(false)
.no_banner()
.rotation_size_mb(10)
.rotation_keep(3)
.file_format("{level}: {message}")
.server_format("<{prefix}> {message}");
assert_eq!(cfg.directory, Path::new("custom"));
assert_eq!(
cfg.resolved_archive_directory(),
Path::new("custom/archive")
);
assert_eq!(cfg.resolved_filename(), "custom.log");
assert_eq!(cfg.resolved_prefix(), "[Custom]");
assert_eq!(cfg.level, LevelFilter::Warn);
assert!(!cfg.also_to_server);
assert!(matches!(cfg.banner, BannerMode::Off));
assert_eq!(cfg.file_format, "{level}: {message}");
assert_eq!(cfg.server_format, "<{prefix}> {message}");
let rotation = cfg.rotation.expect("explicit rotation kept");
assert_eq!(rotation.max_bytes, 10 * 1024 * 1024);
assert_eq!(rotation.keep, Some(3));
}
#[test]
fn rotation_no_cleanup_resets_keep_to_none() {
let cfg = LoggerConfig::new("foo")
.rotation_keep(5)
.rotation_no_cleanup();
let rotation = cfg.rotation.expect("rotation still active");
assert_eq!(rotation.keep, None);
}
#[test]
fn apply_format_substitutes_placeholders() {
let line = apply_format(
"[{timestamp}] [{level}] {message}",
None,
"2026-06-08 12:30:45",
"INFO",
"ready",
);
assert_eq!(line, "[2026-06-08 12:30:45] [INFO] ready");
let server = apply_format(
"{prefix} {message}",
Some("[my-plugin]"),
"2026-06-08 12:30:45",
"WARN",
"stalled",
);
assert_eq!(server, "[my-plugin] stalled");
}
#[test]
fn apply_format_supports_width_specifiers() {
let right = apply_format("[{level:>5}] {message}", None, "ts", "INFO", "msg");
assert_eq!(right, "[ INFO] msg");
let left = apply_format("[{level:<5}] {message}", None, "ts", "INFO", "msg");
assert_eq!(left, "[INFO ] msg");
let center = apply_format("[{level:^6}] {message}", None, "ts", "INFO", "msg");
assert_eq!(center, "[ INFO ] msg");
}
#[test]
fn apply_format_width_smaller_than_value_does_not_truncate() {
let line = apply_format("[{level:>2}] {message}", None, "ts", "INFO", "msg");
assert_eq!(line, "[INFO] msg");
}
#[test]
fn apply_format_leaves_unknown_placeholders_untouched() {
let line = apply_format(
"{foo} {message}",
None,
"2026-06-08 12:30:45",
"INFO",
"ready",
);
assert_eq!(line, "{foo} ready");
}
#[test]
fn custom_banner_lines_emit_in_order() {
let cfg = LoggerConfig::new("foo").banner_with(|meta| {
vec![
String::from("=== plugin start ==="),
format!("hello {}!", meta.name),
]
});
let lines = match &cfg.banner {
BannerMode::Custom(builder) => builder(&BannerMetadata::new(
"foo",
"1.0",
"ZOTTCE",
"https://example.com",
)),
_ => unreachable!(),
};
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], "=== plugin start ===");
assert_eq!(lines[1], "hello foo!");
}
#[test]
fn no_rotation_disables_archives() {
let cfg = LoggerConfig::new("foo").rotation_size_mb(20).no_rotation();
assert!(cfg.rotation.is_none());
}
#[test]
fn rotation_size_mb_zero_disables() {
let cfg = LoggerConfig::new("foo").rotation_size_mb(0);
assert!(cfg.rotation.is_none());
}
#[test]
fn level_round_trip() {
for level in [
LevelFilter::Off,
LevelFilter::Error,
LevelFilter::Warn,
LevelFilter::Info,
LevelFilter::Debug,
LevelFilter::Trace,
] {
assert_eq!(u8_to_level(level_to_u8(level)), level);
}
}
#[test]
fn set_and_read_level() {
set_level(LevelFilter::Warn);
assert_eq!(level(), LevelFilter::Warn);
set_level(LevelFilter::Trace);
assert_eq!(level(), LevelFilter::Trace);
}
#[cfg(feature = "compression")]
#[test]
fn compress_archives_builder_sets_flag() {
let cfg = LoggerConfig::new("foo").compress_archives(true);
assert!(cfg.compress_archives);
let cfg = LoggerConfig::new("foo").compress_archives(false);
assert!(!cfg.compress_archives);
let cfg = LoggerConfig::new("foo");
assert!(
!cfg.compress_archives,
"compression must stay opt-in when the builder is not called"
);
}
#[cfg(feature = "compression")]
#[test]
fn find_next_archive_index_counts_gz_variants() {
let tmp = std::env::temp_dir().join(format!(
"rust-samp-test-archive-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("foo.log.2.gz"), b"compressed").unwrap();
std::fs::write(tmp.join("foo.log.5"), b"plain").unwrap();
let next = super::find_next_archive_index(&tmp, "foo.log");
assert_eq!(next, 6);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn env_var_prefix_uppercases_and_sanitises() {
assert_eq!(super::env_var_prefix("memcached"), "MEMCACHED");
assert_eq!(super::env_var_prefix("streamer-rs"), "STREAMER_RS");
assert_eq!(super::env_var_prefix("my.plugin"), "MY_PLUGIN");
assert_eq!(super::env_var_prefix("plugin_v2"), "PLUGIN_V2");
}
#[test]
fn parse_level_accepts_known_names() {
assert_eq!(super::parse_level("off"), Some(LevelFilter::Off));
assert_eq!(super::parse_level("ERROR"), Some(LevelFilter::Error));
assert_eq!(super::parse_level("warn"), Some(LevelFilter::Warn));
assert_eq!(super::parse_level("warning"), Some(LevelFilter::Warn));
assert_eq!(super::parse_level(" info "), Some(LevelFilter::Info));
assert_eq!(super::parse_level("debug"), Some(LevelFilter::Debug));
assert_eq!(super::parse_level("trace"), Some(LevelFilter::Trace));
assert_eq!(super::parse_level("nope"), None);
assert_eq!(super::parse_level(""), None);
}
#[test]
fn parse_bool_accepts_common_truthy() {
for s in ["1", "true", "TRUE", " yes ", "on"] {
assert!(super::parse_bool(s), "{s:?} should parse as true");
}
for s in ["0", "false", "no", "off", "", "anything"] {
assert!(!super::parse_bool(s), "{s:?} should parse as false");
}
}
#[test]
fn from_env_applies_overrides_and_ignores_garbage() {
let crate_name = format!("rust_samp_test_{}", std::process::id());
let prefix = super::env_var_prefix(&crate_name);
unsafe {
std::env::set_var(format!("{prefix}_LOG_LEVEL"), "debug");
std::env::set_var(format!("{prefix}_LOG_DIR"), "/tmp/rust-samp-from-env");
std::env::set_var(format!("{prefix}_LOG_ROTATION_MB"), "not-a-number");
std::env::set_var(format!("{prefix}_LOG_NO_BANNER"), "1");
std::env::set_var(format!("{prefix}_LOG_SERVER"), "false");
}
let cfg = LoggerConfig::new(crate_name).from_env();
assert_eq!(cfg.level, LevelFilter::Debug);
assert_eq!(cfg.directory, PathBuf::from("/tmp/rust-samp-from-env"));
assert!(matches!(cfg.banner, BannerMode::Off));
assert!(!cfg.also_to_server);
assert!(matches!(
cfg.rotation,
Some(Rotation {
max_bytes: DEFAULT_ROTATION_BYTES,
..
})
));
unsafe {
std::env::remove_var(format!("{prefix}_LOG_LEVEL"));
std::env::remove_var(format!("{prefix}_LOG_DIR"));
std::env::remove_var(format!("{prefix}_LOG_ROTATION_MB"));
std::env::remove_var(format!("{prefix}_LOG_NO_BANNER"));
std::env::remove_var(format!("{prefix}_LOG_SERVER"));
}
}
#[test]
fn add_sink_appends_in_call_order() {
struct Counter(std::sync::atomic::AtomicUsize);
impl super::Sink for Counter {
fn emit(&self, _record: &super::SinkRecord<'_>) {
self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
}
let cfg = LoggerConfig::new("foo");
assert_eq!(cfg.sinks.len(), 0, "no sinks by default");
let cfg = cfg
.add_sink(Box::new(Counter(0.into())))
.add_sink(Box::new(Counter(0.into())));
assert_eq!(cfg.sinks.len(), 2);
}
#[test]
fn flush_without_install_is_noop() {
super::flush();
super::flush();
}
}