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::Mutex;
27use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU8, Ordering};
28
29use log::{LevelFilter, Log, Metadata, Record};
30use time::OffsetDateTime;
31use time::format_description::BorrowedFormatItem;
32use time::macros::format_description;
33
34use crate::runtime::Runtime;
35
36/// File timestamp format — `YYYY-MM-DD HH:MM:SS`.
37const TIMESTAMP_FORMAT: &[BorrowedFormatItem<'_>] =
38    format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
39
40/// Default rotation threshold — 50 MB per archived file.
41const DEFAULT_ROTATION_BYTES: u64 = 50 * 1024 * 1024;
42
43/// Default layout for lines written to the plugin's dedicated log file.
44/// Placeholders: `{timestamp}` (`YYYY-MM-DD HH:MM:SS`), `{level}` (`INFO`,
45/// `WARN`, ...), `{message}` (formatted args).
46const DEFAULT_FILE_FORMAT: &str = "[{timestamp}] [{level}] {message}";
47
48/// Default layout for lines forwarded to the server console. The server
49/// adds its own timestamp; the SDK only contributes prefix + level +
50/// message. Placeholders: `{prefix}`, `{level}`, `{message}`.
51const DEFAULT_SERVER_FORMAT: &str = "{prefix} {message}";
52
53/// Configuration for [`install`]. Built via the fluent setters; defaults
54/// are derived from `CARGO_PKG_NAME` (captured at the caller's compile time
55/// by [`enable_logger!`]).
56///
57/// [`enable_logger!`]: crate::enable_logger
58pub struct LoggerConfig {
59    crate_name: String,
60    directory: PathBuf,
61    filename: Option<String>,
62    prefix: Option<String>,
63    level: LevelFilter,
64    also_to_server: bool,
65    banner: BannerMode,
66    rotation: Option<Rotation>,
67    file_format: String,
68    server_format: String,
69    /// When the `compression` Cargo feature is enabled, rotated archives
70    /// are gzipped into `{filename}.{N}.gz` and the uncompressed file is
71    /// removed. Opt-in via [`LoggerConfig::compress_archives`].
72    #[cfg(feature = "compression")]
73    compress_archives: bool,
74    /// Additional log sinks supplied by the plugin author. The SDK never
75    /// instantiates one of these on its own — see [`Sink`].
76    sinks: Vec<Box<dyn Sink>>,
77}
78
79/// Receiver of formatted log records for forwarding to an **external
80/// destination chosen by the plugin author** — Sentry, an OTLP
81/// collector, an in-house HTTP endpoint, a database, anything.
82///
83/// # Privacy
84///
85/// The SDK never installs a `Sink` on its own. Records are only ever
86/// forwarded when the plugin's own source code calls
87/// [`LoggerConfig::add_sink`] with an instance. There is no default
88/// destination, no telemetry built into `rust-samp`, no opt-out
89/// switch hiding silent network traffic — if your plugin does not
90/// construct a `Sink`, nothing leaves the host.
91///
92/// Server operators who want to audit this can grep the plugin's
93/// source for `add_sink(`: zero hits means nothing is exported. The
94/// SDK itself contains zero `add_sink` calls.
95///
96/// # Implementing
97///
98/// Implement [`Sink::emit`] to forward records however you like. The
99/// method is called from inside the logger's lock and should not
100/// block on slow I/O — use a background thread / channel if your
101/// transport is HTTP-based.
102pub trait Sink: Send + Sync {
103    /// Called once per accepted log record.
104    fn emit(&self, record: &SinkRecord<'_>);
105}
106
107/// Single log record handed to a [`Sink`]. Borrows from the active
108/// `log::Record` and the SDK's per-record context — do not retain
109/// references past the `emit` call.
110#[derive(Debug)]
111pub struct SinkRecord<'a> {
112    /// Formatted timestamp (`YYYY-MM-DD HH:MM:SS`) of the record.
113    pub timestamp: &'a str,
114    /// Log level of the record (`ERROR`, `WARN`, `INFO`, `DEBUG`, `TRACE`).
115    pub level: log::Level,
116    /// Log target reported by the `log::Record` (the originating
117    /// module path by default).
118    pub target: &'a str,
119    /// Formatted message body.
120    pub message: &'a str,
121    /// Plugin prefix (`[crate-name]` by default).
122    pub prefix: &'a str,
123}
124
125/// Type alias for the custom banner builder — receives the metadata
126/// captured by the macro and returns the lines to render.
127pub type BannerBuilder = dyn Fn(&BannerMetadata) -> Vec<String> + Send + Sync;
128
129/// What [`install`] does when the configuration's banner is reached.
130pub enum BannerMode {
131    /// No banner at all.
132    Off,
133    /// Built-in 5-line banner with `CARGO_PKG_NAME`, version, authors and
134    /// repository. The standard choice.
135    Default,
136    /// Lines produced by the caller. Each line goes out at `Info` level
137    /// through the same pipeline as the rest of the logger.
138    Custom(Box<BannerBuilder>),
139}
140
141impl std::fmt::Debug for BannerMode {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        match self {
144            Self::Off => f.write_str("Off"),
145            Self::Default => f.write_str("Default"),
146            Self::Custom(_) => f.write_str("Custom(<fn>)"),
147        }
148    }
149}
150
151impl std::fmt::Debug for LoggerConfig {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        let mut s = f.debug_struct("LoggerConfig");
154        s.field("crate_name", &self.crate_name)
155            .field("directory", &self.directory)
156            .field("filename", &self.filename)
157            .field("prefix", &self.prefix)
158            .field("level", &self.level)
159            .field("also_to_server", &self.also_to_server)
160            .field("banner", &self.banner)
161            .field("rotation", &self.rotation)
162            .field("file_format", &self.file_format)
163            .field("server_format", &self.server_format)
164            .field("sinks", &format_args!("[{} sink(s)]", self.sinks.len()));
165        #[cfg(feature = "compression")]
166        s.field("compress_archives", &self.compress_archives);
167        s.finish()
168    }
169}
170
171/// Size-based rotation rules.
172///
173/// `keep`:
174/// - `Some(N)` — shift-style rotation: the active file becomes
175///   `{name}.log.1`, existing archives shift down (`.1` → `.2`, ...),
176///   and `.log.N` is deleted to enforce the cap. The most recent `N`
177///   archives stay on disk.
178/// - `None` — append-style rotation: every rotated file is renamed to
179///   the next free `{name}.log.{index}` slot and never deleted by the
180///   SDK. The dev keeps full control over cleanup (manual, `logrotate`,
181///   external script, etc.).
182#[derive(Debug, Clone, Copy)]
183struct Rotation {
184    max_bytes: u64,
185    keep: Option<u32>,
186}
187
188impl LoggerConfig {
189    /// Builds a config seeded from the caller's `CARGO_PKG_NAME` — the
190    /// `enable_logger!` macro is the intended entry point and forwards
191    /// `env!("CARGO_PKG_NAME")` here automatically.
192    #[must_use]
193    pub fn new(crate_name: impl Into<String>) -> Self {
194        Self {
195            crate_name: crate_name.into(),
196            directory: PathBuf::from("logs"),
197            filename: None,
198            prefix: None,
199            level: LevelFilter::Info,
200            also_to_server: true,
201            banner: BannerMode::Default,
202            rotation: Some(Rotation {
203                max_bytes: DEFAULT_ROTATION_BYTES,
204                // Never auto-delete by default. The dev opts in to
205                // pruning via `.rotation_keep(N)`.
206                keep: None,
207            }),
208            file_format: DEFAULT_FILE_FORMAT.to_owned(),
209            server_format: DEFAULT_SERVER_FORMAT.to_owned(),
210            #[cfg(feature = "compression")]
211            compress_archives: false,
212            sinks: Vec::new(),
213        }
214    }
215
216    /// Registers an additional [`Sink`] that will receive every accepted
217    /// log record alongside the file and server writes.
218    ///
219    /// **The SDK does not install any sink on its own.** This builder is
220    /// the *only* way a `Sink` ends up active — calling it is an
221    /// explicit choice by the plugin author. There is no hidden
222    /// telemetry, no default destination, no environment variable that
223    /// flips this on, and no automatic data collection. Server
224    /// operators auditing what a `rust-samp` plugin can export only need
225    /// to grep its source for `add_sink(` — zero hits means zero
226    /// external traffic from the logger.
227    ///
228    /// Multiple sinks may be registered; each one receives every record.
229    /// The order of calls is the order of dispatch.
230    ///
231    /// Sinks run inside the logger's lock — for HTTP-style backends
232    /// (Sentry, OTLP, …) forward to a background thread / channel
233    /// rather than calling the network from `emit`.
234    #[must_use]
235    pub fn add_sink(mut self, sink: Box<dyn Sink>) -> Self {
236        self.sinks.push(sink);
237        self
238    }
239
240    /// Gzip-compresses every rotated archive into `{filename}.{N}.gz` and
241    /// removes the uncompressed file. Off by default.
242    ///
243    /// Requires the `compression` Cargo feature, which pulls in `flate2`
244    /// with the pure-Rust backend. Compression runs synchronously inside
245    /// the rotation step — for verbose plugins this is a worthwhile
246    /// trade vs. unbounded disk usage; for low-volume plugins it is
247    /// usually unnecessary.
248    ///
249    /// Compatible with both rotation strategies (append-style and the
250    /// `rotation_keep(N)` shift-style cleanup).
251    #[cfg(feature = "compression")]
252    #[must_use]
253    pub fn compress_archives(mut self, yes: bool) -> Self {
254        self.compress_archives = yes;
255        self
256    }
257
258    /// Applies overrides read from environment variables. Lets server
259    /// operators retune the logger **without recompiling** the plugin —
260    /// flip a level, redirect the directory, change the rotation
261    /// threshold etc. by exporting an env var before starting the
262    /// server.
263    ///
264    /// The prefix is derived from the crate name passed to
265    /// [`LoggerConfig::new`] (typically `CARGO_PKG_NAME`) uppercased,
266    /// with non-alphanumeric characters replaced by `_`. For a plugin
267    /// named `streamer-rs` the prefix is `STREAMER_RS_LOG_`.
268    ///
269    /// | Env var | Equivalent builder |
270    /// | --- | --- |
271    /// | `<PREFIX>_LOG_LEVEL` (`off`/`error`/`warn`/`info`/`debug`/`trace`) | [`level`](Self::level) |
272    /// | `<PREFIX>_LOG_DIR` (path) | [`directory`](Self::directory) |
273    /// | `<PREFIX>_LOG_FILE` (filename) | [`filename`](Self::filename) |
274    /// | `<PREFIX>_LOG_ROTATION_MB` (u64) | [`rotation_size_mb`](Self::rotation_size_mb) |
275    /// | `<PREFIX>_LOG_ROTATION_KEEP` (u32) | [`rotation_keep`](Self::rotation_keep) |
276    /// | `<PREFIX>_LOG_NO_ROTATION` (`1`/`true`) | [`no_rotation`](Self::no_rotation) |
277    /// | `<PREFIX>_LOG_NO_BANNER` (`1`/`true`) | [`no_banner`](Self::no_banner) |
278    /// | `<PREFIX>_LOG_SERVER` (`0`/`false`) | [`also_to_server(false)`](Self::also_to_server) |
279    /// | `<PREFIX>_LOG_COMPRESS` (`1`/`true`, requires `compression` feature) | [`compress_archives(true)`](Self::compress_archives) |
280    ///
281    /// Missing env vars leave the existing value untouched, so calling
282    /// `.from_env()` at the end of a builder chain treats the env vars
283    /// as **overrides** of the code defaults — production wins. Invalid
284    /// values (unparseable integers, unknown level names) are reported
285    /// through the server console at startup and the previous value is
286    /// kept.
287    #[must_use]
288    pub fn from_env(mut self) -> Self {
289        let prefix = env_var_prefix(&self.crate_name);
290        self.apply_env(&prefix);
291        self
292    }
293
294    fn apply_env(&mut self, prefix: &str) {
295        if let Some(raw) = read_env(prefix, "LEVEL") {
296            match parse_level(&raw) {
297                Some(l) => self.level = l,
298                None => warn_invalid(prefix, "LEVEL", &raw),
299            }
300        }
301        if let Some(raw) = read_env(prefix, "DIR") {
302            self.directory = PathBuf::from(raw);
303        }
304        if let Some(raw) = read_env(prefix, "FILE") {
305            self.filename = Some(raw);
306        }
307        if let Some(raw) = read_env(prefix, "ROTATION_MB") {
308            match raw.parse::<u64>() {
309                Ok(0) => self.rotation = None,
310                Ok(mb) => {
311                    let max_bytes = mb.saturating_mul(1024 * 1024);
312                    let keep = self.rotation.and_then(|r| r.keep);
313                    self.rotation = Some(Rotation { max_bytes, keep });
314                }
315                Err(_) => warn_invalid(prefix, "ROTATION_MB", &raw),
316            }
317        }
318        if let Some(raw) = read_env(prefix, "ROTATION_KEEP") {
319            match raw.parse::<u32>() {
320                Ok(keep) => {
321                    let max_bytes = self
322                        .rotation
323                        .map_or(DEFAULT_ROTATION_BYTES, |r| r.max_bytes);
324                    self.rotation = Some(Rotation {
325                        max_bytes,
326                        keep: Some(keep),
327                    });
328                }
329                Err(_) => warn_invalid(prefix, "ROTATION_KEEP", &raw),
330            }
331        }
332        if let Some(raw) = read_env(prefix, "NO_ROTATION")
333            && parse_bool(&raw)
334        {
335            self.rotation = None;
336        }
337        if let Some(raw) = read_env(prefix, "NO_BANNER")
338            && parse_bool(&raw)
339        {
340            self.banner = BannerMode::Off;
341        }
342        if let Some(raw) = read_env(prefix, "SERVER") {
343            self.also_to_server = parse_bool(&raw);
344        }
345        #[cfg(feature = "compression")]
346        if let Some(raw) = read_env(prefix, "COMPRESS") {
347            self.compress_archives = parse_bool(&raw);
348        }
349    }
350
351    /// Directory under which the active log file lives. Default: `logs/`.
352    /// The path is resolved relative to the server's working directory.
353    /// Rotated archives are always placed under `{directory}/archive/` —
354    /// the active log stays directly in `directory` so the folder root
355    /// shows only current files.
356    #[must_use]
357    pub fn directory(mut self, path: impl Into<PathBuf>) -> Self {
358        self.directory = path.into();
359        self
360    }
361
362    /// Filename inside [`directory`]. Default: `{crate-name}.log`.
363    ///
364    /// [`directory`]: Self::directory
365    #[must_use]
366    pub fn filename(mut self, name: impl Into<String>) -> Self {
367        self.filename = Some(name.into());
368        self
369    }
370
371    /// Prefix prepended to every line written to the server's log.
372    /// Default: `[{crate-name}]`. The plugin's dedicated file omits the
373    /// prefix because every line in it already belongs to this plugin.
374    #[must_use]
375    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
376        self.prefix = Some(prefix.into());
377        self
378    }
379
380    /// Threshold below which `log::warn!`, `log::info!` and friends are
381    /// silently dropped. Default: [`LevelFilter::Info`]. Can be adjusted
382    /// at runtime via [`set_level`].
383    #[must_use]
384    pub fn level(mut self, level: LevelFilter) -> Self {
385        self.level = level;
386        self
387    }
388
389    /// Whether each log line is also forwarded to the server's own log
390    /// (visible in the server console and the server's main log file).
391    /// Default: `true`.
392    #[must_use]
393    pub fn also_to_server(mut self, enabled: bool) -> Self {
394        self.also_to_server = enabled;
395        self
396    }
397
398    /// Selects the banner strategy. Default: [`BannerMode::Default`] (the
399    /// built-in 5-line banner). Pass [`BannerMode::Off`] to suppress
400    /// every banner line, or [`BannerMode::Custom`] to render the lines
401    /// yourself.
402    #[must_use]
403    pub fn banner(mut self, mode: BannerMode) -> Self {
404        self.banner = mode;
405        self
406    }
407
408    /// Shorthand for `banner(BannerMode::Off)`.
409    #[must_use]
410    pub fn no_banner(mut self) -> Self {
411        self.banner = BannerMode::Off;
412        self
413    }
414
415    /// Shorthand for `banner(BannerMode::Custom(Box::new(builder)))`.
416    /// `builder` receives the manifest fields captured by the macro and
417    /// returns the lines to render — each line goes out at `Info` level
418    /// through the same pipeline as the rest of the logger.
419    #[must_use]
420    pub fn banner_with<F>(mut self, builder: F) -> Self
421    where
422        F: Fn(&BannerMetadata) -> Vec<String> + Send + Sync + 'static,
423    {
424        self.banner = BannerMode::Custom(Box::new(builder));
425        self
426    }
427
428    /// Layout for lines written to the plugin's dedicated log file.
429    /// Placeholders honoured: `{timestamp}`, `{level}`, `{message}`.
430    /// Default: `"[{timestamp}] [{level}] {message}"`.
431    #[must_use]
432    pub fn file_format(mut self, format: impl Into<String>) -> Self {
433        self.file_format = format.into();
434        self
435    }
436
437    /// Layout for lines forwarded to the server console.
438    /// Placeholders honoured: `{prefix}`, `{level}`, `{message}`.
439    /// Default: `"{prefix} {message}"`.
440    #[must_use]
441    pub fn server_format(mut self, format: impl Into<String>) -> Self {
442        self.server_format = format.into();
443        self
444    }
445
446    /// Disable size-based rotation entirely. The active log file grows
447    /// indefinitely — only set this if an external rotator (e.g.
448    /// `logrotate`) takes over.
449    #[must_use]
450    pub fn no_rotation(mut self) -> Self {
451        self.rotation = None;
452        self
453    }
454
455    /// Threshold at which the active file is rotated, in megabytes.
456    /// Default: 50 MB. Disables rotation if set to 0.
457    ///
458    /// Whether old archives are deleted is controlled separately by
459    /// [`rotation_keep`] — by default the SDK never deletes; it only
460    /// renames into the archive directory.
461    ///
462    /// [`rotation_keep`]: Self::rotation_keep
463    #[must_use]
464    pub fn rotation_size_mb(mut self, mb: u64) -> Self {
465        if mb == 0 {
466            self.rotation = None;
467        } else {
468            let max_bytes = mb.saturating_mul(1024 * 1024);
469            let keep = self.rotation.and_then(|r| r.keep);
470            self.rotation = Some(Rotation { max_bytes, keep });
471        }
472        self
473    }
474
475    /// Opts in to size-bounded cleanup: keep the latest `keep` archives
476    /// (newest = `.log.1`, oldest = `.log.{keep}`) and delete anything
477    /// older. Total disk footprint becomes `(keep + 1) * rotation_size_mb`.
478    ///
479    /// Off by default — the SDK never deletes log files unless the dev
480    /// explicitly requests it.
481    #[must_use]
482    pub fn rotation_keep(mut self, keep: u32) -> Self {
483        let max_bytes = self
484            .rotation
485            .map_or(DEFAULT_ROTATION_BYTES, |r| r.max_bytes);
486        self.rotation = Some(Rotation {
487            max_bytes,
488            keep: Some(keep),
489        });
490        self
491    }
492
493    /// Reverts to append-style rotation — every rotated file gets a
494    /// fresh, never-reused index and the SDK never deletes anything.
495    /// This is the default; the method exists so a builder chain can
496    /// undo a previous `.rotation_keep(N)`.
497    #[must_use]
498    pub fn rotation_no_cleanup(mut self) -> Self {
499        let max_bytes = self
500            .rotation
501            .map_or(DEFAULT_ROTATION_BYTES, |r| r.max_bytes);
502        self.rotation = Some(Rotation {
503            max_bytes,
504            keep: None,
505        });
506        self
507    }
508
509    fn resolved_filename(&self) -> String {
510        self.filename
511            .clone()
512            .unwrap_or_else(|| format!("{}.log", self.crate_name))
513    }
514
515    fn resolved_prefix(&self) -> String {
516        self.prefix
517            .clone()
518            .unwrap_or_else(|| format!("[{}]", self.crate_name))
519    }
520
521    fn log_path(&self) -> PathBuf {
522        self.directory.join(self.resolved_filename())
523    }
524
525    fn resolved_archive_directory(&self) -> PathBuf {
526        self.directory.join("archive")
527    }
528}
529
530/// Errors returned by [`install`].
531#[derive(Debug)]
532pub enum InstallError {
533    /// The logger was already installed in this process. `log::set_logger`
534    /// rejects a second installation, so the SDK enforces the same.
535    AlreadyInstalled,
536    /// Creating the directory or opening the log file failed.
537    Io(std::io::Error),
538}
539
540impl std::fmt::Display for InstallError {
541    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
542        match self {
543            Self::AlreadyInstalled => f.write_str("logger already installed"),
544            Self::Io(e) => write!(f, "i/o error: {e}"),
545        }
546    }
547}
548
549impl std::error::Error for InstallError {
550    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
551        match self {
552            Self::AlreadyInstalled => None,
553            Self::Io(e) => Some(e),
554        }
555    }
556}
557
558impl From<std::io::Error> for InstallError {
559    fn from(e: std::io::Error) -> Self {
560        Self::Io(e)
561    }
562}
563
564// ---------------------------------------------------------------------------
565// Runtime state
566// ---------------------------------------------------------------------------
567
568/// Sentinel preventing two `install` calls from racing.
569static INSTALLED: AtomicBool = AtomicBool::new(false);
570
571/// Raw pointer to the live `LoggerImpl` after a successful `install`.
572/// Set before `log::set_boxed_logger` takes ownership of the box, so it
573/// always points to the same allocation the `log` crate dispatches to.
574/// Used by [`flush`] to reach the file handle directly without going
575/// through `log::logger()` (whose `flush` is a no-op for any logger that
576/// returns from `Log::flush` without forcing the OS-level sync).
577static INSTANCE: AtomicPtr<LoggerImpl> = AtomicPtr::new(ptr::null_mut());
578
579/// Runtime-adjustable level filter. Mirrors `log::set_max_level` but lets
580/// the SDK route through `LevelFilter` without re-importing the crate.
581static LEVEL: AtomicU8 = AtomicU8::new(level_to_u8(LevelFilter::Info));
582
583const fn level_to_u8(l: LevelFilter) -> u8 {
584    match l {
585        LevelFilter::Off => 0,
586        LevelFilter::Error => 1,
587        LevelFilter::Warn => 2,
588        LevelFilter::Info => 3,
589        LevelFilter::Debug => 4,
590        LevelFilter::Trace => 5,
591    }
592}
593
594const fn u8_to_level(v: u8) -> LevelFilter {
595    match v {
596        0 => LevelFilter::Off,
597        1 => LevelFilter::Error,
598        2 => LevelFilter::Warn,
599        3 => LevelFilter::Info,
600        4 => LevelFilter::Debug,
601        _ => LevelFilter::Trace,
602    }
603}
604
605/// Adjusts the global threshold without reinstalling. Intended for plugin
606/// natives that expose a runtime knob — bind it to a Pawn-callable
607/// helper such as `MyPlugin_SetLogLevel(level)`.
608pub fn set_level(level: LevelFilter) {
609    LEVEL.store(level_to_u8(level), Ordering::Relaxed);
610    log::set_max_level(level);
611}
612
613/// Current threshold — useful for diagnostic natives that want to report
614/// the active level back to scripts.
615#[must_use]
616pub fn level() -> LevelFilter {
617    u8_to_level(LEVEL.load(Ordering::Relaxed))
618}
619
620/// Forces the active log file to flush to disk.
621///
622/// `log::logger().flush()` only triggers the `flush` method of the
623/// currently registered logger, which is not guaranteed to drain the
624/// SDK's file handle when called via the trait object. This helper
625/// reaches the live `LoggerImpl` directly and calls `File::flush`
626/// under the same `Mutex` the writer uses, so panic hooks and
627/// shutdown paths can persist pending lines deterministically.
628///
629/// No-op when the logger has not been installed.
630pub fn flush() {
631    let ptr = INSTANCE.load(Ordering::Acquire);
632    if ptr.is_null() {
633        return;
634    }
635    // Safety: `INSTANCE` is set only inside `install` from the same
636    // allocation handed to `log::set_boxed_logger`, which leaks it for
637    // 'static. It is never written again, so the pointee outlives any
638    // call to `flush`.
639    let logger = unsafe { &*ptr };
640    log::Log::flush(logger);
641}
642
643// ---------------------------------------------------------------------------
644// Installer
645// ---------------------------------------------------------------------------
646
647/// Installs the SDK logger as the global `log` implementation.
648///
649/// Called by the [`enable_logger!`] and [`enable_logger_with!`] macros;
650/// rarely invoked directly. Prefer the macros — they capture the caller's
651/// `CARGO_PKG_NAME` for the default prefix and filename.
652///
653/// # Errors
654/// - [`InstallError::AlreadyInstalled`] if a logger (this one or any
655///   other) was already registered with the `log` crate in this process.
656/// - [`InstallError::Io`] if the log directory or file could not be
657///   opened.
658///
659/// [`enable_logger!`]: crate::enable_logger
660/// [`enable_logger_with!`]: crate::enable_logger_with
661pub fn install(config: LoggerConfig) -> Result<(), InstallError> {
662    if INSTALLED.swap(true, Ordering::AcqRel) {
663        return Err(InstallError::AlreadyInstalled);
664    }
665
666    fs::create_dir_all(&config.directory)?;
667    let path = config.log_path();
668    let file = OpenOptions::new().create(true).append(true).open(&path)?;
669    let initial_size = file.metadata().map(|m| m.len()).unwrap_or(0);
670
671    let prefix = config.resolved_prefix();
672    let level = config.level;
673    let filename = config.resolved_filename();
674    let archive_directory = config.resolved_archive_directory();
675    let next_archive_index = find_next_archive_index(&archive_directory, &filename);
676    #[cfg(feature = "compression")]
677    let compress_archives = config.compress_archives;
678    let LoggerConfig {
679        also_to_server,
680        banner,
681        rotation,
682        file_format,
683        server_format,
684        sinks,
685        ..
686    } = config;
687
688    let logger = Box::new(LoggerImpl {
689        prefix,
690        also_to_server,
691        rotation,
692        path,
693        filename,
694        archive_directory,
695        file_format,
696        server_format,
697        #[cfg(feature = "compression")]
698        compress_archives,
699        sinks,
700        state: Mutex::new(LoggerState {
701            file: Some(file),
702            current_size: initial_size,
703            file_write_reported: false,
704            next_archive_index,
705        }),
706    });
707
708    set_level(level);
709
710    let logger_ptr: *const LoggerImpl = &raw const *logger;
711    INSTANCE.store(logger_ptr.cast_mut(), Ordering::Release);
712    log::set_boxed_logger(logger).map_err(|_| {
713        INSTANCE.store(ptr::null_mut(), Ordering::Release);
714        INSTALLED.store(false, Ordering::Release);
715        InstallError::AlreadyInstalled
716    })?;
717
718    print_banner_inner(&banner);
719
720    Ok(())
721}
722
723// ---------------------------------------------------------------------------
724// log::Log implementation
725// ---------------------------------------------------------------------------
726
727struct LoggerImpl {
728    prefix: String,
729    also_to_server: bool,
730    rotation: Option<Rotation>,
731    path: PathBuf,
732    filename: String,
733    archive_directory: PathBuf,
734    file_format: String,
735    server_format: String,
736    #[cfg(feature = "compression")]
737    compress_archives: bool,
738    sinks: Vec<Box<dyn Sink>>,
739    state: Mutex<LoggerState>,
740}
741
742struct LoggerState {
743    file: Option<File>,
744    current_size: u64,
745    /// `true` once a file-write failure has been surfaced to the server
746    /// console. Prevents one transient I/O glitch from spamming the log
747    /// loop with the same error on every line.
748    file_write_reported: bool,
749    /// Next archive index to use under append-style rotation
750    /// (`rotation.keep == None`). Seeded by [`find_next_archive_index`]
751    /// at install time; bumped on every rotate.
752    next_archive_index: u32,
753}
754
755impl Log for LoggerImpl {
756    fn enabled(&self, metadata: &Metadata<'_>) -> bool {
757        metadata.level() <= u8_to_level(LEVEL.load(Ordering::Relaxed))
758    }
759
760    fn log(&self, record: &Record<'_>) {
761        if !self.enabled(record.metadata()) {
762            return;
763        }
764
765        let message = format!("{}", record.args());
766        let level = record.level().as_str();
767
768        let timestamp = OffsetDateTime::now_local()
769            .unwrap_or_else(|_| OffsetDateTime::now_utc())
770            .format(TIMESTAMP_FORMAT)
771            .unwrap_or_else(|_| String::from("0000-00-00 00:00:00"));
772
773        // Forward to the server's own log honouring `server_format`.
774        if self.also_to_server {
775            let server_line = apply_format(
776                &self.server_format,
777                Some(&self.prefix),
778                &timestamp,
779                level,
780                &message,
781            );
782            Runtime::get().log(server_line);
783        }
784
785        // Write to the plugin's dedicated file honouring `file_format`.
786        let mut line = apply_format(&self.file_format, None, &timestamp, level, &message);
787        line.push('\n');
788
789        let mut state = match self.state.lock() {
790            Ok(s) => s,
791            Err(p) => p.into_inner(),
792        };
793
794        if let Some(rotation) = self.rotation
795            && state.current_size + line.len() as u64 > rotation.max_bytes
796        {
797            self.rotate(&mut state, rotation);
798        }
799
800        if let Some(file) = state.file.as_mut() {
801            match file.write_all(line.as_bytes()) {
802                Ok(()) => state.current_size += line.len() as u64,
803                Err(e) => {
804                    if !state.file_write_reported {
805                        state.file_write_reported = true;
806                        Runtime::get().log(format!(
807                            "{} failed to write {}: {}. Further file-write errors will be suppressed.",
808                            self.prefix,
809                            self.path.display(),
810                            e,
811                        ));
812                    }
813                }
814            }
815        }
816
817        // Forward to plugin-supplied external sinks (Sentry, OTLP, custom
818        // backends). Empty by default — the SDK never registers a sink.
819        if !self.sinks.is_empty() {
820            let sink_record = SinkRecord {
821                timestamp: &timestamp,
822                level: record.level(),
823                target: record.target(),
824                message: &message,
825                prefix: &self.prefix,
826            };
827            for sink in &self.sinks {
828                sink.emit(&sink_record);
829            }
830        }
831    }
832
833    fn flush(&self) {
834        if let Ok(mut state) = self.state.lock()
835            && let Some(file) = state.file.as_mut()
836        {
837            let _ = file.flush();
838        }
839    }
840}
841
842impl LoggerImpl {
843    /// Closes the active file, moves it into the archive directory and
844    /// reopens a fresh one. Two strategies depending on `rotation.keep`:
845    ///
846    /// - `Some(N > 0)`: shift-style. Deletes `.log.N`, shifts every
847    ///   existing archive down by one (`.{i}` → `.{i+1}`), active becomes
848    ///   `.log.1`. The most recent `N` archives are retained.
849    /// - `None` (or `Some(0)`): append-style. Active is renamed to the
850    ///   next free `.log.{next_archive_index}` slot, with no cleanup.
851    fn rotate(&self, state: &mut LoggerState, rotation: Rotation) {
852        // Drop the file handle before renaming to avoid Windows file locks.
853        state.file = None;
854
855        // Lazy: only create when the first rotation actually happens.
856        if let Err(e) = fs::create_dir_all(&self.archive_directory) {
857            self.report_file_error(state, "create archive directory", &e);
858            // We still try to open a fresh active file below so logging
859            // does not die outright.
860            self.reopen_active(state);
861            return;
862        }
863
864        match rotation.keep {
865            Some(keep) if keep > 0 => self.rotate_shift(keep),
866            // Append-style: `None` or `Some(0)`. Active → next free slot.
867            _ => {
868                let index = state.next_archive_index;
869                state.next_archive_index = state.next_archive_index.saturating_add(1);
870                let archived = self.archive_path(index);
871                if fs::rename(&self.path, &archived).is_ok() {
872                    self.compress_archive(&archived);
873                }
874            }
875        }
876
877        self.reopen_active(state);
878    }
879
880    /// Gzips `archived` into `archived.gz` and removes the original when
881    /// the `compression` feature is enabled **and** the dev opted in via
882    /// [`LoggerConfig::compress_archives`]. Silent no-op otherwise.
883    ///
884    /// Errors are intentionally swallowed: rotation must not block log
885    /// emission. A failure leaves the uncompressed `.log.N` in place,
886    /// which is still strictly better than data loss.
887    #[cfg(feature = "compression")]
888    fn compress_archive(&self, archived: &std::path::Path) {
889        if !self.compress_archives {
890            return;
891        }
892        let gz_path = {
893            let mut p = archived.as_os_str().to_owned();
894            p.push(".gz");
895            PathBuf::from(p)
896        };
897        let Ok(input) = fs::File::open(archived) else {
898            return;
899        };
900        let Ok(output) = fs::File::create(&gz_path) else {
901            return;
902        };
903        let mut encoder = flate2::write::GzEncoder::new(output, flate2::Compression::default());
904        let mut reader = std::io::BufReader::new(input);
905        if std::io::copy(&mut reader, &mut encoder).is_err() {
906            let _ = fs::remove_file(&gz_path);
907            return;
908        }
909        if encoder.finish().is_err() {
910            let _ = fs::remove_file(&gz_path);
911            return;
912        }
913        let _ = fs::remove_file(archived);
914    }
915
916    #[cfg(not(feature = "compression"))]
917    #[allow(clippy::unused_self)]
918    fn compress_archive(&self, _archived: &std::path::Path) {}
919
920    /// Shift-style rotation: `.log.{keep}` is dropped, `.{i}` shifts to
921    /// `.{i+1}`, active becomes `.log.1`. When the `compression` feature
922    /// is enabled, `.gz` variants are handled in lockstep so a mixed
923    /// archive directory (compressed + uncompressed) stays consistent.
924    fn rotate_shift(&self, keep: u32) {
925        let _ = fs::remove_file(self.archive_path(keep));
926        #[cfg(feature = "compression")]
927        let _ = fs::remove_file(append_gz(&self.archive_path(keep)));
928        for index in (1..keep).rev() {
929            let src = self.archive_path(index);
930            let dst = self.archive_path(index + 1);
931            if src.exists() {
932                let _ = fs::rename(&src, &dst);
933            }
934            #[cfg(feature = "compression")]
935            {
936                let src_gz = append_gz(&src);
937                let dst_gz = append_gz(&dst);
938                if src_gz.exists() {
939                    let _ = fs::rename(&src_gz, &dst_gz);
940                }
941            }
942        }
943        let archived = self.archive_path(1);
944        if fs::rename(&self.path, &archived).is_ok() {
945            self.compress_archive(&archived);
946        }
947    }
948
949    fn reopen_active(&self, state: &mut LoggerState) {
950        match OpenOptions::new()
951            .create(true)
952            .append(true)
953            .open(&self.path)
954        {
955            Ok(file) => {
956                state.file = Some(file);
957                state.current_size = 0;
958            }
959            Err(e) => self.report_file_error(state, "reopen", &e),
960        }
961    }
962
963    fn report_file_error(&self, state: &mut LoggerState, action: &str, e: &std::io::Error) {
964        if !state.file_write_reported {
965            state.file_write_reported = true;
966            Runtime::get().log(format!(
967                "{} failed to {} {}: {}. Further file-write errors will be suppressed.",
968                self.prefix,
969                action,
970                self.path.display(),
971                e,
972            ));
973        }
974    }
975
976    fn archive_path(&self, index: u32) -> PathBuf {
977        self.archive_directory
978            .join(format!("{}.{}", self.filename, index))
979    }
980}
981
982/// Uppercased prefix derived from the crate name for env var lookup.
983/// Non-alphanumeric characters become `_`. `streamer-rs` → `STREAMER_RS`.
984fn env_var_prefix(crate_name: &str) -> String {
985    crate_name
986        .chars()
987        .map(|c| {
988            if c.is_ascii_alphanumeric() {
989                c.to_ascii_uppercase()
990            } else {
991                '_'
992            }
993        })
994        .collect()
995}
996
997fn read_env(prefix: &str, key: &str) -> Option<String> {
998    let name = format!("{prefix}_LOG_{key}");
999    std::env::var(&name).ok().filter(|s| !s.is_empty())
1000}
1001
1002fn parse_level(raw: &str) -> Option<LevelFilter> {
1003    match raw.trim().to_ascii_lowercase().as_str() {
1004        "off" => Some(LevelFilter::Off),
1005        "error" => Some(LevelFilter::Error),
1006        "warn" | "warning" => Some(LevelFilter::Warn),
1007        "info" => Some(LevelFilter::Info),
1008        "debug" => Some(LevelFilter::Debug),
1009        "trace" => Some(LevelFilter::Trace),
1010        _ => None,
1011    }
1012}
1013
1014fn parse_bool(raw: &str) -> bool {
1015    matches!(
1016        raw.trim().to_ascii_lowercase().as_str(),
1017        "1" | "true" | "yes" | "on"
1018    )
1019}
1020
1021/// Surfaces an invalid env var value to the server console at install
1022/// time. The logger is not registered yet, so this routes through the
1023/// raw [`Runtime`] log sink directly. Falls back to `eprintln!` when
1024/// the runtime is not initialised (e.g. unit tests that exercise
1025/// [`LoggerConfig::from_env`] in isolation).
1026fn warn_invalid(prefix: &str, key: &str, raw: &str) {
1027    let msg = format!(
1028        "[rust-samp] ignoring invalid env var {prefix}_LOG_{key}={raw:?} — keeping previous value",
1029    );
1030    if let Some(rt) = Runtime::try_get() {
1031        rt.log(msg);
1032    } else {
1033        eprintln!("{msg}");
1034    }
1035}
1036
1037#[cfg(feature = "compression")]
1038fn append_gz(path: &std::path::Path) -> PathBuf {
1039    let mut s = path.as_os_str().to_owned();
1040    s.push(".gz");
1041    PathBuf::from(s)
1042}
1043
1044/// Scans the archive directory for existing `{filename}.{N}` siblings of
1045/// the active log and returns the next free `N`. Used to seed
1046/// [`LoggerState::next_archive_index`] so append-style rotation never
1047/// reuses an index across restarts.
1048fn find_next_archive_index(archive_dir: &std::path::Path, filename: &str) -> u32 {
1049    let prefix = format!("{filename}.");
1050    let mut max = 0u32;
1051    if let Ok(entries) = fs::read_dir(archive_dir) {
1052        for entry in entries.flatten() {
1053            if let Some(name) = entry.file_name().to_str()
1054                && let Some(rest) = name.strip_prefix(&prefix)
1055            {
1056                // Accept both `.{N}` and `.{N}.gz` so the next index
1057                // skips slots reused by an earlier compressed rotation.
1058                let idx_str = rest.strip_suffix(".gz").unwrap_or(rest);
1059                if let Ok(index) = idx_str.parse::<u32>() {
1060                    max = max.max(index);
1061                }
1062            }
1063        }
1064    }
1065    max.saturating_add(1)
1066}
1067
1068// ---------------------------------------------------------------------------
1069// Banner
1070// ---------------------------------------------------------------------------
1071
1072thread_local! {
1073    /// Captured by [`crate::enable_logger`] before [`install`] is called so
1074    /// the banner can introspect the caller's manifest. Each macro
1075    /// invocation overwrites it, which is fine because installation is a
1076    /// one-shot event per process.
1077    static BANNER_METADATA: std::cell::RefCell<Option<BannerMetadata>> =
1078        const { std::cell::RefCell::new(None) };
1079}
1080
1081/// Macro plumbing — captures the caller's `CARGO_PKG_*` values so
1082/// [`print_banner`] can render them. Not part of the public API surface;
1083/// the macros call this on the user's behalf.
1084#[doc(hidden)]
1085pub fn __set_banner_metadata(metadata: BannerMetadata) {
1086    BANNER_METADATA.with(|cell| {
1087        *cell.borrow_mut() = Some(metadata);
1088    });
1089}
1090
1091/// Manifest fields fed by the macro from the caller's `env!` values.
1092#[derive(Debug, Clone)]
1093pub struct BannerMetadata {
1094    pub name: &'static str,
1095    pub version: &'static str,
1096    pub authors: &'static str,
1097    pub repository: &'static str,
1098}
1099
1100impl BannerMetadata {
1101    /// Constructor used by [`crate::enable_logger`] — there is no reason
1102    /// to call this directly; the macro is the API.
1103    #[must_use]
1104    pub fn new(
1105        name: &'static str,
1106        version: &'static str,
1107        authors: &'static str,
1108        repository: &'static str,
1109    ) -> Self {
1110        Self {
1111            name,
1112            version,
1113            authors,
1114            repository,
1115        }
1116    }
1117}
1118
1119/// Replaces `{timestamp}`, `{level}`, `{message}` and (when provided)
1120/// `{prefix}` placeholders in the layout templates. Supports optional
1121/// alignment+width specifiers borrowed from Rust's format syntax:
1122///
1123/// - `{level:<5}` — left-aligned, padded to width 5
1124/// - `{level:>5}` — right-aligned, padded to width 5
1125/// - `{level:^5}` — centred, padded to width 5
1126///
1127/// Unknown placeholders pass through untouched so the dev can spot typos
1128/// in their format string.
1129fn apply_format(
1130    template: &str,
1131    prefix: Option<&str>,
1132    timestamp: &str,
1133    level: &str,
1134    message: &str,
1135) -> String {
1136    let mut out = String::with_capacity(template.len());
1137    let bytes = template.as_bytes();
1138    let mut i = 0;
1139
1140    while i < bytes.len() {
1141        if bytes[i] == b'{'
1142            && let Some(close) = template[i + 1..].find('}')
1143        {
1144            let end = i + 1 + close;
1145            let spec = &template[i + 1..end];
1146            if let Some(rendered) = render_placeholder(spec, prefix, timestamp, level, message) {
1147                out.push_str(&rendered);
1148            } else {
1149                // Unknown placeholder — emit verbatim so devs see typos.
1150                out.push_str(&template[i..=end]);
1151            }
1152            i = end + 1;
1153        } else {
1154            out.push(bytes[i] as char);
1155            i += 1;
1156        }
1157    }
1158
1159    out
1160}
1161
1162/// Resolves a single `{...}` group. Returns `None` for unknown names so
1163/// the caller can pass the raw `{spec}` through.
1164fn render_placeholder(
1165    spec: &str,
1166    prefix: Option<&str>,
1167    timestamp: &str,
1168    level: &str,
1169    message: &str,
1170) -> Option<String> {
1171    let (name, format_spec) = spec.split_once(':').unwrap_or((spec, ""));
1172    let value: &str = match name {
1173        "timestamp" => timestamp,
1174        "level" => level,
1175        "message" => message,
1176        "prefix" => prefix.unwrap_or(""),
1177        _ => return None,
1178    };
1179
1180    if format_spec.is_empty() {
1181        return Some(value.to_owned());
1182    }
1183
1184    let (alignment, width_str) = match format_spec.chars().next() {
1185        Some('<') => (Alignment::Left, &format_spec[1..]),
1186        Some('>') => (Alignment::Right, &format_spec[1..]),
1187        Some('^') => (Alignment::Center, &format_spec[1..]),
1188        _ => return Some(value.to_owned()),
1189    };
1190
1191    let Ok(width) = width_str.parse::<usize>() else {
1192        return Some(value.to_owned());
1193    };
1194
1195    Some(match alignment {
1196        Alignment::Left => format!("{value:<width$}"),
1197        Alignment::Right => format!("{value:>width$}"),
1198        Alignment::Center => format!("{value:^width$}"),
1199    })
1200}
1201
1202enum Alignment {
1203    Left,
1204    Right,
1205    Center,
1206}
1207
1208fn print_banner_inner(mode: &BannerMode) {
1209    let metadata = BANNER_METADATA.with(|cell| cell.borrow().clone());
1210    let Some(meta) = metadata else {
1211        // The free-function `install` was called without the macro. Skip
1212        // the banner instead of emitting half-empty defaults.
1213        return;
1214    };
1215
1216    let lines = match mode {
1217        BannerMode::Off => return,
1218        BannerMode::Default => default_banner_lines(&meta),
1219        BannerMode::Custom(builder) => builder(&meta),
1220    };
1221
1222    for line in lines {
1223        log::info!("{line}");
1224    }
1225}
1226
1227fn default_banner_lines(meta: &BannerMetadata) -> Vec<String> {
1228    let authors = if meta.authors.trim().is_empty() {
1229        "Unknown"
1230    } else {
1231        meta.authors
1232    };
1233    let repository = if meta.repository.trim().is_empty() {
1234        "N/A"
1235    } else {
1236        meta.repository
1237    };
1238
1239    vec![
1240        String::new(),
1241        format!("  | {} {}", meta.name, meta.version),
1242        String::from("  |-------------------------------"),
1243        format!("  | Author: {}", authors),
1244        format!("  | Repository: {}", repository),
1245        String::new(),
1246    ]
1247}
1248
1249/// Re-emits the default banner after installation. Rarely useful at
1250/// runtime; kept public so plugins can re-print on demand (for
1251/// instance, after a Pawn-driven reload). Honours [`BannerMode::Default`]
1252/// regardless of what was supplied to `install` — custom banners are
1253/// not memoised, so plugins that need their own format should call
1254/// `log::info!` themselves.
1255pub fn print_banner() {
1256    // The runtime mode is not stored after install (the logger has no
1257    // banner field); we always re-emit the default style. Plugins with a
1258    // custom banner can call `log::info!` themselves to repeat it.
1259    print_banner_inner(&BannerMode::Default);
1260}
1261
1262// ---------------------------------------------------------------------------
1263// Tests
1264// ---------------------------------------------------------------------------
1265
1266#[cfg(test)]
1267mod tests {
1268    use super::*;
1269    use std::path::Path;
1270
1271    #[test]
1272    fn config_resolves_defaults() {
1273        let cfg = LoggerConfig::new("my-plugin");
1274        assert_eq!(cfg.resolved_filename(), "my-plugin.log");
1275        assert_eq!(cfg.resolved_prefix(), "[my-plugin]");
1276        assert_eq!(cfg.log_path(), Path::new("logs/my-plugin.log"));
1277        assert_eq!(cfg.resolved_archive_directory(), Path::new("logs/archive"));
1278        assert_eq!(cfg.level, LevelFilter::Info);
1279        assert!(cfg.also_to_server);
1280        assert!(matches!(cfg.banner, BannerMode::Default));
1281        assert_eq!(cfg.file_format, DEFAULT_FILE_FORMAT);
1282        assert_eq!(cfg.server_format, DEFAULT_SERVER_FORMAT);
1283        let rotation = cfg.rotation.expect("default rotation enabled");
1284        assert_eq!(rotation.max_bytes, 50 * 1024 * 1024);
1285        // Default is append-style: never auto-delete archives.
1286        assert_eq!(rotation.keep, None);
1287    }
1288
1289    #[test]
1290    fn config_overrides_apply() {
1291        let cfg = LoggerConfig::new("foo")
1292            .directory("custom")
1293            .filename("custom.log")
1294            .prefix("[Custom]")
1295            .level(LevelFilter::Warn)
1296            .also_to_server(false)
1297            .no_banner()
1298            .rotation_size_mb(10)
1299            .rotation_keep(3)
1300            .file_format("{level}: {message}")
1301            .server_format("<{prefix}> {message}");
1302        assert_eq!(cfg.directory, Path::new("custom"));
1303        // Archive folder is always `{directory}/archive` — overriding
1304        // `directory` automatically retargets the archive directory too.
1305        assert_eq!(
1306            cfg.resolved_archive_directory(),
1307            Path::new("custom/archive")
1308        );
1309        assert_eq!(cfg.resolved_filename(), "custom.log");
1310        assert_eq!(cfg.resolved_prefix(), "[Custom]");
1311        assert_eq!(cfg.level, LevelFilter::Warn);
1312        assert!(!cfg.also_to_server);
1313        assert!(matches!(cfg.banner, BannerMode::Off));
1314        assert_eq!(cfg.file_format, "{level}: {message}");
1315        assert_eq!(cfg.server_format, "<{prefix}> {message}");
1316        let rotation = cfg.rotation.expect("explicit rotation kept");
1317        assert_eq!(rotation.max_bytes, 10 * 1024 * 1024);
1318        assert_eq!(rotation.keep, Some(3));
1319    }
1320
1321    #[test]
1322    fn rotation_no_cleanup_resets_keep_to_none() {
1323        let cfg = LoggerConfig::new("foo")
1324            .rotation_keep(5)
1325            .rotation_no_cleanup();
1326        let rotation = cfg.rotation.expect("rotation still active");
1327        assert_eq!(rotation.keep, None);
1328    }
1329
1330    #[test]
1331    fn apply_format_substitutes_placeholders() {
1332        let line = apply_format(
1333            "[{timestamp}] [{level}] {message}",
1334            None,
1335            "2026-06-08 12:30:45",
1336            "INFO",
1337            "ready",
1338        );
1339        assert_eq!(line, "[2026-06-08 12:30:45] [INFO] ready");
1340
1341        let server = apply_format(
1342            "{prefix} {message}",
1343            Some("[my-plugin]"),
1344            "2026-06-08 12:30:45",
1345            "WARN",
1346            "stalled",
1347        );
1348        assert_eq!(server, "[my-plugin] stalled");
1349    }
1350
1351    #[test]
1352    fn apply_format_supports_width_specifiers() {
1353        let right = apply_format("[{level:>5}] {message}", None, "ts", "INFO", "msg");
1354        assert_eq!(right, "[ INFO] msg");
1355
1356        let left = apply_format("[{level:<5}] {message}", None, "ts", "INFO", "msg");
1357        assert_eq!(left, "[INFO ] msg");
1358
1359        let center = apply_format("[{level:^6}] {message}", None, "ts", "INFO", "msg");
1360        assert_eq!(center, "[ INFO ] msg");
1361    }
1362
1363    #[test]
1364    fn apply_format_width_smaller_than_value_does_not_truncate() {
1365        let line = apply_format("[{level:>2}] {message}", None, "ts", "INFO", "msg");
1366        // Rust's `{:>w$}` only pads — never truncates — so INFO survives.
1367        assert_eq!(line, "[INFO] msg");
1368    }
1369
1370    #[test]
1371    fn apply_format_leaves_unknown_placeholders_untouched() {
1372        let line = apply_format(
1373            "{foo} {message}",
1374            None,
1375            "2026-06-08 12:30:45",
1376            "INFO",
1377            "ready",
1378        );
1379        assert_eq!(line, "{foo} ready");
1380    }
1381
1382    #[test]
1383    fn custom_banner_lines_emit_in_order() {
1384        let cfg = LoggerConfig::new("foo").banner_with(|meta| {
1385            vec![
1386                String::from("=== plugin start ==="),
1387                format!("hello {}!", meta.name),
1388            ]
1389        });
1390        let lines = match &cfg.banner {
1391            BannerMode::Custom(builder) => builder(&BannerMetadata::new(
1392                "foo",
1393                "1.0",
1394                "ZOTTCE",
1395                "https://example.com",
1396            )),
1397            _ => unreachable!(),
1398        };
1399        assert_eq!(lines.len(), 2);
1400        assert_eq!(lines[0], "=== plugin start ===");
1401        assert_eq!(lines[1], "hello foo!");
1402    }
1403
1404    #[test]
1405    fn no_rotation_disables_archives() {
1406        let cfg = LoggerConfig::new("foo").rotation_size_mb(20).no_rotation();
1407        assert!(cfg.rotation.is_none());
1408    }
1409
1410    #[test]
1411    fn rotation_size_mb_zero_disables() {
1412        let cfg = LoggerConfig::new("foo").rotation_size_mb(0);
1413        assert!(cfg.rotation.is_none());
1414    }
1415
1416    #[test]
1417    fn level_round_trip() {
1418        for level in [
1419            LevelFilter::Off,
1420            LevelFilter::Error,
1421            LevelFilter::Warn,
1422            LevelFilter::Info,
1423            LevelFilter::Debug,
1424            LevelFilter::Trace,
1425        ] {
1426            assert_eq!(u8_to_level(level_to_u8(level)), level);
1427        }
1428    }
1429
1430    #[test]
1431    fn set_and_read_level() {
1432        set_level(LevelFilter::Warn);
1433        assert_eq!(level(), LevelFilter::Warn);
1434        set_level(LevelFilter::Trace);
1435        assert_eq!(level(), LevelFilter::Trace);
1436    }
1437
1438    #[cfg(feature = "compression")]
1439    #[test]
1440    fn compress_archives_builder_sets_flag() {
1441        let cfg = LoggerConfig::new("foo").compress_archives(true);
1442        assert!(cfg.compress_archives);
1443        let cfg = LoggerConfig::new("foo").compress_archives(false);
1444        assert!(!cfg.compress_archives);
1445        let cfg = LoggerConfig::new("foo");
1446        assert!(
1447            !cfg.compress_archives,
1448            "compression must stay opt-in when the builder is not called"
1449        );
1450    }
1451
1452    #[cfg(feature = "compression")]
1453    #[test]
1454    fn find_next_archive_index_counts_gz_variants() {
1455        let tmp = std::env::temp_dir().join(format!(
1456            "rust-samp-test-archive-{}-{}",
1457            std::process::id(),
1458            std::time::SystemTime::now()
1459                .duration_since(std::time::UNIX_EPOCH)
1460                .unwrap()
1461                .as_nanos()
1462        ));
1463        std::fs::create_dir_all(&tmp).unwrap();
1464        // mixed archive directory: one .gz and one plain
1465        std::fs::write(tmp.join("foo.log.2.gz"), b"compressed").unwrap();
1466        std::fs::write(tmp.join("foo.log.5"), b"plain").unwrap();
1467
1468        // Next free index must skip past the highest seen — regardless
1469        // of whether the highest is `.N` or `.N.gz`.
1470        let next = super::find_next_archive_index(&tmp, "foo.log");
1471        assert_eq!(next, 6);
1472
1473        let _ = std::fs::remove_dir_all(&tmp);
1474    }
1475
1476    #[test]
1477    fn env_var_prefix_uppercases_and_sanitises() {
1478        assert_eq!(super::env_var_prefix("memcached"), "MEMCACHED");
1479        assert_eq!(super::env_var_prefix("streamer-rs"), "STREAMER_RS");
1480        assert_eq!(super::env_var_prefix("my.plugin"), "MY_PLUGIN");
1481        assert_eq!(super::env_var_prefix("plugin_v2"), "PLUGIN_V2");
1482    }
1483
1484    #[test]
1485    fn parse_level_accepts_known_names() {
1486        assert_eq!(super::parse_level("off"), Some(LevelFilter::Off));
1487        assert_eq!(super::parse_level("ERROR"), Some(LevelFilter::Error));
1488        assert_eq!(super::parse_level("warn"), Some(LevelFilter::Warn));
1489        assert_eq!(super::parse_level("warning"), Some(LevelFilter::Warn));
1490        assert_eq!(super::parse_level("  info "), Some(LevelFilter::Info));
1491        assert_eq!(super::parse_level("debug"), Some(LevelFilter::Debug));
1492        assert_eq!(super::parse_level("trace"), Some(LevelFilter::Trace));
1493        assert_eq!(super::parse_level("nope"), None);
1494        assert_eq!(super::parse_level(""), None);
1495    }
1496
1497    #[test]
1498    fn parse_bool_accepts_common_truthy() {
1499        for s in ["1", "true", "TRUE", " yes ", "on"] {
1500            assert!(super::parse_bool(s), "{s:?} should parse as true");
1501        }
1502        for s in ["0", "false", "no", "off", "", "anything"] {
1503            assert!(!super::parse_bool(s), "{s:?} should parse as false");
1504        }
1505    }
1506
1507    #[test]
1508    fn from_env_applies_overrides_and_ignores_garbage() {
1509        // Use a unique prefix per test process to avoid collision with
1510        // any real env the test runner already has set.
1511        let crate_name = format!("rust_samp_test_{}", std::process::id());
1512        let prefix = super::env_var_prefix(&crate_name);
1513
1514        // Set: valid level + dir + invalid rotation_mb (should be
1515        // ignored, not panic) + bool toggles.
1516        // SAFETY: tests run single-threaded enough for env mutation; the
1517        // env is otherwise unused by the rest of the suite.
1518        unsafe {
1519            std::env::set_var(format!("{prefix}_LOG_LEVEL"), "debug");
1520            std::env::set_var(format!("{prefix}_LOG_DIR"), "/tmp/rust-samp-from-env");
1521            std::env::set_var(format!("{prefix}_LOG_ROTATION_MB"), "not-a-number");
1522            std::env::set_var(format!("{prefix}_LOG_NO_BANNER"), "1");
1523            std::env::set_var(format!("{prefix}_LOG_SERVER"), "false");
1524        }
1525
1526        let cfg = LoggerConfig::new(crate_name).from_env();
1527
1528        assert_eq!(cfg.level, LevelFilter::Debug);
1529        assert_eq!(cfg.directory, PathBuf::from("/tmp/rust-samp-from-env"));
1530        assert!(matches!(cfg.banner, BannerMode::Off));
1531        assert!(!cfg.also_to_server);
1532        // ROTATION_MB was garbage — must have stayed on the default.
1533        assert!(matches!(
1534            cfg.rotation,
1535            Some(Rotation {
1536                max_bytes: DEFAULT_ROTATION_BYTES,
1537                ..
1538            })
1539        ));
1540
1541        // SAFETY: same as above — cleaning up after ourselves.
1542        unsafe {
1543            std::env::remove_var(format!("{prefix}_LOG_LEVEL"));
1544            std::env::remove_var(format!("{prefix}_LOG_DIR"));
1545            std::env::remove_var(format!("{prefix}_LOG_ROTATION_MB"));
1546            std::env::remove_var(format!("{prefix}_LOG_NO_BANNER"));
1547            std::env::remove_var(format!("{prefix}_LOG_SERVER"));
1548        }
1549    }
1550
1551    #[test]
1552    fn add_sink_appends_in_call_order() {
1553        struct Counter(std::sync::atomic::AtomicUsize);
1554        impl super::Sink for Counter {
1555            fn emit(&self, _record: &super::SinkRecord<'_>) {
1556                self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1557            }
1558        }
1559
1560        let cfg = LoggerConfig::new("foo");
1561        assert_eq!(cfg.sinks.len(), 0, "no sinks by default");
1562
1563        let cfg = cfg
1564            .add_sink(Box::new(Counter(0.into())))
1565            .add_sink(Box::new(Counter(0.into())));
1566        assert_eq!(cfg.sinks.len(), 2);
1567    }
1568
1569    #[test]
1570    fn flush_without_install_is_noop() {
1571        // `install` is not callable in unit tests (it tries to register a
1572        // global `log` logger and create the `logs/` directory), but the
1573        // public `flush` helper must remain safe when no instance is
1574        // registered. Should not panic, deadlock, or touch any file.
1575        super::flush();
1576        super::flush();
1577    }
1578}