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: `linesmith [<level>]: <message>`. A future doctor
22//! command can buffer and reformat the same emissions.
23//!
24//! Not a general-purpose logger: no filtering by target, no structured
25//! fields, no sink swap. Add those when a call site needs them.
26
27use std::io::{self, Write};
28use std::sync::atomic::{AtomicU8, Ordering};
29
30/// Logger severity. Variants are ordered `Off < Warn < Debug` so a
31/// call fires when its own level is `<=` the configured level.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
33pub enum Level {
34    Off = 0,
35    /// Default. Real data-loss drops (cache/lock write failures,
36    /// gix-discovery errors) surface without opt-in.
37    Warn = 1,
38    /// Opt-in verbosity. Every `Ok(None)` hide path in a segment
39    /// emits a one-line diagnostic naming the gate that triggered.
40    Debug = 2,
41}
42
43// The `AtomicU8` store round-trips raw discriminants through
44// `set_level` / `level()`. Pin the layout so a reorder of the variants
45// becomes a compile error instead of a silent flip.
46const _: () = assert!(Level::Off as u8 == 0);
47const _: () = assert!(Level::Warn as u8 == 1);
48const _: () = assert!(Level::Debug as u8 == 2);
49
50pub const ENV_VAR: &str = "LINESMITH_LOG";
51
52const DEFAULT_LEVEL: Level = Level::Warn;
53static LEVEL: AtomicU8 = AtomicU8::new(DEFAULT_LEVEL as u8);
54
55/// Apply `raw` (the snapshotted `LINESMITH_LOG` value, `None` if
56/// unset) to the process-wide level. On an unrecognized value the
57/// logger resets to [`DEFAULT_LEVEL`] and writes one line to
58/// `warn_sink`; the driver threads the injected CLI stderr through so
59/// tests and embedders don't see ambient stderr pollution.
60pub fn apply(raw: Option<&str>, warn_sink: &mut dyn Write) {
61    match decide_init(raw) {
62        InitDecision::Keep => {}
63        InitDecision::Set(l) => set_level(l),
64        InitDecision::Warn(bad) => {
65            let _ = writeln!(
66                warn_sink,
67                "linesmith: {ENV_VAR}={bad:?} unrecognized; using default ({DEFAULT_LEVEL:?})"
68            );
69            set_level(DEFAULT_LEVEL);
70        }
71    }
72}
73
74/// Pure decision form of [`apply`]: what to do given the raw env-var
75/// read. Split out so the decision tree is unit-testable without
76/// touching the process env.
77#[derive(Debug, PartialEq, Eq)]
78pub(crate) enum InitDecision<'a> {
79    /// Env var unset; leave the logger at its prior level.
80    Keep,
81    /// Env var parsed; call [`set_level`] with this value.
82    Set(Level),
83    /// Env var set but unparseable.
84    Warn(&'a str),
85}
86
87pub(crate) fn decide_init(raw: Option<&str>) -> InitDecision<'_> {
88    match raw {
89        None => InitDecision::Keep,
90        Some(s) => match Level::parse(s) {
91            Some(l) => InitDecision::Set(l),
92            None => InitDecision::Warn(s),
93        },
94    }
95}
96
97/// Override the process-wide level. Exposed for tests and embedders
98/// that want to pick a level without touching the env.
99pub fn set_level(l: Level) {
100    LEVEL.store(l as u8, Ordering::Relaxed);
101}
102
103#[must_use]
104pub fn level() -> Level {
105    from_u8(LEVEL.load(Ordering::Relaxed))
106}
107
108/// Reconstruct a [`Level`] from its stored byte. An out-of-range byte
109/// is a store bug; `debug_assert!` surfaces it in tests while release
110/// builds saturate to the verbose end so nothing is accidentally
111/// suppressed.
112fn from_u8(n: u8) -> Level {
113    match n {
114        0 => Level::Off,
115        1 => Level::Warn,
116        2 => Level::Debug,
117        _ => {
118            debug_assert!(false, "logging::LEVEL holds out-of-range byte {n}");
119            Level::Debug
120        }
121    }
122}
123
124/// `true` when `at_least` or a more verbose level is active. A call
125/// fires when its own level is `<=` the currently configured level.
126#[must_use]
127pub fn is_enabled(at_least: Level) -> bool {
128    level() >= at_least
129}
130
131/// Emit `msg` at `lvl` when the configured level allows. A broken
132/// stderr (closed pipe, full disk) drops the write silently — the
133/// statusline has no recovery path and panicking would nuke an
134/// otherwise-good render.
135pub fn emit(lvl: Level, msg: &str) {
136    if !is_enabled(lvl) {
137        return;
138    }
139    let tag = match lvl {
140        Level::Off => return,
141        Level::Warn => "warn",
142        Level::Debug => "debug",
143    };
144    let _ = writeln!(io::stderr().lock(), "linesmith [{tag}]: {msg}");
145}
146
147/// Emit a structural-failure diagnostic. Bypasses the level gate:
148/// even `LINESMITH_LOG=off` does not suppress it, because the only
149/// things that reach this function are render failures a user has no
150/// other way of seeing.
151pub fn emit_error(msg: &str) {
152    emit_error_to(msg, &mut io::stderr().lock());
153}
154
155/// Same as [`emit_error`] but to a caller-supplied sink. Separate
156/// from `emit_error` so unit tests can assert the message without
157/// capturing process stderr.
158pub(crate) fn emit_error_to(msg: &str, sink: &mut dyn Write) {
159    let _ = writeln!(sink, "linesmith [error]: {msg}");
160}
161
162impl Level {
163    /// Parse the [`ENV_VAR`] string. Accepts `warn` → `Warn`, `debug`
164    /// / `trace` / `all` → `Debug`, `off` / `none` / `0` → `Off`.
165    /// `error` and `info` are rejected on purpose: the 3-level ladder
166    /// has no `Error` slot, and silently collapsing `error` to `warn`
167    /// would ship `LINESMITH_LOG=error` users every warn-level line.
168    #[must_use]
169    pub fn parse(s: &str) -> Option<Self> {
170        match s.trim().to_ascii_lowercase().as_str() {
171            "off" | "none" | "0" => Some(Level::Off),
172            "warn" | "warning" => Some(Level::Warn),
173            "debug" | "trace" | "all" => Some(Level::Debug),
174            _ => None,
175        }
176    }
177}
178
179/// Emit a warning-level diagnostic. Fires at [`DEFAULT_LEVEL`] and up.
180#[macro_export]
181macro_rules! lsm_warn {
182    ($($arg:tt)*) => {
183        $crate::logging::emit($crate::logging::Level::Warn, &format!($($arg)*))
184    };
185}
186
187/// Emit a debug-level diagnostic. Gated behind `LINESMITH_LOG=debug`;
188/// the `format!` call is skipped when suppressed.
189#[macro_export]
190macro_rules! lsm_debug {
191    ($($arg:tt)*) => {
192        if $crate::logging::is_enabled($crate::logging::Level::Debug) {
193            $crate::logging::emit($crate::logging::Level::Debug, &format!($($arg)*));
194        }
195    };
196}
197
198/// Emit a structural-failure diagnostic that bypasses the level gate.
199/// Reserved for failures a user has no other way of seeing — segment
200/// render errors, fatal plugin init, contract violations — so a user
201/// who set `LINESMITH_LOG=off` still sees "the statusline broke."
202#[macro_export]
203macro_rules! lsm_error {
204    ($($arg:tt)*) => {
205        $crate::logging::emit_error(&format!($($arg)*))
206    };
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    // Tests that mutate LEVEL run serially — parallel cargo-test would
214    // otherwise flake when two tests disagree on the expected level.
215    fn lock() -> std::sync::MutexGuard<'static, ()> {
216        use std::sync::{Mutex, OnceLock};
217        static M: OnceLock<Mutex<()>> = OnceLock::new();
218        M.get_or_init(|| Mutex::new(()))
219            .lock()
220            .unwrap_or_else(|p| p.into_inner())
221    }
222
223    #[test]
224    fn default_level_is_warn() {
225        let _g = lock();
226        set_level(DEFAULT_LEVEL);
227        assert_eq!(level(), Level::Warn);
228        assert!(is_enabled(Level::Warn));
229        assert!(!is_enabled(Level::Debug));
230    }
231
232    #[test]
233    fn debug_enables_every_lower_level() {
234        let _g = lock();
235        set_level(Level::Debug);
236        assert!(is_enabled(Level::Warn));
237        assert!(is_enabled(Level::Debug));
238        set_level(DEFAULT_LEVEL);
239    }
240
241    #[test]
242    fn off_suppresses_every_level() {
243        let _g = lock();
244        set_level(Level::Off);
245        assert!(!is_enabled(Level::Warn));
246        assert!(!is_enabled(Level::Debug));
247        set_level(DEFAULT_LEVEL);
248    }
249
250    #[test]
251    fn parse_accepts_common_aliases() {
252        assert_eq!(Level::parse("warn"), Some(Level::Warn));
253        assert_eq!(Level::parse("WARN"), Some(Level::Warn));
254        assert_eq!(Level::parse(" warn "), Some(Level::Warn));
255        assert_eq!(Level::parse("warning"), Some(Level::Warn));
256        assert_eq!(Level::parse("debug"), Some(Level::Debug));
257        assert_eq!(Level::parse("trace"), Some(Level::Debug));
258        assert_eq!(Level::parse("all"), Some(Level::Debug));
259        assert_eq!(Level::parse("off"), Some(Level::Off));
260        assert_eq!(Level::parse("none"), Some(Level::Off));
261        assert_eq!(Level::parse("0"), Some(Level::Off));
262    }
263
264    #[test]
265    fn parse_rejects_error_and_info_aliases() {
266        // `error` / `info` reject intentionally — no Error variant
267        // exists to route them to, and silently promoting either to
268        // `warn` would mislead a user asking for errors-only.
269        assert_eq!(Level::parse("error"), None);
270        assert_eq!(Level::parse("info"), None);
271    }
272
273    #[test]
274    fn parse_rejects_garbage() {
275        assert_eq!(Level::parse("verbose"), None);
276        assert_eq!(Level::parse(""), None);
277        assert_eq!(Level::parse("debug2"), None);
278    }
279
280    #[test]
281    fn decide_init_keeps_default_when_env_unset() {
282        assert_eq!(decide_init(None), InitDecision::Keep);
283    }
284
285    #[test]
286    fn decide_init_parses_recognized_levels() {
287        assert_eq!(decide_init(Some("debug")), InitDecision::Set(Level::Debug));
288        assert_eq!(decide_init(Some("warn")), InitDecision::Set(Level::Warn));
289        assert_eq!(decide_init(Some("off")), InitDecision::Set(Level::Off));
290    }
291
292    #[test]
293    fn decide_init_warns_on_garbage() {
294        assert_eq!(decide_init(Some("loud")), InitDecision::Warn("loud"));
295        assert_eq!(decide_init(Some("")), InitDecision::Warn(""));
296    }
297
298    #[test]
299    fn apply_writes_warning_to_injected_sink_and_resets_to_default() {
300        let _g = lock();
301        set_level(Level::Off);
302        let mut sink = Vec::<u8>::new();
303        apply(Some("loud"), &mut sink);
304        let written = String::from_utf8(sink).expect("utf8");
305        assert!(
306            written.contains("LINESMITH_LOG=\"loud\""),
307            "expected the unrecognized value echoed, got {written:?}"
308        );
309        assert!(written.contains("unrecognized"));
310        // Garbage must reset to DEFAULT_LEVEL so a stale prior
311        // set_level(Off) doesn't persist.
312        assert_eq!(level(), DEFAULT_LEVEL);
313    }
314
315    #[test]
316    fn apply_keeps_level_when_env_unset() {
317        let _g = lock();
318        set_level(Level::Debug);
319        let mut sink = Vec::<u8>::new();
320        apply(None, &mut sink);
321        assert!(sink.is_empty(), "no-env must not write: {sink:?}");
322        assert_eq!(level(), Level::Debug);
323        set_level(DEFAULT_LEVEL);
324    }
325
326    #[test]
327    fn apply_sets_recognized_level_without_writing() {
328        let _g = lock();
329        set_level(Level::Off);
330        let mut sink = Vec::<u8>::new();
331        apply(Some("debug"), &mut sink);
332        assert!(sink.is_empty());
333        assert_eq!(level(), Level::Debug);
334        set_level(DEFAULT_LEVEL);
335    }
336
337    #[test]
338    fn lsm_debug_skips_format_when_suppressed() {
339        // Pin the documented gating: `format!` arg-eval must be
340        // skipped when debug is off. Regression would silently
341        // reintroduce allocation cost the macro exists to avoid.
342        use std::cell::Cell;
343        use std::fmt;
344        struct CountingDisplay<'a>(&'a Cell<u32>);
345        impl fmt::Display for CountingDisplay<'_> {
346            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347                self.0.set(self.0.get() + 1);
348                f.write_str("x")
349            }
350        }
351
352        let _g = lock();
353        let counter = Cell::new(0u32);
354
355        set_level(Level::Warn);
356        lsm_debug!("{}", CountingDisplay(&counter));
357        assert_eq!(counter.get(), 0, "format! must not run when suppressed");
358
359        set_level(Level::Debug);
360        lsm_debug!("{}", CountingDisplay(&counter));
361        assert_eq!(counter.get(), 1, "format! must run when enabled");
362
363        set_level(DEFAULT_LEVEL);
364    }
365
366    #[test]
367    fn from_u8_roundtrips_known_bytes() {
368        assert_eq!(from_u8(0), Level::Off);
369        assert_eq!(from_u8(1), Level::Warn);
370        assert_eq!(from_u8(2), Level::Debug);
371    }
372
373    #[test]
374    #[should_panic(expected = "out-of-range byte")]
375    fn from_u8_debug_panics_on_out_of_range() {
376        // Only covered in debug builds; release saturates to Debug.
377        let _ = from_u8(99);
378    }
379
380    #[test]
381    fn emit_error_bypasses_off_level() {
382        let _g = lock();
383        set_level(Level::Off);
384        let mut sink = Vec::<u8>::new();
385        emit_error_to("render panic", &mut sink);
386        let written = String::from_utf8(sink).expect("utf8");
387        assert_eq!(written, "linesmith [error]: render panic\n");
388        set_level(DEFAULT_LEVEL);
389    }
390
391    #[test]
392    fn emit_error_fires_at_every_level() {
393        let _g = lock();
394        for l in [Level::Off, Level::Warn, Level::Debug] {
395            set_level(l);
396            let mut sink = Vec::<u8>::new();
397            emit_error_to("x", &mut sink);
398            assert!(
399                !sink.is_empty(),
400                "emit_error must fire at level {l:?}, got empty sink"
401            );
402        }
403        set_level(DEFAULT_LEVEL);
404    }
405}