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;