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::io::{self, Write};
33use std::mem;
34use std::sync::atomic::{AtomicU8, Ordering};
35use std::sync::{Arc, Mutex, OnceLock};
36
37/// Logger severity. Variants are ordered `Off < Warn < Debug` so a
38/// call fires when its own level is `<=` the configured level.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
40pub enum Level {
41 Off = 0,
42 /// Default. Real data-loss drops (cache/lock write failures,
43 /// gix-discovery errors) surface without opt-in.
44 Warn = 1,
45 /// Opt-in verbosity. Every `Ok(None)` hide path in a segment
46 /// emits a one-line diagnostic naming the gate that triggered.
47 Debug = 2,
48}
49
50// The `AtomicU8` store round-trips raw discriminants through
51// `set_level` / `level()`. Pin the layout so a reorder of the variants
52// becomes a compile error instead of a silent flip.
53const _: () = assert!(Level::Off as u8 == 0);
54const _: () = assert!(Level::Warn as u8 == 1);
55const _: () = assert!(Level::Debug as u8 == 2);
56
57pub const ENV_VAR: &str = "LINESMITH_LOG";
58
59const DEFAULT_LEVEL: Level = Level::Warn;
60static LEVEL: AtomicU8 = AtomicU8::new(DEFAULT_LEVEL as u8);
61
62/// Apply `raw` (the snapshotted `LINESMITH_LOG` value, `None` if
63/// unset) to the process-wide level. On an unrecognized value the
64/// logger resets to [`DEFAULT_LEVEL`] and writes one line to
65/// `warn_sink`; the driver threads the injected CLI stderr through so
66/// tests and embedders don't see ambient stderr pollution.
67pub fn apply(raw: Option<&str>, warn_sink: &mut dyn Write) {
68 match decide_init(raw) {
69 InitDecision::Keep => {}
70 InitDecision::Set(l) => set_level(l),
71 InitDecision::Warn(bad) => {
72 let _ = writeln!(
73 warn_sink,
74 "linesmith: {ENV_VAR}={bad:?} unrecognized; using default ({DEFAULT_LEVEL:?})"
75 );
76 set_level(DEFAULT_LEVEL);
77 }
78 }
79}
80
81/// Pure decision form of [`apply`]: what to do given the raw env-var
82/// read. Split out so the decision tree is unit-testable without
83/// touching the process env.
84#[derive(Debug, PartialEq, Eq)]
85pub(crate) enum InitDecision<'a> {
86 /// Env var unset; leave the logger at its prior level.
87 Keep,
88 /// Env var parsed; call [`set_level`] with this value.
89 Set(Level),
90 /// Env var set but unparseable.
91 Warn(&'a str),
92}
93
94pub(crate) fn decide_init(raw: Option<&str>) -> InitDecision<'_> {
95 match raw {
96 None => InitDecision::Keep,
97 Some(s) => match Level::parse(s) {
98 Some(l) => InitDecision::Set(l),
99 None => InitDecision::Warn(s),
100 },
101 }
102}
103
104/// Override the process-wide level. Exposed for tests and embedders
105/// that want to pick a level without touching the env.
106pub fn set_level(l: Level) {
107 LEVEL.store(l as u8, Ordering::Relaxed);
108}
109
110#[must_use]
111pub fn level() -> Level {
112 from_u8(LEVEL.load(Ordering::Relaxed))
113}
114
115/// Reconstruct a [`Level`] from its stored byte. An out-of-range byte
116/// is a store bug; `debug_assert!` surfaces it in tests while release
117/// builds saturate to the verbose end so nothing is accidentally
118/// suppressed.
119fn from_u8(n: u8) -> Level {
120 match n {
121 0 => Level::Off,
122 1 => Level::Warn,
123 2 => Level::Debug,
124 _ => {
125 debug_assert!(false, "logging::LEVEL holds out-of-range byte {n}");
126 Level::Debug
127 }
128 }
129}
130
131/// `true` when `at_least` or a more verbose level is active. A call
132/// fires when its own level is `<=` the currently configured level.
133#[must_use]
134pub fn is_enabled(at_least: Level) -> bool {
135 level() >= at_least
136}
137
138/// Pluggable destination for diagnostic emissions. The default
139/// [`StderrSink`] preserves the `linesmith [<level>]: <msg>` format
140/// external scripts grep for; the TUI swaps in a [`CapturedSink`]
141/// for the duration of the alt-screen so macro emissions land in
142/// the warnings panel instead of corrupting the painted frame.
143///
144/// `&self` (not `&mut self`) so a sink instance can be shared via
145/// `Arc<dyn LogSink>`. The `Send + Sync` bound is what `Arc<dyn _>`
146/// requires; today only the render thread emits, but the bound
147/// keeps the door open for future background plugin emitters
148/// without breaking the public surface. Concurrent emit/swap is
149/// not yet supported — a swap that races with an in-flight
150/// `current_sink()` clone can land an emission on the just-
151/// orphaned sink. See `lsm-wyph` follow-ups for the work to close
152/// that.
153pub trait LogSink: Send + Sync {
154 /// Emit a level-gated diagnostic. The caller has already
155 /// confirmed the level is enabled — the sink writes
156 /// unconditionally. Implementations defensively no-op on
157 /// `Level::Off` rather than relying on the caller; the macros
158 /// never pass `Off`, but `emit()` is `pub`.
159 fn emit(&self, lvl: Level, msg: &str);
160
161 /// Emit a structural-failure diagnostic. Always fires regardless
162 /// of the configured level — `LINESMITH_LOG=off` users still
163 /// see "the statusline broke" because there's no other channel.
164 fn emit_error(&self, msg: &str);
165}
166
167/// Default sink. Writes `linesmith [<tag>]: <msg>` lines to
168/// `io::stderr().lock()` and drops the write on a closed pipe /
169/// full disk: the statusline has no recovery path, and panicking
170/// here would nuke an otherwise-good render.
171#[derive(Debug, Default)]
172pub struct StderrSink;
173
174impl LogSink for StderrSink {
175 fn emit(&self, lvl: Level, msg: &str) {
176 let tag = match lvl {
177 Level::Off => return,
178 Level::Warn => "warn",
179 Level::Debug => "debug",
180 };
181 let _ = writeln!(io::stderr().lock(), "linesmith [{tag}]: {msg}");
182 }
183
184 fn emit_error(&self, msg: &str) {
185 let _ = writeln!(io::stderr().lock(), "linesmith [error]: {msg}");
186 }
187}
188
189/// Buffering sink that accumulates formatted entries for an
190/// interactive consumer to drain. The TUI installs one for the
191/// alt-screen lifetime so `lsm_warn!` / `lsm_error!` / `lsm_debug!`
192/// surface in the live-preview warnings panel instead of painting
193/// over the rendered frame.
194///
195/// Captured format is `[<tag>] <msg>` — the surrounding UI prefixes
196/// each line with its own marker (e.g. `⚠`), so the `linesmith`
197/// prefix and colon would be redundant noise.
198#[derive(Debug, Default)]
199pub struct CapturedSink {
200 entries: Mutex<Vec<String>>,
201}
202
203impl CapturedSink {
204 /// Take all currently-buffered entries, leaving the sink empty.
205 /// The TUI calls this between draws so each frame's warnings
206 /// reflect that frame's render only.
207 #[must_use]
208 pub fn drain(&self) -> Vec<String> {
209 let mut g = self.entries.lock().unwrap_or_else(|p| p.into_inner());
210 mem::take(&mut *g)
211 }
212
213 /// Test helper: assert against the buffer without consuming it.
214 #[cfg(test)]
215 fn snapshot(&self) -> Vec<String> {
216 self.entries
217 .lock()
218 .unwrap_or_else(|p| p.into_inner())
219 .clone()
220 }
221}
222
223impl LogSink for CapturedSink {
224 fn emit(&self, lvl: Level, msg: &str) {
225 let tag = match lvl {
226 Level::Off => return,
227 Level::Warn => "warn",
228 Level::Debug => "debug",
229 };
230 self.entries
231 .lock()
232 .unwrap_or_else(|p| p.into_inner())
233 .push(format!("[{tag}] {msg}"));
234 }
235
236 fn emit_error(&self, msg: &str) {
237 self.entries
238 .lock()
239 .unwrap_or_else(|p| p.into_inner())
240 .push(format!("[error] {msg}"));
241 }
242}
243
244/// Process-wide active sink. `OnceLock` because `Arc::new(...)`
245/// allocates and `Arc::new` is not const-stable, so the slot can't
246/// be a plain `static`. Init fires on first emission or first sink
247/// swap, whichever comes first.
248static SINK: OnceLock<Mutex<Arc<dyn LogSink>>> = OnceLock::new();
249
250/// Test-only serialization helper. Tests that install a custom
251/// sink (or mutate `LEVEL`) must take this lock first — without
252/// it, a parallel test installing its own sink can race the
253/// active-sink slot and steal each other's emissions.
254///
255/// `#[doc(hidden)]` because this isn't part of the supported
256/// public API. Cross-crate tests in this workspace use it; future
257/// removal isn't a SemVer-breaking change. The leading underscore
258/// signals "not for production code".
259#[doc(hidden)]
260pub fn _test_serial_lock() -> std::sync::MutexGuard<'static, ()> {
261 static M: OnceLock<Mutex<()>> = OnceLock::new();
262 M.get_or_init(|| Mutex::new(()))
263 .lock()
264 .unwrap_or_else(|p| p.into_inner())
265}
266
267/// RAII restorer for [`THREAD_SINK`]. Mirrors [`SinkGuard`] so the
268/// thread-local resets on every exit path from [`_test_capture_warns`],
269/// including unwinding panic under the dev/test profile.
270#[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"]
271struct ThreadSinkGuard {
272 /// The prior thread-local value, which may itself be `Some(sink)`
273 /// (nested helper) or `None` (first-time install). `Drop` takes
274 /// the field out via `Option::take` and assigns it back into the
275 /// thread-local; the field's outer `Option` is the borrow-checker
276 /// affordance for that `take`, not a guard-active sentinel.
277 prior: Option<Arc<CapturedSink>>,
278}
279
280impl Drop for ThreadSinkGuard {
281 fn drop(&mut self) {
282 // `cell.replace` returns the old value out from under the
283 // borrow so its `Drop` runs after the `RefMut` is released —
284 // matters if a future sink's Drop ever emits (which would
285 // re-enter `with_thread_sink` and try to borrow this cell).
286 let prior = self.prior.take();
287 let _old = THREAD_SINK.with(|cell| cell.replace(prior));
288 }
289}
290
291/// Test-only helper: run `f` with a thread-local [`CapturedSink`]
292/// shadowing the active sink for the calling thread, then return
293/// `f`'s result paired with the drained captured entries (captured-
294/// sink format: `[warn] <msg>` for warns, `[error] <msg>` for
295/// structural failures).
296///
297/// The thread-local sink is consulted **before** the process-wide
298/// level gate inside [`emit`], so calls through [`emit`] or
299/// [`emit_error`] (i.e. `lsm_warn!` / `lsm_error!`) capture regardless
300/// of `LINESMITH_LOG` or a peer test's `set_level`. Peer threads stay
301/// routed to the global sink, so parallel `cargo test` workers don't
302/// pollute each other's warn counts.
303///
304/// **Caveat for `lsm_debug!`:** the macro pre-gates on
305/// `is_enabled(Debug)` before calling `emit`, so a thread-local
306/// capture won't see debug emissions unless the process-wide level
307/// is `Debug`. Tests asserting debug output should hold
308/// [`_test_serial_lock`] and `set_level(Level::Debug)` directly
309/// rather than relying on the helper.
310///
311/// Holds [`_test_serial_lock`] for hermeticity with other tests that
312/// mutate process-wide state through the same lock. **Not reentrant**:
313/// a nested call on the same thread deadlocks at the serial lock —
314/// move shared setup outside the closure rather than nesting helpers.
315///
316/// `#[doc(hidden)]` and leading-underscore for the same reasons
317/// [`_test_serial_lock`] is: cross-crate test access without a
318/// SemVer commitment.
319#[doc(hidden)]
320pub fn _test_capture_warns<F, T>(f: F) -> (T, Vec<String>)
321where
322 F: FnOnce() -> T,
323{
324 let _serial = _test_serial_lock();
325 let sink = Arc::new(CapturedSink::default());
326 let prior = THREAD_SINK.with(|cell| cell.replace(Some(sink.clone())));
327 let _restore = ThreadSinkGuard { prior };
328 let result = f();
329 let captured = sink.drain();
330 (result, captured)
331}
332
333fn sink_slot() -> &'static Mutex<Arc<dyn LogSink>> {
334 SINK.get_or_init(|| Mutex::new(Arc::new(StderrSink)))
335}
336
337fn current_sink() -> Arc<dyn LogSink> {
338 sink_slot()
339 .lock()
340 .unwrap_or_else(|p| p.into_inner())
341 .clone()
342}
343
344/// Replace the active sink and return the prior one. Use
345/// [`SinkGuard`] for scoped install/restore; this raw function is
346/// `pub(crate)` because the only documented in-tree caller is
347/// `SinkGuard::install` itself. If a real out-of-crate embedder
348/// shows up, promote it back to `pub` then.
349pub(crate) fn install_sink(new_sink: Arc<dyn LogSink>) -> Arc<dyn LogSink> {
350 let mut g = sink_slot().lock().unwrap_or_else(|p| p.into_inner());
351 mem::replace(&mut *g, new_sink)
352}
353
354/// RAII handle that installs a custom sink and restores the prior
355/// one on drop. The TUI uses this so stderr emission resumes after
356/// the alt-screen exits in the normal-return path. Note: the
357/// workspace's release profile sets `panic = "abort"`, so on a
358/// release-build panic this `Drop` does **not** run — the panic
359/// hook (in [`super::tui`]) is what restores the terminal under
360/// abort, and stderr is owned by the alt-screen until then. Under
361/// the default unwind profile (dev/test), stack unwinding drops
362/// the guard normally.
363///
364/// Nested guards in nested scopes restore in reverse construction
365/// order so long as Rust's normal stack-LIFO drop applies (no
366/// explicit `mem::take` of the field, no moves into a heap-owned
367/// container that delays drop). Don't move the guard into
368/// `Box`/`Arc`/`Vec` and expect LIFO.
369#[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"]
370pub struct SinkGuard {
371 /// `Option` only so `Drop` can `take()` the prior out of
372 /// `&mut self`. Invariant: always `Some` outside `Drop`. If a
373 /// future `defuse(self) -> Arc<dyn LogSink>` method is added,
374 /// it must consume `self` via `mem::forget` rather than
375 /// `take()`-ing this field, or restore semantics silently
376 /// regress.
377 prior: Option<Arc<dyn LogSink>>,
378}
379
380impl SinkGuard {
381 /// Install `new_sink` as the active sink, capturing the prior
382 /// one for restoration on drop.
383 pub fn install(new_sink: Arc<dyn LogSink>) -> Self {
384 Self {
385 prior: Some(install_sink(new_sink)),
386 }
387 }
388}
389
390impl Drop for SinkGuard {
391 fn drop(&mut self) {
392 if let Some(prior) = self.prior.take() {
393 install_sink(prior);
394 }
395 }
396}
397
398// Per-thread sink overlay for testability. `None` in production;
399// `_test_capture_warns` swaps in a thread-private CapturedSink for
400// the duration of a test closure. Two concurrent captures installing
401// the *global* SinkGuard would race the slot and see each other's
402// emissions; the thread-local shadow makes the destination
403// per-thread, so parallel `cargo test` workers each get their own
404// captured set.
405thread_local! {
406 static THREAD_SINK: RefCell<Option<Arc<CapturedSink>>> = const { RefCell::new(None) };
407}
408
409/// Look up the calling thread's installed test sink, if any, and
410/// invoke `f` on it. Clones the `Arc` out before calling `f` so the
411/// `RefCell` borrow is released by the time `f` runs — a future sink
412/// impl that recursed into `emit` would otherwise hit a borrow
413/// panic. `try_with` returns false if the thread-local has already
414/// been destroyed (TLS teardown ordering during thread exit), routing
415/// the emission to the global sink rather than panicking.
416#[must_use = "callers must skip the global sink when the thread-local fired, or the emission double-routes"]
417fn with_thread_sink<F: FnOnce(&CapturedSink)>(f: F) -> bool {
418 let sink = THREAD_SINK
419 .try_with(|cell| cell.borrow().clone())
420 .ok()
421 .flatten();
422 if let Some(sink) = sink {
423 f(&sink);
424 true
425 } else {
426 false
427 }
428}
429
430/// Emit `msg` at `lvl` through the active [`LogSink`]. Production
431/// path: the default sink writes to stderr; the TUI's
432/// [`CapturedSink`] buffers for in-frame display; the level gate
433/// suppresses below the configured threshold.
434///
435/// A thread-local capture (installed by [`_test_capture_warns`])
436/// shadows the active sink **and** bypasses the level gate for the
437/// calling thread — tests assert that a warn fires, not whether the
438/// runtime gate happens to be open. Mirrors the existing always-fire
439/// semantics of [`emit_error`].
440pub fn emit(lvl: Level, msg: &str) {
441 if with_thread_sink(|sink| sink.emit(lvl, msg)) {
442 return;
443 }
444 if !is_enabled(lvl) {
445 return;
446 }
447 current_sink().emit(lvl, msg);
448}
449
450/// Emit a structural-failure diagnostic. Bypasses the level gate:
451/// even `LINESMITH_LOG=off` does not suppress it, because the only
452/// things that reach this function are render failures a user has no
453/// other way of seeing.
454pub fn emit_error(msg: &str) {
455 if with_thread_sink(|sink| sink.emit_error(msg)) {
456 return;
457 }
458 current_sink().emit_error(msg);
459}
460
461impl Level {
462 /// Parse the [`ENV_VAR`] string. Accepts `warn` → `Warn`, `debug`
463 /// / `trace` / `all` → `Debug`, `off` / `none` / `0` → `Off`.
464 /// `error` and `info` are rejected on purpose: the 3-level ladder
465 /// has no `Error` slot, and silently collapsing `error` to `warn`
466 /// would ship `LINESMITH_LOG=error` users every warn-level line.
467 #[must_use]
468 pub fn parse(s: &str) -> Option<Self> {
469 match s.trim().to_ascii_lowercase().as_str() {
470 "off" | "none" | "0" => Some(Level::Off),
471 "warn" | "warning" => Some(Level::Warn),
472 "debug" | "trace" | "all" => Some(Level::Debug),
473 _ => None,
474 }
475 }
476}
477
478/// Emit a warning-level diagnostic. Fires at [`DEFAULT_LEVEL`] and up.
479#[macro_export]
480macro_rules! lsm_warn {
481 ($($arg:tt)*) => {
482 $crate::logging::emit($crate::logging::Level::Warn, &format!($($arg)*))
483 };
484}
485
486/// Emit a debug-level diagnostic. Gated behind `LINESMITH_LOG=debug`;
487/// the `format!` call is skipped when suppressed.
488#[macro_export]
489macro_rules! lsm_debug {
490 ($($arg:tt)*) => {
491 if $crate::logging::is_enabled($crate::logging::Level::Debug) {
492 $crate::logging::emit($crate::logging::Level::Debug, &format!($($arg)*));
493 }
494 };
495}
496
497/// Emit a structural-failure diagnostic that bypasses the level gate.
498/// Reserved for failures a user has no other way of seeing — segment
499/// render errors, fatal plugin init, contract violations — so a user
500/// who set `LINESMITH_LOG=off` still sees "the statusline broke."
501#[macro_export]
502macro_rules! lsm_error {
503 ($($arg:tt)*) => {
504 $crate::logging::emit_error(&format!($($arg)*))
505 };
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 // Tests that mutate LEVEL or the active sink run serially —
513 // parallel cargo-test would otherwise flake when two tests
514 // disagree on the expected state. Wraps the same mutex
515 // cross-crate tests use via `_test_serial_lock` so logging
516 // tests and consumer tests (e.g. `tui::preview`) coordinate.
517 fn lock() -> std::sync::MutexGuard<'static, ()> {
518 super::_test_serial_lock()
519 }
520
521 #[test]
522 fn default_level_is_warn() {
523 let _g = lock();
524 set_level(DEFAULT_LEVEL);
525 assert_eq!(level(), Level::Warn);
526 assert!(is_enabled(Level::Warn));
527 assert!(!is_enabled(Level::Debug));
528 }
529
530 #[test]
531 fn debug_enables_every_lower_level() {
532 let _g = lock();
533 set_level(Level::Debug);
534 assert!(is_enabled(Level::Warn));
535 assert!(is_enabled(Level::Debug));
536 set_level(DEFAULT_LEVEL);
537 }
538
539 #[test]
540 fn off_suppresses_every_level() {
541 let _g = lock();
542 set_level(Level::Off);
543 assert!(!is_enabled(Level::Warn));
544 assert!(!is_enabled(Level::Debug));
545 set_level(DEFAULT_LEVEL);
546 }
547
548 #[test]
549 fn parse_accepts_common_aliases() {
550 assert_eq!(Level::parse("warn"), Some(Level::Warn));
551 assert_eq!(Level::parse("WARN"), Some(Level::Warn));
552 assert_eq!(Level::parse(" warn "), Some(Level::Warn));
553 assert_eq!(Level::parse("warning"), Some(Level::Warn));
554 assert_eq!(Level::parse("debug"), Some(Level::Debug));
555 assert_eq!(Level::parse("trace"), Some(Level::Debug));
556 assert_eq!(Level::parse("all"), Some(Level::Debug));
557 assert_eq!(Level::parse("off"), Some(Level::Off));
558 assert_eq!(Level::parse("none"), Some(Level::Off));
559 assert_eq!(Level::parse("0"), Some(Level::Off));
560 }
561
562 #[test]
563 fn parse_rejects_error_and_info_aliases() {
564 // `error` / `info` reject intentionally — no Error variant
565 // exists to route them to, and silently promoting either to
566 // `warn` would mislead a user asking for errors-only.
567 assert_eq!(Level::parse("error"), None);
568 assert_eq!(Level::parse("info"), None);
569 }
570
571 #[test]
572 fn parse_rejects_garbage() {
573 assert_eq!(Level::parse("verbose"), None);
574 assert_eq!(Level::parse(""), None);
575 assert_eq!(Level::parse("debug2"), None);
576 }
577
578 #[test]
579 fn decide_init_keeps_default_when_env_unset() {
580 assert_eq!(decide_init(None), InitDecision::Keep);
581 }
582
583 #[test]
584 fn decide_init_parses_recognized_levels() {
585 assert_eq!(decide_init(Some("debug")), InitDecision::Set(Level::Debug));
586 assert_eq!(decide_init(Some("warn")), InitDecision::Set(Level::Warn));
587 assert_eq!(decide_init(Some("off")), InitDecision::Set(Level::Off));
588 }
589
590 #[test]
591 fn decide_init_warns_on_garbage() {
592 assert_eq!(decide_init(Some("loud")), InitDecision::Warn("loud"));
593 assert_eq!(decide_init(Some("")), InitDecision::Warn(""));
594 }
595
596 #[test]
597 fn apply_writes_warning_to_injected_sink_and_resets_to_default() {
598 let _g = lock();
599 set_level(Level::Off);
600 let mut sink = Vec::<u8>::new();
601 apply(Some("loud"), &mut sink);
602 let written = String::from_utf8(sink).expect("utf8");
603 assert!(
604 written.contains("LINESMITH_LOG=\"loud\""),
605 "expected the unrecognized value echoed, got {written:?}"
606 );
607 assert!(written.contains("unrecognized"));
608 // Garbage must reset to DEFAULT_LEVEL so a stale prior
609 // set_level(Off) doesn't persist.
610 assert_eq!(level(), DEFAULT_LEVEL);
611 }
612
613 #[test]
614 fn apply_keeps_level_when_env_unset() {
615 let _g = lock();
616 set_level(Level::Debug);
617 let mut sink = Vec::<u8>::new();
618 apply(None, &mut sink);
619 assert!(sink.is_empty(), "no-env must not write: {sink:?}");
620 assert_eq!(level(), Level::Debug);
621 set_level(DEFAULT_LEVEL);
622 }
623
624 #[test]
625 fn apply_sets_recognized_level_without_writing() {
626 let _g = lock();
627 set_level(Level::Off);
628 let mut sink = Vec::<u8>::new();
629 apply(Some("debug"), &mut sink);
630 assert!(sink.is_empty());
631 assert_eq!(level(), Level::Debug);
632 set_level(DEFAULT_LEVEL);
633 }
634
635 #[test]
636 fn lsm_debug_skips_format_when_suppressed() {
637 // Pin the documented gating: `format!` arg-eval must be
638 // skipped when debug is off. Regression would silently
639 // reintroduce allocation cost the macro exists to avoid.
640 use std::cell::Cell;
641 use std::fmt;
642 struct CountingDisplay<'a>(&'a Cell<u32>);
643 impl fmt::Display for CountingDisplay<'_> {
644 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
645 self.0.set(self.0.get() + 1);
646 f.write_str("x")
647 }
648 }
649
650 let _g = lock();
651 let counter = Cell::new(0u32);
652
653 set_level(Level::Warn);
654 lsm_debug!("{}", CountingDisplay(&counter));
655 assert_eq!(counter.get(), 0, "format! must not run when suppressed");
656
657 set_level(Level::Debug);
658 lsm_debug!("{}", CountingDisplay(&counter));
659 assert_eq!(counter.get(), 1, "format! must run when enabled");
660
661 set_level(DEFAULT_LEVEL);
662 }
663
664 #[test]
665 fn from_u8_roundtrips_known_bytes() {
666 assert_eq!(from_u8(0), Level::Off);
667 assert_eq!(from_u8(1), Level::Warn);
668 assert_eq!(from_u8(2), Level::Debug);
669 }
670
671 #[test]
672 #[cfg(debug_assertions)]
673 #[should_panic(expected = "out-of-range byte")]
674 fn from_u8_debug_panics_on_out_of_range() {
675 // Only covered in debug builds; release saturates to Debug.
676 let _ = from_u8(99);
677 }
678
679 #[test]
680 #[cfg(not(debug_assertions))]
681 fn from_u8_saturates_out_of_range_to_debug_in_release() {
682 // Out-of-range bytes saturate to Debug so nothing is suppressed.
683 assert_eq!(from_u8(3), Level::Debug);
684 assert_eq!(from_u8(99), Level::Debug);
685 assert_eq!(from_u8(u8::MAX), Level::Debug);
686 }
687
688 #[test]
689 fn captured_sink_records_warn_emit_with_compact_format() {
690 // Pin the captured-sink shape: `[<tag>] <msg>` with no
691 // `linesmith` prefix and no colon. The TUI warnings panel
692 // already wraps each line with `⚠`, so the prefix is noise.
693 let _g = lock();
694 set_level(Level::Warn);
695 let captured = Arc::new(CapturedSink::default());
696 let _restore = SinkGuard::install(captured.clone());
697 emit(Level::Warn, "hello");
698 assert_eq!(captured.snapshot(), vec!["[warn] hello".to_string()]);
699 set_level(DEFAULT_LEVEL);
700 }
701
702 #[test]
703 fn captured_sink_records_error_bypassing_off_level() {
704 // emit_error always fires — including when the level gate
705 // would otherwise suppress every emission. Pin both the
706 // bypass and the `[error] msg` capture format.
707 let _g = lock();
708 set_level(Level::Off);
709 let captured = Arc::new(CapturedSink::default());
710 let _restore = SinkGuard::install(captured.clone());
711 emit_error("render panic");
712 assert_eq!(
713 captured.snapshot(),
714 vec!["[error] render panic".to_string()]
715 );
716 set_level(DEFAULT_LEVEL);
717 }
718
719 #[test]
720 fn captured_sink_skips_debug_emit_when_level_warn() {
721 // The level gate runs in `emit()` *before* dispatching to
722 // the sink. Pin that a Debug emission with the level set to
723 // Warn never reaches the sink — otherwise `LINESMITH_LOG`
724 // would silently stop gating once the TUI installed a
725 // capture sink.
726 let _g = lock();
727 set_level(Level::Warn);
728 let captured = Arc::new(CapturedSink::default());
729 let _restore = SinkGuard::install(captured.clone());
730 emit(Level::Debug, "verbose");
731 assert!(captured.snapshot().is_empty());
732 set_level(DEFAULT_LEVEL);
733 }
734
735 #[test]
736 fn emit_error_fires_at_every_level() {
737 // emit_error bypasses the level gate at every setting, not
738 // just Off. Pin Off + Warn + Debug so a future "optimize
739 // emit_error to share the emit() short-circuit" refactor
740 // breaks the Warn/Debug arms here, not just at Off.
741 let _g = lock();
742 for l in [Level::Off, Level::Warn, Level::Debug] {
743 set_level(l);
744 let captured = Arc::new(CapturedSink::default());
745 let _restore = SinkGuard::install(captured.clone());
746 emit_error("structural failure");
747 assert_eq!(
748 captured.snapshot(),
749 vec!["[error] structural failure".to_string()],
750 "emit_error must fire at level {l:?}",
751 );
752 }
753 set_level(DEFAULT_LEVEL);
754 }
755
756 #[test]
757 fn captured_sink_drain_returns_entries_and_empties_buffer() {
758 let _g = lock();
759 set_level(Level::Warn);
760 let captured = Arc::new(CapturedSink::default());
761 let _restore = SinkGuard::install(captured.clone());
762 emit(Level::Warn, "first");
763 emit(Level::Warn, "second");
764 let drained = captured.drain();
765 assert_eq!(
766 drained,
767 vec!["[warn] first".to_string(), "[warn] second".to_string()]
768 );
769 // Drain consumes — second drain is empty.
770 assert!(captured.drain().is_empty());
771 set_level(DEFAULT_LEVEL);
772 }
773
774 #[test]
775 fn sink_guard_restores_prior_sink_on_drop_lifo_three_deep() {
776 // The TUI relies on RAII restore for stderr emissions to
777 // resume after the alt-screen exits. A three-level nest
778 // catches a regression where Drop accidentally restores the
779 // first-installed sink (or any non-immediate prior) — a
780 // two-level nest would let that bug pass.
781 let _g = lock();
782 set_level(Level::Warn);
783 let outer = Arc::new(CapturedSink::default());
784 let _outer_g = SinkGuard::install(outer.clone());
785 {
786 let middle = Arc::new(CapturedSink::default());
787 let _middle_g = SinkGuard::install(middle.clone());
788 {
789 let inner = Arc::new(CapturedSink::default());
790 let _inner_g = SinkGuard::install(inner.clone());
791 emit(Level::Warn, "inner");
792 assert_eq!(inner.snapshot(), vec!["[warn] inner".to_string()]);
793 assert!(middle.snapshot().is_empty());
794 assert!(outer.snapshot().is_empty());
795 }
796 // _inner_g dropped → middle is active again.
797 emit(Level::Warn, "middle");
798 assert_eq!(middle.snapshot(), vec!["[warn] middle".to_string()]);
799 assert!(outer.snapshot().is_empty());
800 }
801 // _middle_g dropped → outer is active again.
802 emit(Level::Warn, "outer");
803 assert_eq!(outer.snapshot(), vec!["[warn] outer".to_string()]);
804 set_level(DEFAULT_LEVEL);
805 }
806
807 #[test]
808 fn install_sink_returns_prior_for_manual_restore() {
809 // The raw `install_sink` is `pub(crate)`; this pins the
810 // round-trip contract `SinkGuard::install` itself depends
811 // on. Force the level explicitly: the test runs serially,
812 // but a future test that leaves `Off` would otherwise
813 // silently suppress the emit and break the assertion.
814 let _g = lock();
815 set_level(Level::Warn);
816 let captured = Arc::new(CapturedSink::default());
817 let prior = install_sink(captured.clone());
818 emit(Level::Warn, "captured");
819 assert_eq!(captured.snapshot(), vec!["[warn] captured".to_string()]);
820 let _ = install_sink(prior);
821 }
822
823 #[test]
824 fn test_capture_warns_returns_function_result_and_captured_emissions() {
825 let (result, captured) = _test_capture_warns(|| {
826 emit(Level::Warn, "first");
827 emit(Level::Warn, "second");
828 42
829 });
830 assert_eq!(result, 42);
831 assert_eq!(
832 captured,
833 vec!["[warn] first".to_string(), "[warn] second".to_string()]
834 );
835 }
836
837 #[test]
838 fn test_capture_warns_captures_emit_error_regardless_of_level() {
839 let (_, captured) = _test_capture_warns(|| {
840 emit_error("structural failure");
841 });
842 assert_eq!(captured, vec!["[error] structural failure".to_string()]);
843 }
844
845 #[test]
846 fn emit_routes_to_thread_local_sink_even_when_global_level_is_off() {
847 // `emit` consults the thread-local sink before the level gate,
848 // so a peer test that left `LEVEL=Off` can't silently suppress
849 // emissions inside a capture window. Setup is inline rather than
850 // via `_test_capture_warns` because we already hold the serial
851 // lock (the helper takes it; std::sync::Mutex is non-reentrant).
852 let _g = lock();
853 set_level(Level::Off);
854 let sink = Arc::new(CapturedSink::default());
855 let prior = THREAD_SINK.with(|cell| cell.replace(Some(sink.clone())));
856 let _restore = ThreadSinkGuard { prior };
857 emit(Level::Warn, "still captured");
858 assert_eq!(sink.drain(), vec!["[warn] still captured".to_string()]);
859 set_level(DEFAULT_LEVEL);
860 }
861
862 #[test]
863 fn test_capture_warns_subsequent_call_starts_empty() {
864 // No state leaks across invocations: each call installs a
865 // fresh CapturedSink and the thread-local restores to its
866 // prior value on return.
867 let _ = _test_capture_warns(|| emit(Level::Warn, "first"));
868 let (_, second) = _test_capture_warns(|| {});
869 assert!(
870 second.is_empty(),
871 "second call must start with no captured entries, got {second:?}"
872 );
873 }
874
875 #[test]
876 fn concrete_sink_types_remain_thread_safe() {
877 // `Arc<dyn LogSink>` requires Send+Sync via the trait
878 // bound, so the trait-object case is enforced at compile
879 // time without a runtime assertion. The concrete-type pins
880 // here catch a future field addition (e.g. `Cell`,
881 // `RefCell`) that would auto-derive a non-Sync `StderrSink`
882 // or `CapturedSink` — at which point neither could be
883 // wrapped in `Arc<dyn LogSink>` and the trait-object
884 // bound's compile error would name the trait, not the
885 // field. Naming the concrete types here makes the failure
886 // point at the right line.
887 fn assert_send_sync<T: Send + Sync>() {}
888 assert_send_sync::<StderrSink>();
889 assert_send_sync::<CapturedSink>();
890 }
891}