Skip to main content

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}