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}