Skip to main content

samp/
logger.rs

1//! Turnkey logging for plugins.
2//!
3//! Provides a high-level `install` entry point (wrapped by the
4//! [`enable_logger!`] and [`enable_logger_with!`] macros at the crate root)
5//! that wires up:
6//!
7//! - A per-plugin file under `logs/{crate}.log` with size-based rotation
8//! - A dual sink to the server's own log (SA-MP `logprintf` /
9//!   open.mp `ICore::logLn`)
10//! - A prefix derived from the caller's `CARGO_PKG_NAME` (overridable)
11//! - A runtime-adjustable log level so the plugin can expose its own
12//!   Pawn-side knob (`SetLogLevel`, etc.)
13//! - A startup banner that introspects `CARGO_PKG_*` at the caller's site
14//!
15//! Plain `log::info!` / `log::warn!` / `log::error!` calls inside the
16//! plugin route through this implementation once installed; there is no
17//! parallel set of helper macros to remember.
18//!
19//! [`enable_logger!`]: crate::enable_logger
20//! [`enable_logger_with!`]: crate::enable_logger_with
21
22use 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
37/// File timestamp format — `YYYY-MM-DD HH:MM:SS`.
38const TIMESTAMP_FORMAT: &[BorrowedFormatItem<'_>] =
39    format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
40
41/// Default rotation threshold — 50 MB per archived file.
42const DEFAULT_ROTATION_BYTES: u64 = 50 * 1024 * 1024;
43
44/// Default layout for lines written to the plugin's dedicated log file.
45/// Placeholders: `{timestamp}` (`YYYY-MM-DD HH:MM:SS`), `{level}` (`INFO`,
46/// `WARN`, ...), `{message}` (formatted args).
47const DEFAULT_FILE_FORMAT: &str = "[{timestamp}] [{level}] {message}";
48
49/// Default layout for lines forwarded to the server console. The server
50/// adds its own timestamp; the SDK only contributes prefix + level +
51/// message. Placeholders: `{prefix}`, `{level}`, `{message}`.
52const DEFAULT_SERVER_FORMAT: &str = "{prefix} {message}";
53
54/// Configuration for [`install`]. Built via the fluent setters; defaults
55/// are derived from `CARGO_PKG_NAME` (captured at the caller's compile time
56/// by [`enable_logger!`]).
57///
58/// [`enable_logger!`]: crate::enable_logger
59pub 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    /// When the `compression` Cargo feature is enabled, rotated archives
71    /// are gzipped into `{filename}.{N}.gz` and the uncompressed file is
72    /// removed. Opt-in via [`LoggerConfig::compress_archives`].
73    #[cfg(feature = "compression")]
74    compress_archives: bool,
75    /// Additional log sinks supplied by the plugin author. The SDK never
76    /// instantiates one of these on its own — see [`Sink`].
77    sinks: Vec<Box<dyn Sink>>,
78}
79
80/// Receiver of formatted log records for forwarding to an **external
81/// destination chosen by the plugin author** — Sentry, an OTLP
82/// collector, an in-house HTTP endpoint, a database, anything.
83///
84/// # Privacy
85///
86/// The SDK never installs a `Sink` on its own. Records are only ever
87/// forwarded when the plugin's own source code calls
88/// [`LoggerConfig::add_sink`] with an instance. There is no default
89/// destination, no telemetry built into `rust-samp`, no opt-out
90/// switch hiding silent network traffic — if your plugin does not
91/// construct a `Sink`, nothing leaves the host.
92///
93/// Server operators who want to audit this can grep the plugin's
94/// source for `add_sink(`: zero hits means nothing is exported. The
95/// SDK itself contains zero `add_sink` calls.
96///
97/// # Implementing
98///
99/// Implement [`Sink::emit`] to forward records however you like. The
100/// method is called from inside the logger's lock and should not
101/// block on slow I/O — use a background thread / channel if your
102/// transport is HTTP-based.
103pub trait Sink: Send + Sync {
104    /// Called once per accepted log record.
105    fn emit(&self, record: &SinkRecord<'_>);
106}
107
108/// Single log record handed to a [`Sink`]. Borrows from the active
109/// `log::Record` and the SDK's per-record context — do not retain
110/// references past the `emit` call.
111#[derive(Debug)]
112pub struct SinkRecord<'a> {
113    /// Formatted timestamp (`YYYY-MM-DD HH:MM:SS`) of the record.
114    pub timestamp: &'a str,
115    /// Log level of the record (`ERROR`, `WARN`, `INFO`, `DEBUG`, `TRACE`).
116    pub level: log::Level,
117    /// Log target reported by the `log::Record` (the originating
118    /// module path by default).
119    pub target: &'a str,
120    /// Formatted message body.
121    pub message: &'a str,
122    /// Plugin prefix (`[crate-name]` by default).
123    pub prefix: &'a str,
124}
125
126/// Type alias for the custom banner builder — receives the metadata
127/// captured by the macro and returns the lines to render.
128pub type BannerBuilder = dyn Fn(&BannerMetadata) -> Vec<String> + Send + Sync;
129
130/// What [`install`] does when the configuration's banner is reached.
131pub enum BannerMode {
132    /// No banner at all.
133    Off,
134    /// Built-in 5-line banner with `CARGO_PKG_NAME`, version, authors and
135    /// repository. The standard choice.
136    Default,
137    /// Lines produced by the caller. Each line goes out at `Info` level
138    /// through the same pipeline as the rest of the logger.
139    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/// Size-based rotation rules.
173///
174/// `keep`:
175/// - `Some(N)` — shift-style rotation: the active file becomes
176///   `{name}.log.1`, existing archives shift down (`.1` → `.2`, ...),
177///   and `.log.N` is deleted to enforce the cap. The most recent `N`
178///   archives stay on disk.
179/// - `None` — append-style rotation: every rotated file is renamed to
180///   the next free `{name}.log.{index}` slot and never deleted by the
181///   SDK. The dev keeps full control over cleanup (manual, `logrotate`,
182///   external script, etc.).
183#[derive(Debug, Clone, Copy)]
184struct Rotation {
185    max_bytes: u64,
186    keep: Option<u32>,
187}
188
189impl LoggerConfig {
190    /// Builds a config seeded from the caller's `CARGO_PKG_NAME` — the
191    /// `enable_logger!` macro is the intended entry point and forwards
192    /// `env!("CARGO_PKG_NAME")` here automatically.
193    #[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                // Never auto-delete by default. The dev opts in to
206                // pruning via `.rotation_keep(N)`.
207                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    /// Registers an additional [`Sink`] that will receive every accepted
218    /// log record alongside the file and server writes.
219    ///
220    /// **The SDK does not install any sink on its own.** This builder is
221    /// the *only* way a `Sink` ends up active — calling it is an
222    /// explicit choice by the plugin author. There is no hidden
223    /// telemetry, no default destination, no environment variable that
224    /// flips this on, and no automatic data collection. Server
225    /// operators auditing what a `rust-samp` plugin can export only need
226    /// to grep its source for `add_sink(` — zero hits means zero
227    /// external traffic from the logger.
228    ///
229    /// Multiple sinks may be registered; each one receives every record.
230    /// The order of calls is the order of dispatch.
231    ///
232    /// Sinks run inside the logger's lock — for HTTP-style backends
233    /// (Sentry, OTLP, …) forward to a background thread / channel
234    /// rather than calling the network from `emit`.
235    #[must_use]
236    pub fn add_sink(mut self, sink: Box<dyn Sink>) -> Self {
237        self.sinks.push(sink);
238        self
239    }
240
241    /// Gzip-compresses every rotated archive into `{filename}.{N}.gz` and
242    /// removes the uncompressed file. Off by default.
243    ///
244    /// Requires the `compression` Cargo feature, which pulls in `flate2`
245    /// with the pure-Rust backend. Compression runs synchronously inside
246    /// the rotation step — for verbose plugins this is a worthwhile
247    /// trade vs. unbounded disk usage; for low-volume plugins it is
248    /// usually unnecessary.
249    ///
250    /// Compatible with both rotation strategies (append-style and the
251    /// `rotation_keep(N)` shift-style cleanup).
252    #[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    /// Applies overrides read from environment variables. Lets server
260    /// operators retune the logger **without recompiling** the plugin —
261    /// flip a level, redirect the directory, change the rotation
262    /// threshold etc. by exporting an env var before starting the
263    /// server.
264    ///
265    /// The prefix is derived from the crate name passed to
266    /// [`LoggerConfig::new`] (typically `CARGO_PKG_NAME`) uppercased,
267    /// with non-alphanumeric characters replaced by `_`. For a plugin
268    /// named `streamer-rs` the prefix is `STREAMER_RS_LOG_`.
269    ///
270    /// | Env var | Equivalent builder |
271    /// | --- | --- |
272    /// | `<PREFIX>_LOG_LEVEL` (`off`/`error`/`warn`/`info`/`debug`/`trace`) | [`level`](Self::level) |
273    /// | `<PREFIX>_LOG_DIR` (path) | [`directory`](Self::directory) |
274    /// | `<PREFIX>_LOG_FILE` (filename) | [`filename`](Self::filename) |
275    /// | `<PREFIX>_LOG_ROTATION_MB` (u64) | [`rotation_size_mb`](Self::rotation_size_mb) |
276    /// | `<PREFIX>_LOG_ROTATION_KEEP` (u32) | [`rotation_keep`](Self::rotation_keep) |
277    /// | `<PREFIX>_LOG_NO_ROTATION` (`1`/`true`) | [`no_rotation`](Self::no_rotation) |
278    /// | `<PREFIX>_LOG_NO_BANNER` (`1`/`true`) | [`no_banner`](Self::no_banner) |
279    /// | `<PREFIX>_LOG_SERVER` (`0`/`false`) | [`also_to_server(false)`](Self::also_to_server) |
280    /// | `<PREFIX>_LOG_COMPRESS` (`1`/`true`, requires `compression` feature) | [`compress_archives(true)`](Self::compress_archives) |
281    ///
282    /// Missing env vars leave the existing value untouched, so calling
283    /// `.from_env()` at the end of a builder chain treats the env vars
284    /// as **overrides** of the code defaults — production wins. Invalid
285    /// values (unparseable integers, unknown level names) are reported
286    /// through the server console at startup and the previous value is
287    /// kept.
288    #[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    /// Directory under which the active log file lives. Default: `logs/`.
353    /// The path is resolved relative to the server's working directory.
354    /// Rotated archives are always placed under `{directory}/archive/` —
355    /// the active log stays directly in `directory` so the folder root
356    /// shows only current files.
357    #[must_use]
358    pub fn directory(mut self, path: impl Into<PathBuf>) -> Self {
359        self.directory = path.into();
360        self
361    }
362
363    /// Filename inside [`directory`]. Default: `{crate-name}.log`.
364    ///
365    /// [`directory`]: Self::directory
366    #[must_use]
367    pub fn filename(mut self, name: impl Into<String>) -> Self {
368        self.filename = Some(name.into());
369        self
370    }
371
372    /// Prefix prepended to every line written to the server's log.
373    /// Default: `[{crate-name}]`. The plugin's dedicated file omits the
374    /// prefix because every line in it already belongs to this plugin.
375    #[must_use]
376    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
377        self.prefix = Some(prefix.into());
378        self
379    }
380
381    /// Threshold below which `log::warn!`, `log::info!` and friends are
382    /// silently dropped. Default: [`LevelFilter::Info`]. Can be adjusted
383    /// at runtime via [`set_level`].
384    #[must_use]
385    pub fn level(mut self, level: LevelFilter) -> Self {
386        self.level = level;
387        self
388    }
389
390    /// Whether each log line is also forwarded to the server's own log
391    /// (visible in the server console and the server's main log file).
392    /// Default: `true`.
393    #[must_use]
394    pub fn also_to_server(mut self, enabled: bool) -> Self {
395        self.also_to_server = enabled;
396        self
397    }
398
399    /// Selects the banner strategy. Default: [`BannerMode::Default`] (the
400    /// built-in 5-line banner). Pass [`BannerMode::Off`] to suppress
401    /// every banner line, or [`BannerMode::Custom`] to render the lines
402    /// yourself.
403    #[must_use]
404    pub fn banner(mut self, mode: BannerMode) -> Self {
405        self.banner = mode;
406        self
407    }
408
409    /// Shorthand for `banner(BannerMode::Off)`.
410    #[must_use]
411    pub fn no_banner(mut self) -> Self {
412        self.banner = BannerMode::Off;
413        self
414    }
415
416    /// Shorthand for `banner(BannerMode::Custom(Box::new(builder)))`.
417    /// `builder` receives the manifest fields captured by the macro and
418    /// returns the lines to render — each line goes out at `Info` level
419    /// through the same pipeline as the rest of the logger.
420    #[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    /// Layout for lines written to the plugin's dedicated log file.
430    /// Placeholders honoured: `{timestamp}`, `{level}`, `{message}`.
431    /// Default: `"[{timestamp}] [{level}] {message}"`.
432    #[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    /// Layout for lines forwarded to the server console.
439    /// Placeholders honoured: `{prefix}`, `{level}`, `{message}`.
440    /// Default: `"{prefix} {message}"`.
441    #[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    /// Disable size-based rotation entirely. The active log file grows
448    /// indefinitely — only set this if an external rotator (e.g.
449    /// `logrotate`) takes over.
450    #[must_use]
451    pub fn no_rotation(mut self) -> Self {
452        self.rotation = None;
453        self
454    }
455
456    /// Threshold at which the active file is rotated, in megabytes.
457    /// Default: 50 MB. Disables rotation if set to 0.
458    ///
459    /// Whether old archives are deleted is controlled separately by
460    /// [`rotation_keep`] — by default the SDK never deletes; it only
461    /// renames into the archive directory.
462    ///
463    /// [`rotation_keep`]: Self::rotation_keep
464    #[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    /// Opts in to size-bounded cleanup: keep the latest `keep` archives
477    /// (newest = `.log.1`, oldest = `.log.{keep}`) and delete anything
478    /// older. Total disk footprint becomes `(keep + 1) * rotation_size_mb`.
479    ///
480    /// Off by default — the SDK never deletes log files unless the dev
481    /// explicitly requests it.
482    #[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    /// Reverts to append-style rotation — every rotated file gets a
495    /// fresh, never-reused index and the SDK never deletes anything.
496    /// This is the default; the method exists so a builder chain can
497    /// undo a previous `.rotation_keep(N)`.
498    #[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/// Errors returned by [`install`].
532#[derive(Debug)]
533pub enum InstallError {
534    /// The logger was already installed in this process. `log::set_logger`
535    /// rejects a second installation, so the SDK enforces the same.
536    AlreadyInstalled,
537    /// Creating the directory or opening the log file failed.
538    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
565// ---------------------------------------------------------------------------
566// Runtime state
567// ---------------------------------------------------------------------------
568
569/// Sentinel preventing two `install` calls from racing.
570static INSTALLED: AtomicBool = AtomicBool::new(false);
571
572/// Raw pointer to the live `LoggerImpl` after a successful `install`.
573/// Set before `log::set_boxed_logger` takes ownership of the box, so it
574/// always points to the same allocation the `log` crate dispatches to.
575/// Used by [`flush`] to reach the file handle directly without going
576/// through `log::logger()` (whose `flush` is a no-op for any logger that
577/// returns from `Log::flush` without forcing the OS-level sync).
578static INSTANCE: AtomicPtr<LoggerImpl> = AtomicPtr::new(ptr::null_mut());
579
580/// Runtime-adjustable level filter. Mirrors `log::set_max_level` but lets
581/// the SDK route through `LevelFilter` without re-importing the crate.
582static 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
606/// Adjusts the global threshold without reinstalling. Intended for plugin
607/// natives that expose a runtime knob — bind it to a Pawn-callable
608/// helper such as `MyPlugin_SetLogLevel(level)`.
609pub fn set_level(level: LevelFilter) {
610    LEVEL.store(level_to_u8(level), Ordering::Relaxed);
611    log::set_max_level(level);
612}
613
614/// Current threshold — useful for diagnostic natives that want to report
615/// the active level back to scripts.
616#[must_use]
617pub fn level() -> LevelFilter {
618    u8_to_level(LEVEL.load(Ordering::Relaxed))
619}
620
621/// Forces the active log file to flush to disk.
622///
623/// `log::logger().flush()` only triggers the `flush` method of the
624/// currently registered logger, which is not guaranteed to drain the
625/// SDK's file handle when called via the trait object. This helper
626/// reaches the live `LoggerImpl` directly and calls `File::flush`
627/// under the same `Mutex` the writer uses, so panic hooks and
628/// shutdown paths can persist pending lines deterministically.
629///
630/// No-op when the logger has not been installed.
631pub fn flush() {
632    let ptr = INSTANCE.load(Ordering::Acquire);
633    if ptr.is_null() {
634        return;
635    }
636    // Safety: `INSTANCE` is set only inside `install` from the same
637    // allocation handed to `log::set_boxed_logger`, which leaks it for
638    // 'static. It is never written again, so the pointee outlives any
639    // call to `flush`.
640    let logger = unsafe { &*ptr };
641    log::Log::flush(logger);
642}
643
644// ---------------------------------------------------------------------------
645// Installer
646// ---------------------------------------------------------------------------
647
648/// Installs the SDK logger as the global `log` implementation.
649///
650/// Called by the [`enable_logger!`] and [`enable_logger_with!`] macros;
651/// rarely invoked directly. Prefer the macros — they capture the caller's
652/// `CARGO_PKG_NAME` for the default prefix and filename.
653///
654/// # Errors
655/// - [`InstallError::AlreadyInstalled`] if a logger (this one or any
656///   other) was already registered with the `log` crate in this process.
657/// - [`InstallError::Io`] if the log directory or file could not be
658///   opened.
659///
660/// [`enable_logger!`]: crate::enable_logger
661/// [`enable_logger_with!`]: crate::enable_logger_with
662pub 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            // Said here as well as returned: plugins commonly discard the
675            // result, and the server admin is the one who can fix the path.
676            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
738// ---------------------------------------------------------------------------
739// log::Log implementation
740// ---------------------------------------------------------------------------
741
742struct 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
758/// The timestamp of each line, rebuilt once per second.
759///
760/// Asking for the local time costs more than the rest of a line: with more
761/// than one thread alive — always, in a server — `OffsetDateTime::now_local`
762/// takes the slow path on every call, and formatting follows. The text only
763/// changes once a second, and the UTC offset only with daylight saving time,
764/// so the text is kept for its second and the offset is checked once a minute.
765struct Clock {
766    cache: Mutex<ClockCache>,
767}
768
769struct ClockCache {
770    /// Unix second `text` is for; `i64::MIN` before the first line.
771    second: i64,
772    /// Unix minute `offset` was last read in.
773    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                // Where the local offset cannot be read, the last one known
798                // stands — UTC until there is one.
799                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    /// `true` once a file-write failure has been surfaced to the server
818    /// console. Prevents one transient I/O glitch from spamming the log
819    /// loop with the same error on every line.
820    file_write_reported: bool,
821    /// Next archive index to use under append-style rotation
822    /// (`rotation.keep == None`). Seeded by [`find_next_archive_index`]
823    /// at install time; bumped on every rotate.
824    next_archive_index: u32,
825}
826
827/// Lines for the server's log written on other threads, waiting for the main
828/// thread. Neither server's log is thread-safe: open.mp interleaves lines
829/// written from two threads at once.
830static SERVER_BACKLOG: Mutex<Vec<(log::Level, String)>> = Mutex::new(Vec::new());
831
832/// Lines kept for the main thread; past this the newest are dropped and
833/// counted, so a plugin without a tick cannot grow it forever. The plugin's
834/// own file still gets every line.
835const SERVER_BACKLOG_LIMIT: usize = 10_000;
836static SERVER_BACKLOG_DROPPED: AtomicUsize = AtomicUsize::new(0);
837/// Set (under the backlog's lock) when a line is queued or dropped, so the
838/// flush on every tick costs a load when, as usual, nothing is waiting.
839static SERVER_BACKLOG_WAITING: AtomicBool = AtomicBool::new(false);
840
841/// Sends a line to the server's log now when on the main thread, otherwise
842/// queues it for the next [`flush_server_backlog`].
843pub(crate) fn to_server(level: log::Level, line: String) {
844    queue_for_server(level, line, false);
845}
846
847/// [`to_server`] for the logger's own failure reports: rare (one per kind of
848/// failure) and the one line that must not be dropped for a full backlog.
849fn 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    // Lines logged before the server hands over its log (the plugin is built,
855    // and its logger installed, before SA-MP's `Load` or open.mp's `onLoad`)
856    // wait in the backlog too, instead of reaching only the console.
857    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
877/// Writes the lines queued for the server's log, once it has one. Main thread
878/// only: called when the server hands its log over, on every tick, and before
879/// any line the main thread logs.
880pub(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
906/// Only once [`server_log_ready`].
907fn 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/// Maps a `log` level onto the server's own classification.
921#[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        // Forward to the server's own log honouring `server_format`.
947        //
948        // With a level, where the server has them: open.mp classifies each line
949        // and a warning routed through the plain log would arrive as a message,
950        // losing exactly the severity that makes it worth reading. SA-MP's
951        // `logprintf` has no levels, and the fallback handles that.
952        if self.also_to_server {
953            let server_line = apply_format(
954                &self.server_format,
955                Some(&self.prefix),
956                &timestamp,
957                level,
958                &message,
959            );
960            to_server(record.level(), server_line);
961        }
962
963        // Write to the plugin's dedicated file honouring `file_format`.
964        let mut line = apply_format(&self.file_format, None, &timestamp, 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        // Forward to plugin-supplied external sinks (Sentry, OTLP, custom
996        // backends). Empty by default — the SDK never registers a sink.
997        if !self.sinks.is_empty() {
998            let sink_record = SinkRecord {
999                timestamp: &timestamp,
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    /// Closes the active file, moves it into the archive directory and
1022    /// reopens a fresh one. Two strategies depending on `rotation.keep`:
1023    ///
1024    /// - `Some(N > 0)`: shift-style. Deletes `.log.N`, shifts every
1025    ///   existing archive down by one (`.{i}` → `.{i+1}`), active becomes
1026    ///   `.log.1`. The most recent `N` archives are retained.
1027    /// - `None` (or `Some(0)`): append-style. Active is renamed to the
1028    ///   next free `.log.{next_archive_index}` slot, with no cleanup.
1029    fn rotate(&self, state: &mut LoggerState, rotation: Rotation) {
1030        // Drop the file handle before renaming to avoid Windows file locks.
1031        state.file = None;
1032
1033        // Lazy: only create when the first rotation actually happens.
1034        if let Err(e) = fs::create_dir_all(&self.archive_directory) {
1035            self.report_file_error(state, "create archive directory", &e);
1036            // We still try to open a fresh active file below so logging
1037            // does not die outright.
1038            self.reopen_active(state);
1039            return;
1040        }
1041
1042        match rotation.keep {
1043            Some(keep) if keep > 0 => self.rotate_shift(keep),
1044            // Append-style: `None` or `Some(0)`. Active → next free slot.
1045            _ => {
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    /// Gzips `archived` into `archived.gz` and removes the original when
1059    /// the `compression` feature is enabled **and** the dev opted in via
1060    /// [`LoggerConfig::compress_archives`]. Silent no-op otherwise.
1061    ///
1062    /// Errors are intentionally swallowed: rotation must not block log
1063    /// emission. A failure leaves the uncompressed `.log.N` in place,
1064    /// which is still strictly better than data loss.
1065    #[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    /// Shift-style rotation: `.log.{keep}` is dropped, `.{i}` shifts to
1099    /// `.{i+1}`, active becomes `.log.1`. When the `compression` feature
1100    /// is enabled, `.gz` variants are handled in lockstep so a mixed
1101    /// archive directory (compressed + uncompressed) stays consistent.
1102    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
1160/// Uppercased prefix derived from the crate name for env var lookup.
1161/// Non-alphanumeric characters become `_`. `streamer-rs` → `STREAMER_RS`.
1162fn 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
1199/// Surfaces an invalid env var value to the server console at install
1200/// time. The logger is not registered yet, so this routes through the
1201/// raw [`Runtime`] log sink directly. Falls back to `eprintln!` when
1202/// the runtime is not initialised (e.g. unit tests that exercise
1203/// [`LoggerConfig::from_env`] in isolation).
1204fn 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
1222/// Scans the archive directory for existing `{filename}.{N}` siblings of
1223/// the active log and returns the next free `N`. Used to seed
1224/// [`LoggerState::next_archive_index`] so append-style rotation never
1225/// reuses an index across restarts.
1226fn 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                // Accept both `.{N}` and `.{N}.gz` so the next index
1235                // skips slots reused by an earlier compressed rotation.
1236                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
1246// ---------------------------------------------------------------------------
1247// Banner
1248// ---------------------------------------------------------------------------
1249
1250thread_local! {
1251    /// Captured by [`crate::enable_logger`] before [`install`] is called so
1252    /// the banner can introspect the caller's manifest. Each macro
1253    /// invocation overwrites it, which is fine because installation is a
1254    /// one-shot event per process.
1255    static BANNER_METADATA: std::cell::RefCell<Option<BannerMetadata>> =
1256        const { std::cell::RefCell::new(None) };
1257}
1258
1259/// Macro plumbing — captures the caller's `CARGO_PKG_*` values so
1260/// [`print_banner`] can render them. Not part of the public API surface;
1261/// the macros call this on the user's behalf.
1262#[doc(hidden)]
1263pub fn __set_banner_metadata(metadata: BannerMetadata) {
1264    BANNER_METADATA.with(|cell| {
1265        *cell.borrow_mut() = Some(metadata);
1266    });
1267}
1268
1269/// Manifest fields fed by the macro from the caller's `env!` values.
1270#[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    /// Constructor used by [`crate::enable_logger`] — there is no reason
1280    /// to call this directly; the macro is the API.
1281    #[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
1297/// Replaces `{timestamp}`, `{level}`, `{message}` and (when provided)
1298/// `{prefix}` placeholders in the layout templates. Supports optional
1299/// alignment+width specifiers borrowed from Rust's format syntax:
1300///
1301/// - `{level:<5}` — left-aligned, padded to width 5
1302/// - `{level:>5}` — right-aligned, padded to width 5
1303/// - `{level:^5}` — centred, padded to width 5
1304///
1305/// Unknown placeholders pass through untouched so the dev can spot typos
1306/// in their format string.
1307fn 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                // Unknown placeholder — emit verbatim so devs see typos.
1328                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
1340/// Resolves a single `{...}` group. Returns `None` for unknown names so
1341/// the caller can pass the raw `{spec}` through.
1342fn 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        // The free-function `install` was called without the macro. Skip
1390        // the banner instead of emitting half-empty defaults.
1391        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
1427/// Re-emits the default banner after installation. Rarely useful at
1428/// runtime; kept public so plugins can re-print on demand (for
1429/// instance, after a Pawn-driven reload). Honours [`BannerMode::Default`]
1430/// regardless of what was supplied to `install` — custom banners are
1431/// not memoised, so plugins that need their own format should call
1432/// `log::info!` themselves.
1433pub fn print_banner() {
1434    // The runtime mode is not stored after install (the logger has no
1435    // banner field); we always re-emit the default style. Plugins with a
1436    // custom banner can call `log::info!` themselves to repeat it.
1437    print_banner_inner(&BannerMode::Default);
1438}
1439
1440// ---------------------------------------------------------------------------
1441// Tests
1442// ---------------------------------------------------------------------------
1443
1444#[cfg(test)]
1445mod tests {
1446    use super::*;
1447    use std::path::Path;
1448
1449    /// The test runtime has no server log, so nothing ever drains the
1450    /// backlog: tests empty it by hand. Other tests may log meanwhile, hence
1451    /// the checks for "contains" and "at least".
1452    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        // A thread spawned here is never the one that created the runtime.
1464        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        // `YYYY-MM-DD HH:MM:SS`
1506        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        // Default is append-style: never auto-delete archives.
1533        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        // Archive folder is always `{directory}/archive` — overriding
1551        // `directory` automatically retargets the archive directory too.
1552        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        // Rust's `{:>w$}` only pads — never truncates — so INFO survives.
1614        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        // mixed archive directory: one .gz and one plain
1712        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        // Next free index must skip past the highest seen — regardless
1716        // of whether the highest is `.N` or `.N.gz`.
1717        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        // Use a unique prefix per test process to avoid collision with
1757        // any real env the test runner already has set.
1758        let crate_name = format!("rust_samp_test_{}", std::process::id());
1759        let prefix = super::env_var_prefix(&crate_name);
1760
1761        // Set: valid level + dir + invalid rotation_mb (should be
1762        // ignored, not panic) + bool toggles.
1763        // SAFETY: tests run single-threaded enough for env mutation; the
1764        // env is otherwise unused by the rest of the suite.
1765        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        // ROTATION_MB was garbage — must have stayed on the default.
1780        assert!(matches!(
1781            cfg.rotation,
1782            Some(Rotation {
1783                max_bytes: DEFAULT_ROTATION_BYTES,
1784                ..
1785            })
1786        ));
1787
1788        // SAFETY: same as above — cleaning up after ourselves.
1789        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        // `install` is not callable in unit tests (it tries to register a
1819        // global `log` logger and create the `logs/` directory), but the
1820        // public `flush` helper must remain safe when no instance is
1821        // registered. Should not panic, deadlock, or touch any file.
1822        super::flush();
1823        super::flush();
1824    }
1825}