Skip to main content

cordis/
logger.rs

1//! Cordis logger — buffered, exporter-pluggable, fiber-aware logging.
2//!
3//! Mirrors the reference-kernel timer/logger primitive family (upstream-style
4//! semantics, native Rust shape): a [`LoggerService`] keeps a bounded ring of
5//! recent [`Message`]s, fans accepted messages out to registered
6//! [`Exporter`] sinks, and gates work behind a cheap [`LoggerService::enabled`]
7//! check so disabled paths never assemble arguments.
8//!
9//! Semantics:
10//! - **Kinds** ([`LogKind`]): `Error` / `Warn` / `Info` / `Debug`. Each kind
11//!   maps onto a [`LogLevel`] severity where lower = more severe; a message is
12//!   emitted when its kind severity is at or above (more severe than or equal
13//!   to) the effective threshold.
14//! - **Levels per name**: [`LoggerService::set_level`] pins a threshold for one
15//!   logger name; unlisted names fall back to [`LoggerService::set_default_level`]
16//!   (default: `Debug`, i.e. pass-everything).
17//! - **Per-fiber override**: rides the existing intercept channel — install
18//!   [`LoggerIntercept`] via `ctx.intercept(..)` and every write through that
19//!   context handle resolves it at write time. `name: None` matches every
20//!   logger; `level: Some(..)` replaces the effective threshold for matching
21//!   writes. Nothing on the hot structs changes; resolution uses the public
22//!   relaxed read.
23//! - **Exporters**: [`ExporterConfig`] carries a per-name level map and a
24//!   `max_length` text cap; unlisted names pass unrestricted. Registration
25//!   returns a [`Disposable`] whose disposal removes the sink (effect-owned).
26//! - **Rendering**: [`Message::render`] applies printf-style placeholders
27//!   (`%s %d %i %f %o %O %c %C %%`) when the leading argument is a format
28//!   string, otherwise joins arguments with spaces. `%c` colorizes with a
29//!   stable per-name hash over the ANSI16 palette; `%C` adds bold.
30//! - **Derived names**: [`hyphenate`] turns `CamelCase` into `kebab-case`;
31//!   [`derived_name`] applies it to a type's short name for logger naming.
32
33use std::collections::{HashMap, VecDeque};
34use std::fmt::Write as _;
35use std::sync::atomic::{AtomicU64, Ordering};
36use std::sync::Arc;
37
38use parking_lot::RwLock;
39
40use crate::context::Context;
41use crate::effect::Disposable;
42use crate::service::Service;
43
44// ---------------------------------------------------------------------------
45// Kinds and levels
46// ---------------------------------------------------------------------------
47
48/// Severity family of one log record. Maps onto [`LogLevel`] severities where
49/// `Error` is the most severe and `Debug` the least.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum LogKind {
52    Error,
53    Warn,
54    Info,
55    Debug,
56}
57
58impl LogKind {
59    /// Severity rank: lower = more severe. Used by threshold comparisons.
60    pub fn severity(self) -> u8 {
61        match self {
62            LogKind::Error => 0,
63            LogKind::Warn => 1,
64            LogKind::Info => 2,
65            LogKind::Debug => 3,
66        }
67    }
68
69    /// Stable lowercase label (`"error"`, `"warn"`, `"info"`, `"debug"`).
70    pub fn as_str(self) -> &'static str {
71        match self {
72            LogKind::Error => "error",
73            LogKind::Warn => "warn",
74            LogKind::Info => "info",
75            LogKind::Debug => "debug",
76        }
77    }
78}
79
80/// Numeric severity threshold. A kind passes when
81/// `kind.severity() <= level.0` (more-severe-or-equal). Ordering follows the
82/// ranks, so `LogLevel::INFO < LogLevel::DEBUG` and `INFO.allows(DEBUG)` is
83/// false while `INFO.allows(WARN)` is true.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
85pub struct LogLevel(u8);
86
87impl LogLevel {
88    pub const ERROR: LogLevel = LogLevel(0);
89    pub const WARN: LogLevel = LogLevel(1);
90    pub const INFO: LogLevel = LogLevel(2);
91    pub const DEBUG: LogLevel = LogLevel(3);
92
93    /// `true` when a message of `kind` meets this threshold.
94    pub fn allows(self, kind: LogKind) -> bool {
95        kind.severity() <= self.0
96    }
97}
98
99impl From<LogKind> for LogLevel {
100    fn from(kind: LogKind) -> Self {
101        LogLevel(kind.severity())
102    }
103}
104
105// ---------------------------------------------------------------------------
106// Arguments and messages
107// ---------------------------------------------------------------------------
108
109/// One printf argument. `From` impls keep call sites terse:
110/// `vec!["user".into(), 42.into()]`.
111#[derive(Debug, Clone, PartialEq)]
112pub enum LogArg {
113    String(String),
114    Integer(i64),
115    Unsigned(u64),
116    Float(f64),
117    Bool(bool),
118    /// Structured payload; `%o` renders compact JSON, `%O` pretty JSON.
119    Object(serde_json::Value),
120}
121
122impl From<&str> for LogArg {
123    fn from(v: &str) -> Self {
124        LogArg::String(v.to_string())
125    }
126}
127
128impl From<String> for LogArg {
129    fn from(v: String) -> Self {
130        LogArg::String(v)
131    }
132}
133
134impl From<i64> for LogArg {
135    fn from(v: i64) -> Self {
136        LogArg::Integer(v)
137    }
138}
139
140impl From<i32> for LogArg {
141    fn from(v: i32) -> Self {
142        LogArg::Integer(v as i64)
143    }
144}
145
146impl From<u64> for LogArg {
147    fn from(v: u64) -> Self {
148        LogArg::Unsigned(v)
149    }
150}
151
152impl From<usize> for LogArg {
153    fn from(v: usize) -> Self {
154        LogArg::Unsigned(v as u64)
155    }
156}
157
158impl From<f64> for LogArg {
159    fn from(v: f64) -> Self {
160        LogArg::Float(v)
161    }
162}
163
164impl From<bool> for LogArg {
165    fn from(v: bool) -> Self {
166        LogArg::Bool(v)
167    }
168}
169
170impl From<serde_json::Value> for LogArg {
171    fn from(v: serde_json::Value) -> Self {
172        LogArg::Object(v)
173    }
174}
175
176/// ANSI16 foreground palette: `30..=37` plus bright `90..=97`.
177const ANSI16: [u8; 16] = [30, 31, 32, 33, 34, 35, 36, 37, 90, 91, 92, 93, 94, 95, 96, 97];
178
179/// FNV-1a 64-bit — stable across processes and platforms, so a given logger
180/// name always lands on the same palette slot.
181fn name_color_code(name: &str) -> u8 {
182    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
183    for byte in name.bytes() {
184        hash ^= byte as u64;
185        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
186    }
187    ANSI16[(hash % ANSI16.len() as u64) as usize]
188}
189
190fn render_object(value: &serde_json::Value, pretty: bool) -> String {
191    if !pretty {
192        return value.to_string();
193    }
194    serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
195}
196
197/// Plain (uncolored) text of one argument. Objects render as compact JSON
198/// regardless of the specifier that reached them; scalars render naturally.
199fn arg_text(arg: &LogArg, pretty_objects: bool) -> String {
200    match arg {
201        LogArg::String(s) => s.clone(),
202        LogArg::Integer(v) => v.to_string(),
203        LogArg::Unsigned(v) => v.to_string(),
204        LogArg::Float(v) => v.to_string(),
205        LogArg::Bool(v) => v.to_string(),
206        LogArg::Object(v) => render_object(v, pretty_objects),
207    }
208}
209
210/// One buffered log record.
211#[derive(Debug, Clone, PartialEq)]
212pub struct Message {
213    /// Monotonic per-service sequence starting at 1.
214    pub sequence: u64,
215    /// Milliseconds since the Unix epoch at write time.
216    pub timestamp_ms: u64,
217    /// Logger name (usually the hyphenated component name).
218    pub name: String,
219    pub kind: LogKind,
220    /// Severity of `kind` at write time (kept numeric for exporters).
221    pub level: LogLevel,
222    pub args: Vec<LogArg>,
223    /// Identity of the emitting context's registration fiber. Kernel fibers
224    /// carry no user-facing name, so this is a stable per-fiber label derived
225    /// without touching any shared struct.
226    pub fiber_name: String,
227}
228
229impl Message {
230    /// Render the arguments to display text.
231    ///
232    /// When the leading argument is a `String` containing `%`, it is treated
233    /// as a printf-style format consuming subsequent arguments (`%s` string,
234    /// `%d`/`%i` integer, `%f` float, `%o` compact object, `%O` pretty object,
235    /// `%c` colorized with the per-name palette slot, `%C` bold colorized,
236    /// `%%` literal percent). Unknown specifiers and exhausted arguments stay
237    /// literal; unconsumed arguments are appended space-separated. Without a
238    /// format head, arguments are joined with single spaces.
239    pub fn render(&self) -> String {
240        let fmt_head = match self.args.first() {
241            Some(LogArg::String(s)) if s.contains('%') => Some(s.as_str()),
242            _ => None,
243        };
244
245        let mut out = String::new();
246        let Some(fmt) = fmt_head else {
247            for (i, arg) in self.args.iter().enumerate() {
248                if i > 0 {
249                    out.push(' ');
250                }
251                out.push_str(&arg_text(arg, false));
252            }
253            return out;
254        };
255
256        let mut next = 1usize;
257        let chars: Vec<char> = fmt.chars().collect();
258        let mut i = 0usize;
259        while i < chars.len() {
260            let ch = chars[i];
261            if ch != '%' {
262                out.push(ch);
263                i += 1;
264                continue;
265            }
266            i += 1;
267            let Some(&spec) = chars.get(i) else {
268                out.push('%');
269                break;
270            };
271            i += 1;
272            match spec {
273                '%' => out.push('%'),
274                's' | 'd' | 'i' | 'f' | 'o' | 'O' => match self.args.get(next) {
275                    Some(arg) => {
276                        out.push_str(&arg_text(arg, spec == 'O'));
277                        next += 1;
278                    }
279                    None => {
280                        out.push('%');
281                        out.push(spec);
282                    }
283                },
284                'c' | 'C' => match self.args.get(next) {
285                    Some(arg) => {
286                        let text = arg_text(arg, false);
287                        let code = name_color_code(&self.name);
288                        let _ = write!(out, "\x1b[{code}m{}\x1b[0m", text);
289                        if spec == 'C' {
290                            // Bold variant: rewrite the intro sequence.
291                            let body = &out[out.len() - text.len() - 5..out.len()];
292                            let _ = body;
293                            let colored =
294                                format!("\x1b[{code};1m{text}\x1b[0m");
295                            out.truncate(out.len() - text.len() - 5 - ("\x1b[0m".len()));
296                            let _ = colored;
297                            let _ = write!(out, "\x1b[{code};1m{text}\x1b[0m");
298                        }
299                        next += 1;
300                    }
301                    None => {
302                        out.push('%');
303                        out.push(spec);
304                    }
305                },
306                other => {
307                    out.push('%');
308                    out.push(other);
309                }
310            }
311        }
312
313        // Unconsumed trailing arguments are appended space-separated.
314        for arg in self.args.iter().skip(next) {
315            out.push(' ');
316            out.push_str(&arg_text(arg, false));
317        }
318        out
319    }
320}
321
322// ---------------------------------------------------------------------------
323// Exporters
324// ---------------------------------------------------------------------------
325
326/// Per-sink configuration: per-name thresholds (unlisted names pass freely)
327/// and a maximum rendered-text length handed to the sink.
328#[derive(Debug, Clone)]
329pub struct ExporterConfig {
330    /// Logger name → minimum severity the sink accepts.
331    pub levels: HashMap<String, LogLevel>,
332    /// Character cap applied to the rendered text per message.
333    pub max_length: usize,
334}
335
336impl Default for ExporterConfig {
337    fn default() -> Self {
338        Self {
339            levels: HashMap::new(),
340            max_length: 4096,
341        }
342    }
343}
344
345impl ExporterConfig {
346    pub fn new(levels: HashMap<String, LogLevel>, max_length: usize) -> Self {
347        Self { levels, max_length }
348    }
349
350    /// Threshold for one logger name; unlisted names pass unrestricted.
351    pub fn threshold(&self, name: &str) -> LogLevel {
352        self.levels
353            .get(name)
354            .copied()
355            .unwrap_or(LogLevel::DEBUG)
356    }
357
358    /// Char-boundary-safe truncation to `max_length` characters.
359    pub fn truncate(&self, text: String) -> String {
360        if self.max_length == 0 {
361            return String::new();
362        }
363        if text.chars().count() <= self.max_length {
364            return text;
365        }
366        text.chars().take(self.max_length).collect()
367    }
368}
369
370/// One log sink. Receives the structured [`Message`] plus the rendered text
371/// (already truncated to the sink's `max_length`). Exporters must not panic;
372/// they run inline on the writer's thread.
373pub trait Exporter: Send + Sync + 'static {
374    fn export(&self, message: &Message, text: &str);
375}
376
377struct ExportSlot {
378    exporter: Arc<dyn Exporter>,
379    config: ExporterConfig,
380    /// `Arc::as_ptr` address — the removal key carried by the disposable.
381    key: usize,
382}
383
384// ---------------------------------------------------------------------------
385// Per-fiber override via the intercept channel
386// ---------------------------------------------------------------------------
387
388/// Per-fiber logger override riding the existing intercept channel. Install
389/// with `ctx.intercept(LoggerIntercept { .. })`; every write through that
390/// context handle resolves it at write time.
391///
392/// - `name: None` matches every logger; `Some(n)` matches logger `n` only.
393/// - `level: Some(l)` replaces the effective threshold for matching writes
394///   (overriding both the per-name and default configuration).
395#[derive(Debug, Clone, Default, PartialEq, Eq)]
396pub struct LoggerIntercept {
397    pub name: Option<String>,
398    pub level: Option<LogLevel>,
399}
400
401impl Service for LoggerIntercept {
402    fn name(&self) -> &'static str {
403        "logger_intercept"
404    }
405}
406
407impl LoggerIntercept {
408    pub fn matches(&self, logger_name: &str) -> bool {
409        match &self.name {
410            Some(n) => n == logger_name,
411            None => true,
412        }
413    }
414}
415
416// ---------------------------------------------------------------------------
417// LoggerService
418// ---------------------------------------------------------------------------
419
420/// Bounded buffer of recent messages plus exporter fan-out.
421///
422/// Provide it once on the root context (`ctx.provide(LoggerService::new())`);
423/// writes go through the [`Context`] facade methods defined in this module or
424/// directly on the service. Every write path:
425/// 1. resolves the effective threshold (per-name → intercept → default),
426/// 2. bails BEFORE argument assembly when the kind fails the gate,
427/// 3. appends the message to the ring (dropping the oldest at capacity),
428/// 4. fans out to every exporter accepting `(name, kind)`.
429pub struct LoggerService {
430    buffer: RwLock<VecDeque<Arc<Message>>>,
431    capacity: usize,
432    seq: AtomicU64,
433    levels: RwLock<HashMap<String, LogLevel>>,
434    default_level: RwLock<LogLevel>,
435    exporters: RwLock<Vec<ExportSlot>>,
436}
437
438impl Service for LoggerService {
439    fn name(&self) -> &'static str {
440        "logger_service"
441    }
442}
443
444fn unix_now_ms() -> u64 {
445    std::time::SystemTime::now()
446        .duration_since(std::time::UNIX_EPOCH)
447        .map(|d| d.as_millis() as u64)
448        .unwrap_or(0)
449}
450
451/// Stable per-fiber label derived from the emitting context's registration
452/// fiber pointer — no shared struct carries fiber names, and this stays unique
453/// per context without any lifecycle coupling.
454fn fiber_label(ctx: &Context) -> String {
455    fiber_label_from_fiber(&ctx.fiber())
456}
457
458fn fiber_label_from_fiber(fiber: &Arc<crate::fiber::Fiber>) -> String {
459    let ptr = Arc::as_ptr(fiber) as *const () as usize;
460    format!("fiber-{ptr:x}")
461}
462
463impl Default for LoggerService {
464    fn default() -> Self {
465        Self::with_capacity(1000)
466    }
467}
468
469impl LoggerService {
470    pub fn new() -> Self {
471        Self::default()
472    }
473
474    pub fn with_capacity(capacity: usize) -> Self {
475        Self {
476            buffer: RwLock::new(VecDeque::with_capacity(capacity.min(1024))),
477            capacity: capacity.max(1),
478            seq: AtomicU64::new(0),
479            levels: RwLock::new(HashMap::new()),
480            default_level: RwLock::new(LogLevel::DEBUG),
481            exporters: RwLock::new(Vec::new()),
482        }
483    }
484
485    /// Pin the threshold for one logger name.
486    pub fn set_level(&self, name: impl Into<String>, level: LogLevel) {
487        self.levels.write().insert(name.into(), level);
488    }
489
490    /// Clear a pinned per-name threshold.
491    pub fn clear_level(&self, name: &str) {
492        self.levels.write().remove(name);
493    }
494
495    /// Threshold for names without a pinned entry. Default: `Debug` (everything).
496    pub fn set_default_level(&self, level: LogLevel) {
497        *self.default_level.write() = level;
498    }
499
500    /// Effective threshold for `name` on `ctx`: per-name pin, else the
501    /// intercept override when one matches, else the service default.
502    fn effective_threshold(&self, ctx: &Context, name: &str) -> LogLevel {
503        let mut level = *self.default_level.read();
504        if let Some(pinned) = self.levels.read().get(name) {
505            level = *pinned;
506        }
507        if let Some(intercept) = ctx.get_relaxed::<LoggerIntercept>() {
508            if intercept.matches(name) {
509                if let Some(forced) = intercept.level {
510                    level = forced;
511                }
512            }
513        }
514        level
515    }
516
517    /// Cheap gate: would a `kind` write from `name` on `ctx` be recorded?
518    /// Callers assembling expensive arguments SHOULD consult this first (or
519    /// use [`LoggerService::log_with`], which applies it automatically).
520    pub fn enabled(&self, ctx: &Context, name: &str, kind: LogKind) -> bool {
521        self.effective_threshold(ctx, name).allows(kind)
522    }
523
524    /// Core write path. `assemble` runs ONLY when the gate accepts — disabled
525    /// paths never pay for argument construction.
526    pub fn log_with<F>(&self, ctx: &Arc<Context>, name: &str, kind: LogKind, assemble: F)
527    where
528        F: FnOnce() -> Vec<LogArg>,
529    {
530        if !self.enabled(ctx, name, kind) {
531            return;
532        }
533        self.emit(fiber_label(ctx), name, kind, assemble());
534    }
535
536    /// Eager variant: arguments are already built. Still gated.
537    pub fn log(&self, ctx: &Arc<Context>, name: &str, kind: LogKind, args: Vec<LogArg>) {
538        self.log_with(ctx, name, kind, || args)
539    }
540
541    /// Write path for callers holding only `&Context`: the owning fiber is
542    /// passed explicitly (via [`Context::fiber`]) instead of recovered from
543    /// an `Arc<Context>` handle. Same gating as [`Self::log_with`].
544    pub fn log_ref<F>(
545        &self,
546        fiber: &Arc<crate::fiber::Fiber>,
547        scope: &Context,
548        name: &str,
549        kind: LogKind,
550        assemble: F,
551    ) where
552        F: FnOnce() -> Vec<LogArg>,
553    {
554        if !self.effective_threshold(scope, name).allows(kind) {
555            return;
556        }
557        self.emit(fiber_label_from_fiber(fiber), name, kind, assemble());
558    }
559
560    pub fn error(&self, ctx: &Arc<Context>, name: &str, args: Vec<LogArg>) {
561        self.log(ctx, name, LogKind::Error, args)
562    }
563
564    pub fn warn(&self, ctx: &Arc<Context>, name: &str, args: Vec<LogArg>) {
565        self.log(ctx, name, LogKind::Warn, args)
566    }
567
568    pub fn info(&self, ctx: &Arc<Context>, name: &str, args: Vec<LogArg>) {
569        self.log(ctx, name, LogKind::Info, args)
570    }
571
572    pub fn debug(&self, ctx: &Arc<Context>, name: &str, args: Vec<LogArg>) {
573        self.log(ctx, name, LogKind::Debug, args)
574    }
575
576    fn emit(&self, fiber_name: String, name: &str, kind: LogKind, args: Vec<LogArg>) {
577        let sequence = self.seq.fetch_add(1, Ordering::Relaxed) + 1;
578        let message = Arc::new(Message {
579            sequence,
580            timestamp_ms: unix_now_ms(),
581            name: name.to_string(),
582            kind,
583            level: LogLevel::from(kind),
584            args,
585            fiber_name,
586        });
587
588        {
589            let mut buffer = self.buffer.write();
590            buffer.push_back(message.clone());
591            while buffer.len() > self.capacity {
592                buffer.pop_front();
593            }
594        }
595
596        // Snapshot the slots under a short lock; sinks run unlocked so an
597        // exporter that logs back cannot deadlock the registry.
598        let slots: Vec<(Arc<dyn Exporter>, ExporterConfig)> = self
599            .exporters
600            .read()
601            .iter()
602            .filter(|slot| slot.config.threshold(name).allows(kind))
603            .map(|slot| (slot.exporter.clone(), slot.config.clone()))
604            .collect();
605        if slots.is_empty() {
606            return;
607        }
608        let rendered = message.render();
609        for (exporter, config) in slots {
610            let text = config.truncate(rendered.clone());
611            exporter.export(&message, &text);
612        }
613    }
614
615    /// Register a sink; the returned [`Disposable`] removes it on disposal.
616    pub fn register(
617        self: &Arc<Self>,
618        exporter: Arc<dyn Exporter>,
619        config: ExporterConfig,
620    ) -> Box<dyn Disposable> {
621        let key = Arc::as_ptr(&exporter) as *const () as usize;
622        self.exporters.write().push(ExportSlot {
623            exporter,
624            config,
625            key,
626        });
627        let weak = Arc::downgrade(self);
628        Box::new(move || {
629            if let Some(service) = weak.upgrade() {
630                service.exporters.write().retain(|slot| slot.key != key);
631            }
632        })
633    }
634
635    /// Snapshot of the buffered messages, oldest first. Clones `Arc`s only.
636    pub fn snapshot(&self) -> Vec<Arc<Message>> {
637        self.buffer.read().iter().cloned().collect()
638    }
639
640    pub fn buffered_len(&self) -> usize {
641        self.buffer.read().len()
642    }
643}
644
645// ---------------------------------------------------------------------------
646// Derived names
647// ---------------------------------------------------------------------------
648
649/// `CamelCase` → `kebab-case`. Handles acronym heads (`HTTPServer` →
650/// `http-server`), preserves digits, leaves kebab input untouched.
651pub fn hyphenate(input: &str) -> String {
652    let chars: Vec<char> = input.chars().collect();
653    let mut out = String::with_capacity(input.len() + 4);
654    for (i, &ch) in chars.iter().enumerate() {
655        let prev = if i > 0 { chars[i - 1] } else { '\0' };
656        let next = chars.get(i + 1).copied();
657        let boundary = ch.is_uppercase()
658            && i > 0
659            && prev != '-'
660            && (!prev.is_uppercase() || matches!(next, Some(n) if n.is_ascii_lowercase()));
661        if boundary {
662            out.push('-');
663        }
664        out.push(ch.to_ascii_lowercase());
665    }
666    out
667}
668
669/// Hyphenated short name of a type: the last `::` segment of
670/// `std::any::type_name`, kebab-cased. Intended for logger naming:
671/// `ctx.info(&derived_name::<Self>(), ..)`.
672pub fn derived_name<T: ?Sized>() -> String {
673    let full = std::any::type_name::<T>();
674    let short = full.rsplit("::").next().unwrap_or(full);
675    hyphenate(short)
676}
677
678// ---------------------------------------------------------------------------
679// Context facade — additive only; no hot-struct changes
680// ---------------------------------------------------------------------------
681
682impl Context {
683    /// Gated write with lazy argument assembly through the provided
684    /// [`LoggerService`] (no-op when absent). The intercept channel is
685    /// consulted on `self`, so per-fiber overrides apply to child contexts.
686    pub fn log_with<F>(&self, name: &str, kind: LogKind, assemble: F)
687    where
688        F: FnOnce() -> Vec<LogArg>,
689    {
690        if let Some(logger) = self.get::<LoggerService>() {
691            logger.log_ref(&self.fiber(), self, name, kind, assemble);
692        }
693    }
694
695    /// Gated write with pre-built arguments.
696    pub fn log(&self, name: &str, kind: LogKind, args: Vec<LogArg>) {
697        self.log_with(name, kind, || args)
698    }
699
700    pub fn info(&self, name: &str, args: Vec<LogArg>) {
701        self.log(name, LogKind::Info, args)
702    }
703
704    pub fn warn(&self, name: &str, args: Vec<LogArg>) {
705        self.log(name, LogKind::Warn, args)
706    }
707
708    pub fn debug(&self, name: &str, args: Vec<LogArg>) {
709        self.log(name, LogKind::Debug, args)
710    }
711
712    pub fn error(&self, name: &str, args: Vec<LogArg>) {
713        self.log(name, LogKind::Error, args)
714    }
715}
716
717// ---------------------------------------------------------------------------
718// Tests
719// ---------------------------------------------------------------------------
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724    use std::sync::atomic::AtomicBool;
725    use std::sync::Mutex;
726
727    struct CountingExporter {
728        seen: Mutex<Vec<String>>,
729    }
730
731    impl CountingExporter {
732        fn new() -> Arc<Self> {
733            Arc::new(Self {
734                seen: Mutex::new(Vec::new()),
735            })
736        }
737
738        fn texts(&self) -> Vec<String> {
739            self.seen.lock().unwrap().clone()
740        }
741    }
742
743    impl Exporter for CountingExporter {
744        fn export(&self, _message: &Message, text: &str) {
745            self.seen.lock().unwrap().push(text.to_string());
746        }
747    }
748
749    fn service() -> Arc<LoggerService> {
750        Arc::new(LoggerService::new())
751    }
752
753    #[test]
754    fn buffer_bounded_at_capacity_snapshot_reads() {
755        let logger = LoggerService::with_capacity(4);
756        let ctx = Context::new_root();
757        for i in 0..10u64 {
758            logger.log(&ctx, "ring", LogKind::Info, vec![i.into()]);
759        }
760
761        assert_eq!(logger.buffered_len(), 4);
762        let snap = logger.snapshot();
763        assert_eq!(snap.len(), 4);
764        // Oldest evicted: retention window is the LAST four sequences.
765        let seqs: Vec<u64> = snap.iter().map(|m| m.sequence).collect();
766        assert_eq!(seqs, vec![7, 8, 9, 10]);
767        // Snapshot reads are detached views: further writes leave it alone.
768        logger.log(&ctx, "ring", LogKind::Info, vec![99u64.into()]);
769        assert_eq!(snap.len(), 4);
770        assert_eq!(logger.buffered_len(), 4);
771        assert_eq!(logger.snapshot()[3].args[0], LogArg::Unsigned(99));
772    }
773
774    #[tokio::test]
775    async fn exporter_disposal_removes_sink() {
776        let logger = service();
777        let ctx = Context::new_root();
778        let sink = CountingExporter::new();
779        let handle = logger.register(
780            sink.clone(),
781            ExporterConfig {
782                max_length: 64,
783                ..ExporterConfig::default()
784            },
785        );
786
787        logger.info(&ctx, "sink-test", vec!["one".into()]);
788        assert_eq!(sink.texts(), vec!["one".to_string()]);
789
790        handle.dispose();
791        logger.info(&ctx, "sink-test", vec!["two".into()]);
792        // Disposed sinks stop receiving; buffer still records.
793        assert_eq!(sink.texts(), vec!["one".to_string()]);
794        assert_eq!(logger.buffered_len(), 2);
795    }
796
797    #[test]
798    fn level_routing_per_name_with_default_fallback() {
799        let logger = service();
800        let ctx = Context::new_root();
801
802        // Default fallback: Warn admits warn/error, drops info/debug.
803        logger.set_default_level(LogLevel::WARN);
804        assert!(logger.enabled(&ctx, "quiet", LogKind::Warn));
805        assert!(!logger.enabled(&ctx, "quiet", LogKind::Info));
806
807        // Per-name pin overrides the default for that name only.
808        logger.set_level("loud", LogLevel::DEBUG);
809        assert!(logger.enabled(&ctx, "loud", LogKind::Debug));
810        assert!(!logger.enabled(&ctx, "still-quiet", LogKind::Debug));
811
812        // Routing is observable in the buffer.
813        logger.debug(&ctx, "loud", vec!["kept".into()]);
814        logger.debug(&ctx, "still-quiet", vec!["dropped".into()]);
815        let names: Vec<String> = logger
816            .snapshot()
817            .iter()
818            .map(|m| m.name.clone())
819            .collect();
820        assert_eq!(names, vec!["loud".to_string()]);
821
822        // Raising the default flips previously-dropped names.
823        logger.set_default_level(LogLevel::DEBUG);
824        logger.debug(&ctx, "still-quiet", vec!["kept-now".into()]);
825        assert_eq!(logger.snapshot().len(), 2);
826
827        // Clearing the pin falls back again.
828        logger.clear_level("loud");
829        assert_eq!(
830            logger.effective_threshold(&ctx, "loud"),
831            LogLevel::DEBUG
832        );
833    }
834
835    #[test]
836    fn printf_placeholders_format_correctly() {
837        let logger = service();
838        let ctx = Context::new_root();
839
840        logger.info(
841            &ctx,
842            "fmt",
843            vec![
844                "%s=%d %i %f %o %% %q trailing".into(),
845                "user".into(),
846                42i64.into(),
847                (-7i64).into(),
848                2.5f64.into(),
849                serde_json::json!({"a": 1}).into(),
850                "extra".into(),
851            ],
852        );
853        let text = logger.snapshot()[0].render();
854        let pretty_b = serde_json::to_string_pretty(&serde_json::json!({"b": [1]})).unwrap();
855        assert_eq!(
856            text,
857            format!(
858                "user=42 -7 2.5 {{\"a\":1}} % %q trailing extra"
859            )
860        );
861
862        // %O renders pretty JSON; %% collapses; %c colorizes with the
863        // name-stable palette slot.
864        logger.debug(
865            &ctx,
866            "fmt",
867            vec![
868                "%O|%c|done".into(),
869                serde_json::json!({"b": [1]}).into(),
870                "tag".into(),
871            ],
872        );
873        let colored = logger.snapshot()[1].render();
874        assert!(colored.starts_with(&pretty_b[..pretty_b.len() - 1]));
875        assert!(colored.contains("\x1b["));
876        assert!(colored.ends_with("|done"));
877
878        // Exhausted arguments leave the specifier literal.
879        let bare = Message {
880            sequence: 0,
881            timestamp_ms: 0,
882            name: "fmt".into(),
883            kind: LogKind::Info,
884            level: LogLevel::INFO,
885            args: vec!["%d items".into()],
886            fiber_name: String::new(),
887        };
888        assert_eq!(bare.render(), "%d items");
889
890        // Without a format head, arguments join with spaces.
891        let plain = Message {
892            sequence: 0,
893            timestamp_ms: 0,
894            name: "fmt".into(),
895            kind: LogKind::Info,
896            level: LogLevel::INFO,
897            args: vec!["a".into(), 1i64.into(), true.into()],
898            fiber_name: String::new(),
899        };
900        assert_eq!(plain.render(), "a 1 true");
901    }
902
903    #[test]
904    fn enabled_gate_skips_arg_assembly() {
905        let logger = service();
906        let ctx = Context::new_root();
907        logger.set_default_level(LogLevel::WARN);
908
909        let assembled = AtomicBool::new(false);
910        let flip = |flag: &AtomicBool| flag.store(true, Ordering::SeqCst);
911
912        // Disabled path: the assembler never runs.
913        logger.log_with(&ctx, "gate", LogKind::Debug, || {
914            flip(&assembled);
915            vec!["expensive".into()]
916        });
917        assert!(!assembled.load(Ordering::SeqCst));
918        assert!(logger.snapshot().is_empty());
919
920        // Enabled path: assembler runs exactly once and the record lands.
921        logger.warn(&ctx, "gate", vec![]);
922        logger.log_with(&ctx, "gate", LogKind::Warn, || {
923            flip(&assembled);
924            vec!["cheap-enough".into()]
925        });
926        assert!(assembled.load(Ordering::SeqCst));
927        assert_eq!(logger.buffered_len(), 2);
928
929        // The public gate agrees with the routing decision.
930        assert!(logger.enabled(&ctx, "gate", LogKind::Warn));
931        assert!(!logger.enabled(&ctx, "gate", LogKind::Info));
932    }
933
934    #[tokio::test]
935    async fn logger_intercept_overrides_level() {
936        let root = Context::new_root();
937        root.provide(LoggerService::new());
938
939        // Baseline on the root handle: default Debug passes everything.
940        let logger = root.get::<LoggerService>().unwrap();
941        root.debug("svc", vec!["root-passes".into()]);
942        assert_eq!(logger.buffered_len(), 1);
943
944        // Fiber-scoped override: only "svc" drops to Error-only on the child.
945        let child = root.intercept(LoggerIntercept {
946            name: Some("svc".into()),
947            level: Some(LogLevel::ERROR),
948        });
949        child.debug("svc", vec!["suppressed".into()]);
950        child.error("svc", vec!["survives".into()]);
951        // Non-matching names keep the ambient configuration.
952        child.debug("other", vec!["other-passes".into()]);
953
954        let msgs = logger.snapshot();
955        let texts: Vec<&str> = msgs
956            .iter()
957            .map(|m| arg_text(&m.args[0], false).leak() as &str)
958            .collect();
959        assert_eq!(texts, ["root-passes", "survives", "other-passes"]);
960
961        // Wildcard intercept: name=None forces the level for every logger.
962        let wild = root.intercept(LoggerIntercept {
963            name: None,
964            level: Some(LogLevel::ERROR),
965        });
966        wild.info("anything", vec!["blocked".into()]);
967        assert_eq!(logger.buffered_len(), 3);
968    }
969
970    #[test]
971    fn hyphenate_and_derived_names() {
972        assert_eq!(hyphenate("ToolsService"), "tools-service");
973        assert_eq!(hyphenate("HTTPServer"), "http-server");
974        assert_eq!(hyphenate("already-kebab"), "already-kebab");
975        assert_eq!(hyphenate("V2Plan"), "v2-plan");
976        assert_eq!(derived_name::<LoggerService>(), "logger-service");
977    }
978
979    #[test]
980    fn exporter_config_truncates_and_filters() {
981        let mut levels = HashMap::new();
982        levels.insert("db".to_string(), LogLevel::WARN);
983        let config = ExporterConfig::new(levels, 5);
984        assert_eq!(config.threshold("db"), LogLevel::WARN);
985        assert_eq!(config.threshold("other"), LogLevel::DEBUG);
986        assert_eq!(config.truncate("abcdefg".into()), "abcde");
987        assert_eq!(config.truncate("ok".into()), "ok");
988        assert_eq!(ExporterConfig::default().truncate("héllo".into()), "héllo");
989
990        let wide = ExporterConfig {
991            max_length: 0,
992            ..ExporterConfig::default()
993        };
994        assert_eq!(wide.truncate("anything".into()), "");
995    }
996
997    #[test]
998    fn colorization_is_stable_per_name() {
999        let mk = |name: &str| {
1000            Message {
1001                sequence: 0,
1002                timestamp_ms: 0,
1003                name: name.into(),
1004                kind: LogKind::Info,
1005                level: LogLevel::INFO,
1006                args: vec!["%c".into(), "payload".into()],
1007                fiber_name: String::new(),
1008            }
1009            .render()
1010        };
1011        let a1 = mk("alpha");
1012        let a2 = mk("alpha");
1013        assert_eq!(a1, a2);
1014        assert!(a1.contains("\x1b["));
1015        assert!(a1.contains("payload"));
1016        // Bold variant (%C) differs from plain (%c).
1017        let bold = Message {
1018            sequence: 0,
1019            timestamp_ms: 0,
1020            name: "alpha".into(),
1021            kind: LogKind::Info,
1022            level: LogLevel::INFO,
1023            args: vec!["%C".into(), "payload".into()],
1024            fiber_name: String::new(),
1025        }
1026        .render();
1027        assert_ne!(bold, a1);
1028    }
1029}