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::sync::Mutex;
26use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
27
28use log::{LevelFilter, Log, Metadata, Record};
29use time::OffsetDateTime;
30use time::format_description::BorrowedFormatItem;
31use time::macros::format_description;
32
33use crate::runtime::Runtime;
34
35/// File timestamp format — `YYYY-MM-DD HH:MM:SS`.
36const TIMESTAMP_FORMAT: &[BorrowedFormatItem<'_>] =
37    format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
38
39/// Default rotation threshold — 50 MB per archived file.
40const DEFAULT_ROTATION_BYTES: u64 = 50 * 1024 * 1024;
41
42/// Default layout for lines written to the plugin's dedicated log file.
43/// Placeholders: `{timestamp}` (`YYYY-MM-DD HH:MM:SS`), `{level}` (`INFO`,
44/// `WARN`, ...), `{message}` (formatted args).
45const DEFAULT_FILE_FORMAT: &str = "[{timestamp}] [{level}] {message}";
46
47/// Default layout for lines forwarded to the server console. The server
48/// adds its own timestamp; the SDK only contributes prefix + level +
49/// message. Placeholders: `{prefix}`, `{level}`, `{message}`.
50const DEFAULT_SERVER_FORMAT: &str = "{prefix} {message}";
51
52/// Configuration for [`install`]. Built via the fluent setters; defaults
53/// are derived from `CARGO_PKG_NAME` (captured at the caller's compile time
54/// by [`enable_logger!`]).
55///
56/// [`enable_logger!`]: crate::enable_logger
57pub struct LoggerConfig {
58    crate_name: String,
59    directory: PathBuf,
60    filename: Option<String>,
61    prefix: Option<String>,
62    level: LevelFilter,
63    also_to_server: bool,
64    banner: BannerMode,
65    rotation: Option<Rotation>,
66    file_format: String,
67    server_format: String,
68}
69
70/// Type alias for the custom banner builder — receives the metadata
71/// captured by the macro and returns the lines to render.
72pub type BannerBuilder = dyn Fn(&BannerMetadata) -> Vec<String> + Send + Sync;
73
74/// What [`install`] does when the configuration's banner is reached.
75pub enum BannerMode {
76    /// No banner at all.
77    Off,
78    /// Built-in 5-line banner with `CARGO_PKG_NAME`, version, authors and
79    /// repository. The standard choice.
80    Default,
81    /// Lines produced by the caller. Each line goes out at `Info` level
82    /// through the same pipeline as the rest of the logger.
83    Custom(Box<BannerBuilder>),
84}
85
86impl std::fmt::Debug for BannerMode {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        match self {
89            Self::Off => f.write_str("Off"),
90            Self::Default => f.write_str("Default"),
91            Self::Custom(_) => f.write_str("Custom(<fn>)"),
92        }
93    }
94}
95
96impl std::fmt::Debug for LoggerConfig {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        f.debug_struct("LoggerConfig")
99            .field("crate_name", &self.crate_name)
100            .field("directory", &self.directory)
101            .field("filename", &self.filename)
102            .field("prefix", &self.prefix)
103            .field("level", &self.level)
104            .field("also_to_server", &self.also_to_server)
105            .field("banner", &self.banner)
106            .field("rotation", &self.rotation)
107            .field("file_format", &self.file_format)
108            .field("server_format", &self.server_format)
109            .finish()
110    }
111}
112
113/// Size-based rotation rules.
114///
115/// `keep`:
116/// - `Some(N)` — shift-style rotation: the active file becomes
117///   `{name}.log.1`, existing archives shift down (`.1` → `.2`, ...),
118///   and `.log.N` is deleted to enforce the cap. The most recent `N`
119///   archives stay on disk.
120/// - `None` — append-style rotation: every rotated file is renamed to
121///   the next free `{name}.log.{index}` slot and never deleted by the
122///   SDK. The dev keeps full control over cleanup (manual, `logrotate`,
123///   external script, etc.).
124#[derive(Debug, Clone, Copy)]
125struct Rotation {
126    max_bytes: u64,
127    keep: Option<u32>,
128}
129
130impl LoggerConfig {
131    /// Builds a config seeded from the caller's `CARGO_PKG_NAME` — the
132    /// `enable_logger!` macro is the intended entry point and forwards
133    /// `env!("CARGO_PKG_NAME")` here automatically.
134    #[must_use]
135    pub fn new(crate_name: impl Into<String>) -> Self {
136        Self {
137            crate_name: crate_name.into(),
138            directory: PathBuf::from("logs"),
139            filename: None,
140            prefix: None,
141            level: LevelFilter::Info,
142            also_to_server: true,
143            banner: BannerMode::Default,
144            rotation: Some(Rotation {
145                max_bytes: DEFAULT_ROTATION_BYTES,
146                // Never auto-delete by default. The dev opts in to
147                // pruning via `.rotation_keep(N)`.
148                keep: None,
149            }),
150            file_format: DEFAULT_FILE_FORMAT.to_owned(),
151            server_format: DEFAULT_SERVER_FORMAT.to_owned(),
152        }
153    }
154
155    /// Directory under which the active log file lives. Default: `logs/`.
156    /// The path is resolved relative to the server's working directory.
157    /// Rotated archives are always placed under `{directory}/archive/` —
158    /// the active log stays directly in `directory` so the folder root
159    /// shows only current files.
160    #[must_use]
161    pub fn directory(mut self, path: impl Into<PathBuf>) -> Self {
162        self.directory = path.into();
163        self
164    }
165
166    /// Filename inside [`directory`]. Default: `{crate-name}.log`.
167    ///
168    /// [`directory`]: Self::directory
169    #[must_use]
170    pub fn filename(mut self, name: impl Into<String>) -> Self {
171        self.filename = Some(name.into());
172        self
173    }
174
175    /// Prefix prepended to every line written to the server's log.
176    /// Default: `[{crate-name}]`. The plugin's dedicated file omits the
177    /// prefix because every line in it already belongs to this plugin.
178    #[must_use]
179    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
180        self.prefix = Some(prefix.into());
181        self
182    }
183
184    /// Threshold below which `log::warn!`, `log::info!` and friends are
185    /// silently dropped. Default: [`LevelFilter::Info`]. Can be adjusted
186    /// at runtime via [`set_level`].
187    #[must_use]
188    pub fn level(mut self, level: LevelFilter) -> Self {
189        self.level = level;
190        self
191    }
192
193    /// Whether each log line is also forwarded to the server's own log
194    /// (visible in the server console and the server's main log file).
195    /// Default: `true`.
196    #[must_use]
197    pub fn also_to_server(mut self, enabled: bool) -> Self {
198        self.also_to_server = enabled;
199        self
200    }
201
202    /// Selects the banner strategy. Default: [`BannerMode::Default`] (the
203    /// built-in 5-line banner). Pass [`BannerMode::Off`] to suppress
204    /// every banner line, or [`BannerMode::Custom`] to render the lines
205    /// yourself.
206    #[must_use]
207    pub fn banner(mut self, mode: BannerMode) -> Self {
208        self.banner = mode;
209        self
210    }
211
212    /// Shorthand for `banner(BannerMode::Off)`.
213    #[must_use]
214    pub fn no_banner(mut self) -> Self {
215        self.banner = BannerMode::Off;
216        self
217    }
218
219    /// Shorthand for `banner(BannerMode::Custom(Box::new(builder)))`.
220    /// `builder` receives the manifest fields captured by the macro and
221    /// returns the lines to render — each line goes out at `Info` level
222    /// through the same pipeline as the rest of the logger.
223    #[must_use]
224    pub fn banner_with<F>(mut self, builder: F) -> Self
225    where
226        F: Fn(&BannerMetadata) -> Vec<String> + Send + Sync + 'static,
227    {
228        self.banner = BannerMode::Custom(Box::new(builder));
229        self
230    }
231
232    /// Layout for lines written to the plugin's dedicated log file.
233    /// Placeholders honoured: `{timestamp}`, `{level}`, `{message}`.
234    /// Default: `"[{timestamp}] [{level}] {message}"`.
235    #[must_use]
236    pub fn file_format(mut self, format: impl Into<String>) -> Self {
237        self.file_format = format.into();
238        self
239    }
240
241    /// Layout for lines forwarded to the server console.
242    /// Placeholders honoured: `{prefix}`, `{level}`, `{message}`.
243    /// Default: `"{prefix} {message}"`.
244    #[must_use]
245    pub fn server_format(mut self, format: impl Into<String>) -> Self {
246        self.server_format = format.into();
247        self
248    }
249
250    /// Disable size-based rotation entirely. The active log file grows
251    /// indefinitely — only set this if an external rotator (e.g.
252    /// `logrotate`) takes over.
253    #[must_use]
254    pub fn no_rotation(mut self) -> Self {
255        self.rotation = None;
256        self
257    }
258
259    /// Threshold at which the active file is rotated, in megabytes.
260    /// Default: 50 MB. Disables rotation if set to 0.
261    ///
262    /// Whether old archives are deleted is controlled separately by
263    /// [`rotation_keep`] — by default the SDK never deletes; it only
264    /// renames into the archive directory.
265    ///
266    /// [`rotation_keep`]: Self::rotation_keep
267    #[must_use]
268    pub fn rotation_size_mb(mut self, mb: u64) -> Self {
269        if mb == 0 {
270            self.rotation = None;
271        } else {
272            let max_bytes = mb.saturating_mul(1024 * 1024);
273            let keep = self.rotation.and_then(|r| r.keep);
274            self.rotation = Some(Rotation { max_bytes, keep });
275        }
276        self
277    }
278
279    /// Opts in to size-bounded cleanup: keep the latest `keep` archives
280    /// (newest = `.log.1`, oldest = `.log.{keep}`) and delete anything
281    /// older. Total disk footprint becomes `(keep + 1) * rotation_size_mb`.
282    ///
283    /// Off by default — the SDK never deletes log files unless the dev
284    /// explicitly requests it.
285    #[must_use]
286    pub fn rotation_keep(mut self, keep: u32) -> Self {
287        let max_bytes = self
288            .rotation
289            .map_or(DEFAULT_ROTATION_BYTES, |r| r.max_bytes);
290        self.rotation = Some(Rotation {
291            max_bytes,
292            keep: Some(keep),
293        });
294        self
295    }
296
297    /// Reverts to append-style rotation — every rotated file gets a
298    /// fresh, never-reused index and the SDK never deletes anything.
299    /// This is the default; the method exists so a builder chain can
300    /// undo a previous `.rotation_keep(N)`.
301    #[must_use]
302    pub fn rotation_no_cleanup(mut self) -> Self {
303        let max_bytes = self
304            .rotation
305            .map_or(DEFAULT_ROTATION_BYTES, |r| r.max_bytes);
306        self.rotation = Some(Rotation {
307            max_bytes,
308            keep: None,
309        });
310        self
311    }
312
313    fn resolved_filename(&self) -> String {
314        self.filename
315            .clone()
316            .unwrap_or_else(|| format!("{}.log", self.crate_name))
317    }
318
319    fn resolved_prefix(&self) -> String {
320        self.prefix
321            .clone()
322            .unwrap_or_else(|| format!("[{}]", self.crate_name))
323    }
324
325    fn log_path(&self) -> PathBuf {
326        self.directory.join(self.resolved_filename())
327    }
328
329    fn resolved_archive_directory(&self) -> PathBuf {
330        self.directory.join("archive")
331    }
332}
333
334/// Errors returned by [`install`].
335#[derive(Debug)]
336pub enum InstallError {
337    /// The logger was already installed in this process. `log::set_logger`
338    /// rejects a second installation, so the SDK enforces the same.
339    AlreadyInstalled,
340    /// Creating the directory or opening the log file failed.
341    Io(std::io::Error),
342}
343
344impl std::fmt::Display for InstallError {
345    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
346        match self {
347            Self::AlreadyInstalled => f.write_str("logger already installed"),
348            Self::Io(e) => write!(f, "i/o error: {e}"),
349        }
350    }
351}
352
353impl std::error::Error for InstallError {
354    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
355        match self {
356            Self::AlreadyInstalled => None,
357            Self::Io(e) => Some(e),
358        }
359    }
360}
361
362impl From<std::io::Error> for InstallError {
363    fn from(e: std::io::Error) -> Self {
364        Self::Io(e)
365    }
366}
367
368// ---------------------------------------------------------------------------
369// Runtime state
370// ---------------------------------------------------------------------------
371
372/// Sentinel preventing two `install` calls from racing.
373static INSTALLED: AtomicBool = AtomicBool::new(false);
374
375/// Runtime-adjustable level filter. Mirrors `log::set_max_level` but lets
376/// the SDK route through `LevelFilter` without re-importing the crate.
377static LEVEL: AtomicU8 = AtomicU8::new(level_to_u8(LevelFilter::Info));
378
379const fn level_to_u8(l: LevelFilter) -> u8 {
380    match l {
381        LevelFilter::Off => 0,
382        LevelFilter::Error => 1,
383        LevelFilter::Warn => 2,
384        LevelFilter::Info => 3,
385        LevelFilter::Debug => 4,
386        LevelFilter::Trace => 5,
387    }
388}
389
390const fn u8_to_level(v: u8) -> LevelFilter {
391    match v {
392        0 => LevelFilter::Off,
393        1 => LevelFilter::Error,
394        2 => LevelFilter::Warn,
395        3 => LevelFilter::Info,
396        4 => LevelFilter::Debug,
397        _ => LevelFilter::Trace,
398    }
399}
400
401/// Adjusts the global threshold without reinstalling. Intended for plugin
402/// natives that expose a runtime knob — bind it to a Pawn-callable
403/// helper such as `MyPlugin_SetLogLevel(level)`.
404pub fn set_level(level: LevelFilter) {
405    LEVEL.store(level_to_u8(level), Ordering::Relaxed);
406    log::set_max_level(level);
407}
408
409/// Current threshold — useful for diagnostic natives that want to report
410/// the active level back to scripts.
411#[must_use]
412pub fn level() -> LevelFilter {
413    u8_to_level(LEVEL.load(Ordering::Relaxed))
414}
415
416// ---------------------------------------------------------------------------
417// Installer
418// ---------------------------------------------------------------------------
419
420/// Installs the SDK logger as the global `log` implementation.
421///
422/// Called by the [`enable_logger!`] and [`enable_logger_with!`] macros;
423/// rarely invoked directly. Prefer the macros — they capture the caller's
424/// `CARGO_PKG_NAME` for the default prefix and filename.
425///
426/// # Errors
427/// - [`InstallError::AlreadyInstalled`] if a logger (this one or any
428///   other) was already registered with the `log` crate in this process.
429/// - [`InstallError::Io`] if the log directory or file could not be
430///   opened.
431///
432/// [`enable_logger!`]: crate::enable_logger
433/// [`enable_logger_with!`]: crate::enable_logger_with
434pub fn install(config: LoggerConfig) -> Result<(), InstallError> {
435    if INSTALLED.swap(true, Ordering::AcqRel) {
436        return Err(InstallError::AlreadyInstalled);
437    }
438
439    fs::create_dir_all(&config.directory)?;
440    let path = config.log_path();
441    let file = OpenOptions::new().create(true).append(true).open(&path)?;
442    let initial_size = file.metadata().map(|m| m.len()).unwrap_or(0);
443
444    let prefix = config.resolved_prefix();
445    let level = config.level;
446    let filename = config.resolved_filename();
447    let archive_directory = config.resolved_archive_directory();
448    let next_archive_index = find_next_archive_index(&archive_directory, &filename);
449    let LoggerConfig {
450        also_to_server,
451        banner,
452        rotation,
453        file_format,
454        server_format,
455        ..
456    } = config;
457
458    let logger = Box::new(LoggerImpl {
459        prefix,
460        also_to_server,
461        rotation,
462        path,
463        filename,
464        archive_directory,
465        file_format,
466        server_format,
467        state: Mutex::new(LoggerState {
468            file: Some(file),
469            current_size: initial_size,
470            file_write_reported: false,
471            next_archive_index,
472        }),
473    });
474
475    set_level(level);
476
477    log::set_boxed_logger(logger).map_err(|_| {
478        INSTALLED.store(false, Ordering::Release);
479        InstallError::AlreadyInstalled
480    })?;
481
482    print_banner_inner(&banner);
483
484    Ok(())
485}
486
487// ---------------------------------------------------------------------------
488// log::Log implementation
489// ---------------------------------------------------------------------------
490
491struct LoggerImpl {
492    prefix: String,
493    also_to_server: bool,
494    rotation: Option<Rotation>,
495    path: PathBuf,
496    filename: String,
497    archive_directory: PathBuf,
498    file_format: String,
499    server_format: String,
500    state: Mutex<LoggerState>,
501}
502
503struct LoggerState {
504    file: Option<File>,
505    current_size: u64,
506    /// `true` once a file-write failure has been surfaced to the server
507    /// console. Prevents one transient I/O glitch from spamming the log
508    /// loop with the same error on every line.
509    file_write_reported: bool,
510    /// Next archive index to use under append-style rotation
511    /// (`rotation.keep == None`). Seeded by [`find_next_archive_index`]
512    /// at install time; bumped on every rotate.
513    next_archive_index: u32,
514}
515
516impl Log for LoggerImpl {
517    fn enabled(&self, metadata: &Metadata<'_>) -> bool {
518        metadata.level() <= u8_to_level(LEVEL.load(Ordering::Relaxed))
519    }
520
521    fn log(&self, record: &Record<'_>) {
522        if !self.enabled(record.metadata()) {
523            return;
524        }
525
526        let message = format!("{}", record.args());
527        let level = record.level().as_str();
528
529        let timestamp = OffsetDateTime::now_local()
530            .unwrap_or_else(|_| OffsetDateTime::now_utc())
531            .format(TIMESTAMP_FORMAT)
532            .unwrap_or_else(|_| String::from("0000-00-00 00:00:00"));
533
534        // Forward to the server's own log honouring `server_format`.
535        if self.also_to_server {
536            let server_line = apply_format(
537                &self.server_format,
538                Some(&self.prefix),
539                &timestamp,
540                level,
541                &message,
542            );
543            Runtime::get().log(server_line);
544        }
545
546        // Write to the plugin's dedicated file honouring `file_format`.
547        let mut line = apply_format(&self.file_format, None, &timestamp, level, &message);
548        line.push('\n');
549
550        let mut state = match self.state.lock() {
551            Ok(s) => s,
552            Err(p) => p.into_inner(),
553        };
554
555        if let Some(rotation) = self.rotation
556            && state.current_size + line.len() as u64 > rotation.max_bytes
557        {
558            self.rotate(&mut state, rotation);
559        }
560
561        if let Some(file) = state.file.as_mut() {
562            match file.write_all(line.as_bytes()) {
563                Ok(()) => state.current_size += line.len() as u64,
564                Err(e) => {
565                    if !state.file_write_reported {
566                        state.file_write_reported = true;
567                        Runtime::get().log(format!(
568                            "{} failed to write {}: {}. Further file-write errors will be suppressed.",
569                            self.prefix,
570                            self.path.display(),
571                            e,
572                        ));
573                    }
574                }
575            }
576        }
577    }
578
579    fn flush(&self) {
580        if let Ok(mut state) = self.state.lock()
581            && let Some(file) = state.file.as_mut()
582        {
583            let _ = file.flush();
584        }
585    }
586}
587
588impl LoggerImpl {
589    /// Closes the active file, moves it into the archive directory and
590    /// reopens a fresh one. Two strategies depending on `rotation.keep`:
591    ///
592    /// - `Some(N > 0)`: shift-style. Deletes `.log.N`, shifts every
593    ///   existing archive down by one (`.{i}` → `.{i+1}`), active becomes
594    ///   `.log.1`. The most recent `N` archives are retained.
595    /// - `None` (or `Some(0)`): append-style. Active is renamed to the
596    ///   next free `.log.{next_archive_index}` slot, with no cleanup.
597    fn rotate(&self, state: &mut LoggerState, rotation: Rotation) {
598        // Drop the file handle before renaming to avoid Windows file locks.
599        state.file = None;
600
601        // Lazy: only create when the first rotation actually happens.
602        if let Err(e) = fs::create_dir_all(&self.archive_directory) {
603            self.report_file_error(state, "create archive directory", &e);
604            // We still try to open a fresh active file below so logging
605            // does not die outright.
606            self.reopen_active(state);
607            return;
608        }
609
610        match rotation.keep {
611            Some(keep) if keep > 0 => self.rotate_shift(keep),
612            // Append-style: `None` or `Some(0)`. Active → next free slot.
613            _ => {
614                let index = state.next_archive_index;
615                state.next_archive_index = state.next_archive_index.saturating_add(1);
616                let _ = fs::rename(&self.path, self.archive_path(index));
617            }
618        }
619
620        self.reopen_active(state);
621    }
622
623    /// Shift-style rotation: `.log.{keep}` is dropped, `.{i}` shifts to
624    /// `.{i+1}`, active becomes `.log.1`.
625    fn rotate_shift(&self, keep: u32) {
626        let _ = fs::remove_file(self.archive_path(keep));
627        for index in (1..keep).rev() {
628            let src = self.archive_path(index);
629            let dst = self.archive_path(index + 1);
630            if src.exists() {
631                let _ = fs::rename(&src, &dst);
632            }
633        }
634        let _ = fs::rename(&self.path, self.archive_path(1));
635    }
636
637    fn reopen_active(&self, state: &mut LoggerState) {
638        match OpenOptions::new()
639            .create(true)
640            .append(true)
641            .open(&self.path)
642        {
643            Ok(file) => {
644                state.file = Some(file);
645                state.current_size = 0;
646            }
647            Err(e) => self.report_file_error(state, "reopen", &e),
648        }
649    }
650
651    fn report_file_error(&self, state: &mut LoggerState, action: &str, e: &std::io::Error) {
652        if !state.file_write_reported {
653            state.file_write_reported = true;
654            Runtime::get().log(format!(
655                "{} failed to {} {}: {}. Further file-write errors will be suppressed.",
656                self.prefix,
657                action,
658                self.path.display(),
659                e,
660            ));
661        }
662    }
663
664    fn archive_path(&self, index: u32) -> PathBuf {
665        self.archive_directory
666            .join(format!("{}.{}", self.filename, index))
667    }
668}
669
670/// Scans the archive directory for existing `{filename}.{N}` siblings of
671/// the active log and returns the next free `N`. Used to seed
672/// [`LoggerState::next_archive_index`] so append-style rotation never
673/// reuses an index across restarts.
674fn find_next_archive_index(archive_dir: &std::path::Path, filename: &str) -> u32 {
675    let prefix = format!("{filename}.");
676    let mut max = 0u32;
677    if let Ok(entries) = fs::read_dir(archive_dir) {
678        for entry in entries.flatten() {
679            if let Some(name) = entry.file_name().to_str()
680                && let Some(rest) = name.strip_prefix(&prefix)
681                && let Ok(index) = rest.parse::<u32>()
682            {
683                max = max.max(index);
684            }
685        }
686    }
687    max.saturating_add(1)
688}
689
690// ---------------------------------------------------------------------------
691// Banner
692// ---------------------------------------------------------------------------
693
694thread_local! {
695    /// Captured by [`crate::enable_logger`] before [`install`] is called so
696    /// the banner can introspect the caller's manifest. Each macro
697    /// invocation overwrites it, which is fine because installation is a
698    /// one-shot event per process.
699    static BANNER_METADATA: std::cell::RefCell<Option<BannerMetadata>> =
700        const { std::cell::RefCell::new(None) };
701}
702
703/// Macro plumbing — captures the caller's `CARGO_PKG_*` values so
704/// [`print_banner`] can render them. Not part of the public API surface;
705/// the macros call this on the user's behalf.
706#[doc(hidden)]
707pub fn __set_banner_metadata(metadata: BannerMetadata) {
708    BANNER_METADATA.with(|cell| {
709        *cell.borrow_mut() = Some(metadata);
710    });
711}
712
713/// Manifest fields fed by the macro from the caller's `env!` values.
714#[derive(Debug, Clone)]
715pub struct BannerMetadata {
716    pub name: &'static str,
717    pub version: &'static str,
718    pub authors: &'static str,
719    pub repository: &'static str,
720}
721
722impl BannerMetadata {
723    /// Constructor used by [`crate::enable_logger`] — there is no reason
724    /// to call this directly; the macro is the API.
725    #[must_use]
726    pub fn new(
727        name: &'static str,
728        version: &'static str,
729        authors: &'static str,
730        repository: &'static str,
731    ) -> Self {
732        Self {
733            name,
734            version,
735            authors,
736            repository,
737        }
738    }
739}
740
741/// Replaces `{timestamp}`, `{level}`, `{message}` and (when provided)
742/// `{prefix}` placeholders in the layout templates. Supports optional
743/// alignment+width specifiers borrowed from Rust's format syntax:
744///
745/// - `{level:<5}` — left-aligned, padded to width 5
746/// - `{level:>5}` — right-aligned, padded to width 5
747/// - `{level:^5}` — centred, padded to width 5
748///
749/// Unknown placeholders pass through untouched so the dev can spot typos
750/// in their format string.
751fn apply_format(
752    template: &str,
753    prefix: Option<&str>,
754    timestamp: &str,
755    level: &str,
756    message: &str,
757) -> String {
758    let mut out = String::with_capacity(template.len());
759    let bytes = template.as_bytes();
760    let mut i = 0;
761
762    while i < bytes.len() {
763        if bytes[i] == b'{'
764            && let Some(close) = template[i + 1..].find('}')
765        {
766            let end = i + 1 + close;
767            let spec = &template[i + 1..end];
768            if let Some(rendered) = render_placeholder(spec, prefix, timestamp, level, message) {
769                out.push_str(&rendered);
770            } else {
771                // Unknown placeholder — emit verbatim so devs see typos.
772                out.push_str(&template[i..=end]);
773            }
774            i = end + 1;
775        } else {
776            out.push(bytes[i] as char);
777            i += 1;
778        }
779    }
780
781    out
782}
783
784/// Resolves a single `{...}` group. Returns `None` for unknown names so
785/// the caller can pass the raw `{spec}` through.
786fn render_placeholder(
787    spec: &str,
788    prefix: Option<&str>,
789    timestamp: &str,
790    level: &str,
791    message: &str,
792) -> Option<String> {
793    let (name, format_spec) = spec.split_once(':').unwrap_or((spec, ""));
794    let value: &str = match name {
795        "timestamp" => timestamp,
796        "level" => level,
797        "message" => message,
798        "prefix" => prefix.unwrap_or(""),
799        _ => return None,
800    };
801
802    if format_spec.is_empty() {
803        return Some(value.to_owned());
804    }
805
806    let (alignment, width_str) = match format_spec.chars().next() {
807        Some('<') => (Alignment::Left, &format_spec[1..]),
808        Some('>') => (Alignment::Right, &format_spec[1..]),
809        Some('^') => (Alignment::Center, &format_spec[1..]),
810        _ => return Some(value.to_owned()),
811    };
812
813    let Ok(width) = width_str.parse::<usize>() else {
814        return Some(value.to_owned());
815    };
816
817    Some(match alignment {
818        Alignment::Left => format!("{value:<width$}"),
819        Alignment::Right => format!("{value:>width$}"),
820        Alignment::Center => format!("{value:^width$}"),
821    })
822}
823
824enum Alignment {
825    Left,
826    Right,
827    Center,
828}
829
830fn print_banner_inner(mode: &BannerMode) {
831    let metadata = BANNER_METADATA.with(|cell| cell.borrow().clone());
832    let Some(meta) = metadata else {
833        // The free-function `install` was called without the macro. Skip
834        // the banner instead of emitting half-empty defaults.
835        return;
836    };
837
838    let lines = match mode {
839        BannerMode::Off => return,
840        BannerMode::Default => default_banner_lines(&meta),
841        BannerMode::Custom(builder) => builder(&meta),
842    };
843
844    for line in lines {
845        log::info!("{line}");
846    }
847}
848
849fn default_banner_lines(meta: &BannerMetadata) -> Vec<String> {
850    let authors = if meta.authors.trim().is_empty() {
851        "Unknown"
852    } else {
853        meta.authors
854    };
855    let repository = if meta.repository.trim().is_empty() {
856        "N/A"
857    } else {
858        meta.repository
859    };
860
861    vec![
862        String::new(),
863        format!("  | {} {}", meta.name, meta.version),
864        String::from("  |-------------------------------"),
865        format!("  | Author: {}", authors),
866        format!("  | Repository: {}", repository),
867        String::new(),
868    ]
869}
870
871/// Re-emits the default banner after installation. Rarely useful at
872/// runtime; kept public so plugins can re-print on demand (for
873/// instance, after a Pawn-driven reload). Honours [`BannerMode::Default`]
874/// regardless of what was supplied to `install` — custom banners are
875/// not memoised, so plugins that need their own format should call
876/// `log::info!` themselves.
877pub fn print_banner() {
878    // The runtime mode is not stored after install (the logger has no
879    // banner field); we always re-emit the default style. Plugins with a
880    // custom banner can call `log::info!` themselves to repeat it.
881    print_banner_inner(&BannerMode::Default);
882}
883
884// ---------------------------------------------------------------------------
885// Tests
886// ---------------------------------------------------------------------------
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891    use std::path::Path;
892
893    #[test]
894    fn config_resolves_defaults() {
895        let cfg = LoggerConfig::new("my-plugin");
896        assert_eq!(cfg.resolved_filename(), "my-plugin.log");
897        assert_eq!(cfg.resolved_prefix(), "[my-plugin]");
898        assert_eq!(cfg.log_path(), Path::new("logs/my-plugin.log"));
899        assert_eq!(cfg.resolved_archive_directory(), Path::new("logs/archive"));
900        assert_eq!(cfg.level, LevelFilter::Info);
901        assert!(cfg.also_to_server);
902        assert!(matches!(cfg.banner, BannerMode::Default));
903        assert_eq!(cfg.file_format, DEFAULT_FILE_FORMAT);
904        assert_eq!(cfg.server_format, DEFAULT_SERVER_FORMAT);
905        let rotation = cfg.rotation.expect("default rotation enabled");
906        assert_eq!(rotation.max_bytes, 50 * 1024 * 1024);
907        // Default is append-style: never auto-delete archives.
908        assert_eq!(rotation.keep, None);
909    }
910
911    #[test]
912    fn config_overrides_apply() {
913        let cfg = LoggerConfig::new("foo")
914            .directory("custom")
915            .filename("custom.log")
916            .prefix("[Custom]")
917            .level(LevelFilter::Warn)
918            .also_to_server(false)
919            .no_banner()
920            .rotation_size_mb(10)
921            .rotation_keep(3)
922            .file_format("{level}: {message}")
923            .server_format("<{prefix}> {message}");
924        assert_eq!(cfg.directory, Path::new("custom"));
925        // Archive folder is always `{directory}/archive` — overriding
926        // `directory` automatically retargets the archive directory too.
927        assert_eq!(
928            cfg.resolved_archive_directory(),
929            Path::new("custom/archive")
930        );
931        assert_eq!(cfg.resolved_filename(), "custom.log");
932        assert_eq!(cfg.resolved_prefix(), "[Custom]");
933        assert_eq!(cfg.level, LevelFilter::Warn);
934        assert!(!cfg.also_to_server);
935        assert!(matches!(cfg.banner, BannerMode::Off));
936        assert_eq!(cfg.file_format, "{level}: {message}");
937        assert_eq!(cfg.server_format, "<{prefix}> {message}");
938        let rotation = cfg.rotation.expect("explicit rotation kept");
939        assert_eq!(rotation.max_bytes, 10 * 1024 * 1024);
940        assert_eq!(rotation.keep, Some(3));
941    }
942
943    #[test]
944    fn rotation_no_cleanup_resets_keep_to_none() {
945        let cfg = LoggerConfig::new("foo")
946            .rotation_keep(5)
947            .rotation_no_cleanup();
948        let rotation = cfg.rotation.expect("rotation still active");
949        assert_eq!(rotation.keep, None);
950    }
951
952    #[test]
953    fn apply_format_substitutes_placeholders() {
954        let line = apply_format(
955            "[{timestamp}] [{level}] {message}",
956            None,
957            "2026-06-08 12:30:45",
958            "INFO",
959            "ready",
960        );
961        assert_eq!(line, "[2026-06-08 12:30:45] [INFO] ready");
962
963        let server = apply_format(
964            "{prefix} {message}",
965            Some("[my-plugin]"),
966            "2026-06-08 12:30:45",
967            "WARN",
968            "stalled",
969        );
970        assert_eq!(server, "[my-plugin] stalled");
971    }
972
973    #[test]
974    fn apply_format_supports_width_specifiers() {
975        let right = apply_format("[{level:>5}] {message}", None, "ts", "INFO", "msg");
976        assert_eq!(right, "[ INFO] msg");
977
978        let left = apply_format("[{level:<5}] {message}", None, "ts", "INFO", "msg");
979        assert_eq!(left, "[INFO ] msg");
980
981        let center = apply_format("[{level:^6}] {message}", None, "ts", "INFO", "msg");
982        assert_eq!(center, "[ INFO ] msg");
983    }
984
985    #[test]
986    fn apply_format_width_smaller_than_value_does_not_truncate() {
987        let line = apply_format("[{level:>2}] {message}", None, "ts", "INFO", "msg");
988        // Rust's `{:>w$}` only pads — never truncates — so INFO survives.
989        assert_eq!(line, "[INFO] msg");
990    }
991
992    #[test]
993    fn apply_format_leaves_unknown_placeholders_untouched() {
994        let line = apply_format(
995            "{foo} {message}",
996            None,
997            "2026-06-08 12:30:45",
998            "INFO",
999            "ready",
1000        );
1001        assert_eq!(line, "{foo} ready");
1002    }
1003
1004    #[test]
1005    fn custom_banner_lines_emit_in_order() {
1006        let cfg = LoggerConfig::new("foo").banner_with(|meta| {
1007            vec![
1008                String::from("=== plugin start ==="),
1009                format!("hello {}!", meta.name),
1010            ]
1011        });
1012        let lines = match &cfg.banner {
1013            BannerMode::Custom(builder) => builder(&BannerMetadata::new(
1014                "foo",
1015                "1.0",
1016                "ZOTTCE",
1017                "https://example.com",
1018            )),
1019            _ => unreachable!(),
1020        };
1021        assert_eq!(lines.len(), 2);
1022        assert_eq!(lines[0], "=== plugin start ===");
1023        assert_eq!(lines[1], "hello foo!");
1024    }
1025
1026    #[test]
1027    fn no_rotation_disables_archives() {
1028        let cfg = LoggerConfig::new("foo").rotation_size_mb(20).no_rotation();
1029        assert!(cfg.rotation.is_none());
1030    }
1031
1032    #[test]
1033    fn rotation_size_mb_zero_disables() {
1034        let cfg = LoggerConfig::new("foo").rotation_size_mb(0);
1035        assert!(cfg.rotation.is_none());
1036    }
1037
1038    #[test]
1039    fn level_round_trip() {
1040        for level in [
1041            LevelFilter::Off,
1042            LevelFilter::Error,
1043            LevelFilter::Warn,
1044            LevelFilter::Info,
1045            LevelFilter::Debug,
1046            LevelFilter::Trace,
1047        ] {
1048            assert_eq!(u8_to_level(level_to_u8(level)), level);
1049        }
1050    }
1051
1052    #[test]
1053    fn set_and_read_level() {
1054        set_level(LevelFilter::Warn);
1055        assert_eq!(level(), LevelFilter::Warn);
1056        set_level(LevelFilter::Trace);
1057        assert_eq!(level(), LevelFilter::Trace);
1058    }
1059}