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}