Skip to main content

crcbl_core/
log.rs

1//! The engine's logging: five macros, a filter, and the sink they write to.
2//!
3//! ```
4//! crcbl_core::info!("shell: first configure at {}x{}", 960, 720);
5//! crcbl_core::warn!("no config dir; values will not persist");
6//! ```
7//!
8//! [`error!`], [`warn!`], [`info!`], [`debug!`] and [`trace!`] take `format!`
9//! arguments and tag each record with the calling module, which is what the
10//! filter below matches on. All five forward to [`log_at!`], so the target, the
11//! level check and the route to the sink are written once.
12//!
13//! It is deliberately small rather than a dependency on a logging framework:
14//! the engine needs level filtering, a readable line and a way for a test to
15//! read back what it said, and every framework that does more brings a runtime,
16//! a feature matrix, and opinions about async.
17//!
18//! # The `log` crate is underneath, and stays
19//!
20//! Not for the engine's own call sites — those go straight to the sink — but
21//! because `wgpu`, `naga` and `gpu-allocator` report through that facade, and
22//! their diagnostics have repeatedly been the ones that mattered: a shader
23//! naga refused, a device that would not open. The sink implements `log::Log`
24//! as well, so third-party records land in the same stream, at the same
25//! filter, in the same format. Dropping the facade would not remove the
26//! dependency — it would only stop us hearing from it.
27//!
28//! Both entry points meet at `StderrLogger::emit`, so there is one filter
29//! check, one capture point and one line format rather than one of each per
30//! path.
31//!
32//! # What a line looks like
33//!
34//! ```text
35//! [   0.0000s INFO  crcbl_core::log] run started 2026-08-15 05:20:07 UTC
36//! [   0.0081s INFO  crcbl::engine] shell: first configure at 960x720
37//! ```
38//!
39//! Seconds since start on every line, because "how long into the run" is the
40//! question a frame loop asks; the wall-clock date once, at start-up, so those
41//! seconds can be lined up against something outside the process without paying
42//! a date conversion per line.
43//!
44//! [`capture`] is the second thing that sink does: it hands a test the records
45//! the engine emitted, so a log line that is the only evidence of a decision
46//! can be asserted on instead of trusted. See its docs for the concurrency
47//! argument.
48//!
49//! [`console`] is the third: a bounded ring every sink pushes into, which is
50//! what the debug console draws. It holds records the filter refused, so the
51//! panel can show what the terminal did not — debug-console decision 4 in
52//! `docs/notes/tooling.md`.
53//!
54//! A log file is the fourth, and the only one that is opt-in: [`attach_file`]
55//! sends every line stderr gets to a rotated file as well, for the build that
56//! has no stderr — a `windows_subsystem = "windows"` exe — and chains a panic
57//! hook that writes the panic's own message there too. Nothing opens one
58//! unless a game calls that, or a player sets [`FILE_ENV_VAR`] for a front end
59//! that honours it.
60//!
61//! Filtering is `env_logger`-style, read from `CRCBL_LOG`:
62//!
63//! ```text
64//! CRCBL_LOG=info                          # global level
65//! CRCBL_LOG=warn,crcbl_render=debug       # global level plus per-module overrides
66//! CRCBL_LOG=off,crcbl_vk=trace            # silence everything but one module
67//! ```
68//!
69//! A directive's target matches a module path prefix at `::` boundaries, and
70//! the **longest** matching directive wins, so `crcbl_render=debug` and
71//! `crcbl_render::graph=trace` compose the way you would expect.
72//!
73//! It is settable while the engine runs, through [`set_filter`] and the `log`
74//! console command below, so a directive can be widened at the moment something
75//! is going wrong rather than on the next launch.
76//!
77//! **The filter is this module's, not a sink's.** `crcbl::web`'s queueing
78//! logger takes the process's slot in a browser, where there is no stderr to
79//! write to, so a filter kept inside `StderrLogger` would have been unreadable
80//! and unsettable for a whole tier — which is what `log` reported there until
81//! [`register_sink`] and [`sink_permits`] existed. A sink outside this crate
82//! declares itself with the first and decides its records with the second, and
83//! then `log warn,crcbl_vk=trace` means the same thing in a browser as in a
84//! terminal, per-target directives included.
85
86pub mod console;
87mod file;
88mod panic_hook;
89
90use std::cell::RefCell;
91use std::env;
92use std::fmt;
93use std::io::{self, Write as _};
94use std::marker::PhantomData;
95use std::path::{Path, PathBuf};
96use std::sync::atomic::{AtomicBool, Ordering};
97use std::sync::{Mutex, OnceLock, PoisonError, RwLock, RwLockReadGuard};
98use std::time::{Duration, Instant};
99
100use ::log::{Log, Metadata, Record, SetLoggerError};
101use crcbl_console::Fault;
102
103/// The ring's two constants, up a level: they carry `CONSOLE_` in their names
104/// already, so `log::CONSOLE_TARGET` reads as well as `log::console::` does.
105/// The functions do not — `console::push` and `console::snapshot` say which
106/// log they mean and `push` alone would not — so they stay in [`console`].
107pub use console::{CONSOLE_RING_LINES, CONSOLE_TARGET};
108
109use file::FileSink;
110pub use file::{FILE_ENV_VAR, LOG_FILE_MAX_BYTES, LOG_FILES_KEPT};
111
112/// The five levels, and the filter form of them.
113///
114/// Re-exported so a caller can name `crcbl_core::log::Level` without depending
115/// on the `log` crate directly — the macros expand to this path, and a macro
116/// that named a crate its caller had not taken would not compile there. They
117/// **are** `log`'s types rather than copies: the sink bridges third-party
118/// records from `wgpu`, `naga` and `gpu-allocator` into the same path, so a
119/// parallel enum would only be something to convert through.
120pub use ::log::{Level, LevelFilter};
121
122/// The level macros, reachable as `crcbl_core::log::info!` as well as at the
123/// crate root.
124///
125/// `#[macro_export]` puts a macro at the crate root and nowhere else, which
126/// would leave the macros and the sink they write to in two different places.
127/// Re-exporting them here means `log` is one module whichever half a caller
128/// wants — and it is the path the engine already used, so the crates calling
129/// `crcbl::log::info!` did not have to be rewritten to reach ours.
130pub use crate::{debug, error, info, log_at, trace, warn};
131
132/// Environment variable holding the filter directives.
133pub const ENV_VAR: &str = "CRCBL_LOG";
134
135/// Filter used when `CRCBL_LOG` is unset or empty.
136pub const DEFAULT_FILTER: &str = "info";
137
138/// A parsed set of filter directives.
139#[derive(Clone, Debug)]
140pub struct Filter {
141    default: LevelFilter,
142    /// Module-path prefix → level, longest prefix first.
143    targets: Vec<(String, LevelFilter)>,
144}
145
146impl Filter {
147    /// [`DEFAULT_FILTER`], as a `const`.
148    ///
149    /// [`FILTER`] is a `static` and a `RwLock<Filter>` can only be built in one
150    /// without a lazy initialiser if the value inside it is const-constructible,
151    /// which `Filter::parse(DEFAULT_FILTER)` is not. Spelling the same filter
152    /// twice is the price, and
153    /// `the_const_initial_filter_is_the_default_one_parsed` is what stops the
154    /// two drifting.
155    const INITIAL: Self = Self {
156        default: LevelFilter::Info,
157        targets: Vec::new(),
158    };
159
160    /// Parses a comma-separated directive list.
161    ///
162    /// Each directive is either a bare level (setting the default) or
163    /// `target=level`. Unparseable directives are skipped rather than fatal: a
164    /// typo in an environment variable must not stop the engine from starting.
165    /// [`try_parse`](Self::try_parse) is the same reading for a caller who can
166    /// be told.
167    #[must_use]
168    pub fn parse(directives: &str) -> Self {
169        Self::read(directives).0
170    }
171
172    /// Parses, refusing the first directive it cannot read.
173    ///
174    /// What the `log` console command uses. [`parse`](Self::parse) skips a bad
175    /// directive because a typo in `CRCBL_LOG` must not stop the engine from
176    /// starting; a person typing at the console is there to be answered, and a
177    /// filter that silently ignored half of what they wrote would be read as one
178    /// that did not work.
179    ///
180    /// # Errors
181    ///
182    /// The first directive that is neither a level nor `target=level`, as it was
183    /// written.
184    pub fn try_parse(directives: &str) -> Result<Self, BadDirective> {
185        let (filter, refused) = Self::read(directives);
186        match refused.into_iter().next() {
187            Some(directive) => Err(BadDirective(directive)),
188            None => Ok(filter),
189        }
190    }
191
192    /// The one reading of a directive list: the filter, and what it could not
193    /// read.
194    ///
195    /// Both public forms come through here, so the rule for what a directive
196    /// means cannot differ between the one that skips and the one that refuses.
197    fn read(directives: &str) -> (Self, Vec<String>) {
198        // Anything not named by a directive keeps the default level; a filter
199        // string of only overrides ("crcbl_vk=trace") must not silence the
200        // rest of the engine.
201        let mut filter = Self {
202            default: parse_level(DEFAULT_FILTER).unwrap_or(LevelFilter::Info),
203            targets: Vec::new(),
204        };
205        let mut refused = Vec::new();
206        for directive in directives.split(',') {
207            let directive = directive.trim();
208            if directive.is_empty() {
209                continue;
210            }
211            match directive.split_once('=') {
212                Some((target, level)) => {
213                    if let Some(level) = parse_level(level.trim()) {
214                        filter.targets.push((target.trim().to_owned(), level));
215                    } else {
216                        refused.push(directive.to_owned());
217                    }
218                }
219                None => {
220                    if let Some(level) = parse_level(directive) {
221                        filter.default = level;
222                    } else {
223                        refused.push(directive.to_owned());
224                    }
225                }
226            }
227        }
228        // Longest prefix first, so `find` below picks the most specific match.
229        filter
230            .targets
231            .sort_by_key(|(target, _)| core::cmp::Reverse(target.len()));
232        (filter, refused)
233    }
234
235    /// Reads the filter from `CRCBL_LOG`, falling back to [`DEFAULT_FILTER`].
236    #[must_use]
237    pub fn from_env() -> Self {
238        match env::var(ENV_VAR) {
239            Ok(value) if !value.trim().is_empty() => Self::parse(&value),
240            _ => Self::parse(DEFAULT_FILTER),
241        }
242    }
243
244    /// The level in force for `target`.
245    #[must_use]
246    pub fn level_for(&self, target: &str) -> LevelFilter {
247        self.targets
248            .iter()
249            .find(|(prefix, _)| target_matches(target, prefix))
250            .map_or(self.default, |(_, level)| *level)
251    }
252
253    /// Whether this filter admits a record at `level` from `target`.
254    ///
255    /// The one reading of "does the filter allow this", so the stderr sink and
256    /// a sink outside this crate — [`sink_permits`] — cannot decide a record
257    /// differently.
258    fn permits(&self, level: Level, target: &str) -> bool {
259        level <= self.level_for(target)
260    }
261
262    /// The loosest level any target can produce — what the `log` facade needs
263    /// for its global fast path.
264    #[must_use]
265    pub fn max_level(&self) -> LevelFilter {
266        self.targets
267            .iter()
268            .map(|(_, level)| *level)
269            .chain([self.default])
270            .max()
271            .unwrap_or(LevelFilter::Off)
272    }
273}
274
275impl Default for Filter {
276    fn default() -> Self {
277        Self::parse(DEFAULT_FILTER)
278    }
279}
280
281/// Writes the directives back in the form [`Filter::parse`] accepts.
282///
283/// So `log` can print a filter that can be typed straight back in, and so the
284/// two spellings cannot drift: `the_filter_round_trips_through_its_own_display`
285/// re-parses this.
286///
287/// The default level comes first and the overrides follow it, longest prefix
288/// first, which is the order they are matched in and not the order they were
289/// written in — a re-parse sorts them the same way, so the round trip is stable
290/// rather than merely equivalent.
291impl fmt::Display for Filter {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        f.write_str(level_directive(self.default))?;
294        for (target, level) in &self.targets {
295            write!(f, ",{target}={}", level_directive(*level))?;
296        }
297        Ok(())
298    }
299}
300
301/// A directive [`Filter::try_parse`] could not read.
302#[derive(Clone, Debug, PartialEq, Eq)]
303pub struct BadDirective(String);
304
305impl BadDirective {
306    /// The directive, as it was written.
307    #[must_use]
308    pub fn directive(&self) -> &str {
309        &self.0
310    }
311}
312
313impl fmt::Display for BadDirective {
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        write!(
316            f,
317            "`{}` is not a filter directive: write a level, or `target=level`",
318            self.0
319        )
320    }
321}
322
323impl std::error::Error for BadDirective {}
324
325/// Whether `target` is `prefix` or a module below it.
326fn target_matches(target: &str, prefix: &str) -> bool {
327    target.starts_with(prefix)
328        && (target.len() == prefix.len() || target[prefix.len()..].starts_with("::"))
329}
330
331/// How a directive spells a level — which is not how [`LevelFilter`] does:
332/// `Display` for that is `OFF`, and a directive is read case-insensitively but
333/// written lower case, the way `CRCBL_LOG` is set on a command line.
334const fn level_directive(level: LevelFilter) -> &'static str {
335    match level {
336        LevelFilter::Off => "off",
337        LevelFilter::Error => "error",
338        LevelFilter::Warn => "warn",
339        LevelFilter::Info => "info",
340        LevelFilter::Debug => "debug",
341        LevelFilter::Trace => "trace",
342    }
343}
344
345fn parse_level(text: &str) -> Option<LevelFilter> {
346    match text.to_ascii_lowercase().as_str() {
347        "off" | "none" => Some(LevelFilter::Off),
348        "error" => Some(LevelFilter::Error),
349        "warn" | "warning" => Some(LevelFilter::Warn),
350        "info" => Some(LevelFilter::Info),
351        "debug" => Some(LevelFilter::Debug),
352        "trace" => Some(LevelFilter::Trace),
353        _ => None,
354    }
355}
356
357/// The stderr logger installed by [`init_logging`].
358///
359/// It holds no filter of its own: [`FILTER`] is the process's one filter and
360/// every sink that honours it reads that, which is what lets [`set_filter`]
361/// mean the same thing whichever sink took the slot — see [`register_sink`].
362#[derive(Debug)]
363struct StderrLogger {
364    start: Instant,
365    /// The log file, once [`attach_file`] has opened one.
366    ///
367    /// A `Mutex` because any thread may log: it is what keeps two threads'
368    /// lines from interleaving inside the file, the way stderr's own lock does
369    /// for stderr. It is only taken for a record that passed the filter, and
370    /// only while one `write_all` runs.
371    file: Mutex<Option<FileSink>>,
372}
373
374impl StderrLogger {
375    fn new() -> Self {
376        Self {
377            start: Instant::now(),
378            file: Mutex::new(None),
379        }
380    }
381
382    /// Opens a rotated log file in `dir` and sends every later line there too.
383    ///
384    /// The file starts with the run's banner, written straight to it rather
385    /// than through the filter, so a file opened after start-up still says when
386    /// its run began — even under `CRCBL_LOG=off`.
387    fn attach(&self, dir: &Path, stem: &str, kept: usize, max_bytes: u64) -> io::Result<PathBuf> {
388        let mut slot = self.file.lock().unwrap_or_else(PoisonError::into_inner);
389        if let Some(open) = slot.as_ref() {
390            return Err(io::Error::new(
391                io::ErrorKind::AlreadyExists,
392                format!("this run already logs to {}", open.path().display()),
393            ));
394        }
395        let mut sink = FileSink::open(dir, stem, kept, max_bytes)?;
396        sink.write_line(&line(
397            self.start.elapsed(),
398            Level::Info,
399            "crcbl_core::log",
400            format_args!("run started {}", start_banner()),
401        ));
402        let path = sink.path().to_owned();
403        *slot = Some(sink);
404        Ok(path)
405    }
406
407    /// Whether the filter admits `metadata`. This, and only this, decides what
408    /// reaches stderr — [`Log::enabled`] below widens for capture, and a record
409    /// let through on that account must still not be printed.
410    fn permits(&self, metadata: &Metadata<'_>) -> bool {
411        read_filter().permits(metadata.level(), metadata.target())
412    }
413
414    /// Capture, ring, filter and write one record.
415    ///
416    /// **The one path.** This module's own macros call it directly and the
417    /// [`Log`] impl below funnels third-party records into it, so there is a
418    /// single filter check, a single capture point and a single line format
419    /// rather than one of each per entry point.
420    fn emit(&self, level: Level, target: &str, args: fmt::Arguments<'_>) {
421        // Seconds since init, not a date: the useful question in a frame loop is
422        // "how long into the run". The wall-clock time the run *started* is
423        // written once by `init_logging`, which is what makes a line here
424        // correlatable with an outside log without paying date formatting per
425        // line — see `start_banner`.
426        let elapsed = self.start.elapsed();
427        // Both before the filter: capture for the reason `enabled` gives, and
428        // the console ring so the panel can show what the terminal did not.
429        push_captured(level, target, args);
430        console::push(level, target, elapsed, args);
431        if !self.permits(&Metadata::builder().level(level).target(target).build()) {
432            return;
433        }
434        // Formatted once for both sinks and written with one call to each, so a
435        // line reaches the file whole and exactly as stderr printed it.
436        let line = line(elapsed, level, target, args);
437        // A failed log write must never take the process down — and a
438        // GUI-subsystem exe has no stderr, so this one fails on every line there.
439        let _ = std::io::stderr().lock().write_all(line.as_bytes());
440        if let Some(file) = self
441            .file
442            .lock()
443            .unwrap_or_else(PoisonError::into_inner)
444            .as_mut()
445        {
446            file.write_line(&line);
447        }
448    }
449}
450
451impl Log for StderrLogger {
452    fn enabled(&self, metadata: &Metadata<'_>) -> bool {
453        // A capturing thread sees everything, whatever `CRCBL_LOG` says: a test
454        // asserting the engine said something must not turn on the developer's
455        // environment. Off this thread — which is every thread in a normal run
456        // — the answer is the filter's, unchanged.
457        self.permits(metadata) || capturing()
458    }
459
460    fn log(&self, record: &Record<'_>) {
461        self.emit(record.level(), record.target(), *record.args());
462    }
463
464    fn flush(&self) {
465        let _ = std::io::stderr().lock().flush();
466    }
467}
468
469/// Emits one record through **whatever logger the process installed**.
470///
471/// Not public API: the macros expand to a call here. It is `#[doc(hidden)]`
472/// rather than private because a `macro_rules!` expands in the *caller's* crate
473/// and has to be able to name it.
474///
475/// **Dispatch goes through the `log` facade rather than straight to
476/// [`StderrLogger`], and that is load-bearing.** This crate's sink is not the
477/// only one: `wasm32` installs `crcbl::web`'s queue instead, because a browser
478/// has no stderr and `Instant::now` panics there. A version of this that
479/// reached for `LOGGER` directly compiles everywhere and silently drops every
480/// engine log line in the browser — which is exactly what the `web/` smoke test
481/// caught when it was written that way.
482///
483/// A process that installed no logger drops the record, which is what the
484/// facade already did.
485#[doc(hidden)]
486pub fn __emit(level: Level, target: &str, args: fmt::Arguments<'_>) {
487    ::log::logger().log(
488        &Record::builder()
489            .level(level)
490            .target(target)
491            .args(args)
492            .build(),
493    );
494}
495
496/// Whether a record at `level` from `target` would go anywhere.
497///
498/// The macros check this before their `format_args!`, so a filtered-out
499/// `debug!` never evaluates the expressions it was passed. Both halves are
500/// asked: `max_level` is the facade's cheap global gate, and `enabled` is the
501/// installed logger's own answer — which is what lets a capturing thread see
502/// records the filter would otherwise drop.
503#[doc(hidden)]
504#[must_use]
505pub fn __enabled(level: Level, target: &str) -> bool {
506    level <= ::log::max_level()
507        && ::log::logger().enabled(&Metadata::builder().level(level).target(target).build())
508}
509
510/// One record as every sink writes it, newline included.
511fn line(elapsed: Duration, level: Level, target: &str, args: fmt::Arguments<'_>) -> String {
512    format!(
513        "[{elapsed:9.4}s {level:<5} {target}] {args}\n",
514        elapsed = elapsed.as_secs_f64(),
515        level = level_name(level),
516    )
517}
518
519const fn level_name(level: Level) -> &'static str {
520    match level {
521        Level::Error => "ERROR",
522        Level::Warn => "WARN",
523        Level::Info => "INFO",
524        Level::Debug => "DEBUG",
525        Level::Trace => "TRACE",
526    }
527}
528
529/// Logs at an explicit [`Level`], taking `format!` arguments.
530///
531/// The five level macros below forward to this, so the three things that are
532/// the same for all of them — where the target comes from, that the filter is
533/// asked before the arguments are built, and how a record reaches the sink —
534/// are written once.
535///
536/// **The target is the calling module's path**, which is what `CRCBL_LOG`'s
537/// per-module directives match against: `CRCBL_LOG=warn,crcbl_render=debug`.
538/// `module_path!` expands where the macro is *used*, so this is the caller's
539/// module and not this one.
540///
541/// **The argument expressions are not evaluated unless something would read
542/// them.** The level check comes first, so a filtered-out call costs a
543/// comparison rather than running whatever the caller passed. Narrower than it
544/// sounds: `format_args!` already defers the *formatting*, so a `Display` impl
545/// would not run either way — what the check saves is evaluating the arguments
546/// and calling into the sink at all.
547///
548/// ```
549/// crcbl_core::log_at!(crcbl_core::log::Level::Info, "one {} and {}", "value", 2);
550/// ```
551#[macro_export]
552macro_rules! log_at {
553    ($level:expr, $($arg:tt)+) => {{
554        let level = $level;
555        let target = ::core::module_path!();
556        if $crate::log::__enabled(level, target) {
557            $crate::log::__emit(level, target, ::core::format_args!($($arg)+));
558        }
559    }};
560}
561
562/// Logs at [`Level::Error`]: something the run could not do.
563///
564/// Takes `format!` arguments. See [`log_at!`] for the target and for when the
565/// arguments are built.
566///
567/// ```
568/// crcbl_core::error!("the device went away: {}", "no adapter");
569/// ```
570#[macro_export]
571macro_rules! error {
572    ($($arg:tt)+) => { $crate::log_at!($crate::log::Level::Error, $($arg)+) };
573}
574
575/// Logs at [`Level::Warn`]: something that will surprise someone later.
576///
577/// ```
578/// crcbl_core::warn!("no config dir; values will not persist");
579/// ```
580#[macro_export]
581macro_rules! warn {
582    ($($arg:tt)+) => { $crate::log_at!($crate::log::Level::Warn, $($arg)+) };
583}
584
585/// Logs at [`Level::Info`]: what the run is doing, at the default level.
586///
587/// ```
588/// crcbl_core::info!("shell: first configure at {}x{}", 960, 720);
589/// ```
590#[macro_export]
591macro_rules! info {
592    ($($arg:tt)+) => { $crate::log_at!($crate::log::Level::Info, $($arg)+) };
593}
594
595/// Logs at [`Level::Debug`]: detail for someone reading this subsystem.
596///
597/// ```
598/// crcbl_core::debug!("shell event: {:?}", "Resized");
599/// ```
600#[macro_export]
601macro_rules! debug {
602    ($($arg:tt)+) => { $crate::log_at!($crate::log::Level::Debug, $($arg)+) };
603}
604
605/// Logs at [`Level::Trace`]: every step, for when nothing else has worked.
606///
607/// **Shares a name with [`mod@crate::trace`], the profiler module**, which is legal
608/// — they are in different namespaces — but means a doc link to either has to
609/// say which: `[mod@trace]` for the module, `[trace!]` for this. `use
610/// crcbl_core::trace;` imports the module and leaves this reachable as
611/// `crcbl_core::trace!`.
612///
613/// ```
614/// crcbl_core::trace!("pumped {} events", 3);
615/// ```
616#[macro_export]
617macro_rules! trace {
618    ($($arg:tt)+) => { $crate::log_at!($crate::log::Level::Trace, $($arg)+) };
619}
620
621/// The process-wide logger.
622///
623/// A `static` rather than a boxed logger so this module works against `log`'s
624/// default features (`set_boxed_logger` needs the crate's `std` feature) and
625/// leaks nothing.
626static LOGGER: OnceLock<StderrLogger> = OnceLock::new();
627
628/// Whether [`LOGGER`] is the logger the `log` crate is actually calling.
629///
630/// Separate from `LOGGER.get().is_some()`, which answers a different question
631/// and was what [`is_installed`] used to read. [`try_init_logging`] has to
632/// build the logger *before* offering it to `log::set_logger`, which takes a
633/// `&'static dyn Log` — so on a process where a host application already owns
634/// the slot, `LOGGER` is initialised and then rejected, and the old answer
635/// reported an install that did not happen.
636///
637/// `Relaxed` is enough in both directions: this flag publishes no data. The
638/// logger itself is published by `OnceLock` and by `log::set_logger`'s own
639/// synchronisation, so a caller that reads `true` here and then logs is
640/// ordered by those, not by this.
641static INSTALLED: AtomicBool = AtomicBool::new(false);
642
643/// Whether a sink outside this module has declared that it honours [`FILTER`].
644///
645/// [`register_sink`] sets it and nothing clears it. Read beside [`INSTALLED`]
646/// by [`honoured`], which is the question [`filter`] and [`set_filter`] both
647/// actually ask: "is there a sink that would notice?"
648///
649/// `Relaxed` for [`INSTALLED`]'s reason — the flag publishes no data, and the
650/// filter it speaks for is published by its own `RwLock`.
651static SINK_REGISTERED: AtomicBool = AtomicBool::new(false);
652
653/// The one filter the process applies, whichever sink is installed.
654///
655/// Behind an `RwLock` because [`set_filter`] swaps it while the engine runs:
656/// every record takes the read side and they do not contend with each other,
657/// which a `Mutex` would not manage, and swapping is rare enough that the write
658/// side never queues behind anything. The alternative — an atomically swapped
659/// `Arc` — buys a lock-free read at the price of a dependency this crate does
660/// not have.
661///
662/// **A module-level static rather than a field on [`StderrLogger`]**, because
663/// that sink is not the only one: `crcbl::web`'s queueing logger takes the slot
664/// in a browser and honours this same filter through [`sink_permits`]. Held in
665/// one place, `log warn,crcbl_vk=trace` means the same thing on both tiers; held
666/// per sink, the web one could only ever have been the facade's global maximum.
667static FILTER: RwLock<Filter> = RwLock::new(Filter::INITIAL);
668
669/// The filter in force, borrowed rather than cloned: this is read once per
670/// record, and a clone would allocate a `Vec<String>` for every log line.
671///
672/// A poisoned lock is stepped over for the reason [`console::push`] gives about
673/// the ring's: a logger that panicked must not stop the run from saying what
674/// happened next.
675fn read_filter() -> RwLockReadGuard<'static, Filter> {
676    FILTER.read().unwrap_or_else(PoisonError::into_inner)
677}
678
679/// Puts `filter` in force and moves the facade's global maximum with it.
680///
681/// **The maximum first**: between the two writes a sink is asked about records
682/// the new filter admits, and the old filter is what decides them. The other
683/// order drops records the caller just asked for.
684fn store_filter(filter: Filter) {
685    ::log::set_max_level(filter.max_level());
686    *FILTER.write().unwrap_or_else(PoisonError::into_inner) = filter;
687}
688
689/// Whether any installed sink honours [`FILTER`].
690///
691/// The question [`filter`] and [`set_filter`] ask: a process whose logger is
692/// some host application's has a filter here that decides nothing, and saying
693/// so is the only honest answer to `log`.
694fn honoured() -> bool {
695    is_installed() || SINK_REGISTERED.load(Ordering::Relaxed)
696}
697
698/// Installs the stderr logger using the filter from `CRCBL_LOG`.
699///
700/// Idempotent and never fatal: if a logger is already installed (a host
701/// application, a test harness, a second call) this is a no-op returning
702/// `false`. Call it once at startup; anything that *needs* to know whether it
703/// won the race can use [`try_init_logging`].
704pub fn init_logging() -> bool {
705    try_init_logging(Filter::from_env()).is_ok()
706}
707
708/// Installs the stderr logger with an explicit filter.
709///
710/// # Errors
711///
712/// If a logger is already installed for the process — including by an earlier
713/// call to this function, whose filter stays in force.
714pub fn try_init_logging(filter: Filter) -> Result<(), SetLoggerError> {
715    let logger = LOGGER.get_or_init(StderrLogger::new);
716    ::log::set_logger(logger)?;
717    INSTALLED.store(true, Ordering::Relaxed);
718    // After the slot is won, so a second call's filter never displaces the
719    // first's — the `?` above is what turns that one back. The window between
720    // the two lines is a fresh process's `log::max_level()`, which is `Off`, so
721    // in the ordinary case no record is offered to any sink before the filter
722    // this caller asked for is in force.
723    store_filter(filter);
724    // Only the winner of the race writes it, so a second `init_logging` does not
725    // claim the run restarted.
726    logger.emit(
727        Level::Info,
728        "crcbl_core::log",
729        format_args!("run started {}", start_banner()),
730    );
731    Ok(())
732}
733
734/// The wall-clock time the run started, as `YYYY-MM-DD HH:MM:SS UTC`.
735///
736/// **Written once, at start-up, and never per line.** Every line carries
737/// seconds-since-start instead, which is the question a frame loop asks; this
738/// is the one value that lets those seconds be lined up against something
739/// outside the process, and paying a date conversion for it once is the whole
740/// point of not putting it on every line.
741///
742/// UTC rather than local time, because a local one needs the platform's
743/// timezone database and the rules that go with it — exactly the kind of
744/// arithmetic that is someone else's solved problem. The civil-date conversion
745/// below is only the proleptic Gregorian calendar from a Unix timestamp, which
746/// is a closed form with no zone rules in it.
747///
748/// Falls back to naming the epoch offset if the system clock is before 1970,
749/// rather than guessing: a clock that far wrong is worth seeing.
750fn start_banner() -> String {
751    let Ok(since_epoch) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) else {
752        return "at an unknown time (the system clock is before 1970)".to_owned();
753    };
754    let secs = since_epoch.as_secs();
755    let (year, month, day) = civil_from_days((secs / 86_400) as i64);
756    let time = secs % 86_400;
757    format!(
758        "{year:04}-{month:02}-{day:02} {h:02}:{m:02}:{s:02} UTC",
759        h = time / 3600,
760        m = (time % 3600) / 60,
761        s = time % 60,
762    )
763}
764
765/// Days since 1970-01-01 to a civil `(year, month, day)`.
766///
767/// Howard Hinnant's `civil_from_days`, the algorithm `<chrono>` is specified
768/// against — transcribed rather than invented, because calendar arithmetic is
769/// the classic place a plausible-looking version is wrong only on the days
770/// nobody tests. It shifts the era to start in March so the leap day lands at
771/// the end of a year and the month-length pattern becomes a closed form.
772///
773/// `days_from_civil_round_trips_every_day_for_four_centuries` checks it against
774/// a full 400-year cycle, which is the period the Gregorian calendar repeats on.
775const fn civil_from_days(days: i64) -> (i64, u32, u32) {
776    let z = days + 719_468;
777    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
778    let day_of_era = z - era * 146_097;
779    let year_of_era =
780        (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
781    let year = year_of_era + era * 400;
782    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
783    let shifted_month = (5 * day_of_year + 2) / 153;
784    let day = (day_of_year - (153 * shifted_month + 2) / 5 + 1) as u32;
785    let month = if shifted_month < 10 {
786        shifted_month + 3
787    } else {
788        shifted_month - 9
789    } as u32;
790    (if month <= 2 { year + 1 } else { year }, month, day)
791}
792
793/// Sends this run's log to `<dir>/<stem>.log` as well as stderr, rotating
794/// earlier runs' files first.
795///
796/// **The opt-in half of a log that survives a build with no console.** Call it
797/// after [`init_logging`]; nothing opens a file otherwise. Earlier runs shift to
798/// `<stem>.1.log`, `<stem>.2.log` and so on, keeping [`LOG_FILES_KEPT`] of them,
799/// and this run's file stops at [`LOG_FILE_MAX_BYTES`]. Every line is written
800/// through to the operating system before the logging call returns, so the last
801/// lines before a panic or a crash are in the file.
802///
803/// **It also installs a panic hook**, because the default one prints a panic's
804/// message and location to stderr alone, which is exactly what such a build
805/// lacks. The hook writes one `ERROR panic` line — the message, `file:line:col`
806/// and the panicking thread's name — to the file and then calls the hook it
807/// replaced, so stderr's report and any hook the game set earlier still run. A
808/// game that owns the process's hook and wants nothing chained in front of it
809/// calls [`attach_file_without_panic_hook`] instead. A hook set *after* this
810/// call replaces this one unless it chains, as this one does, to
811/// [`std::panic::take_hook`]'s result.
812///
813/// `crcbl_store::enable_log_file` is the usual way in: it picks the platform's
814/// directory for logs and turns a failure into a warning. This takes the
815/// directory explicitly, for a caller that has its own.
816///
817/// Returns the path of this run's file. A hook that could not be installed is
818/// a warning through the log, not an error: the file is open either way.
819///
820/// # Errors
821///
822/// When this module's logger is not the one installed (see [`is_installed`]),
823/// when a file is already attached, when `stem` is not a plain file name, and
824/// when the filesystem refuses the directory, the rotation or the file. Logging
825/// to stderr carries on in every case, and no panic hook is installed.
826pub fn attach_file(dir: &Path, stem: &str) -> io::Result<PathBuf> {
827    let logger = installed_logger()?;
828    let path = logger.attach(dir, stem, LOG_FILES_KEPT, LOG_FILE_MAX_BYTES)?;
829    // After a successful attach only, and `attach` refuses a second file for
830    // the run — which is what makes this at most once per process.
831    if let Err(error) = panic_hook::install(logger) {
832        warn!(
833            "log: panics will not be written to {} ({error})",
834            path.display()
835        );
836    }
837    Ok(path)
838}
839
840/// [`attach_file`] without the panic hook, for a game that manages the
841/// process's panic hook itself.
842///
843/// A panic's own message and location then reach only whatever that hook
844/// writes; every line logged before it is in the file as usual.
845///
846/// # Errors
847///
848/// As [`attach_file`].
849pub fn attach_file_without_panic_hook(dir: &Path, stem: &str) -> io::Result<PathBuf> {
850    installed_logger()?.attach(dir, stem, LOG_FILES_KEPT, LOG_FILE_MAX_BYTES)
851}
852
853/// [`LOGGER`], when it is the logger the process is calling.
854fn installed_logger() -> io::Result<&'static StderrLogger> {
855    match LOGGER.get() {
856        Some(logger) if is_installed() => Ok(logger),
857        _ => Err(io::Error::other(
858            "the engine's logger is not the process's, so there is no log to copy to a file",
859        )),
860    }
861}
862
863/// The file [`attach_file`] opened for this run, if it opened one.
864#[must_use]
865pub fn file_path() -> Option<PathBuf> {
866    let logger = LOGGER.get()?;
867    let slot = logger.file.lock().unwrap_or_else(PoisonError::into_inner);
868    slot.as_ref().map(|sink| sink.path().to_owned())
869}
870
871/// Whether the player asked for a log file, through [`FILE_ENV_VAR`].
872///
873/// `1`, `true`, `on` and `yes` ask; anything else, including nothing, does not.
874/// Reading it opens nothing: a front end honours it by calling
875/// `crcbl_store::enable_log_file`, as `crcbl::args::run_front_end` does.
876#[must_use]
877pub fn file_requested() -> bool {
878    file_requested_by(env::var(FILE_ENV_VAR).ok().as_deref())
879}
880
881/// The pure half of [`file_requested`], so the spellings are testable without
882/// an environment.
883fn file_requested_by(value: Option<&str>) -> bool {
884    value.and_then(crate::trace::parse_enabled) == Some(true)
885}
886
887/// Whether this module installed the process logger.
888///
889/// `false` when a host application, a test harness or anything else won the
890/// process's single logger slot first — this module built a logger in that case
891/// and `log` never took it, so nothing it emits reaches this one's filter or
892/// its stderr.
893///
894/// Deliberately not "did this module build a logger", which answers a different
895/// question: [`try_init_logging`] has to build one before offering it to
896/// `log::set_logger`, so on a process where somebody else won, that check
897/// reports an install that was rejected.
898#[must_use]
899pub fn is_installed() -> bool {
900    INSTALLED.load(Ordering::Relaxed)
901}
902
903// ---------------------------------------------------------------------------
904// The live filter
905// ---------------------------------------------------------------------------
906
907/// Declares that the process's logger is a sink this module does not own, and
908/// puts `filter` in force for it.
909///
910/// **What `crcbl::web`'s queueing logger calls the moment it wins the slot.**
911/// A browser has no stderr, so `StderrLogger` is never installed there and
912/// [`is_installed`] is `false` for the whole run — which used to mean
913/// [`filter`] answered `None` and [`set_filter`] refused, so the `log` console
914/// command could only report that there was no filter to move. The filter is
915/// this module's rather than a sink's, so a sink that reads it through
916/// [`sink_permits`] gets the same per-target directives the terminal does; the
917/// registration is how this module knows somebody is reading.
918///
919/// Call it **after** `log::set_logger` has succeeded. A sink that lost the slot
920/// registering here would put a filter in force for a logger that is not
921/// running, which is the dishonest answer [`is_installed`] exists to avoid.
922///
923/// Nothing unregisters: a process installs one logger and keeps it.
924pub fn register_sink(filter: Filter) {
925    SINK_REGISTERED.store(true, Ordering::Relaxed);
926    store_filter(filter);
927}
928
929/// Whether the live filter admits a record at `level` from `target`.
930///
931/// The predicate a sink outside this crate answers `log::Log::enabled` with —
932/// `crcbl::web`'s is the one caller — so that the directives typed at the
933/// console decide its records the way `StderrLogger::permits` decides
934/// stderr's.
935///
936/// **Falls back to the facade's global maximum when no sink has registered**,
937/// which is what a sink that never called [`register_sink`] did before this
938/// existed: an unregistered sink has no filter of this module's to honour, and
939/// answering `false` would silence it outright.
940#[must_use]
941pub fn sink_permits(level: Level, target: &str) -> bool {
942    if !SINK_REGISTERED.load(Ordering::Relaxed) {
943        return level <= ::log::max_level();
944    }
945    read_filter().permits(level, target)
946}
947
948/// The filter the installed logger is applying.
949///
950/// `None` when no installed sink honours it — a host application won the
951/// process's logger slot, or nothing has installed anything yet. There is no
952/// filter to report in that case, and reporting the one this module holds would
953/// describe a sink nothing is writing to.
954#[must_use]
955pub fn filter() -> Option<Filter> {
956    honoured().then(|| read_filter().clone())
957}
958
959/// Swaps the filter the installed sink applies, from the next record on.
960///
961/// Returns `false` when no installed sink honours it, in which case nothing
962/// changed — see [`filter`], [`is_installed`] and [`register_sink`].
963///
964/// **The facade's global maximum is set to match, exactly as installing does.**
965/// That is what makes a widened filter reach the sink at all: a call site above
966/// `log::max_level()` never builds its arguments, so raising the directive
967/// without raising the maximum would change nothing. It cuts the other way too —
968/// narrowing here stops those records reaching the console ring as well as
969/// stderr, and a [`capture`] running on another thread stops seeing them.
970pub fn set_filter(filter: Filter) -> bool {
971    if !honoured() {
972        return false;
973    }
974    store_filter(filter);
975    true
976}
977
978crcbl_console::concommand! {
979    /// Show the log filter, or install directives: `log warn,crcbl_render=debug`.
980    pub fn log(cx, args) {
981        if args.is_empty() {
982            let Some(current) = filter() else {
983                return Err(Fault::new(
984                    "the process's logger honours no engine filter, so there is none to read",
985                ));
986            };
987            cx.print(format!("log {current}"));
988            return Ok(());
989        }
990        // Joined with a comma rather than a space: the directive list has no
991        // spaces in it, so every way of typing one — `log warn,crcbl_vk=trace`,
992        // `log warn, crcbl_vk=trace`, `log warn crcbl_vk=trace` — reads as the
993        // list the person meant.
994        let directives = args.join(",");
995        let wanted = Filter::try_parse(&directives).map_err(|bad| Fault::new(bad.to_string()))?;
996        // The line is the filter as it will be matched, not the text as it was
997        // typed, so `log warn crcbl_vk=trace` answers with the list it became.
998        let installed = wanted.to_string();
999        if !set_filter(wanted) {
1000            return Err(Fault::new(
1001                "the process's logger honours no engine filter, so it was left alone",
1002            ));
1003        }
1004        cx.print(format!("log {installed}"));
1005        Ok(())
1006    }
1007}
1008
1009// ---------------------------------------------------------------------------
1010// Capture
1011// ---------------------------------------------------------------------------
1012
1013/// One record the installed logger saw, reduced to the three things an
1014/// assertion is written against.
1015///
1016/// The message is rendered here rather than kept as `Arguments`, which borrows
1017/// from the stack frame of the `log!` that built it and cannot outlive the call.
1018#[derive(Clone, Debug, PartialEq, Eq)]
1019pub struct CapturedRecord {
1020    /// The level the record was logged at.
1021    pub level: Level,
1022    /// The record's target — the module path, unless the call overrode it.
1023    pub target: String,
1024    /// The formatted message.
1025    pub message: String,
1026}
1027
1028/// Target of the record [`capture`] logs to prove the capture is wired up.
1029const PROBE_TARGET: &str = "crcbl_core::log::capture";
1030
1031/// Serialises the *start* of a capture, and nothing else.
1032///
1033/// Starting one installs the logger, raises the facade's global maximum and
1034/// then probes it, and those three steps are not atomic. Two threads starting a
1035/// capture at the same moment both call [`init_logging`], and the one that
1036/// loses the `set_logger` race can still be inside [`try_init_logging`] — whose
1037/// last act is to set the maximum to *its* filter, putting it back under the
1038/// other thread's probe. The probe then never arrives and the assertion below
1039/// fires on code that is perfectly fine. Seen as a 1-in-50 failure of this
1040/// crate's own capture tests under a thread-per-test runner, which is exactly
1041/// the flake this mechanism exists to avoid producing.
1042///
1043/// Held across the setup only. Captures themselves run concurrently: their
1044/// buffers are thread-local and share nothing.
1045static CAPTURE_SETUP: Mutex<()> = Mutex::new(());
1046
1047thread_local! {
1048    /// The calling thread's capture buffer, `None` when it is not capturing.
1049    ///
1050    /// **Thread-local, not global, and that is the whole design.** Tests in one
1051    /// binary run concurrently on many threads by default, so a single shared
1052    /// buffer would interleave two tests' records and make both assertions
1053    /// depend on the scheduler. The alternatives were a global buffer behind a
1054    /// mutex the API forces every capturing test to hold — which serialises the
1055    /// suite and deadlocks the moment a test panics with it held — or making
1056    /// capture a separate `log::Log` registration, which cannot work at all:
1057    /// `log::set_logger` takes the process slot once and [`init_logging`] has
1058    /// already taken it. Scoping to the thread costs nothing, needs no lock,
1059    /// and is exactly right for a record logged synchronously by the code under
1060    /// test. Its one limit is stated on [`capture`]: work the test hands to
1061    /// another thread logs somewhere this cannot see.
1062    static CAPTURED: RefCell<Option<Vec<CapturedRecord>>> = const { RefCell::new(None) };
1063}
1064
1065/// Whether the calling thread is capturing.
1066fn capturing() -> bool {
1067    CAPTURED.with(|slot| slot.borrow().is_some())
1068}
1069
1070/// Adds a record to the calling thread's buffer, if it has one.
1071fn push_captured(level: Level, target: &str, args: fmt::Arguments<'_>) {
1072    if !capturing() {
1073        return;
1074    }
1075    // Rendered before the buffer is borrowed: `args` runs the caller's own
1076    // `Display` impls, and one of those logging in turn would re-enter here and
1077    // panic on the outstanding `RefCell` borrow.
1078    let captured = CapturedRecord {
1079        level,
1080        target: target.to_owned(),
1081        message: args.to_string(),
1082    };
1083    CAPTURED.with(|slot| {
1084        if let Some(buffer) = slot.borrow_mut().as_mut() {
1085            buffer.push(captured);
1086        }
1087    });
1088}
1089
1090/// Records logged by the calling thread, for as long as this value lives.
1091///
1092/// From [`capture`]. Neither `Send` nor `Sync`: the buffer is the *thread's*,
1093/// and dropping this on another thread would stop that thread's capture while
1094/// leaving this one's running for the rest of the process.
1095#[derive(Debug)]
1096pub struct Capture {
1097    _not_send: PhantomData<*const ()>,
1098}
1099
1100impl Capture {
1101    /// A copy of everything captured so far, oldest first.
1102    ///
1103    /// Copies rather than drains, so an assertion that fails can be followed by
1104    /// one that prints the whole buffer.
1105    #[must_use]
1106    pub fn records(&self) -> Vec<CapturedRecord> {
1107        CAPTURED.with(|slot| slot.borrow().clone().unwrap_or_default())
1108    }
1109}
1110
1111impl Drop for Capture {
1112    fn drop(&mut self) {
1113        CAPTURED.with(|slot| *slot.borrow_mut() = None);
1114    }
1115}
1116
1117/// Starts capturing the records **this thread** logs.
1118///
1119/// For a log line that is the only evidence a decision was taken — a capability
1120/// downgrade, a pacing resolution — so that deleting the line turns a test red
1121/// instead of nothing at all.
1122///
1123/// Capture is additive: stderr still gets whatever `CRCBL_LOG` admits, and a
1124/// process that never calls this behaves exactly as before.
1125///
1126/// # What it does and does not see
1127///
1128/// Everything the calling thread logs, at every level, regardless of
1129/// `CRCBL_LOG` — a test must not pass or fail on the developer's environment.
1130/// It sees nothing logged by any *other* thread, so a test that hands work to a
1131/// worker and asserts on what the worker logged needs a different mechanism
1132/// than this one.
1133///
1134/// # Panics
1135///
1136/// If this thread is already capturing — nesting would leave it ambiguous which
1137/// buffer a record belongs to — or if the capture cannot be proven to work,
1138/// which this checks by logging a record of its own and looking for it. That
1139/// second one means the process slot belongs to a logger that is not this
1140/// module's, or that `log`'s `release_max_level_*` features compiled the record
1141/// away before it was ever offered to a sink.
1142///
1143/// ```
1144/// let logs = crcbl_core::log::capture();
1145/// log::info!("the device does not have wings");
1146///
1147/// let records = logs.records();
1148/// assert_eq!(records.len(), 1);
1149/// assert_eq!(records[0].level, log::Level::Info);
1150/// assert_eq!(records[0].message, "the device does not have wings");
1151/// ```
1152#[must_use]
1153pub fn capture() -> Capture {
1154    // See `CAPTURE_SETUP` for what this excludes. The poison is stepped over
1155    // rather than propagated: this function panics on purpose below, and a test
1156    // that hit one of those must not take every later capture down with it.
1157    let _setup = CAPTURE_SETUP.lock().unwrap_or_else(PoisonError::into_inner);
1158
1159    // Nothing reaches a sink until there is one. Idempotent, and the return
1160    // value is deliberately unused: another crate's harness may have installed
1161    // this module's logger already, and the probe below is what decides whether
1162    // capture actually works — not who won this race.
1163    let _ = init_logging();
1164
1165    CAPTURED.with(|slot| {
1166        let mut slot = slot.borrow_mut();
1167        assert!(
1168            slot.is_none(),
1169            "this thread is already capturing log records"
1170        );
1171        *slot = Some(Vec::new());
1172    });
1173
1174    // The facade's global maximum drops a record before any logger is asked, so
1175    // capture has to lift it. Raised and never lowered: a `Drop` that put it
1176    // back would race a live capture on another thread, and raising it changes
1177    // no output, because the write to stderr is gated on the filter and not on
1178    // this.
1179    if ::log::max_level() < LevelFilter::Trace {
1180        ::log::set_max_level(LevelFilter::Trace);
1181    }
1182
1183    let capture = Capture {
1184        _not_send: PhantomData,
1185    };
1186
1187    // Prove the wiring instead of assuming it. If some other logger owns the
1188    // process slot this capture is silently empty forever, and every "the
1189    // engine said nothing" assertion written against it passes vacuously.
1190    ::log::trace!(target: PROBE_TARGET, "capture probe");
1191    let wired = CAPTURED.with(|slot| {
1192        let mut slot = slot.borrow_mut();
1193        let buffer = slot.as_mut().expect("this thread's capture was just set");
1194        let wired = buffer.iter().any(|record| record.target == PROBE_TARGET);
1195        // The probe is plumbing, not evidence; the caller must not have to
1196        // filter it out of every assertion.
1197        buffer.clear();
1198        wired
1199    });
1200    assert!(
1201        wired,
1202        "log capture is not wired up: the process logger is not crcbl-core's, \
1203         or this level was compiled out"
1204    );
1205
1206    capture
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211    use super::*;
1212
1213    #[test]
1214    fn bare_level_sets_the_default() {
1215        let filter = Filter::parse("debug");
1216        assert_eq!(filter.level_for("anything"), LevelFilter::Debug);
1217        assert_eq!(filter.max_level(), LevelFilter::Debug);
1218    }
1219
1220    #[test]
1221    fn empty_directives_fall_back_to_the_default_filter() {
1222        assert_eq!(Filter::parse("").level_for("crcbl_core"), LevelFilter::Info);
1223        assert_eq!(Filter::default().level_for("crcbl_core"), LevelFilter::Info);
1224    }
1225
1226    #[test]
1227    fn per_target_overrides_apply_to_submodules_only() {
1228        let filter = Filter::parse("warn,crcbl_render=debug");
1229        assert_eq!(filter.level_for("crcbl_core"), LevelFilter::Warn);
1230        assert_eq!(filter.level_for("crcbl_render"), LevelFilter::Debug);
1231        assert_eq!(filter.level_for("crcbl_render::graph"), LevelFilter::Debug);
1232        // Prefix match must respect module boundaries.
1233        assert_eq!(filter.level_for("crcbl_renderer"), LevelFilter::Warn);
1234        assert_eq!(filter.max_level(), LevelFilter::Debug);
1235    }
1236
1237    #[test]
1238    fn the_longest_matching_directive_wins() {
1239        let filter = Filter::parse("off,crcbl_render=warn,crcbl_render::graph=trace");
1240        assert_eq!(filter.level_for("crcbl_vk"), LevelFilter::Off);
1241        assert_eq!(filter.level_for("crcbl_render"), LevelFilter::Warn);
1242        assert_eq!(filter.level_for("crcbl_render::graph"), LevelFilter::Trace);
1243        assert_eq!(
1244            filter.level_for("crcbl_render::graph::pass"),
1245            LevelFilter::Trace
1246        );
1247        assert_eq!(filter.max_level(), LevelFilter::Trace);
1248    }
1249
1250    #[test]
1251    fn overrides_without_a_bare_level_keep_the_default() {
1252        let filter = Filter::parse("crcbl_vk=trace");
1253        assert_eq!(filter.level_for("crcbl_core"), LevelFilter::Info);
1254        assert_eq!(filter.level_for("crcbl_vk"), LevelFilter::Trace);
1255    }
1256
1257    #[test]
1258    fn nonsense_directives_are_ignored_not_fatal() {
1259        let filter = Filter::parse("info,,  ,crcbl_vk=louder,=warn,bogus");
1260        assert_eq!(filter.level_for("crcbl_core"), LevelFilter::Info);
1261        assert_eq!(filter.level_for("crcbl_vk"), LevelFilter::Info);
1262        assert_eq!(
1263            filter.level_for(""),
1264            LevelFilter::Warn,
1265            "an empty target matches everything"
1266        );
1267    }
1268
1269    #[test]
1270    fn level_aliases_and_case_are_accepted() {
1271        let filter = Filter::parse("WARNING,crcbl_net=NONE,crcbl_ui=Error");
1272        assert_eq!(filter.level_for("x"), LevelFilter::Warn);
1273        assert_eq!(filter.level_for("crcbl_net"), LevelFilter::Off);
1274        assert_eq!(filter.level_for("crcbl_ui"), LevelFilter::Error);
1275    }
1276
1277    /// **`log` prints a filter that can be typed straight back in.**
1278    ///
1279    /// Two spellings of one thing drift; this is what stops them. A `Display`
1280    /// that dropped a directive, or wrote a level `parse_level` does not know,
1281    /// would still print something plausible — so the check is the round trip
1282    /// and not the text.
1283    #[test]
1284    fn the_filter_round_trips_through_its_own_display() {
1285        for directives in [
1286            "info",
1287            "off",
1288            "warn,crcbl_render=debug",
1289            "off,crcbl_render=warn,crcbl_render::graph=trace",
1290            "crcbl_vk=trace",
1291        ] {
1292            let filter = Filter::parse(directives);
1293            let written = filter.to_string();
1294            let reparsed = Filter::parse(&written);
1295            assert_eq!(reparsed.to_string(), written, "{directives}");
1296            for target in [
1297                "crcbl_core",
1298                "crcbl_render",
1299                "crcbl_render::graph",
1300                "crcbl_vk",
1301            ] {
1302                assert_eq!(
1303                    reparsed.level_for(target),
1304                    filter.level_for(target),
1305                    "{directives} at {target}",
1306                );
1307            }
1308        }
1309    }
1310
1311    /// The overrides are written in match order, longest prefix first, which is
1312    /// not the order they were typed in — so the round trip above is stable and
1313    /// not merely equivalent.
1314    #[test]
1315    fn display_writes_the_default_then_the_overrides_in_match_order() {
1316        let filter = Filter::parse("crcbl_render::graph=trace,warn,crcbl_render=debug");
1317        assert_eq!(
1318            filter.to_string(),
1319            "warn,crcbl_render::graph=trace,crcbl_render=debug",
1320        );
1321    }
1322
1323    /// **`parse` skips what it cannot read and `try_parse` names it**, which is
1324    /// the difference between a typo in an environment variable and a typo
1325    /// somebody just typed at a console.
1326    #[test]
1327    fn try_parse_refuses_the_directive_parse_would_skip() {
1328        let refused = Filter::try_parse("info,crcbl_vk=louder").expect_err("`louder` is no level");
1329        assert_eq!(refused.directive(), "crcbl_vk=louder");
1330        assert!(refused.to_string().contains("crcbl_vk=louder"), "{refused}");
1331
1332        assert_eq!(
1333            Filter::parse("info,crcbl_vk=louder").level_for("crcbl_vk"),
1334            LevelFilter::Info,
1335            "the lenient form still skips it",
1336        );
1337        assert_eq!(
1338            Filter::try_parse("bogus")
1339                .expect_err("a bare word is no level")
1340                .directive(),
1341            "bogus",
1342        );
1343        assert!(
1344            Filter::try_parse("warn,,  ,crcbl_vk=trace").is_ok(),
1345            "empties are not directives"
1346        );
1347    }
1348
1349    /// Serialises the tests that put a filter in force.
1350    ///
1351    /// They share [`FILTER`] and each asserts on the one it just wrote, so
1352    /// concurrently they would be asserting on each other's.
1353    static FILTER_ORDER: Mutex<()> = Mutex::new(());
1354
1355    /// Runs `body` with `directives` in force, and puts back what was there.
1356    ///
1357    /// **The facade's global maximum is deliberately left alone**, which is the
1358    /// one thing this does not share with [`set_filter`]: the maximum decides
1359    /// nothing for the assertions here, which read [`FILTER`] through `permits`,
1360    /// while lowering it would silence a [`capture`] running on another thread.
1361    /// The filter tests that need both halves are `tests/console_log.rs`, in a
1362    /// binary of their own.
1363    ///
1364    /// **`installing_the_logger_is_idempotent` takes this lock too, and must.**
1365    /// `try_init_logging` calls `store_filter` without it, so under `cargo test`
1366    /// — one process, threaded, unlike nextest's process per test — an install
1367    /// landing inside one of these bodies replaces the directives it just wrote
1368    /// with the installed filter, and the body then asserts on that instead.
1369    fn with_filter<R>(directives: &str, body: impl FnOnce() -> R) -> R {
1370        let _order = FILTER_ORDER.lock().unwrap_or_else(PoisonError::into_inner);
1371        let previous = std::mem::replace(
1372            &mut *FILTER.write().unwrap_or_else(PoisonError::into_inner),
1373            Filter::parse(directives),
1374        );
1375        let result = body();
1376        *FILTER.write().unwrap_or_else(PoisonError::into_inner) = previous;
1377        result
1378    }
1379
1380    /// **[`Filter::INITIAL`] is [`DEFAULT_FILTER`], parsed.**
1381    ///
1382    /// The one spelled twice — a `const` for the `static` and a string for
1383    /// everyone else — so this is what stops the two drifting the day the
1384    /// default level changes.
1385    #[test]
1386    fn the_const_initial_filter_is_the_default_one_parsed() {
1387        assert_eq!(
1388            Filter::INITIAL.to_string(),
1389            Filter::parse(DEFAULT_FILTER).to_string(),
1390        );
1391    }
1392
1393    /// **The ring is fed before the filter, and the filter still decides
1394    /// stderr.**
1395    ///
1396    /// The two halves of debug-console decision 4, and each fails silently
1397    /// alone: a ring fed after the filter shows the panel exactly what the
1398    /// terminal already printed, and a ring that widened the filter would print
1399    /// every dropped line to CI.
1400    ///
1401    /// `capture` cannot answer the stderr half — it deliberately captures
1402    /// *before* the filter too, so a capturing thread sees everything — which is
1403    /// why `permits` is read directly here. It is documented as the one thing
1404    /// that decides what is written.
1405    #[test]
1406    fn the_ring_holds_records_the_filter_refused() {
1407        let quiet = "crcbl_core::log::tests::refused";
1408        let loud = "crcbl_core::log::tests::printed";
1409        let logger = StderrLogger::new();
1410        with_filter(&format!("info,{quiet}=off"), || {
1411            assert!(
1412                !logger.permits(
1413                    &Metadata::builder()
1414                        .level(Level::Error)
1415                        .target(quiet)
1416                        .build()
1417                ),
1418                "the directive silences that target, so none of its records reach stderr \
1419                 — filter {filter:?}, max level {max_level}, installed {installed}",
1420                filter = *read_filter(),
1421                max_level = ::log::max_level(),
1422                installed = is_installed(),
1423            );
1424            assert!(
1425                logger.permits(&Metadata::builder().level(Level::Info).target(loud).build()),
1426                "the default level admits the other target",
1427            );
1428
1429            logger.emit(Level::Error, quiet, format_args!("under the filter"));
1430            logger.emit(Level::Info, loud, format_args!("over the filter"));
1431        });
1432
1433        let mine: Vec<console::Record> = console::snapshot()
1434            .into_iter()
1435            .filter(|record| record.target == quiet || record.target == loud)
1436            .collect();
1437        assert_eq!(
1438            mine.iter()
1439                .map(|record| (record.target.as_str(), record.message.as_str()))
1440                .collect::<Vec<_>>(),
1441            [(quiet, "under the filter"), (loud, "over the filter")],
1442            "both records are in the ring, whatever stderr got",
1443        );
1444        assert_eq!(mine[0].level, Level::Error);
1445    }
1446
1447    #[test]
1448    fn logger_respects_the_filter_without_being_installed() {
1449        let logger = StderrLogger::new();
1450        with_filter("warn,noisy=trace", || {
1451            assert!(
1452                logger.enabled(
1453                    &Metadata::builder()
1454                        .level(Level::Error)
1455                        .target("any")
1456                        .build()
1457                )
1458            );
1459            let meta = Metadata::builder().level(Level::Info).target("any").build();
1460            assert!(
1461                !logger.enabled(&meta),
1462                "an uninstalled logger admits nothing at the default level \
1463                 — filter {filter:?}, max level {max_level}, permits {permits}, \
1464                 capturing {capturing}, installed {installed}, thread {thread:?}",
1465                filter = *read_filter(),
1466                max_level = ::log::max_level(),
1467                permits = logger.permits(&meta),
1468                capturing = capturing(),
1469                installed = is_installed(),
1470                thread = std::thread::current().name(),
1471            );
1472            assert!(
1473                logger.enabled(
1474                    &Metadata::builder()
1475                        .level(Level::Trace)
1476                        .target("noisy")
1477                        .build()
1478                )
1479            );
1480            logger.log(
1481                &Record::builder()
1482                    .args(format_args!("hello"))
1483                    .target("noisy")
1484                    .build(),
1485            );
1486        });
1487        logger.flush();
1488    }
1489
1490    /// The environment variable that tells the re-executed test binary it is
1491    /// the child of [`a_host_owning_the_slot_means_this_module_installed_nothing`].
1492    const HOST_SLOT_CHILD: &str = "CRCBL_LOG_HOST_SLOT_CHILD";
1493
1494    /// **A host that owns the logger slot first means this module installed
1495    /// nothing, and [`is_installed`] has to say so.**
1496    ///
1497    /// The answer used to be `LOGGER.get().is_some()`, and `try_init_logging`
1498    /// builds that logger before offering it to `log::set_logger` — which takes
1499    /// a `&'static dyn Log`, so there is no other order. On a process where the
1500    /// host won, the old answer was `true` for a logger `log` had rejected: a
1501    /// caller checking before deciding whether to route its own diagnostics
1502    /// here would have been told yes and then dropped them.
1503    ///
1504    /// # Why a child process
1505    ///
1506    /// The logger slot is per process and settable once, so this test and
1507    /// `installing_the_logger_is_idempotent` cannot both have it — and under a
1508    /// plain `cargo test`, which runs a crate's unit tests as threads in one
1509    /// binary, whichever ran second would fail on the other's install. The
1510    /// child gets a slot nobody has touched. This is `trace.rs`'
1511    /// `the_environment_variable_is_what_turns_the_gate_on` pattern, including
1512    /// the check that the child actually ran a test.
1513    #[test]
1514    #[cfg_attr(miri, ignore = "miri cannot spawn the child process this needs")]
1515    fn a_host_owning_the_slot_means_this_module_installed_nothing() {
1516        if env::var(HOST_SLOT_CHILD).is_ok() {
1517            /// Somebody else's logger, minimal and doing nothing: what is being
1518            /// checked is who owns the slot, not what the owner does with it.
1519            struct HostLogger;
1520            impl Log for HostLogger {
1521                fn enabled(&self, _metadata: &Metadata<'_>) -> bool {
1522                    false
1523                }
1524                fn log(&self, _record: &Record<'_>) {}
1525                fn flush(&self) {}
1526            }
1527            static HOST: HostLogger = HostLogger;
1528
1529            assert!(!is_installed(), "nothing has installed anything yet");
1530            ::log::set_logger(&HOST).expect("the host wins the empty slot");
1531
1532            assert!(
1533                try_init_logging(Filter::parse("trace")).is_err(),
1534                "the slot is taken, so this must not claim it"
1535            );
1536            assert!(
1537                !is_installed(),
1538                "log::set_logger rejected this module's logger, so it is not installed, \
1539                 whatever LOGGER holds"
1540            );
1541            assert!(!init_logging(), "the convenience form agrees");
1542            assert!(!is_installed());
1543            return;
1544        }
1545
1546        let output = crcbl_spawn::command(
1547            std::env::current_exe().expect("a test binary knows its own path"),
1548        )
1549        .args([
1550            "--exact",
1551            "log::tests::a_host_owning_the_slot_means_this_module_installed_nothing",
1552        ])
1553        .env(HOST_SLOT_CHILD, "1")
1554        .output()
1555        .expect("re-running this test binary");
1556        let report = String::from_utf8_lossy(&output.stdout);
1557        assert!(output.status.success(), "the child failed:\n{report}");
1558        // `--exact` with a name that matches nothing exits zero, so without this
1559        // the whole test passes vacuously the moment it is renamed.
1560        assert!(
1561            report.contains("1 passed"),
1562            "the child ran no test — has this one been renamed?\n{report}"
1563        );
1564    }
1565
1566    /// Installs the process logger.
1567    ///
1568    /// **Takes [`FILTER_ORDER`], because `try_init_logging` writes [`FILTER`]
1569    /// and takes no lock of its own.** Under nextest each test owns a process
1570    /// and this fights nobody, but `cargo test` runs the whole binary's tests as
1571    /// threads of one process — so without the order lock this install lands
1572    /// inside a sibling's [`with_filter`] body and leaves it asserting on
1573    /// `trace` instead of the directives it wrote. That is the whole of the
1574    /// whole-workspace-only flake: the failures printed
1575    /// `filter Filter { default: Trace, targets: [] }, max level TRACE,
1576    /// installed true`, which is this call's argument and nothing else's.
1577    #[test]
1578    fn installing_the_logger_is_idempotent() {
1579        let _order = FILTER_ORDER.lock().unwrap_or_else(PoisonError::into_inner);
1580        assert!(!is_installed());
1581        assert!(try_init_logging(Filter::parse("trace")).is_ok());
1582        assert!(is_installed());
1583        assert_eq!(
1584            file_path(),
1585            None,
1586            "installing the logger opens no file: the file is opt-in"
1587        );
1588        assert_eq!(::log::max_level(), LevelFilter::Trace);
1589
1590        ::log::info!("crcbl-core log smoke test");
1591        ::log::logger().flush();
1592
1593        // A second install loses the race but must not panic.
1594        assert!(try_init_logging(Filter::parse("off")).is_err());
1595        assert!(!init_logging());
1596        assert_eq!(
1597            ::log::max_level(),
1598            LevelFilter::Trace,
1599            "the first filter stays in force"
1600        );
1601    }
1602
1603    /// **The log file is off unless the player spelled it on.**
1604    #[test]
1605    fn only_a_yes_asks_for_the_log_file() {
1606        for value in [
1607            None,
1608            Some(""),
1609            Some("0"),
1610            Some("off"),
1611            Some("no"),
1612            Some("yse"),
1613        ] {
1614            assert!(!file_requested_by(value), "{value:?}");
1615        }
1616        for value in ["1", "true", " ON ", "yes"] {
1617            assert!(file_requested_by(Some(value)), "{value:?}");
1618        }
1619    }
1620
1621    /// Nothing is captured until someone asks, and the sink must not pay for
1622    /// the feature on the way past.
1623    #[test]
1624    fn a_thread_that_never_asked_captures_nothing() {
1625        assert!(!capturing());
1626        push_captured(
1627            Level::Info,
1628            "crcbl_core",
1629            format_args!("dropped on the floor"),
1630        );
1631        assert!(!capturing());
1632    }
1633
1634    /// **The calendar arithmetic, against dates taken from outside this
1635    /// repository.**
1636    ///
1637    /// A transcription slip here passes every other test in the workspace and
1638    /// is wrong only on the days nobody happens to run it. The values are the
1639    /// well-known ones: the epoch, the two century rules that disagree (2000 is
1640    /// a leap year, 1900 is not — so 2000-02-29 exists and the day counts
1641    /// either side of it differ), and a leap day in a plain leap year.
1642    #[test]
1643    fn the_civil_date_matches_known_timestamps() {
1644        // `date -u -d @<secs> +%F` produced each of these.
1645        for (secs, want) in [
1646            (0_i64, (1970, 1, 1)),
1647            (86_399, (1970, 1, 1)),
1648            (86_400, (1970, 1, 2)),
1649            (951_782_400, (2000, 2, 29)), // the century leap year
1650            (1_078_012_800, (2004, 2, 29)),
1651            (1_709_164_800, (2024, 2, 29)),
1652            (1_755_216_000, (2025, 8, 15)),
1653            (4_102_444_800, (2100, 1, 1)), // 2100 is *not* a leap year
1654        ] {
1655            assert_eq!(
1656                civil_from_days(secs / 86_400),
1657                (want.0, want.1, want.2),
1658                "{secs}"
1659            );
1660        }
1661    }
1662
1663    /// Every day of a full Gregorian cycle round-trips, which is the period the
1664    /// calendar repeats on — so a rule that is wrong for any day is wrong for
1665    /// one of these.
1666    #[test]
1667    fn days_from_civil_round_trips_every_day_for_four_centuries() {
1668        let mut previous = civil_from_days(0);
1669        for day in 1..=146_097_i64 {
1670            let (year, month, dom) = civil_from_days(day);
1671            assert!((1..=12).contains(&month), "day {day} gave month {month}");
1672            assert!((1..=31).contains(&dom), "day {day} gave day {dom}");
1673            // Strictly increasing, so no day is skipped or repeated.
1674            assert!(
1675                (year, month, dom) > previous,
1676                "day {day}: {previous:?} then {:?}",
1677                (year, month, dom)
1678            );
1679            previous = (year, month, dom);
1680        }
1681    }
1682
1683    /// The banner is the one line that says when the run started, so it has to
1684    /// be shaped the way a reader expects rather than merely non-empty.
1685    #[test]
1686    fn the_start_banner_names_a_date_and_a_time() {
1687        let banner = start_banner();
1688        assert!(banner.ends_with(" UTC"), "{banner}");
1689        let (date, rest) = banner.split_once(' ').expect("date then time");
1690        assert_eq!(date.len(), "YYYY-MM-DD".len(), "{banner}");
1691        assert_eq!(date.matches('-').count(), 2, "{banner}");
1692        assert_eq!(rest.split_once(' ').expect("time then zone").0.len(), 8);
1693    }
1694
1695    #[test]
1696    fn level_names_are_padded_consistently() {
1697        for level in [
1698            Level::Error,
1699            Level::Warn,
1700            Level::Info,
1701            Level::Debug,
1702            Level::Trace,
1703        ] {
1704            assert!(level_name(level).len() <= 5);
1705        }
1706    }
1707}