Skip to main content

linesmith_core/
logging.rs

1//! In-crate structured-logging facade.
2//!
3//! The statusline is a single-shot, stderr-free-by-default process; a
4//! full `tracing`/`log` stack would bloat the binary for a narrow
5//! diagnostic surface. This module exposes level-gated emission
6//! through two macros, [`lsm_warn!`] and [`lsm_debug!`], controlled by
7//! the [`LINESMITH_LOG`](ENV_VAR) env var.
8//!
9//! Default level is [`Level::Warn`] so genuine drops (cache-write
10//! failures, lock-write failures) surface without opt-in. Silent
11//! [`Ok(None)`] hide paths in rate-limit segments log at
12//! [`Level::Debug`] and require `LINESMITH_LOG=debug` to appear.
13//!
14//! Structural failures that always warrant a user-visible signal
15//! (segment render panics, fatal plugin init errors) emit through
16//! [`lsm_error!`], which bypasses the level gate. `LINESMITH_LOG=off`
17//! quiets chatter; it is not a silence-all-signals switch, because a
18//! broken statusline needs a stderr line the user can grep. Scripts
19//! that want absolute silence can `2>/dev/null`.
20//!
21//! Output format on stderr: `linesmith [<level>]: <message>`. The
22//! TUI installs a [`CapturedSink`] in place of [`StderrSink`] for
23//! the duration of the alt-screen so macro emissions don't paint
24//! over the rendered frame; captured entries use the compact
25//! `[<level>] <message>` form (no `linesmith` prefix, no colon)
26//! since the surrounding UI provides context.
27//!
28//! Not a general-purpose logger: no filtering by target, no
29//! structured fields. Add those when a call site needs them.
30
31use std::cell::RefCell;
32use std::collections::VecDeque;
33use std::io::{self, Write};
34use std::mem;
35use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
36use std::sync::{Arc, Mutex, OnceLock, RwLock};
37use std::time::Instant;
38
39/// Logger severity. Variants are ordered `Off < Warn < Debug` so a
40/// call fires when its own level is `<=` the configured level.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
42pub enum Level {
43    Off = 0,
44    /// Default. Real data-loss drops (cache/lock write failures,
45    /// gix-discovery errors) surface without opt-in.
46    Warn = 1,
47    /// Opt-in verbosity. Every `Ok(None)` hide path in a segment
48    /// emits a one-line diagnostic naming the gate that triggered.
49    Debug = 2,
50}
51
52// The `AtomicU8` store round-trips raw discriminants through
53// `set_level` / `level()`. Pin the layout so a reorder of the variants
54// becomes a compile error instead of a silent flip.
55const _: () = assert!(Level::Off as u8 == 0);
56const _: () = assert!(Level::Warn as u8 == 1);
57const _: () = assert!(Level::Debug as u8 == 2);
58
59pub const ENV_VAR: &str = "LINESMITH_LOG";
60
61const DEFAULT_LEVEL: Level = Level::Warn;
62static LEVEL: AtomicU8 = AtomicU8::new(DEFAULT_LEVEL as u8);
63
64/// Apply `raw` (the snapshotted `LINESMITH_LOG` value, `None` if
65/// unset) to the process-wide level. On an unrecognized value the
66/// logger resets to [`DEFAULT_LEVEL`] and writes one line to
67/// `warn_sink`; the driver threads the injected CLI stderr through so
68/// tests and embedders don't see ambient stderr pollution.
69pub fn apply(raw: Option<&str>, warn_sink: &mut dyn Write) {
70    match decide_init(raw) {
71        InitDecision::Keep => {}
72        InitDecision::Set(l) => set_level(l),
73        InitDecision::Warn(bad) => {
74            let _ = writeln!(
75                warn_sink,
76                "linesmith: {ENV_VAR}={bad:?} unrecognized; using default ({DEFAULT_LEVEL:?})"
77            );
78            set_level(DEFAULT_LEVEL);
79        }
80    }
81}
82
83/// Pure decision form of [`apply`]: what to do given the raw env-var
84/// read. Split out so the decision tree is unit-testable without
85/// touching the process env.
86#[derive(Debug, PartialEq, Eq)]
87pub(crate) enum InitDecision<'a> {
88    /// Env var unset; leave the logger at its prior level.
89    Keep,
90    /// Env var parsed; call [`set_level`] with this value.
91    Set(Level),
92    /// Env var set but unparseable.
93    Warn(&'a str),
94}
95
96pub(crate) fn decide_init(raw: Option<&str>) -> InitDecision<'_> {
97    match raw {
98        None => InitDecision::Keep,
99        Some(s) => match Level::parse(s) {
100            Some(l) => InitDecision::Set(l),
101            None => InitDecision::Warn(s),
102        },
103    }
104}
105
106/// Override the process-wide level. Exposed for tests and embedders
107/// that want to pick a level without touching the env.
108pub fn set_level(l: Level) {
109    LEVEL.store(l as u8, Ordering::Relaxed);
110}
111
112#[must_use]
113pub fn level() -> Level {
114    from_u8(LEVEL.load(Ordering::Relaxed))
115}
116
117/// Reconstruct a [`Level`] from its stored byte. An out-of-range byte
118/// is a store bug; `debug_assert!` surfaces it in tests while release
119/// builds saturate to the verbose end so nothing is accidentally
120/// suppressed.
121fn from_u8(n: u8) -> Level {
122    match n {
123        0 => Level::Off,
124        1 => Level::Warn,
125        2 => Level::Debug,
126        _ => {
127            debug_assert!(false, "logging::LEVEL holds out-of-range byte {n}");
128            Level::Debug
129        }
130    }
131}
132
133/// `true` when `at_least` or a more verbose level is active. A call
134/// fires when its own level is `<=` the currently configured level.
135#[must_use]
136pub fn is_enabled(at_least: Level) -> bool {
137    level() >= at_least
138}
139
140/// Pluggable destination for diagnostic emissions. The default
141/// [`StderrSink`] preserves the `linesmith [<level>]: <msg>` format
142/// external scripts grep for; the TUI swaps in a [`CapturedSink`]
143/// for the duration of the alt-screen so macro emissions land in
144/// the warnings panel instead of corrupting the painted frame.
145///
146/// `&self` (not `&mut self`) so a sink instance can be shared via
147/// `Arc<dyn LogSink>`. The `Send + Sync` bound is what `Arc<dyn _>`
148/// requires and lets background plugin emitters share the slot
149/// safely with the render thread. The active-sink slot is an
150/// `RwLock`: emits take read locks (concurrent) and `install_sink`
151/// takes the write lock, which waits for in-flight emits to drain
152/// before swapping. This applies only to dispatches that go
153/// through [`emit`] / [`emit_error`] — a thread that holds its own
154/// `Arc<dyn LogSink>` (the prior returned by `install_sink`, or a
155/// future plugin handle) can still drive that sink directly after
156/// a swap.
157///
158/// Implementations must not call back into [`emit`] / [`emit_error`]
159/// or the `lsm_warn!` / `lsm_debug!` / `lsm_error!` macros from
160/// within their own `emit` / `emit_error`. The slot's read lock is
161/// held across the dispatch, and `std::sync::RwLock`'s reentrancy
162/// is platform-defined — a recursive read can deadlock against a
163/// pending `install_sink` on writer-preferring runtimes (musl,
164/// Windows SRW). lsm-261p tracks evaluating a fair RwLock so this
165/// invariant can relax.
166pub trait LogSink: Send + Sync {
167    /// Emit a level-gated diagnostic. The caller has already
168    /// confirmed the level is enabled — the sink writes
169    /// unconditionally. Implementations defensively no-op on
170    /// `Level::Off` rather than relying on the caller; the macros
171    /// never pass `Off`, but `emit()` is `pub`.
172    fn emit(&self, lvl: Level, msg: &str);
173
174    /// Emit a structural-failure diagnostic. Always fires regardless
175    /// of the configured level — `LINESMITH_LOG=off` users still
176    /// see "the statusline broke" because there's no other channel.
177    fn emit_error(&self, msg: &str);
178}
179
180/// Default sink. Writes `linesmith [<tag>]: <msg>` lines to
181/// `io::stderr().lock()` and drops the write on a closed pipe /
182/// full disk: the statusline has no recovery path, and panicking
183/// here would nuke an otherwise-good render.
184#[derive(Debug, Default)]
185pub struct StderrSink;
186
187impl LogSink for StderrSink {
188    fn emit(&self, lvl: Level, msg: &str) {
189        let tag = match lvl {
190            Level::Off => return,
191            Level::Warn => "warn",
192            Level::Debug => "debug",
193        };
194        let _ = writeln!(io::stderr().lock(), "linesmith [{tag}]: {msg}");
195    }
196
197    fn emit_error(&self, msg: &str) {
198        let _ = writeln!(io::stderr().lock(), "linesmith [error]: {msg}");
199    }
200}
201
202/// Default cap for the [`CapturedSink`] inter-frame ring. Sized for a
203/// chatty render (one warn per segment across a multi-line config) plus
204/// burst headroom from future background emitters. Each entry is a
205/// short formatted string, so the worst-case memory at the cap is on
206/// the order of tens of KiB — small enough to be permanently allocated
207/// for the alt-screen lifetime.
208pub const CAPTURED_SINK_DEFAULT_CAP: usize = 256;
209
210/// One captured emission, tagged with the [`Instant`] it landed at the
211/// sink. Consumers that display the buffer over time (the TUI warnings
212/// panel once v0.2 background plugins can emit between draws) compute
213/// relative age against `Instant::now()` at render time; the monotonic
214/// clock means a host suspending mid-session doesn't show negative
215/// ages or jump backwards.
216///
217/// `text` carries the existing compact form (`[<tag>] <msg>`); the age
218/// is metadata, not embedded in the text, so non-temporal renderers
219/// (boot-failure stderr flush) can ignore the timestamp entirely.
220#[derive(Debug, Clone)]
221pub struct CapturedEntry {
222    pub at: Instant,
223    pub text: String,
224}
225
226/// Detailed drain result. Carries the entries plus a `dropped` count
227/// for emissions silently evicted by the bounded buffer since the
228/// previous drain, so the consumer can surface a "(N earlier entries
229/// dropped)" line instead of letting the loss go unnoticed.
230#[derive(Debug, Default)]
231pub struct CapturedDrain {
232    pub entries: Vec<CapturedEntry>,
233    pub dropped: usize,
234}
235
236/// Buffering sink that accumulates formatted entries for an
237/// interactive consumer to drain. The TUI installs one for the
238/// alt-screen lifetime so `lsm_warn!` / `lsm_error!` / `lsm_debug!`
239/// surface in the live-preview warnings panel instead of painting
240/// over the rendered frame.
241///
242/// Captured format is `[<tag>] <msg>` — the surrounding UI prefixes
243/// each line with its own marker (e.g. `⚠`), so the `linesmith`
244/// prefix and colon would be redundant noise.
245///
246/// The buffer is bounded at [`CAPTURED_SINK_DEFAULT_CAP`] with
247/// drop-oldest eviction and a counter exposed via [`Self::drain_detailed`].
248/// Today every emit happens during a synchronous render so drain
249/// follows within microseconds; once v0.2 background plugins emit
250/// between draws, the cap is what keeps a long-idle session from
251/// accumulating megabytes of unread diagnostics. The [`Self::emit_seq`]
252/// counter lets an idle event loop notice a new emission and trigger
253/// a redraw without polling the mutex on every tick.
254#[derive(Debug)]
255pub struct CapturedSink {
256    entries: Mutex<VecDeque<CapturedEntry>>,
257    cap: usize,
258    /// Entries evicted since the last drain. Reset on every drain
259    /// (legacy `drain` routes through `drain_detailed` and so resets
260    /// it too) — only `drain_detailed` returns the count to the
261    /// consumer, so callers that need eviction visibility must use
262    /// it exclusively and not interleave with legacy `drain`.
263    ///
264    /// Updated under the `entries` mutex so a concurrent push's
265    /// eviction increment can't race the drain's reset.
266    dropped: AtomicUsize,
267    /// Lifetime emission counter — monotonically increments on every
268    /// `push`, never resets. Paired with `last_drain_seq` to drive the
269    /// TUI's rising-edge wake (see the `emit_seq` method doc for the
270    /// pre-draw / post-drain watermark math).
271    emit_seq: AtomicUsize,
272    /// Snapshot of `emit_seq` at the moment of the most recent drain.
273    /// Lets the event loop tell apart "in-frame emits the render
274    /// already drained and showed the user" from "background emits
275    /// that arrived during the draw but were not drained" — the
276    /// former bumps `emit_seq` AND `last_drain_seq` in lockstep, the
277    /// latter advances only `emit_seq`. The watermark advances to
278    /// `max(pre_draw_emit_seq, last_drain_seq)` so neither bites.
279    last_drain_seq: AtomicUsize,
280}
281
282impl Default for CapturedSink {
283    fn default() -> Self {
284        Self::with_capacity(CAPTURED_SINK_DEFAULT_CAP)
285    }
286}
287
288impl CapturedSink {
289    /// Construct a sink with an explicit cap. A `cap` of zero is
290    /// clamped to one — a literal zero-cap would drop every emission,
291    /// leaving the consumer with nothing but a rising `dropped` counter.
292    #[must_use]
293    pub fn with_capacity(cap: usize) -> Self {
294        let cap = cap.max(1);
295        Self {
296            entries: Mutex::new(VecDeque::with_capacity(cap.min(64))),
297            cap,
298            dropped: AtomicUsize::new(0),
299            emit_seq: AtomicUsize::new(0),
300            last_drain_seq: AtomicUsize::new(0),
301        }
302    }
303
304    /// Legacy text-only drain. Strips timestamps and dropped-count
305    /// metadata; used by callers (boot-failure stderr flush, the
306    /// `_test_capture_warns` helper) that only need the existing
307    /// `[<tag>] <msg>` strings. Resets the pending counter so an
308    /// event loop watching this sink stops requesting redraws.
309    #[must_use]
310    pub fn drain(&self) -> Vec<String> {
311        let detailed = self.drain_detailed();
312        detailed.entries.into_iter().map(|e| e.text).collect()
313    }
314
315    /// Structured drain. Returns every buffered entry with its
316    /// emission timestamp and the number of entries evicted by the
317    /// cap since the previous drain. Resets both counters.
318    ///
319    /// Counter resets happen while holding the `entries` mutex.
320    /// A concurrent emit serializes on the same lock, so its
321    /// increment is either fully observed by this drain (and
322    /// reflected in the returned counts) or strictly ordered
323    /// after the reset (and visible to the next drain) — never
324    /// silently clobbered.
325    #[must_use]
326    pub fn drain_detailed(&self) -> CapturedDrain {
327        let mut g = self.entries.lock().unwrap_or_else(|p| p.into_inner());
328        let entries: Vec<CapturedEntry> = g.drain(..).collect();
329        // Stamp last_drain_seq with the emit_seq value at this exact
330        // moment so the event loop can tell drained-and-rendered
331        // emissions apart from background emissions that arrived
332        // after the drain. `emit_seq` is read under the mutex (same
333        // critical section as the push side) so the snapshot reflects
334        // every emit that contributed to the drained entries.
335        let seq_at_drain = self.emit_seq.load(Ordering::Acquire);
336        self.last_drain_seq.store(seq_at_drain, Ordering::Release);
337        let dropped = self.dropped.swap(0, Ordering::AcqRel);
338        CapturedDrain { entries, dropped }
339    }
340
341    /// Current cap. Lock-free read so consumers that render the
342    /// drop-notice ("(N earlier entries dropped — cap M)") pick up
343    /// the live value rather than baking in the `Default` constant.
344    #[must_use]
345    pub fn capacity(&self) -> usize {
346        self.cap
347    }
348
349    /// Monotonic lifetime emission counter. The TUI event loop drives
350    /// a rising-edge wake by comparing this against a watermark of
351    /// `max(pre_draw_emit_seq, last_drain_seq)`:
352    ///
353    /// - **Pre-draw snapshot** keeps background emissions that arrive
354    ///   *after* the in-frame drain on the unseen side of the watermark
355    ///   — the next idle tick redraws them.
356    /// - **`last_drain_seq`** lets in-frame emissions (the render
357    ///   itself emitting warnings while building the frame) advance
358    ///   the watermark — they were drained and shown to the user via
359    ///   the warnings panel, so they shouldn't re-trigger a redraw.
360    ///
361    /// Acquire-load pairs with the Release store in `push` so on
362    /// weakly-ordered architectures (ARM, RISC-V) the wake signal
363    /// doesn't lag a poll tick.
364    #[must_use]
365    pub fn emit_seq(&self) -> usize {
366        self.emit_seq.load(Ordering::Acquire)
367    }
368
369    /// `emit_seq` value at the moment of the most recent drain.
370    /// Always `<= emit_seq()`. See [`Self::emit_seq`] for how the
371    /// event loop combines the two into its watermark.
372    #[must_use]
373    pub fn last_drain_seq(&self) -> usize {
374        self.last_drain_seq.load(Ordering::Acquire)
375    }
376
377    /// Test helper: assert against the buffer without consuming it.
378    #[cfg(test)]
379    fn snapshot(&self) -> Vec<String> {
380        self.entries
381            .lock()
382            .unwrap_or_else(|p| p.into_inner())
383            .iter()
384            .map(|e| e.text.clone())
385            .collect()
386    }
387
388    fn push(&self, text: String) {
389        let entry = CapturedEntry {
390            at: Instant::now(),
391            text,
392        };
393        let mut g = self.entries.lock().unwrap_or_else(|p| p.into_inner());
394        if g.len() == self.cap {
395            g.pop_front();
396            self.dropped.fetch_add(1, Ordering::Release);
397        }
398        g.push_back(entry);
399        self.emit_seq.fetch_add(1, Ordering::Release);
400    }
401}
402
403impl LogSink for CapturedSink {
404    fn emit(&self, lvl: Level, msg: &str) {
405        let tag = match lvl {
406            Level::Off => return,
407            Level::Warn => "warn",
408            Level::Debug => "debug",
409        };
410        self.push(format!("[{tag}] {msg}"));
411    }
412
413    fn emit_error(&self, msg: &str) {
414        self.push(format!("[error] {msg}"));
415    }
416}
417
418/// Process-wide active sink. `OnceLock` because `Arc::new(...)`
419/// allocates and `Arc::new` is not const-stable, so the slot can't
420/// be a plain `static`. Init fires on first emission or first sink
421/// swap, whichever comes first.
422///
423/// `RwLock` rather than `Mutex` so concurrent emits run without
424/// blocking each other — emits take the read lock for the duration of
425/// `sink.emit(...)`, `install_sink` takes the write lock and drains
426/// in-flight emits before swapping. Closes the race where the old
427/// `clone-the-Arc-then-emit` pattern let a concurrent install land
428/// an emission on the just-orphaned slot.
429///
430/// Reader/writer fairness is platform-dependent on `std::sync::RwLock`.
431/// Background emitters should not loop-emit without backoff: a
432/// sustained read-side load can delay a writer (install) on a
433/// reader-preferring runtime. See lsm-261p for the fair-RwLock
434/// follow-up that will make this bound explicit.
435static SINK: OnceLock<RwLock<Arc<dyn LogSink>>> = OnceLock::new();
436
437/// Test-only serialization helper. Tests that install a custom
438/// sink (or mutate `LEVEL`) must take this lock first — without
439/// it, a parallel test installing its own sink can race the
440/// active-sink slot and steal each other's emissions.
441///
442/// `#[doc(hidden)]` because this isn't part of the supported
443/// public API. Cross-crate tests in this workspace use it; future
444/// removal isn't a SemVer-breaking change. The leading underscore
445/// signals "not for production code".
446#[doc(hidden)]
447pub fn _test_serial_lock() -> std::sync::MutexGuard<'static, ()> {
448    static M: OnceLock<Mutex<()>> = OnceLock::new();
449    M.get_or_init(|| Mutex::new(()))
450        .lock()
451        .unwrap_or_else(|p| p.into_inner())
452}
453
454/// RAII restorer for [`THREAD_SINK`]. Mirrors [`SinkGuard`] so the
455/// thread-local resets on every exit path from [`_test_capture_warns`],
456/// including unwinding panic under the dev/test profile.
457#[must_use = "binding to `_` drops the guard immediately, which restores the prior thread-local sink before the helper's window opens; bind to a real name to hold it"]
458struct ThreadSinkGuard {
459    /// The prior thread-local value, which may itself be `Some(sink)`
460    /// (nested helper) or `None` (first-time install). `Drop` takes
461    /// the field out via `Option::take` and assigns it back into the
462    /// thread-local; the field's outer `Option` is the borrow-checker
463    /// affordance for that `take`, not a guard-active sentinel.
464    prior: Option<Arc<CapturedSink>>,
465}
466
467impl Drop for ThreadSinkGuard {
468    fn drop(&mut self) {
469        // `cell.replace` returns the old value out from under the
470        // borrow so its `Drop` runs after the `RefMut` is released —
471        // matters if a future sink's Drop ever emits (which would
472        // re-enter `with_thread_sink` and try to borrow this cell).
473        let prior = self.prior.take();
474        let _old = THREAD_SINK.with(|cell| cell.replace(prior));
475    }
476}
477
478/// Test-only helper: run `f` with a thread-local [`CapturedSink`]
479/// shadowing the active sink for the calling thread, then return
480/// `f`'s result paired with the drained captured entries (captured-
481/// sink format: `[warn] <msg>` for warns, `[error] <msg>` for
482/// structural failures).
483///
484/// The thread-local sink is consulted **before** the process-wide
485/// level gate inside [`emit`], so calls through [`emit`] or
486/// [`emit_error`] (i.e. `lsm_warn!` / `lsm_error!`) capture regardless
487/// of `LINESMITH_LOG` or a peer test's `set_level`. Peer threads stay
488/// routed to the global sink, so parallel `cargo test` workers don't
489/// pollute each other's warn counts.
490///
491/// **Caveat for `lsm_debug!`:** the macro pre-gates on
492/// `is_enabled(Debug)` before calling `emit`, so a thread-local
493/// capture won't see debug emissions unless the process-wide level
494/// is `Debug`. Tests asserting debug output should hold
495/// [`_test_serial_lock`] and `set_level(Level::Debug)` directly
496/// rather than relying on the helper.
497///
498/// Holds [`_test_serial_lock`] for hermeticity with other tests that
499/// mutate process-wide state through the same lock. **Not reentrant**:
500/// a nested call on the same thread deadlocks at the serial lock —
501/// move shared setup outside the closure rather than nesting helpers.
502///
503/// `#[doc(hidden)]` and leading-underscore for the same reasons
504/// [`_test_serial_lock`] is: cross-crate test access without a
505/// SemVer commitment.
506#[doc(hidden)]
507pub fn _test_capture_warns<F, T>(f: F) -> (T, Vec<String>)
508where
509    F: FnOnce() -> T,
510{
511    let _serial = _test_serial_lock();
512    let sink = Arc::new(CapturedSink::default());
513    let prior = THREAD_SINK.with(|cell| cell.replace(Some(sink.clone())));
514    let _restore = ThreadSinkGuard { prior };
515    let result = f();
516    let captured = sink.drain();
517    (result, captured)
518}
519
520fn sink_slot() -> &'static RwLock<Arc<dyn LogSink>> {
521    SINK.get_or_init(|| RwLock::new(Arc::new(StderrSink)))
522}
523
524/// Replace the active sink and return the prior one. Use
525/// [`SinkGuard`] for scoped install/restore; this raw function is
526/// `pub(crate)` because the only documented in-tree caller is
527/// `SinkGuard::install` itself. If a real out-of-crate embedder
528/// shows up, promote it back to `pub` then.
529///
530/// Takes the slot's write lock, which blocks until every in-flight
531/// `emit`/`emit_error` (each holding a read lock for its duration)
532/// returns. Wait is bounded per-emission but cumulative — on a
533/// reader-preferring `std::sync::RwLock` runtime, a sustained
534/// background-emit load can keep the swap pending.
535pub(crate) fn install_sink(new_sink: Arc<dyn LogSink>) -> Arc<dyn LogSink> {
536    let mut g = sink_slot().write().unwrap_or_else(|p| p.into_inner());
537    mem::replace(&mut *g, new_sink)
538}
539
540/// RAII handle that installs a custom sink and restores the prior
541/// one on drop. The TUI uses this so stderr emission resumes after
542/// the alt-screen exits in the normal-return path. Note: the
543/// workspace's release profile sets `panic = "abort"`, so on a
544/// release-build panic this `Drop` does **not** run — the panic
545/// hook (in [`super::tui`]) is what restores the terminal under
546/// abort, and stderr is owned by the alt-screen until then. Under
547/// the default unwind profile (dev/test), stack unwinding drops
548/// the guard normally.
549///
550/// Nested guards in nested scopes restore in reverse construction
551/// order so long as Rust's normal stack-LIFO drop applies (no
552/// explicit `mem::take` of the field, no moves into a heap-owned
553/// container that delays drop). Don't move the guard into
554/// `Box`/`Arc`/`Vec` and expect LIFO.
555#[must_use = "binding to `_` drops the guard immediately, which restores the prior sink right away; bind to `_g` (or any real name) to hold it for the scope"]
556pub struct SinkGuard {
557    /// `Option` only so `Drop` can `take()` the prior out of
558    /// `&mut self`. Invariant: always `Some` outside `Drop`. If a
559    /// future `defuse(self) -> Arc<dyn LogSink>` method is added,
560    /// it must consume `self` via `mem::forget` rather than
561    /// `take()`-ing this field, or restore semantics silently
562    /// regress.
563    prior: Option<Arc<dyn LogSink>>,
564}
565
566impl SinkGuard {
567    /// Install `new_sink` as the active sink, capturing the prior
568    /// one for restoration on drop.
569    pub fn install(new_sink: Arc<dyn LogSink>) -> Self {
570        Self {
571            prior: Some(install_sink(new_sink)),
572        }
573    }
574}
575
576impl Drop for SinkGuard {
577    fn drop(&mut self) {
578        if let Some(prior) = self.prior.take() {
579            install_sink(prior);
580        }
581    }
582}
583
584// Per-thread sink overlay for testability. `None` in production;
585// `_test_capture_warns` swaps in a thread-private CapturedSink for
586// the duration of a test closure. Two concurrent captures installing
587// the *global* SinkGuard would race the slot and see each other's
588// emissions; the thread-local shadow makes the destination
589// per-thread, so parallel `cargo test` workers each get their own
590// captured set.
591thread_local! {
592    static THREAD_SINK: RefCell<Option<Arc<CapturedSink>>> = const { RefCell::new(None) };
593}
594
595/// Look up the calling thread's installed test sink, if any, and
596/// invoke `f` on it. Clones the `Arc` out before calling `f` so the
597/// `RefCell` borrow is released by the time `f` runs — a future sink
598/// impl that recursed into `emit` would otherwise hit a borrow
599/// panic. `try_with` returns false if the thread-local has already
600/// been destroyed (TLS teardown ordering during thread exit), routing
601/// the emission to the global sink rather than panicking.
602#[must_use = "callers must skip the global sink when the thread-local fired, or the emission double-routes"]
603fn with_thread_sink<F: FnOnce(&CapturedSink)>(f: F) -> bool {
604    let sink = THREAD_SINK
605        .try_with(|cell| cell.borrow().clone())
606        .ok()
607        .flatten();
608    if let Some(sink) = sink {
609        f(&sink);
610        true
611    } else {
612        false
613    }
614}
615
616/// Emit `msg` at `lvl` through the active [`LogSink`]. Production
617/// path: the default sink writes to stderr; the TUI's
618/// [`CapturedSink`] buffers for in-frame display; the level gate
619/// suppresses below the configured threshold.
620///
621/// A thread-local capture (installed by [`_test_capture_warns`])
622/// shadows the active sink **and** bypasses the level gate for the
623/// calling thread — tests assert that a warn fires, not whether the
624/// runtime gate happens to be open. Mirrors the existing always-fire
625/// semantics of [`emit_error`].
626pub fn emit(lvl: Level, msg: &str) {
627    if with_thread_sink(|sink| sink.emit(lvl, msg)) {
628        return;
629    }
630    if !is_enabled(lvl) {
631        return;
632    }
633    let guard = sink_slot().read().unwrap_or_else(|p| p.into_inner());
634    guard.emit(lvl, msg);
635}
636
637/// Emit a structural-failure diagnostic. Bypasses the level gate:
638/// even `LINESMITH_LOG=off` does not suppress it, because the only
639/// things that reach this function are render failures a user has no
640/// other way of seeing.
641pub fn emit_error(msg: &str) {
642    if with_thread_sink(|sink| sink.emit_error(msg)) {
643        return;
644    }
645    let guard = sink_slot().read().unwrap_or_else(|p| p.into_inner());
646    guard.emit_error(msg);
647}
648
649impl Level {
650    /// Parse the [`ENV_VAR`] string. Accepts `warn` → `Warn`, `debug`
651    /// / `trace` / `all` → `Debug`, `off` / `none` / `0` → `Off`.
652    /// `error` and `info` are rejected on purpose: the 3-level ladder
653    /// has no `Error` slot, and silently collapsing `error` to `warn`
654    /// would ship `LINESMITH_LOG=error` users every warn-level line.
655    #[must_use]
656    pub fn parse(s: &str) -> Option<Self> {
657        match s.trim().to_ascii_lowercase().as_str() {
658            "off" | "none" | "0" => Some(Level::Off),
659            "warn" | "warning" => Some(Level::Warn),
660            "debug" | "trace" | "all" => Some(Level::Debug),
661            _ => None,
662        }
663    }
664}
665
666/// Emit a warning-level diagnostic. Fires at [`DEFAULT_LEVEL`] and up.
667#[macro_export]
668macro_rules! lsm_warn {
669    ($($arg:tt)*) => {
670        $crate::logging::emit($crate::logging::Level::Warn, &format!($($arg)*))
671    };
672}
673
674/// Emit a debug-level diagnostic. Gated behind `LINESMITH_LOG=debug`;
675/// the `format!` call is skipped when suppressed.
676#[macro_export]
677macro_rules! lsm_debug {
678    ($($arg:tt)*) => {
679        if $crate::logging::is_enabled($crate::logging::Level::Debug) {
680            $crate::logging::emit($crate::logging::Level::Debug, &format!($($arg)*));
681        }
682    };
683}
684
685/// Emit a structural-failure diagnostic that bypasses the level gate.
686/// Reserved for failures a user has no other way of seeing — segment
687/// render errors, fatal plugin init, contract violations — so a user
688/// who set `LINESMITH_LOG=off` still sees "the statusline broke."
689#[macro_export]
690macro_rules! lsm_error {
691    ($($arg:tt)*) => {
692        $crate::logging::emit_error(&format!($($arg)*))
693    };
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699
700    // Tests that mutate LEVEL or the active sink run serially —
701    // parallel cargo-test would otherwise flake when two tests
702    // disagree on the expected state. Wraps the same mutex
703    // cross-crate tests use via `_test_serial_lock` so logging
704    // tests and consumer tests (e.g. `tui::preview`) coordinate.
705    fn lock() -> std::sync::MutexGuard<'static, ()> {
706        super::_test_serial_lock()
707    }
708
709    #[test]
710    fn default_level_is_warn() {
711        let _g = lock();
712        set_level(DEFAULT_LEVEL);
713        assert_eq!(level(), Level::Warn);
714        assert!(is_enabled(Level::Warn));
715        assert!(!is_enabled(Level::Debug));
716    }
717
718    #[test]
719    fn debug_enables_every_lower_level() {
720        let _g = lock();
721        set_level(Level::Debug);
722        assert!(is_enabled(Level::Warn));
723        assert!(is_enabled(Level::Debug));
724        set_level(DEFAULT_LEVEL);
725    }
726
727    #[test]
728    fn off_suppresses_every_level() {
729        let _g = lock();
730        set_level(Level::Off);
731        assert!(!is_enabled(Level::Warn));
732        assert!(!is_enabled(Level::Debug));
733        set_level(DEFAULT_LEVEL);
734    }
735
736    #[test]
737    fn parse_accepts_common_aliases() {
738        assert_eq!(Level::parse("warn"), Some(Level::Warn));
739        assert_eq!(Level::parse("WARN"), Some(Level::Warn));
740        assert_eq!(Level::parse(" warn "), Some(Level::Warn));
741        assert_eq!(Level::parse("warning"), Some(Level::Warn));
742        assert_eq!(Level::parse("debug"), Some(Level::Debug));
743        assert_eq!(Level::parse("trace"), Some(Level::Debug));
744        assert_eq!(Level::parse("all"), Some(Level::Debug));
745        assert_eq!(Level::parse("off"), Some(Level::Off));
746        assert_eq!(Level::parse("none"), Some(Level::Off));
747        assert_eq!(Level::parse("0"), Some(Level::Off));
748    }
749
750    #[test]
751    fn parse_rejects_error_and_info_aliases() {
752        // `error` / `info` reject intentionally — no Error variant
753        // exists to route them to, and silently promoting either to
754        // `warn` would mislead a user asking for errors-only.
755        assert_eq!(Level::parse("error"), None);
756        assert_eq!(Level::parse("info"), None);
757    }
758
759    #[test]
760    fn parse_rejects_garbage() {
761        assert_eq!(Level::parse("verbose"), None);
762        assert_eq!(Level::parse(""), None);
763        assert_eq!(Level::parse("debug2"), None);
764    }
765
766    #[test]
767    fn decide_init_keeps_default_when_env_unset() {
768        assert_eq!(decide_init(None), InitDecision::Keep);
769    }
770
771    #[test]
772    fn decide_init_parses_recognized_levels() {
773        assert_eq!(decide_init(Some("debug")), InitDecision::Set(Level::Debug));
774        assert_eq!(decide_init(Some("warn")), InitDecision::Set(Level::Warn));
775        assert_eq!(decide_init(Some("off")), InitDecision::Set(Level::Off));
776    }
777
778    #[test]
779    fn decide_init_warns_on_garbage() {
780        assert_eq!(decide_init(Some("loud")), InitDecision::Warn("loud"));
781        assert_eq!(decide_init(Some("")), InitDecision::Warn(""));
782    }
783
784    #[test]
785    fn apply_writes_warning_to_injected_sink_and_resets_to_default() {
786        let _g = lock();
787        set_level(Level::Off);
788        let mut sink = Vec::<u8>::new();
789        apply(Some("loud"), &mut sink);
790        let written = String::from_utf8(sink).expect("utf8");
791        assert!(
792            written.contains("LINESMITH_LOG=\"loud\""),
793            "expected the unrecognized value echoed, got {written:?}"
794        );
795        assert!(written.contains("unrecognized"));
796        // Garbage must reset to DEFAULT_LEVEL so a stale prior
797        // set_level(Off) doesn't persist.
798        assert_eq!(level(), DEFAULT_LEVEL);
799    }
800
801    #[test]
802    fn apply_keeps_level_when_env_unset() {
803        let _g = lock();
804        set_level(Level::Debug);
805        let mut sink = Vec::<u8>::new();
806        apply(None, &mut sink);
807        assert!(sink.is_empty(), "no-env must not write: {sink:?}");
808        assert_eq!(level(), Level::Debug);
809        set_level(DEFAULT_LEVEL);
810    }
811
812    #[test]
813    fn apply_sets_recognized_level_without_writing() {
814        let _g = lock();
815        set_level(Level::Off);
816        let mut sink = Vec::<u8>::new();
817        apply(Some("debug"), &mut sink);
818        assert!(sink.is_empty());
819        assert_eq!(level(), Level::Debug);
820        set_level(DEFAULT_LEVEL);
821    }
822
823    #[test]
824    fn lsm_debug_skips_format_when_suppressed() {
825        // Pin the documented gating: `format!` arg-eval must be
826        // skipped when debug is off. Regression would silently
827        // reintroduce allocation cost the macro exists to avoid.
828        use std::cell::Cell;
829        use std::fmt;
830        struct CountingDisplay<'a>(&'a Cell<u32>);
831        impl fmt::Display for CountingDisplay<'_> {
832            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
833                self.0.set(self.0.get() + 1);
834                f.write_str("x")
835            }
836        }
837
838        let _g = lock();
839        let counter = Cell::new(0u32);
840
841        set_level(Level::Warn);
842        lsm_debug!("{}", CountingDisplay(&counter));
843        assert_eq!(counter.get(), 0, "format! must not run when suppressed");
844
845        set_level(Level::Debug);
846        lsm_debug!("{}", CountingDisplay(&counter));
847        assert_eq!(counter.get(), 1, "format! must run when enabled");
848
849        set_level(DEFAULT_LEVEL);
850    }
851
852    #[test]
853    fn from_u8_roundtrips_known_bytes() {
854        assert_eq!(from_u8(0), Level::Off);
855        assert_eq!(from_u8(1), Level::Warn);
856        assert_eq!(from_u8(2), Level::Debug);
857    }
858
859    #[test]
860    #[cfg(debug_assertions)]
861    #[should_panic(expected = "out-of-range byte")]
862    fn from_u8_debug_panics_on_out_of_range() {
863        // Only covered in debug builds; release saturates to Debug.
864        let _ = from_u8(99);
865    }
866
867    #[test]
868    #[cfg(not(debug_assertions))]
869    fn from_u8_saturates_out_of_range_to_debug_in_release() {
870        // Out-of-range bytes saturate to Debug so nothing is suppressed.
871        assert_eq!(from_u8(3), Level::Debug);
872        assert_eq!(from_u8(99), Level::Debug);
873        assert_eq!(from_u8(u8::MAX), Level::Debug);
874    }
875
876    #[test]
877    fn captured_sink_records_warn_emit_with_compact_format() {
878        // Pin the captured-sink shape: `[<tag>] <msg>` with no
879        // `linesmith` prefix and no colon. The TUI warnings panel
880        // already wraps each line with `⚠`, so the prefix is noise.
881        let _g = lock();
882        set_level(Level::Warn);
883        let captured = Arc::new(CapturedSink::default());
884        let _restore = SinkGuard::install(captured.clone());
885        emit(Level::Warn, "hello");
886        assert_eq!(captured.snapshot(), vec!["[warn] hello".to_string()]);
887        set_level(DEFAULT_LEVEL);
888    }
889
890    #[test]
891    fn captured_sink_records_error_bypassing_off_level() {
892        // emit_error always fires — including when the level gate
893        // would otherwise suppress every emission. Pin both the
894        // bypass and the `[error] msg` capture format.
895        let _g = lock();
896        set_level(Level::Off);
897        let captured = Arc::new(CapturedSink::default());
898        let _restore = SinkGuard::install(captured.clone());
899        emit_error("render panic");
900        assert_eq!(
901            captured.snapshot(),
902            vec!["[error] render panic".to_string()]
903        );
904        set_level(DEFAULT_LEVEL);
905    }
906
907    #[test]
908    fn captured_sink_skips_debug_emit_when_level_warn() {
909        // The level gate runs in `emit()` *before* dispatching to
910        // the sink. Pin that a Debug emission with the level set to
911        // Warn never reaches the sink — otherwise `LINESMITH_LOG`
912        // would silently stop gating once the TUI installed a
913        // capture sink.
914        let _g = lock();
915        set_level(Level::Warn);
916        let captured = Arc::new(CapturedSink::default());
917        let _restore = SinkGuard::install(captured.clone());
918        emit(Level::Debug, "verbose");
919        assert!(captured.snapshot().is_empty());
920        set_level(DEFAULT_LEVEL);
921    }
922
923    #[test]
924    fn emit_error_fires_at_every_level() {
925        // emit_error bypasses the level gate at every setting, not
926        // only Off. Pin Off + Warn + Debug so a future "optimize
927        // emit_error to share the emit() short-circuit" refactor
928        // breaks the Warn/Debug arms here, not only at Off.
929        let _g = lock();
930        for l in [Level::Off, Level::Warn, Level::Debug] {
931            set_level(l);
932            let captured = Arc::new(CapturedSink::default());
933            let _restore = SinkGuard::install(captured.clone());
934            emit_error("structural failure");
935            assert_eq!(
936                captured.snapshot(),
937                vec!["[error] structural failure".to_string()],
938                "emit_error must fire at level {l:?}",
939            );
940        }
941        set_level(DEFAULT_LEVEL);
942    }
943
944    #[test]
945    fn captured_sink_drain_returns_entries_and_empties_buffer() {
946        let _g = lock();
947        set_level(Level::Warn);
948        let captured = Arc::new(CapturedSink::default());
949        let _restore = SinkGuard::install(captured.clone());
950        emit(Level::Warn, "first");
951        emit(Level::Warn, "second");
952        let drained = captured.drain();
953        assert_eq!(
954            drained,
955            vec!["[warn] first".to_string(), "[warn] second".to_string()]
956        );
957        // Drain consumes — second drain is empty.
958        assert!(captured.drain().is_empty());
959        set_level(DEFAULT_LEVEL);
960    }
961
962    #[test]
963    fn captured_sink_evicts_oldest_when_full_and_counts_drops() {
964        // Bounded ring contract: at cap, the next emit evicts the
965        // front entry and bumps the dropped counter. A regression
966        // that drops the *new* entry instead would leave the
967        // user staring at stale warnings while fresh ones vanish.
968        let captured = CapturedSink::with_capacity(2);
969        captured.emit(Level::Warn, "first");
970        captured.emit(Level::Warn, "second");
971        captured.emit(Level::Warn, "third");
972        let detailed = captured.drain_detailed();
973        let texts: Vec<String> = detailed.entries.iter().map(|e| e.text.clone()).collect();
974        assert_eq!(
975            texts,
976            vec!["[warn] second".to_string(), "[warn] third".to_string()],
977            "drop-oldest must evict 'first', keep latest two",
978        );
979        assert_eq!(detailed.dropped, 1, "exactly one entry evicted");
980    }
981
982    #[test]
983    fn captured_sink_legacy_drain_also_resets_dropped_counter() {
984        // Legacy `drain` delegates to `drain_detailed` and discards
985        // the count, but it still resets `dropped`. Pin this so a
986        // future refactor that inlines `drain` with its own pop
987        // doesn't silently leak the eviction count into the next
988        // detailed drain.
989        let captured = CapturedSink::with_capacity(1);
990        captured.emit(Level::Warn, "a");
991        captured.emit(Level::Warn, "b"); // evicts 'a' → dropped = 1
992        let _ = captured.drain();
993        captured.emit(Level::Warn, "c");
994        let detailed = captured.drain_detailed();
995        assert_eq!(detailed.dropped, 0);
996    }
997
998    #[test]
999    fn captured_sink_dropped_counter_resets_on_detailed_drain() {
1000        // Detailed drain hands the eviction count to the consumer
1001        // and zeroes it so the next drain only sees evictions that
1002        // happened *since* the last detailed pull. Otherwise the
1003        // counter would monotonically rise forever and the panel
1004        // would keep showing "5 dropped" long after they were
1005        // surfaced.
1006        let captured = CapturedSink::with_capacity(1);
1007        captured.emit(Level::Warn, "a");
1008        captured.emit(Level::Warn, "b"); // evicts 'a'
1009        let first = captured.drain_detailed();
1010        assert_eq!(first.dropped, 1);
1011        captured.emit(Level::Warn, "c");
1012        let second = captured.drain_detailed();
1013        assert_eq!(second.dropped, 0, "counter must reset between drains");
1014    }
1015
1016    #[test]
1017    fn captured_sink_with_capacity_clamps_zero_to_one() {
1018        // A literal zero-cap would silently drop every emission —
1019        // the consumer would see an empty `entries` and a rising
1020        // `dropped` count with no payload. Clamp to one so the
1021        // sink at least preserves the most recent entry.
1022        let captured = CapturedSink::with_capacity(0);
1023        captured.emit(Level::Warn, "only");
1024        let detailed = captured.drain_detailed();
1025        assert_eq!(detailed.entries.len(), 1);
1026        assert_eq!(detailed.entries[0].text, "[warn] only");
1027    }
1028
1029    #[test]
1030    fn captured_sink_records_emit_timestamp() {
1031        // Each entry stamps `Instant::now()` at emit so a renderer
1032        // can show relative age. Two emits separated by `sleep`
1033        // produce strictly increasing timestamps.
1034        let captured = CapturedSink::default();
1035        captured.emit(Level::Warn, "first");
1036        std::thread::sleep(std::time::Duration::from_millis(2));
1037        captured.emit(Level::Warn, "second");
1038        let detailed = captured.drain_detailed();
1039        assert_eq!(detailed.entries.len(), 2);
1040        assert!(
1041            detailed.entries[1].at > detailed.entries[0].at,
1042            "second emit must have a later timestamp than the first",
1043        );
1044    }
1045
1046    #[test]
1047    fn captured_sink_last_drain_seq_marks_in_frame_emits_as_seen() {
1048        // The run_loop's watermark uses last_drain_seq to advance
1049        // past in-frame emits the render already showed the user.
1050        // Pin the contract: after a drain, last_drain_seq equals
1051        // emit_seq, and a background emit landing after the drain
1052        // does NOT advance last_drain_seq (so the watermark stays
1053        // below cur_emit_seq and the next idle tick wakes).
1054        let captured = CapturedSink::default();
1055        assert_eq!(captured.last_drain_seq(), 0);
1056        captured.emit(Level::Warn, "in-frame-a");
1057        captured.emit(Level::Warn, "in-frame-b");
1058        let _ = captured.drain_detailed();
1059        assert_eq!(captured.last_drain_seq(), 2, "drain marks both as seen");
1060        assert_eq!(captured.emit_seq(), 2);
1061        captured.emit(Level::Warn, "background");
1062        assert_eq!(
1063            captured.last_drain_seq(),
1064            2,
1065            "post-drain emit must NOT advance last_drain_seq",
1066        );
1067        assert_eq!(captured.emit_seq(), 3, "but emit_seq advances");
1068    }
1069
1070    #[test]
1071    fn captured_sink_emit_seq_is_monotonic_across_drains() {
1072        // The wake signal must NOT reset on drain — sampling
1073        // post-drain would otherwise miss-mark emissions that
1074        // arrived during the draw (between the in-frame drain and
1075        // the loop's post-draw watermark update) as already-seen.
1076        // Pin the monotonic contract so a future refactor that
1077        // re-introduces a drain reset breaks here, not in the run-
1078        // loop watermark.
1079        let captured = CapturedSink::default();
1080        assert_eq!(captured.emit_seq(), 0);
1081        captured.emit(Level::Warn, "a");
1082        captured.emit(Level::Warn, "b");
1083        assert_eq!(captured.emit_seq(), 2);
1084        let _ = captured.drain_detailed();
1085        assert_eq!(captured.emit_seq(), 2, "drain must not reset emit_seq");
1086        captured.emit(Level::Warn, "c");
1087        assert_eq!(captured.emit_seq(), 3);
1088        let _ = captured.drain();
1089        assert_eq!(
1090            captured.emit_seq(),
1091            3,
1092            "legacy drain must not reset emit_seq"
1093        );
1094    }
1095
1096    #[test]
1097    fn sink_guard_restores_prior_sink_on_drop_lifo_three_deep() {
1098        // The TUI relies on RAII restore for stderr emissions to
1099        // resume after the alt-screen exits. A three-level nest
1100        // catches a regression where Drop accidentally restores the
1101        // first-installed sink (or any non-immediate prior) — a
1102        // two-level nest would let that bug pass.
1103        let _g = lock();
1104        set_level(Level::Warn);
1105        let outer = Arc::new(CapturedSink::default());
1106        let _outer_g = SinkGuard::install(outer.clone());
1107        {
1108            let middle = Arc::new(CapturedSink::default());
1109            let _middle_g = SinkGuard::install(middle.clone());
1110            {
1111                let inner = Arc::new(CapturedSink::default());
1112                let _inner_g = SinkGuard::install(inner.clone());
1113                emit(Level::Warn, "inner");
1114                assert_eq!(inner.snapshot(), vec!["[warn] inner".to_string()]);
1115                assert!(middle.snapshot().is_empty());
1116                assert!(outer.snapshot().is_empty());
1117            }
1118            // _inner_g dropped → middle is active again.
1119            emit(Level::Warn, "middle");
1120            assert_eq!(middle.snapshot(), vec!["[warn] middle".to_string()]);
1121            assert!(outer.snapshot().is_empty());
1122        }
1123        // _middle_g dropped → outer is active again.
1124        emit(Level::Warn, "outer");
1125        assert_eq!(outer.snapshot(), vec!["[warn] outer".to_string()]);
1126        set_level(DEFAULT_LEVEL);
1127    }
1128
1129    #[test]
1130    fn install_sink_blocks_until_in_flight_emit_completes_and_emission_lands_on_prior_sink() {
1131        // Pin the lsm-67go race fix. Before the RwLock conversion,
1132        // `current_sink` cloned the Arc and dropped the slot lock
1133        // before calling `.emit()`, so `install_sink` could swap mid-emit
1134        // and the emission landed on the just-orphaned sink. Now `emit`
1135        // holds the read lock across the call; `install_sink` waits on
1136        // the write lock.
1137        //
1138        // Two halves pinned:
1139        //  - Install blocks while emit is in progress.
1140        //  - Emission lands on the prior sink; the new sink stays empty
1141        //    (a double-routing regression would fail this, not a call counter).
1142        run_install_race_test(EmitKind::Warn);
1143    }
1144
1145    #[test]
1146    fn install_sink_blocks_until_in_flight_emit_error_completes() {
1147        // Mirror of the warn-path race test for `emit_error`, which
1148        // bypasses the level gate on a separate code path. A regression
1149        // that reverted only `emit_error` would slip through the warn test.
1150        //
1151        // `set_level(Level::Off)` confirms the level-gate bypass still
1152        // routes through the read-lock-held path.
1153        run_install_race_test(EmitKind::Error);
1154    }
1155
1156    #[derive(Clone, Copy)]
1157    enum EmitKind {
1158        Warn,
1159        Error,
1160    }
1161
1162    fn run_install_race_test(kind: EmitKind) {
1163        use std::sync::atomic::AtomicBool;
1164        use std::sync::Barrier;
1165        use std::time::Duration;
1166
1167        struct BarrierSink {
1168            inside: Arc<Barrier>,
1169            release: Arc<Barrier>,
1170            inner: Arc<CapturedSink>,
1171        }
1172        impl LogSink for BarrierSink {
1173            fn emit(&self, lvl: Level, msg: &str) {
1174                self.inside.wait();
1175                self.release.wait();
1176                self.inner.emit(lvl, msg);
1177            }
1178            fn emit_error(&self, msg: &str) {
1179                self.inside.wait();
1180                self.release.wait();
1181                self.inner.emit_error(msg);
1182            }
1183        }
1184
1185        let _g = lock();
1186        match kind {
1187            EmitKind::Warn => set_level(Level::Warn),
1188            EmitKind::Error => set_level(Level::Off),
1189        }
1190        let inside = Arc::new(Barrier::new(2));
1191        let release = Arc::new(Barrier::new(2));
1192        let prior_inner = Arc::new(CapturedSink::default());
1193        let prior_sink: Arc<dyn LogSink> = Arc::new(BarrierSink {
1194            inside: inside.clone(),
1195            release: release.clone(),
1196            inner: prior_inner.clone(),
1197        });
1198        // `SinkGuard` provides RAII restore so an assertion-induced
1199        // panic still puts the pre-test sink back. A manual
1200        // install_sink → assert → install_sink pair would skip the
1201        // restore on panic and leave a `BarrierSink` installed for
1202        // every subsequent test, hanging them at `inside.wait()`.
1203        let _restore = SinkGuard::install(prior_sink);
1204
1205        let emit_handle = std::thread::spawn(move || match kind {
1206            EmitKind::Warn => emit(Level::Warn, "racy"),
1207            EmitKind::Error => emit_error("racy"),
1208        });
1209        inside.wait();
1210
1211        let new_captured = Arc::new(CapturedSink::default());
1212        let new_sink: Arc<dyn LogSink> = new_captured.clone();
1213        let install_done = Arc::new(AtomicBool::new(false));
1214        let install_done_clone = install_done.clone();
1215        let install_handle = std::thread::spawn(move || {
1216            let prior = install_sink(new_sink);
1217            install_done_clone.store(true, Ordering::Release);
1218            prior
1219        });
1220
1221        // RAII fire-once wrapper around `release.wait()`. If the
1222        // pre-release assertion panics, Drop still hits the barrier
1223        // so the parked emit thread unblocks instead of leaking
1224        // until process exit.
1225        struct ReleaseOnDrop {
1226            release: Arc<Barrier>,
1227            fired: std::cell::Cell<bool>,
1228        }
1229        impl ReleaseOnDrop {
1230            fn fire(&self) {
1231                if !self.fired.replace(true) {
1232                    self.release.wait();
1233                }
1234            }
1235        }
1236        impl Drop for ReleaseOnDrop {
1237            fn drop(&mut self) {
1238                self.fire();
1239            }
1240        }
1241        let release_guard = ReleaseOnDrop {
1242            release: release.clone(),
1243            fired: std::cell::Cell::new(false),
1244        };
1245
1246        // Install must block while the emit holds the read lock.
1247        // A regression to clone-then-emit would let install land in
1248        // microseconds; 75ms is generous enough to distinguish.
1249        std::thread::sleep(Duration::from_millis(75));
1250        assert!(
1251            !install_done.load(Ordering::Acquire),
1252            "install_sink must block while {kind:?} emit is in progress",
1253            kind = match kind {
1254                EmitKind::Warn => "warn",
1255                EmitKind::Error => "error",
1256            },
1257        );
1258
1259        release_guard.fire();
1260        emit_handle.join().expect("emit thread");
1261        let from_install = install_handle.join().expect("install thread");
1262        assert!(install_done.load(Ordering::Acquire));
1263
1264        let expected = match kind {
1265            EmitKind::Warn => "[warn] racy",
1266            EmitKind::Error => "[error] racy",
1267        };
1268        assert_eq!(
1269            prior_inner.snapshot(),
1270            vec![expected.to_string()],
1271            "emission must land on the prior sink",
1272        );
1273        assert!(
1274            new_captured.snapshot().is_empty(),
1275            "post-install sink must not see an emission issued before the swap, got {:?}",
1276            new_captured.snapshot(),
1277        );
1278
1279        // `from_install` holds the racing thread's prior (the
1280        // `BarrierSink`). Drop explicitly so the slot's strong-count
1281        // settles before `_restore` runs install_sink again — the
1282        // new sink (`new_captured`) is currently in the slot; the
1283        // guard's drop replaces it with the pre-test sink.
1284        drop(from_install);
1285        set_level(DEFAULT_LEVEL);
1286    }
1287
1288    #[test]
1289    fn install_sink_returns_prior_for_manual_restore() {
1290        // The raw `install_sink` is `pub(crate)`; this pins the
1291        // round-trip contract `SinkGuard::install` itself depends
1292        // on. Force the level explicitly: the test runs serially,
1293        // but a future test that leaves `Off` would otherwise
1294        // silently suppress the emit and break the assertion.
1295        let _g = lock();
1296        set_level(Level::Warn);
1297        let captured = Arc::new(CapturedSink::default());
1298        let prior = install_sink(captured.clone());
1299        emit(Level::Warn, "captured");
1300        assert_eq!(captured.snapshot(), vec!["[warn] captured".to_string()]);
1301        let _ = install_sink(prior);
1302    }
1303
1304    #[test]
1305    fn test_capture_warns_returns_function_result_and_captured_emissions() {
1306        let (result, captured) = _test_capture_warns(|| {
1307            emit(Level::Warn, "first");
1308            emit(Level::Warn, "second");
1309            42
1310        });
1311        assert_eq!(result, 42);
1312        assert_eq!(
1313            captured,
1314            vec!["[warn] first".to_string(), "[warn] second".to_string()]
1315        );
1316    }
1317
1318    #[test]
1319    fn test_capture_warns_captures_emit_error_regardless_of_level() {
1320        let (_, captured) = _test_capture_warns(|| {
1321            emit_error("structural failure");
1322        });
1323        assert_eq!(captured, vec!["[error] structural failure".to_string()]);
1324    }
1325
1326    #[test]
1327    fn emit_routes_to_thread_local_sink_even_when_global_level_is_off() {
1328        // `emit` consults the thread-local sink before the level gate,
1329        // so a peer test that left `LEVEL=Off` can't silently suppress
1330        // emissions inside a capture window. Setup is inline rather than
1331        // via `_test_capture_warns` because we already hold the serial
1332        // lock (the helper takes it; std::sync::Mutex is non-reentrant).
1333        let _g = lock();
1334        set_level(Level::Off);
1335        let sink = Arc::new(CapturedSink::default());
1336        let prior = THREAD_SINK.with(|cell| cell.replace(Some(sink.clone())));
1337        let _restore = ThreadSinkGuard { prior };
1338        emit(Level::Warn, "still captured");
1339        assert_eq!(sink.drain(), vec!["[warn] still captured".to_string()]);
1340        set_level(DEFAULT_LEVEL);
1341    }
1342
1343    #[test]
1344    fn test_capture_warns_subsequent_call_starts_empty() {
1345        // No state leaks across invocations: each call installs a
1346        // fresh CapturedSink and the thread-local restores to its
1347        // prior value on return.
1348        let _ = _test_capture_warns(|| emit(Level::Warn, "first"));
1349        let (_, second) = _test_capture_warns(|| {});
1350        assert!(
1351            second.is_empty(),
1352            "second call must start with no captured entries, got {second:?}"
1353        );
1354    }
1355
1356    #[test]
1357    fn concrete_sink_types_remain_thread_safe() {
1358        // `Arc<dyn LogSink>` requires Send+Sync via the trait
1359        // bound, so the trait-object case is enforced at compile
1360        // time without a runtime assertion. The concrete-type pins
1361        // here catch a future field addition (e.g. `Cell`,
1362        // `RefCell`) that would auto-derive a non-Sync `StderrSink`
1363        // or `CapturedSink` — at which point neither could be
1364        // wrapped in `Arc<dyn LogSink>` and the trait-object
1365        // bound's compile error would name the trait, not the
1366        // field. Naming the concrete types here makes the failure
1367        // point at the right line.
1368        fn assert_send_sync<T: Send + Sync>() {}
1369        assert_send_sync::<StderrSink>();
1370        assert_send_sync::<CapturedSink>();
1371    }
1372}