Skip to main content

drmkit_log/
lib.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! drmkit's library-wide diagnostic surface.
5//!
6//! Port of `src/log.hpp` from drm-cxx @ `dc2915b`. Two knobs are exposed,
7//! matching the C++ contract:
8//!
9//! 1. [`set_log_level`] — process-wide threshold. Records at or below the
10//!    active level are emitted; the rest are dropped *before* formatting cost
11//!    is paid. A call below the threshold reduces to one relaxed atomic load
12//!    and a branch.
13//!
14//! 2. [`set_log_sink`] — process-wide redirection hook. The default sink
15//!    writes to stderr (errors, warnings) or stdout (info, debug) prefixed
16//!    with `[drm:LEVEL] `. Consumers can forward to `tracing`, journald,
17//!    syslog, or an in-memory test buffer. [`reset_log_sink`] restores the
18//!    default — the Rust spelling of the C++ `set_log_sink(nullptr)` path.
19//!
20//! # Deviations from the C++ original
21//!
22//! The C++ `set_log_sink` is documented thread-unsafe ("install once during
23//! application startup, before spinning up worker threads"), and its
24//! `set_log_level` is a plain unsynchronized write. Here both are genuinely
25//! thread-safe: the level is an [`AtomicU8`] and the sink lives behind an
26//! [`RwLock`] whose guard is released before the sink runs, so a sink that logs
27//! cannot deadlock and a sink that panics cannot wedge logging for the rest of
28//! the process. The startup-only discipline remains good practice; it is no
29//! longer load-bearing.
30//!
31//! # Untrusted content in log records
32//!
33//! Records are delivered to the sink verbatim — no escaping, no newline
34//! stripping. That matters here more than in a typical library, because some
35//! of what drmkit will log is attacker-influenced: EDID monitor names and
36//! serial strings come off the wire from whatever display is plugged in, and
37//! a hostile or simply malformed EDID can carry newlines or ANSI escape
38//! sequences. Written straight to a terminal by the built-in sink, those can
39//! forge log lines or move the cursor.
40//!
41//! The C++ original has the same property. Rather than diverge silently, the
42//! rule for the port is: **sanitize at the point the untrusted string enters a
43//! record, not in the sink.** Any crate that logs an EDID string must sanitize
44//! it before formatting -- none currently logs one -- because the sink cannot
45//! tell a monitor name from a driver name once they are one formatted string.
46//! Consumers who route this into a terminal and cannot trust their displays
47//! should install a sink that escapes control characters.
48//!
49//! # Initial level
50//!
51//! The C++ side bakes its default in at compile time via `DRM_CXX_LOG_LEVEL`.
52//! Here the initial level resolves once, on first use, from the
53//! `DRMKIT_LOG_LEVEL` environment variable (`silent`/`error`/`warn`/`info`/
54//! `debug`, case-insensitive, or the numeric discriminant, plus the aliases
55//! `off`/`none`, `warning` and `trace`). An unset or unparseable value yields
56//! [`LogLevel::Info`], matching the C++ default. An explicit [`set_log_level`]
57//! before first use always wins.
58//!
59//! # Example
60//!
61//! ```
62//! use drmkit_log::{LogLevel, log_warn, set_log_level};
63//!
64//! set_log_level(LogLevel::Warn);
65//! log_warn!("plane {} rejected modifier {:#x}", 31, 0x0100_0000_0000_0001_u64);
66//! ```
67//!
68//! # A note on the examples
69//!
70//! The level and the sink are process-global, and under edition 2024 every
71//! doctest in a crate is merged into one binary and run on parallel threads.
72//! Examples here therefore *demonstrate* the API but never assert on what a
73//! sink received: a concurrently running example would have replaced the sink
74//! or moved the threshold, and the assertion would fail for reasons having
75//! nothing to do with the code. The behavioral assertions live in the unit
76//! tests, which serialize on a mutex the way the C++ gtest fixture does.
77
78use std::fmt;
79use std::io::Write as _;
80use std::sync::atomic::{AtomicU8, Ordering};
81use std::sync::{Arc, OnceLock, RwLock};
82
83mod macros;
84
85#[cfg(feature = "tracing")]
86mod tracing_sink;
87
88#[cfg(feature = "tracing")]
89pub use tracing_sink::install_tracing_sink;
90
91/// Severity of a diagnostic record.
92///
93/// Discriminants match the C++ `drm::LogLevel` byte-for-byte, so a consumer
94/// bridging both libraries can cast between them. Ordering is by verbosity:
95/// a record is admitted when the active level is `>=` the record's level, so
96/// [`LogLevel::Silent`] admits nothing and [`LogLevel::Debug`] admits all.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
98#[repr(u8)]
99#[non_exhaustive]
100pub enum LogLevel {
101    /// Emit nothing.
102    Silent = 0,
103    /// Unrecoverable or consumer-visible failures.
104    Error = 1,
105    /// Recoverable problems and fallbacks taken.
106    Warn = 2,
107    /// Lifecycle milestones. The default.
108    Info = 3,
109    /// Per-frame and per-decision detail.
110    Debug = 4,
111}
112
113impl LogLevel {
114    /// Every level, ascending by verbosity. [`LogLevel::Silent`] is included.
115    pub const ALL: [Self; 5] = [
116        Self::Silent,
117        Self::Error,
118        Self::Warn,
119        Self::Info,
120        Self::Debug,
121    ];
122
123    /// The lowercase tag the default sink prints inside `[drm:...]`.
124    ///
125    /// [`LogLevel::Silent`] has no tag and yields `"silent"` only for
126    /// diagnostics; the built-in sink never prints it -- the level gate
127    /// rejects it, and the sink drops one that `log_channel!` passes through.
128    /// A custom sink may still receive it.
129    #[must_use]
130    pub const fn tag(self) -> &'static str {
131        match self {
132            Self::Silent => "silent",
133            Self::Error => "error",
134            Self::Warn => "warn",
135            Self::Info => "info",
136            Self::Debug => "debug",
137        }
138    }
139
140    /// Whether a record at `record` is admitted when `self` is the active
141    /// level.
142    ///
143    /// ```
144    /// use drmkit_log::LogLevel;
145    /// assert!(LogLevel::Warn.admits(LogLevel::Error));
146    /// assert!(!LogLevel::Warn.admits(LogLevel::Info));
147    /// assert!(!LogLevel::Silent.admits(LogLevel::Error));
148    /// ```
149    #[must_use]
150    pub const fn admits(self, record: Self) -> bool {
151        // Silent must reject everything, including a hypothetical Silent
152        // record; the explicit arm keeps that true independent of ordering.
153        match self {
154            Self::Silent => false,
155            _ => (self as u8) >= (record as u8),
156        }
157    }
158
159    /// Parse a level from a name (`"warn"`, case-insensitive) or from its
160    /// numeric discriminant (`"2"`). Returns `None` if neither matches.
161    #[must_use]
162    pub fn parse(text: &str) -> Option<Self> {
163        let trimmed = text.trim();
164        match trimmed.to_ascii_lowercase().as_str() {
165            "silent" | "off" | "none" | "0" => Some(Self::Silent),
166            "error" | "1" => Some(Self::Error),
167            "warn" | "warning" | "2" => Some(Self::Warn),
168            "info" | "3" => Some(Self::Info),
169            "debug" | "trace" | "4" => Some(Self::Debug),
170            _ => None,
171        }
172    }
173}
174
175impl fmt::Display for LogLevel {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        f.write_str(self.tag())
178    }
179}
180
181/// A sink receives the record's level and its fully formatted body — no level
182/// prefix, no trailing newline.
183///
184/// Sinks are invoked synchronously on the calling thread; a sink needing async
185/// dispatch or extra buffering arranges it internally. A sink must tolerate
186/// being called from any thread, and must not assume it is the only sink the
187/// process will ever install.
188pub type LogSink = Arc<dyn Fn(LogLevel, &str) + Send + Sync + 'static>;
189
190/// Sentinel meaning "initial level not yet resolved from the environment".
191/// Distinct from every real discriminant (0..=4).
192const LEVEL_UNRESOLVED: u8 = u8::MAX;
193
194static LEVEL: AtomicU8 = AtomicU8::new(LEVEL_UNRESOLVED);
195
196fn sink_slot() -> &'static RwLock<Option<LogSink>> {
197    static SINK: OnceLock<RwLock<Option<LogSink>>> = OnceLock::new();
198    SINK.get_or_init(|| RwLock::new(None))
199}
200
201/// The active process-wide threshold.
202///
203/// On the first call — and only then — an unset level resolves from
204/// `DRMKIT_LOG_LEVEL`, defaulting to [`LogLevel::Info`].
205#[must_use]
206pub fn log_level() -> LogLevel {
207    match LEVEL.load(Ordering::Relaxed) {
208        LEVEL_UNRESOLVED => {
209            let resolved = std::env::var("DRMKIT_LOG_LEVEL")
210                .ok()
211                .and_then(|raw| LogLevel::parse(&raw))
212                .unwrap_or(LogLevel::Info);
213            // Racing callers all compute the same value from the same
214            // environment, so a plain store is sufficient; whichever lands
215            // last stores an identical byte. A concurrent `set_log_level`
216            // may be overwritten by this resolution, which is why the
217            // documented ordering is "set before first use".
218            LEVEL
219                .compare_exchange(
220                    LEVEL_UNRESOLVED,
221                    resolved as u8,
222                    Ordering::Relaxed,
223                    Ordering::Relaxed,
224                )
225                .map_or_else(from_discriminant, |_| resolved)
226        }
227        raw => from_discriminant(raw),
228    }
229}
230
231/// Map a stored byte back to a level. Only ever called with a byte this module
232/// wrote, so the fallback is unreachable in practice — but it stays total
233/// rather than panicking inside the logger.
234fn from_discriminant(raw: u8) -> LogLevel {
235    match raw {
236        0 => LogLevel::Silent,
237        1 => LogLevel::Error,
238        2 => LogLevel::Warn,
239        4 => LogLevel::Debug,
240        _ => LogLevel::Info,
241    }
242}
243
244/// Set the process-wide threshold.
245pub fn set_log_level(level: LogLevel) {
246    LEVEL.store(level as u8, Ordering::Relaxed);
247}
248
249/// Install a sink, replacing whatever was there.
250///
251/// The previous sink is dropped once the last in-flight emit using it returns.
252///
253/// ```
254/// use std::sync::{Arc, Mutex};
255/// use drmkit_log::{LogLevel, log_error, set_log_level, set_log_sink};
256///
257/// let captured: Arc<Mutex<Vec<(LogLevel, String)>>> = Arc::new(Mutex::new(Vec::new()));
258/// let sink_target = Arc::clone(&captured);
259/// set_log_sink(move |level, message| {
260///     sink_target.lock().unwrap().push((level, message.to_owned()));
261/// });
262/// set_log_level(LogLevel::Debug);
263///
264/// log_error!("answer is {}", 42);
265/// // Not asserted on here: the sink is process-global and a concurrently
266/// // running example may have replaced it. See "A note on the examples".
267/// ```
268pub fn set_log_sink<F>(sink: F)
269where
270    F: Fn(LogLevel, &str) + Send + Sync + 'static,
271{
272    let mut slot = sink_slot().write().unwrap_or_else(|poisoned| {
273        // A sink panicked while the slot was being replaced. Logging must not
274        // become the reason the process dies, so take the inner value and
275        // carry on; the replacement we are about to install supersedes it.
276        sink_slot().clear_poison();
277        poisoned.into_inner()
278    });
279    *slot = Some(Arc::new(sink));
280}
281
282/// Restore the built-in stdio sink — the Rust spelling of the C++
283/// `set_log_sink(nullptr)` path.
284pub fn reset_log_sink() {
285    let mut slot = sink_slot().write().unwrap_or_else(|poisoned| {
286        sink_slot().clear_poison();
287        poisoned.into_inner()
288    });
289    *slot = None;
290}
291
292/// The installed sink, or `None` when the built-in stdio sink is active.
293///
294/// The C++ `get_log_sink()` always returns a callable because its default is
295/// itself a `std::function`. Here `None` distinguishes "default" from
296/// "consumer-installed", which the C++ side cannot express.
297#[must_use]
298pub fn log_sink() -> Option<LogSink> {
299    let slot = sink_slot()
300        .read()
301        .unwrap_or_else(std::sync::PoisonError::into_inner);
302    slot.clone()
303}
304
305/// The built-in sink: errors and warnings to stderr, info and debug to stdout,
306/// each line prefixed `[drm:LEVEL] `.
307///
308/// Write failures are swallowed. A logger that panics or aborts because stderr
309/// is a closed pipe would turn a diagnostic into an outage — the C++ side has
310/// the same behavior by way of ignoring `fprintf`'s return.
311fn default_sink(level: LogLevel, message: &str) {
312    match level {
313        LogLevel::Silent => {}
314        LogLevel::Error | LogLevel::Warn => {
315            let stream = std::io::stderr();
316            let mut handle = stream.lock();
317            let _ = writeln!(handle, "[drm:{}] {message}", level.tag());
318        }
319        LogLevel::Info | LogLevel::Debug => {
320            let stream = std::io::stdout();
321            let mut handle = stream.lock();
322            let _ = writeln!(handle, "[drm:{}] {message}", level.tag());
323        }
324    }
325}
326
327/// Format and deliver a record to the active sink, bypassing the level gate.
328///
329/// Not part of the stable surface — the `log_*!` macros call it after they
330/// have checked the level. Visible because macros expand in the caller's
331/// crate.
332#[doc(hidden)]
333pub fn __emit(level: LogLevel, args: fmt::Arguments<'_>) {
334    // Clone the Arc out and release the guard *before* running the sink: a
335    // sink that logs re-enters this function, and holding a read guard across
336    // that call risks deadlocking against a concurrent writer under
337    // std::sync::RwLock's unspecified fairness. It also keeps a panicking
338    // sink from poisoning the slot.
339    let installed = log_sink();
340
341    // `format_args!` cannot be reused, so materialize once. The C++ side hands
342    // its sink a formatted std::string for the same reason.
343    let message = fmt::format(args);
344    installed.map_or_else(
345        || default_sink(level, &message),
346        |sink| sink(level, &message),
347    );
348}
349
350#[cfg(test)]
351mod tests;