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::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}