nros_log/lib.rs
1//! Phase 88 — portable leveled-logging facade for nano-ros.
2//!
3//! See [`docs/roadmap/archived/phase-88-nros-log.md`](../../../docs/roadmap/archived/phase-88-nros-log.md)
4//! for the design and acceptance criteria.
5//!
6//! ## Layering
7//!
8//! - This crate carries only the portable types + dispatcher +
9//! macros + `PlatformSink`. No backend code.
10//! - Per-platform log delivery is the responsibility of each
11//! `nros-platform-<rtos>` crate, exposing
12//! `nros_platform_log_write` / `nros_platform_log_flush` via the
13//! `nros_platform_*` ABI (header at
14//! `packages/core/nros-platform-api/include/nros/platform.h`).
15//! - `PlatformSink` is the bridge: a single `LogSink` impl that
16//! forwards to the ABI. Apps that want fan-out (e.g.
17//! `Platform + /rosout`) compose a `&'static [&dyn LogSink]`
18//! manually and pass it to [`init`].
19//!
20//! ## Quick start
21//!
22//! ```ignore
23//! use nros_log::{Logger, Severity};
24//! use nros_log::{nros_info, nros_warn};
25//!
26//! static LOGGER: Logger = Logger::new("my_node");
27//!
28//! fn main() {
29//! nros_log::register_logger(&LOGGER);
30//! nros_log::init(nros_log::sinks::default());
31//! nros_info!(&LOGGER, "started; domain = {}", 42);
32//! }
33//! ```
34
35#![cfg_attr(not(feature = "std"), no_std)]
36#![deny(unsafe_op_in_unsafe_fn)]
37#![warn(missing_docs)]
38
39#[cfg(feature = "alloc")]
40extern crate alloc;
41
42// Phase 88.16.E — portable-atomic polyfill for CAS-less targets
43// (RISC-V `imc`, etc.). Feature unification: a consuming bare-metal
44// crate enables `unsafe-assume-single-core` / `critical-section` on
45// its own `portable-atomic` dep; native CAS targets get the
46// passthrough.
47use portable_atomic::{AtomicPtr, AtomicU8, Ordering};
48
49#[cfg(feature = "log-compat")]
50pub mod log_compat;
51pub mod macros;
52pub mod sinks;
53
54mod buffer;
55
56pub use buffer::{FormatBuffer, format_buffer_capacity};
57
58/// REP-2012 severity levels, mirroring `rcutils_log_severity_t`.
59///
60/// The integer representation is stable and part of the ABI for
61/// `nros_platform_log_write`. Lower value = more verbose.
62#[repr(u8)]
63#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
64pub enum Severity {
65 /// Per-instruction granularity. Off unless `max-level-trace` is
66 /// the active ceiling.
67 Trace = 0,
68 /// Diagnostic information useful while developing.
69 Debug = 1,
70 /// Normal operation events worth surfacing once.
71 Info = 2,
72 /// Unexpected but recoverable conditions.
73 Warn = 3,
74 /// Errors the caller should surface; the system continues.
75 Error = 4,
76 /// Unrecoverable — the system is about to abort.
77 Fatal = 5,
78}
79
80impl Severity {
81 /// Short uppercase label suitable for log-line rendering.
82 #[must_use]
83 pub const fn as_str(self) -> &'static str {
84 match self {
85 Self::Trace => "TRACE",
86 Self::Debug => "DEBUG",
87 Self::Info => "INFO",
88 Self::Warn => "WARN",
89 Self::Error => "ERROR",
90 Self::Fatal => "FATAL",
91 }
92 }
93
94 /// Stable `u8` discriminant for cross-ABI use.
95 #[must_use]
96 pub const fn as_u8(self) -> u8 {
97 self as u8
98 }
99}
100
101/// Reconstruct a [`Severity`] from its `u8` discriminant.
102///
103/// Returns `None` for `> 5`.
104#[must_use]
105pub const fn severity_from_u8(value: u8) -> Option<Severity> {
106 match value {
107 0 => Some(Severity::Trace),
108 1 => Some(Severity::Debug),
109 2 => Some(Severity::Info),
110 3 => Some(Severity::Warn),
111 4 => Some(Severity::Error),
112 5 => Some(Severity::Fatal),
113 _ => None,
114 }
115}
116
117/// Compile-time ceiling check used by the `nros_*!` macros.
118///
119/// Returns `true` iff `severity` is allowed under the configured
120/// `max-level-*` feature.
121#[must_use]
122pub const fn severity_enabled_at_compile_time(severity: Severity) -> bool {
123 if cfg!(feature = "max-level-off") {
124 return false;
125 }
126 let ceiling = compile_time_ceiling();
127 (severity as u8) >= (ceiling as u8)
128}
129
130const fn compile_time_ceiling() -> Severity {
131 if cfg!(feature = "max-level-trace") {
132 Severity::Trace
133 } else if cfg!(feature = "max-level-debug") {
134 Severity::Debug
135 } else if cfg!(feature = "max-level-info") {
136 Severity::Info
137 } else if cfg!(feature = "max-level-warn") {
138 Severity::Warn
139 } else if cfg!(feature = "max-level-error") {
140 Severity::Error
141 } else {
142 // No ceiling feature = treat as `max-level-trace`.
143 Severity::Trace
144 }
145}
146
147/// One log entry, handed to each [`LogSink`].
148///
149/// `message` is already formatted — sinks must NOT re-format.
150#[derive(Debug)]
151pub struct Record<'a> {
152 /// Severity of the record.
153 pub severity: Severity,
154 /// Name of the originating [`Logger`].
155 pub logger_name: &'a str,
156 /// Formatted message text (no trailing newline).
157 pub message: &'a str,
158 /// File the macro invocation came from (`core::file!()`).
159 pub file: &'static str,
160 /// Line within `file` (`core::line!()`).
161 pub line: u32,
162 /// Monotonic timestamp in nanoseconds. `0` if unavailable.
163 pub timestamp_ns: u64,
164}
165
166/// Backend a log record is delivered to.
167///
168/// Implementations must be `Sync` so the dispatcher can hold them
169/// in `&'static [&dyn LogSink]`. ISR-safety is per-impl — see the
170/// table in `docs/roadmap/archived/phase-88-nros-log.md`.
171pub trait LogSink: Sync {
172 /// Render `record`. Called only when the record's severity passes
173 /// both the compile-time ceiling AND the [`Logger`]'s runtime
174 /// threshold.
175 fn log(&self, record: &Record<'_>);
176
177 /// Optional flush hook (default no-op).
178 fn flush(&self) {}
179}
180
181/// A named logger with a runtime severity threshold.
182///
183/// Threshold defaults to [`Severity::Info`]. Use [`register_logger`]
184/// to publish a `'static Logger` so multiple call sites with the
185/// same name share the same threshold.
186pub struct Logger {
187 name: &'static str,
188 level: AtomicU8,
189}
190
191impl Logger {
192 /// `const`-construct with the default threshold ([`Severity::Info`]).
193 #[must_use]
194 pub const fn new(name: &'static str) -> Self {
195 Self {
196 name,
197 level: AtomicU8::new(Severity::Info as u8),
198 }
199 }
200
201 /// `const`-construct with an explicit threshold.
202 #[must_use]
203 pub const fn with_level(name: &'static str, level: Severity) -> Self {
204 Self {
205 name,
206 level: AtomicU8::new(level as u8),
207 }
208 }
209
210 /// Logger name (used as `Record::logger_name`).
211 #[must_use]
212 pub const fn name(&self) -> &'static str {
213 self.name
214 }
215
216 /// Current runtime threshold.
217 #[must_use]
218 pub fn level(&self) -> Severity {
219 severity_from_u8(self.level.load(Ordering::Relaxed)).unwrap_or(Severity::Info)
220 }
221
222 /// Update the runtime threshold.
223 pub fn set_level(&self, level: Severity) {
224 self.level.store(level as u8, Ordering::Relaxed);
225 }
226
227 /// Whether a record at `severity` would be emitted by this
228 /// logger AT RUNTIME.
229 #[must_use]
230 pub fn is_enabled(&self, severity: Severity) -> bool {
231 (severity as u8) >= self.level.load(Ordering::Relaxed)
232 }
233
234 /// Hand `record` to every registered sink, after the runtime
235 /// threshold check.
236 ///
237 /// Macros call this; user code should not.
238 pub fn dispatch(&self, record: &Record<'_>) {
239 if !self.is_enabled(record.severity) {
240 return;
241 }
242 dispatch_to_sinks(record);
243 }
244}
245
246// -----------------------------------------------------------------------------
247// Static intern table for `get_logger("name")`. Bounded; no alloc.
248// -----------------------------------------------------------------------------
249
250/// Maximum number of named loggers that can be registered via
251/// [`register_logger`]. Beyond this, [`get_logger`] returns
252/// [`DEFAULT_LOGGER`].
253pub const MAX_LOGGERS: usize = 32;
254
255/// Catch-all logger returned when the requested name is not
256/// registered (or the intern table is full).
257pub static DEFAULT_LOGGER: Logger = Logger::new("nros");
258
259mod intern {
260 use super::{AtomicPtr, Logger, MAX_LOGGERS, Ordering};
261
262 pub(super) struct InternTable {
263 slots: [AtomicPtr<Logger>; MAX_LOGGERS],
264 }
265
266 impl InternTable {
267 pub(super) const fn new() -> Self {
268 // `AtomicPtr::new` is `const` on both `core::sync::atomic`
269 // and `portable_atomic`, so we can initialise the array
270 // by repeating the call rather than naming a `const` —
271 // which clippy flags as interior-mutable.
272 #[allow(clippy::declare_interior_mutable_const)]
273 const NULL: AtomicPtr<Logger> = AtomicPtr::new(core::ptr::null_mut());
274 Self {
275 slots: [NULL; MAX_LOGGERS],
276 }
277 }
278
279 pub(super) fn lookup(&self, name: &str) -> Option<&'static Logger> {
280 for slot in &self.slots {
281 let ptr = slot.load(Ordering::Acquire);
282 if ptr.is_null() {
283 return None;
284 }
285 // SAFETY: pointer published via Release after the
286 // owner constructed a `'static Logger`. The Acquire
287 // load synchronizes.
288 let logger: &'static Logger = unsafe { &*ptr };
289 if logger.name() == name {
290 return Some(logger);
291 }
292 }
293 None
294 }
295
296 pub(super) fn insert(&self, logger: &'static Logger) -> Option<&'static Logger> {
297 if let Some(existing) = self.lookup(logger.name()) {
298 return Some(existing);
299 }
300 let ptr = logger as *const _ as *mut Logger;
301 for slot in &self.slots {
302 if slot
303 .compare_exchange(
304 core::ptr::null_mut(),
305 ptr,
306 Ordering::AcqRel,
307 Ordering::Acquire,
308 )
309 .is_ok()
310 {
311 return Some(logger);
312 }
313 let existing_ptr = slot.load(Ordering::Acquire);
314 if !existing_ptr.is_null() {
315 // SAFETY: same publication invariant as `lookup`.
316 let existing: &'static Logger = unsafe { &*existing_ptr };
317 if existing.name() == logger.name() {
318 return Some(existing);
319 }
320 }
321 }
322 None
323 }
324 }
325}
326
327static INTERN: intern::InternTable = intern::InternTable::new();
328
329/// Publish `logger` under its name so subsequent `get_logger`
330/// calls with that name return THIS reference.
331///
332/// On name collision returns the pre-existing entry (the input
333/// `logger` is NOT inserted). On a full table returns
334/// [`DEFAULT_LOGGER`].
335pub fn register_logger(logger: &'static Logger) -> &'static Logger {
336 INTERN.insert(logger).unwrap_or(&DEFAULT_LOGGER)
337}
338
339/// Look up a registered logger by name. Returns [`DEFAULT_LOGGER`]
340/// if none is registered (call [`register_logger`] for a `'static
341/// Logger` to publish one).
342///
343/// Total — every call returns a usable handle the macros can
344/// dispatch through.
345#[must_use]
346pub fn get_logger(name: &str) -> &'static Logger {
347 INTERN.lookup(name).unwrap_or(&DEFAULT_LOGGER)
348}
349
350// -----------------------------------------------------------------------------
351// Sink list. Set once at `init`; read every dispatch.
352// -----------------------------------------------------------------------------
353
354static SINKS_PTR: AtomicPtr<&'static [&'static dyn LogSink]> =
355 AtomicPtr::new(core::ptr::null_mut());
356
357/// Install the global sink list.
358///
359/// MUST be called at app startup BEFORE any record-emitting macro
360/// runs (otherwise the dispatch is a no-op — records are silently
361/// dropped). Calling `init` more than once swaps the list
362/// atomically; the previous pointer is leaked (intentional: the
363/// read path is lock-free so we can't safely free).
364///
365/// The sinks themselves must outlive the program (`'static`).
366pub fn init(sinks: &'static [&'static dyn LogSink]) {
367 // Indirect through a small `'static` cell so the read path
368 // dereferences a fat-pointer-sized slot rather than reading
369 // a wide pointer atomically.
370 #[cfg(feature = "alloc")]
371 {
372 let boxed: alloc::boxed::Box<&'static [&'static dyn LogSink]> =
373 alloc::boxed::Box::new(sinks);
374 let ptr = alloc::boxed::Box::into_raw(boxed);
375 SINKS_PTR.store(ptr, Ordering::Release);
376 }
377 #[cfg(not(feature = "alloc"))]
378 {
379 static CELL: SinkSlot = SinkSlot::new();
380 CELL.store(sinks);
381 SINKS_PTR.store(CELL.as_ptr(), Ordering::Release);
382 }
383}
384
385#[cfg(not(feature = "alloc"))]
386struct SinkSlot {
387 inner: core::cell::UnsafeCell<Option<&'static [&'static dyn LogSink]>>,
388}
389
390#[cfg(not(feature = "alloc"))]
391// SAFETY: only written from `init`, which the user contracts to call
392// once at startup before any concurrent reader exists.
393unsafe impl Sync for SinkSlot {}
394
395#[cfg(not(feature = "alloc"))]
396impl SinkSlot {
397 const fn new() -> Self {
398 Self {
399 inner: core::cell::UnsafeCell::new(None),
400 }
401 }
402 fn store(&self, sinks: &'static [&'static dyn LogSink]) {
403 // SAFETY: see Sync note above.
404 unsafe {
405 *self.inner.get() = Some(sinks);
406 }
407 }
408 fn as_ptr(&self) -> *mut &'static [&'static dyn LogSink] {
409 self.inner.get().cast()
410 }
411}
412
413fn dispatch_to_sinks(record: &Record<'_>) {
414 if recursion_guard_check_and_set() {
415 return;
416 }
417 let ptr = SINKS_PTR.load(Ordering::Acquire);
418 if !ptr.is_null() {
419 // SAFETY: `init` published a valid `'static` slice reference.
420 let sinks: &'static [&'static dyn LogSink] = unsafe { *ptr };
421 for sink in sinks {
422 sink.log(record);
423 }
424 }
425 recursion_guard_clear();
426}
427
428/// Flush every registered sink.
429pub fn flush() {
430 let ptr = SINKS_PTR.load(Ordering::Acquire);
431 if ptr.is_null() {
432 return;
433 }
434 // SAFETY: same invariant as `dispatch_to_sinks`.
435 let sinks: &'static [&'static dyn LogSink] = unsafe { *ptr };
436 for sink in sinks {
437 sink.flush();
438 }
439}
440
441// -----------------------------------------------------------------------------
442// Recursion guard — process-global single AtomicBool.
443//
444// Granularity is intentionally coarse (process-wide, not per-thread).
445// The guard exists to break a sink that triggers log() during write
446// — not to serialize concurrent loggers across threads. A thread
447// re-entering through its own sink loses its other in-flight
448// sinks for that call; a different thread logging concurrently is
449// also short-circuited momentarily. This is acceptable: the alt is
450// per-thread storage which doesn't exist uniformly across our
451// `no_std` targets (`thread_local!` requires `std`).
452// -----------------------------------------------------------------------------
453
454use portable_atomic::AtomicBool;
455static RECURSION_GUARD: AtomicBool = AtomicBool::new(false);
456
457fn recursion_guard_check_and_set() -> bool {
458 RECURSION_GUARD
459 .compare_exchange(false, true, Ordering::Acquire, Ordering::Acquire)
460 .is_err()
461}
462
463fn recursion_guard_clear() {
464 RECURSION_GUARD.store(false, Ordering::Release);
465}
466
467// -----------------------------------------------------------------------------
468// Tests (host-only).
469// -----------------------------------------------------------------------------
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn severity_round_trips_through_u8() {
477 for s in [
478 Severity::Trace,
479 Severity::Debug,
480 Severity::Info,
481 Severity::Warn,
482 Severity::Error,
483 Severity::Fatal,
484 ] {
485 assert_eq!(severity_from_u8(s.as_u8()), Some(s));
486 }
487 assert_eq!(severity_from_u8(99), None);
488 }
489
490 #[test]
491 fn logger_runtime_threshold_filters_below() {
492 let logger = Logger::with_level("test_thresh", Severity::Warn);
493 assert!(!logger.is_enabled(Severity::Info));
494 assert!(logger.is_enabled(Severity::Warn));
495 assert!(logger.is_enabled(Severity::Error));
496 logger.set_level(Severity::Debug);
497 assert!(logger.is_enabled(Severity::Info));
498 }
499
500 #[test]
501 fn unregistered_get_logger_returns_default() {
502 let l = get_logger("definitely-not-registered-99");
503 assert_eq!(l.name(), DEFAULT_LOGGER.name());
504 }
505
506 #[test]
507 fn registered_logger_round_trips_through_intern_table() {
508 static LOGGER: Logger = Logger::new("test_intern_round_trip");
509 let published = register_logger(&LOGGER);
510 assert_eq!(published.name(), LOGGER.name());
511 let looked_up = get_logger("test_intern_round_trip");
512 assert!(core::ptr::eq(published, looked_up));
513 }
514
515 #[test]
516 fn compile_time_ceiling_matches_enabled_feature() {
517 let expected = if cfg!(feature = "max-level-off") {
518 None
519 } else if cfg!(feature = "max-level-trace") {
520 Some(Severity::Trace)
521 } else if cfg!(feature = "max-level-debug") {
522 Some(Severity::Debug)
523 } else if cfg!(feature = "max-level-info") {
524 Some(Severity::Info)
525 } else if cfg!(feature = "max-level-warn") {
526 Some(Severity::Warn)
527 } else if cfg!(feature = "max-level-error") {
528 Some(Severity::Error)
529 } else {
530 // No ceiling feature = treat as `max-level-trace`.
531 Some(Severity::Trace)
532 };
533
534 for severity in [
535 Severity::Trace,
536 Severity::Debug,
537 Severity::Info,
538 Severity::Warn,
539 Severity::Error,
540 Severity::Fatal,
541 ] {
542 let enabled = expected.is_some_and(|ceiling| severity >= ceiling);
543 assert_eq!(severity_enabled_at_compile_time(severity), enabled);
544 }
545 }
546}