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