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;