use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, 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,
}
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 {
f.debug_struct("LoggerConfig")
.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)
.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(),
}
}
#[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 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 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);
let LoggerConfig {
also_to_server,
banner,
rotation,
file_format,
server_format,
..
} = config;
let logger = Box::new(LoggerImpl {
prefix,
also_to_server,
rotation,
path,
filename,
archive_directory,
file_format,
server_format,
state: Mutex::new(LoggerState {
file: Some(file),
current_size: initial_size,
file_write_reported: false,
next_archive_index,
}),
});
set_level(level);
log::set_boxed_logger(logger).map_err(|_| {
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,
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,
));
}
}
}
}
}
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 _ = fs::rename(&self.path, self.archive_path(index));
}
}
self.reopen_active(state);
}
fn rotate_shift(&self, keep: u32) {
let _ = fs::remove_file(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);
}
}
let _ = fs::rename(&self.path, self.archive_path(1));
}
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 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 Ok(index) = rest.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);
}
}