Skip to main content

latexml_core/common/
error.rs

1use std::{
2  cell::RefCell,
3  error::Error as ErrorTrait,
4  fmt, io,
5  num::{ParseFloatError, ParseIntError},
6  result,
7};
8
9use once_cell::sync::Lazy;
10
11use crate::common::arena::SymHashMap;
12
13#[derive(Debug, Clone, Default)]
14pub struct LogState {
15  pub undefined:   SymHashMap<usize>,
16  pub missing:     SymHashMap<usize>,
17  pub debug:       usize,
18  pub info:        usize,
19  pub warning:     usize,
20  pub error:       usize,
21  pub fatal:       bool,
22  pub status_code: usize,
23}
24pub enum LogStatus {
25  Debug,
26  Info,
27  Warning,
28  Error,
29  Fatal,
30  Undefined,
31  Missing,
32}
33
34#[thread_local]
35pub static REPORT: Lazy<RefCell<LogState>> = Lazy::new(|| RefCell::new(LogState::default()));
36
37/// Depth of diagnostic-macro emission on this thread (see [`macro_diag_guard`]).
38/// A depth counter, not a bool: `Error!` can raise `Fatal!` inside its own
39/// scope (the too-many-errors escalation), nesting two guards.
40#[thread_local]
41static MACRO_DIAG_DEPTH: std::cell::Cell<u32> = std::cell::Cell::new(0);
42
43/// RAII marker: "the log record currently being emitted comes from a
44/// diagnostic macro that already counted itself via [`note_status`]".
45///
46/// The tally has TWO producers which must never overlap: the macros
47/// (`Info!`/`Warn!`/`Error!`/`Fatal!`/`Debug!`) count at RAISE time — even when
48/// output is suppressed, which the `MAX_ERRORS` cap depends on — and the
49/// logger backend counts every OTHER record it prints (raw `log::warn!` and
50/// friends, which previously printed `Warning:` lines that no counter ever
51/// saw: the 131 MB witness logged 12,105 `Warning:` lines and reported
52/// "2 warnings"). This guard is how the logger tells the two apart.
53pub struct MacroDiagGuard(());
54impl Drop for MacroDiagGuard {
55  fn drop(&mut self) { MACRO_DIAG_DEPTH.set(MACRO_DIAG_DEPTH.get().saturating_sub(1)); }
56}
57#[must_use = "the guard must live across the log emission it marks"]
58pub fn macro_diag_guard() -> MacroDiagGuard {
59  MACRO_DIAG_DEPTH.set(MACRO_DIAG_DEPTH.get() + 1);
60  MacroDiagGuard(())
61}
62
63/// Count a diagnostic record observed by the logger backend, unless it was
64/// emitted by a macro (already counted at raise time) or the report is
65/// mid-borrow (a raw log call from inside a `report_mut!` scope must not
66/// panic the conversion over a tally increment — matching the logger's own
67/// `try_borrow` discipline for `LOG_BUFFER`).
68pub fn note_status_from_logger(status: LogStatus) {
69  if MACRO_DIAG_DEPTH.get() > 0 || REPORT.try_borrow_mut().is_err() {
70    return;
71  }
72  note_status(status, None);
73}
74
75/// When true, Debug!/Info!/Warn! (and their emit_* forms) still count in
76/// the report but do **not** emit anything to stderr/log. `Error!`/`Fatal!`
77/// are NOT suppressible — they emit unconditionally (user decision
78/// 2026-08-03), so success-rate aggregation (cortex) never loses them.
79/// Used by tests/dump-builds that deliberately exercise noisy paths.
80#[thread_local]
81static SUPPRESS_LOG_OUTPUT: std::cell::Cell<bool> = std::cell::Cell::new(false);
82
83/// Set or clear the log-output suppression flag. Returns the previous value.
84pub fn set_suppress_log_output(suppress: bool) -> bool {
85  let prev = SUPPRESS_LOG_OUTPUT.get();
86  SUPPRESS_LOG_OUTPUT.set(suppress);
87  prev
88}
89
90// Thread-local FATAL DEMOTION for bibliography post-processing (user
91// policy 2026-07-04): with the live-state field interpretation, Warn!/
92// Error! report at NATIVE severity and count normally (matching Perl's
93// MergeStatus accounting, Common/Error.pm L669) — problems in bib fields
94// are real conversion diagnostics. Only Fatal! is demoted: it notes and
95// logs as an ERROR (`demoted_fatal:` target) instead of latching the
96// document's sticky fatal — a broken bibliography must never lose the
97// document. The Err return is unchanged, so the failing digestion still
98// aborts (its caller degrades gracefully).
99thread_local! {
100  static DEMOTE_FATALS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
101}
102
103/// Set or clear the fatal-demotion flag. Returns the previous value.
104pub fn set_demote_fatals(demote: bool) -> bool {
105  DEMOTE_FATALS.with(|c| {
106    let prev = c.get();
107    c.set(demote);
108    prev
109  })
110}
111
112/// Returns true if `Fatal!` is currently demoted to Error.
113pub fn is_demote_fatals() -> bool { DEMOTE_FATALS.with(|c| c.get()) }
114
115/// Returns true if log output is currently suppressed.
116pub fn is_log_output_suppressed() -> bool { SUPPRESS_LOG_OUTPUT.get() }
117
118/// Per-thread tracker for the most recently emitted error's
119/// `category:object` signature, plus the count of how many
120/// consecutive errors share the same signature. Used to detect
121/// runaway loops where a single pathological control-sequence (like
122/// plain-TeX `\tabalign` invoked in math mode → unbounded `\halign`
123/// cell loop) keeps emitting the same error indefinitely. See
124/// `wisdom_tabalign_math_runaway.md` for the canonical witness.
125#[thread_local]
126static LAST_ERROR_KEY: RefCell<Option<String>> = RefCell::new(None);
127#[thread_local]
128static CONSECUTIVE_ERROR_COUNT: std::cell::Cell<usize> = std::cell::Cell::new(0);
129
130/// Threshold for "same error fired this many times in a row → bail."
131/// Set well above any legitimate same-error pattern (a paper with
132/// 500+ identical errors would already be near-useless output) but
133/// well below the 10000 MAX_ERRORS cap so runaway papers don't
134/// accumulate huge noise logs. Empirically, the pathological
135/// `\tabalign`-in-math-mode runaway hits >9000 consecutive same
136/// errors; this catches that at 500 instead. The threshold was
137/// tightened from an initial 2000 after verifying no test in the
138/// 1112-test suite exceeds it.
139pub const MAX_CONSECUTIVE_ERRORS: usize = 500;
140
141/// Record an error signature; returns the new consecutive count.
142/// Call from the Error! macro after note_status. Resets count to 1
143/// on a different signature, increments on a match.
144pub fn note_consecutive_error(key: &str) -> usize {
145  let mut last = LAST_ERROR_KEY.borrow_mut();
146  if last.as_deref() == Some(key) {
147    let c = CONSECUTIVE_ERROR_COUNT.get() + 1;
148    CONSECUTIVE_ERROR_COUNT.set(c);
149    c
150  } else {
151    *last = Some(key.to_string());
152    CONSECUTIVE_ERROR_COUNT.set(1);
153    1
154  }
155}
156
157/// The ONE emission primitive every diagnostic flows through (DRY pass,
158/// user directive 2026-08-02): count on the emitting thread's `REPORT`,
159/// then log with the pre-formatted `target`, respecting output suppression —
160/// EXCEPT for `Error` and `Fatal` records, which are emitted UNCONDITIONALLY
161/// (user decision 2026-08-03): frameworks such as cortex aggregate success
162/// rates from `Error:`/`Fatal:` lines, so muting either would hide exactly
163/// the signal they measure. Suppression mutes Debug/Info/Warning only.
164/// The `MacroDiagGuard` marks the emission so the logger backend does not
165/// count it a second time.
166pub fn emit_record(status: LogStatus, target: &str, message: &str) {
167  let _diag_guard = macro_diag_guard();
168  let level = match status {
169    LogStatus::Debug => log::Level::Debug,
170    LogStatus::Info => log::Level::Info,
171    LogStatus::Warning => log::Level::Warn,
172    _ => log::Level::Error,
173  };
174  let unconditional = matches!(status, LogStatus::Error | LogStatus::Fatal);
175  note_status(status, None);
176  if unconditional || !is_log_output_suppressed() {
177    log::log!(target: target, level, "{message}");
178  }
179}
180
181/// The single diagnostic vehicle, function form — for contexts that cannot
182/// use the `Error!`/`Warn!`/`Info!` macros because those are return-based
183/// (`Error!` escalates to `Fatal!`, which `return Err(...)`s, so it only
184/// typechecks in `Result<_, error::Error>` functions).
185///
186/// Perl LaTeXML has exactly one emission vehicle per severity (`Error.pm`),
187/// which is what lets its tally and cortex's aggregation be lossless by
188/// construction. These functions restore that property for Rust's
189/// non-`Result` contexts (post-processing drivers, workers, the LSP server):
190/// they count, emit with a proper `category:object` target, respect output
191/// suppression, and participate in the runaway circuit-breakers — everything
192/// the raw `log::warn!`-family calls they replace silently skipped. Raw
193/// `log::*!` diagnostics are BANNED in workspace crates (see
194/// `tools/lint_raw_log_diag.sh`); the logger backend's own tally
195/// (`note_status_from_logger`) remains only as the net for FOREIGN crates
196/// logging through the `log` facade, which can never use this vehicle.
197pub fn emit_info(category: &str, object: &str, message: &str) {
198  emit_record(LogStatus::Info, &format!("{category}:{object}"), message);
199}
200
201/// See [`emit_info`].
202pub fn emit_warn(category: &str, object: &str, message: &str) {
203  emit_record(LogStatus::Warning, &format!("{category}:{object}"), message);
204}
205
206/// See [`emit_info`]. Unlike the `Error!` macro this cannot escalate by
207/// `return`ing — there is no `Err` channel in the contexts it serves — so
208/// when the error count or the consecutive-error count crosses its cap it
209/// emits the `Fatal:TooManyErrors` record and latches the sticky fatal
210/// instead: the run continues (its caller has no unwind path) but the
211/// conversion's verdict and status code report the fatal honestly.
212pub fn emit_error(category: &str, object: &str, message: &str) {
213  emit_record(LogStatus::Error, &format!("{category}:{object}"), message);
214  if is_demote_fatals() {
215    return;
216  }
217  let maxerrors = match crate::state::try_lookup_int("MAX_ERRORS") {
218    None => usize::MAX, // STATE contended: skip the check for this error
219    Some(v) if v > 0 => v as usize,
220    Some(_) => 100,
221  };
222  let consec = note_consecutive_error(&format!("{category}:{object}"));
223  let over_total = get_status(LogStatus::Error) > maxerrors;
224  let over_consec = consec > MAX_CONSECUTIVE_ERRORS;
225  // Latch exactly at the crossing, not on every error past it.
226  if (over_total && get_status(LogStatus::Error) == maxerrors + 1)
227    || (over_consec && consec == MAX_CONSECUTIVE_ERRORS + 1)
228  {
229    emit_fatal(
230      "TooManyErrors",
231      "MaxLimit",
232      &format!(
233        "Too many errors (> {})!",
234        if over_total {
235          maxerrors
236        } else {
237          MAX_CONSECUTIVE_ERRORS
238        }
239      ),
240    );
241  }
242}
243
244/// See [`emit_info`]. Latches the sticky fatal and emits the canonical
245/// `Fatal:<category>:<object>` line — NEVER suppressed (see [`emit_record`]).
246/// The CALLER owns any early-exit control flow (e.g. `latexml_post`'s
247/// `Fatal!` returns its own `PostError` after this) — a fatal, unlike an
248/// error, needs no cap bookkeeping.
249pub fn emit_fatal(category: &str, object: &str, message: &str) {
250  emit_record(
251    LogStatus::Fatal,
252    &format!("Fatal:{category}:{object} "),
253    message,
254  );
255}
256
257/// Reset the consecutive-error tracker (called from initialize_report).
258fn reset_consecutive_error_tracker() {
259  *LAST_ERROR_KEY.borrow_mut() = None;
260  CONSECUTIVE_ERROR_COUNT.set(0);
261}
262#[macro_export]
263macro_rules! report {
264  () => {
265    (*$crate::common::error::REPORT).borrow()
266  };
267}
268#[macro_export]
269macro_rules! report_mut {
270  () => {
271    (*$crate::common::error::REPORT).borrow_mut()
272  };
273}
274
275/// Clear the sticky `report.fatal` flag. Used by best-effort
276/// helpers (e.g. `\maketitle`'s deferred frontmatter digest) that
277/// silently swallow a digest error and want to undo the
278/// `note_status(Fatal)` side-effect so the overall conversion
279/// status reflects the silently-handled fact.
280pub fn clear_fatal_flag() {
281  let mut report = REPORT.borrow_mut();
282  report.fatal = false;
283}
284
285pub fn note_status(status: LogStatus, what: Option<&str>) {
286  let mut report = REPORT.borrow_mut();
287  use LogStatus::*;
288  match status {
289    Debug => report.debug += 1,
290    Info => report.info += 1,
291    Warning => report.warning += 1,
292    Error => report.error += 1,
293    Fatal => {
294      // Diagnostic for "phantom fatals" (a fatal counted in the final summary
295      // with no `Fatal:` line in the log — an `Err` raised via `Fatal!` that
296      // some caller swallowed without `log_fatal`): dump a backtrace at the
297      // moment the tally first flips. Witness math0402448.
298      if !report.fatal && debug_fatal_enabled() {
299        eprintln!("[debug-fatal] LogStatus::Fatal first noted here:");
300        eprintln!("{}", std::backtrace::Backtrace::force_capture());
301      }
302      report.fatal = true;
303    },
304    Undefined => {
305      // `what` may borrow the arena buffer; `entry` re-interns via `arena::pin`,
306      // which can REALLOCATE that buffer and invalidate `what` mid-read, then
307      // intern whatever bytes now occupy the slot (e.g. a freshly-interned
308      // `\special_relax` family-token name → phantom undefined). Copy out first.
309      let key = what.unwrap_or_default().to_string();
310      let entry = report.undefined.entry(&key).or_insert(0);
311      *entry += 1;
312    },
313    Missing => {
314      let key = what.unwrap_or_default().to_string();
315      let entry = report.missing.entry(&key).or_insert(0);
316      *entry += 1;
317    },
318  }
319}
320
321pub fn get_status(status: LogStatus) -> usize {
322  let report = REPORT.borrow();
323  use LogStatus::*;
324  match status {
325    Debug => report.debug,
326    Info => report.info,
327    Warning => report.warning,
328    Error => report.error,
329    Fatal => {
330      if report.fatal {
331        1
332      } else {
333        0
334      }
335    },
336    Undefined => report.undefined.0.values().sum(),
337    Missing => report.missing.0.values().sum(),
338  }
339}
340
341/// One shared probe for the `LATEXML_DEBUG_FATAL` diagnostics (first-fatal
342/// backtrace, gullet pushback dump, recent-token ring). Lazy-cached so hot
343/// paths pay a single bool test, and a single seam if the env contract grows
344/// (PR #249 review P3-13).
345pub fn debug_fatal_enabled() -> bool {
346  use std::sync::OnceLock;
347  static FLAG: OnceLock<bool> = OnceLock::new();
348  *FLAG.get_or_init(|| std::env::var_os("LATEXML_DEBUG_FATAL").is_some())
349}
350
351pub fn initialize_report() {
352  let mut report = REPORT.borrow_mut();
353  *report = LogState::default();
354  reset_consecutive_error_tracker();
355  LAST_RESOURCE_FATAL.with(|c| *c.borrow_mut() = None);
356}
357
358/// Clear the arena-`SymStr`-keyed report maps (`undefined`, `missing`). MUST be
359/// called whenever the arena is reset (see `crate::reset_thread_engine`): their
360/// keys are arena interner ids, so after `arena::reset()` a stale key resolves to
361/// whatever string now occupies that id — e.g. a `\special_relax` family-token
362/// name — producing phantom "undefined macro" reports across conversions.
363pub fn reset_arena_keyed_reports() {
364  let mut report = REPORT.borrow_mut();
365  report.undefined = Default::default();
366  report.missing = Default::default();
367}
368
369thread_local! {
370  /// Latch for the most recent RESOURCE-class fatal (`ErrorTarget::Timeout`
371  /// with a unit category: token/pushback/if limits, cycle-guard recursion,
372  /// memory budget, conversion deadline). Some layers flatten `Error` into a
373  /// plain string on the way up (the marpa semantics boundary turns it into
374  /// `marpa::error::Error`), destroying the structured identity — which made
375  /// resource fatals indistinguishable from semantic parse rejections and
376  /// produced "phantom fatals" (counted in the summary, never logged, parse
377  /// grinding on). The `Fatal!` macro records here at raise time; consumers
378  /// `take` it to re-classify a flattened error. PR #249 review P1-4.
379  static LAST_RESOURCE_FATAL: RefCell<Option<Error>> = const { RefCell::new(None) };
380}
381
382/// Record a fatal into the resource-fatal latch — only Timeout-target fatals
383/// with payload-free categories are kept (the latch exists for resource
384/// fatals; payload-carrying `ErrorCategory` variants are not cloneable and
385/// are never resource-class).
386pub fn record_last_fatal(e: &Error) {
387  use ErrorCategory as C;
388  if !matches!(e.target, ErrorTarget::Timeout) {
389    return;
390  }
391  let category = match &e.category {
392    C::TokenLimit => C::TokenLimit,
393    C::PushbackLimit => C::PushbackLimit,
394    C::Recursion => C::Recursion,
395    C::IfLimit => C::IfLimit,
396    C::MemoryBudget => C::MemoryBudget,
397    C::Convert => C::Convert,
398    _ => return,
399  };
400  LAST_RESOURCE_FATAL.with(|c| {
401    *c.borrow_mut() = Some(Error {
402      target: ErrorTarget::Timeout,
403      category,
404      message: e.message.clone(),
405    });
406  });
407}
408
409/// Take (and clear) the latched resource fatal, if any. Returns the
410/// structured `Error` so a boundary that received only a flattened string can
411/// propagate the real thing.
412pub fn take_last_resource_fatal() -> Option<Error> {
413  LAST_RESOURCE_FATAL.with(|c| c.borrow_mut().take())
414}
415
416/// Build a status message matching Perl's `getStatusMessage()`.
417/// Format: "N warnings; M errors; K fatal error; L undefined macros[\foo, \bar]; P missing
418/// files[x.sty]" Returns "No obvious problems" when no issues detected.
419/// The canonical machine-readable conversion-status line, `Status:conversion:N`.
420///
421/// The contract every multi-phase executable ends its log with: the cortex
422/// framework derives a task's final severity from the LAST such line in the
423/// log (and defaults to Fatal when it is absent), so `code` must be the
424/// combined `max(core, post)` verdict. Shared here so the CLI, the worker and
425/// the archive `status` member can never drift in format.
426pub fn conversion_status_line(code: usize) -> String { format!("Status:conversion:{code}") }
427
428/// The human-readable end-of-run verdict: `Conversion complete|failed: <counts>`.
429///
430/// `failed` iff `code` is fatal (>= 3), mirroring Perl LaTeXML's summary
431/// line. Callers pass their combined `max(core, post)` code; the counts come
432/// from the shared REPORT counter via [`get_status_message`].
433pub fn conversion_verdict(code: usize) -> String {
434  format!(
435    "Conversion {}: {}",
436    if code >= 3 { "failed" } else { "complete" },
437    get_status_message()
438  )
439}
440
441pub fn get_status_message() -> String {
442  let report = REPORT.borrow();
443  let mut parts = Vec::new();
444  if report.warning > 0 {
445    parts.push(format!(
446      "{} warning{}",
447      report.warning,
448      if report.warning > 1 { "s" } else { "" }
449    ));
450  }
451  if report.error > 0 {
452    parts.push(format!(
453      "{} error{}",
454      report.error,
455      if report.error > 1 { "s" } else { "" }
456    ));
457  }
458  if report.fatal {
459    parts.push("1 fatal error".to_string());
460  }
461  let undef_keys: Vec<String> = report
462    .undefined
463    .keys()
464    .map(|k| crate::common::arena::to_string(*k))
465    .collect();
466  if !undef_keys.is_empty() {
467    parts.push(format!(
468      "{} undefined macro{}[{}]",
469      undef_keys.len(),
470      if undef_keys.len() > 1 { "s" } else { "" },
471      undef_keys.join(", ")
472    ));
473  }
474  let miss_keys: Vec<String> = report
475    .missing
476    .keys()
477    .map(|k| crate::common::arena::to_string(*k))
478    .collect();
479  if !miss_keys.is_empty() {
480    parts.push(format!(
481      "{} missing file{}[{}]",
482      miss_keys.len(),
483      if miss_keys.len() > 1 { "s" } else { "" },
484      miss_keys.join(", ")
485    ));
486  }
487  if parts.is_empty() {
488    "No obvious problems".to_string()
489  } else {
490    parts.join("; ")
491  }
492}
493
494/// Compute the status code from the report state (Perl getStatusCode).
495/// 3 = fatal, 2 = errors, 1 = warnings, 0 = clean.
496pub fn get_status_code() -> usize {
497  let report = REPORT.borrow();
498  if report.fatal {
499    3
500  } else if report.error > 0 {
501    2
502  } else if report.warning > 0 {
503    1
504  } else {
505    0
506  }
507}
508
509/// A thread-portable snapshot of the `REPORT`'s integer status counters
510/// (everything EXCEPT the arena-`SymStr`-keyed `undefined`/`missing` maps,
511/// whose keys are interner ids local to one thread's arena). Used to forward a
512/// worker thread's diagnostic tally back to the main thread: `REPORT` is
513/// `#[thread_local]`, so an `Error!`/`Warn!` raised on a spawned post-processing
514/// worker increments only that worker's counters and is invisible to the
515/// main-thread `status_code` unless merged here. See
516/// [`crate::util::logger::capture`] / [`crate::util::logger::replay_captured`].
517#[derive(Default, Clone, Copy)]
518pub struct ReportCounts {
519  pub debug:   usize,
520  pub info:    usize,
521  pub warning: usize,
522  pub error:   usize,
523  pub fatal:   bool,
524}
525
526/// Snapshot the current thread's `REPORT` integer counters.
527pub fn snapshot_report_counts() -> ReportCounts {
528  let r = REPORT.borrow();
529  ReportCounts {
530    debug:   r.debug,
531    info:    r.info,
532    warning: r.warning,
533    error:   r.error,
534    fatal:   r.fatal,
535  }
536}
537
538/// Overwrite the current thread's `REPORT` counters with a prior snapshot.
539/// The isolation primitive for RECURSIVE/auxiliary digestions whose
540/// diagnostics must not count against the document (Perl analog: the
541/// recursive MakeBibliography session keeps its tally out of the outer
542/// document). Pair with [`set_suppress_log_output`] so neither the lines
543/// nor the counts leak: snapshot -> suppress -> digest -> restore.
544pub fn restore_report_counts(c: ReportCounts) {
545  let mut r = REPORT.borrow_mut();
546  r.debug = c.debug;
547  r.info = c.info;
548  r.warning = c.warning;
549  r.error = c.error;
550  r.fatal = c.fatal;
551}
552
553/// Add a worker thread's [`ReportCounts`] into the current (main) thread's
554/// `REPORT`. Only the integer counts + the sticky `fatal` flag are merged; the
555/// arena-keyed `undefined`/`missing` maps are NOT (a worker has its own
556/// thread-local arena, so those keys are not portable).
557pub fn merge_report_counts(c: ReportCounts) {
558  let mut r = REPORT.borrow_mut();
559  r.debug += c.debug;
560  r.info += c.info;
561  r.warning += c.warning;
562  r.error += c.error;
563  r.fatal |= c.fatal;
564}
565
566//======================================================================
567// Debuggable features (Perl: `DebuggableFeature($name)` registration +
568// `$LaTeXML::DEBUG{$name}` gating, enabled by the CLI's `--debug NAME`).
569// Process-global (not thread-local): the CLI parses args on one thread
570// and may convert on another (e.g. the big-stack worker in
571// bin/latexml_oxide.rs); reads only occur on gated debug paths.
572//======================================================================
573
574static KNOWN_DEBUG_FEATURES: Lazy<std::sync::RwLock<std::collections::BTreeSet<String>>> =
575  Lazy::new(|| std::sync::RwLock::new(std::collections::BTreeSet::new()));
576static ENABLED_DEBUG_FEATURES: Lazy<std::sync::RwLock<rustc_hash::FxHashSet<String>>> =
577  Lazy::new(|| std::sync::RwLock::new(rustc_hash::FxHashSet::default()));
578
579/// Perl: `DebuggableFeature($name)` — register a feature name so it can
580/// be listed/validated for `--debug`.
581pub fn debuggable_feature(name: &str) {
582  if let Ok(mut known) = KNOWN_DEBUG_FEATURES.write() {
583    known.insert(name.to_string());
584  }
585}
586
587/// All registered feature names (sorted), for `--debug` diagnostics.
588pub fn known_debug_features() -> Vec<String> {
589  KNOWN_DEBUG_FEATURES
590    .read()
591    .map(|k| k.iter().cloned().collect())
592    .unwrap_or_default()
593}
594
595/// Perl: `$LaTeXML::DEBUG{$name} = 1` — called by the CLI per `--debug NAME`.
596pub fn enable_debug_feature(name: &str) {
597  if let Ok(mut enabled) = ENABLED_DEBUG_FEATURES.write() {
598    enabled.insert(name.to_string());
599  }
600}
601
602/// Perl: truthiness of `$LaTeXML::DEBUG{$name}`.
603pub fn debug_enabled(name: &str) -> bool {
604  ENABLED_DEBUG_FEATURES
605    .read()
606    .map(|enabled| enabled.contains(name))
607    .unwrap_or(false)
608}
609
610/// Feature-gated debug logging — Perl's `Debug(...) if $LaTeXML::DEBUG{feature}`.
611/// Usage: `DebugFeature!("frontmatter", "FRONT Add {}", entry)`.
612/// Logs with the feature name as the `log` target (so output matches the
613/// previous `log::debug!(target: "frontmatter", ...)` form) and counts a
614/// Debug in the status report, like `Debug!`. NB deliberately does NOT
615/// forward to `Debug!` — its 3-expr `(category, object, message)` arm
616/// would mis-capture a format string with two arguments.
617#[macro_export]
618macro_rules! DebugFeature {
619  ($feature:literal, $($arg:tt)*) => {{
620    if $crate::common::error::debug_enabled($feature) {
621      let __diag_guard = $crate::common::error::macro_diag_guard();
622      $crate::common::error::note_status(
623        $crate::common::error::LogStatus::Debug, None);
624      use log::debug;
625      debug!(target: $feature, $($arg)*);
626    }
627  }};
628}
629
630#[macro_export]
631macro_rules! Debug {
632  ($category:expr_2021, $object:expr_2021, $message:expr_2021) => {{
633    $crate::common::error::emit_record(
634      $crate::common::error::LogStatus::Debug,
635      &format!("{}:{}", $category, $object),
636      &$crate::generate_message!($message))
637  }};
638 ($category:expr_2021, $object:expr_2021, $message:expr_2021, $($details:expr_2021),*) => {{
639    $crate::common::error::emit_record(
640      $crate::common::error::LogStatus::Debug,
641      &format!("{}:{}", $category, $object),
642      &$crate::generate_message!($message, $($details),*))
643  }};
644  ($($simple:expr_2021),*) => {{
645    let __diag_guard = $crate::common::error::macro_diag_guard();
646    $crate::common::error::note_status(
647      $crate::common::error::LogStatus::Debug, None);
648    use log::debug;
649    debug!($($simple),*);
650  }};
651
652}
653
654#[macro_export]
655macro_rules! Info {
656  ($category:expr_2021, $object:expr_2021, $message:expr_2021) => {{
657    $crate::common::error::emit_info(
658      &format!("{}", $category), &format!("{}", $object),
659      &$crate::generate_message!($message))
660  }};
661 ($category:expr_2021, $object:expr_2021, $message:expr_2021, $($details:expr_2021),*) => {{
662    $crate::common::error::emit_info(
663      &format!("{}", $category), &format!("{}", $object),
664      &$crate::generate_message!($message, $($details),*))
665  }};
666  ($($simple:expr_2021),*) => {{
667    let __diag_guard = $crate::common::error::macro_diag_guard();
668    $crate::common::error::note_status(
669      $crate::common::error::LogStatus::Info, None);
670    use log::info;
671    info!($($simple),*);
672  }};
673
674}
675
676#[macro_export]
677macro_rules! Warn {
678  ($category:expr_2021, $object:expr_2021, $message:expr_2021) => {{
679    $crate::common::error::emit_warn(
680      &format!("{}", $category), &format!("{}", $object),
681      &$crate::generate_message!($message))
682  }};
683 ($category:expr_2021, $object:expr_2021, $message:expr_2021, $($details:expr_2021),*) => {{
684    $crate::common::error::emit_warn(
685      &format!("{}", $category), &format!("{}", $object),
686      &$crate::generate_message!($message, $($details),*))
687  }}
688}
689
690#[macro_export]
691macro_rules! Error {
692  ($category:expr_2021, $object:expr_2021, $message:expr_2021) => {{
693    $crate::Error!($category,$object,$message,"")
694  }};
695 ($category:expr_2021, $object:expr_2021, $message:expr_2021, $($details:expr_2021),*) => {{
696    $crate::common::error::emit_record(
697      $crate::common::error::LogStatus::Error,
698      &format!("{}:{}", $category, $object),
699      &$crate::generate_message!($message, $($details),*),
700    );
701    // In the fatal-demotion scope (bibliography post-processing) the
702    // too-many/consecutive-error escalations are SKIPPED: their Fatal!
703    // would demote back into an Error, turning the circuit-breaker into
704    // an error multiplier (run-233 follow-up: 470 self-feeding
705    // "Too many errors" lines on 2605.02213). The bib interpreter has its
706    // own bounded failure latch instead.
707    if !$crate::common::error::is_demote_fatals() {
708    // Borrow-safe read: an Error! can be raised from inside a `state_mut()`
709    // scope (e.g. push_value's BUG branch, a constructor's after_digest),
710    // where a plain `lookup_int` would panic "RefCell already mutably
711    // borrowed" and abort the conversion (tikz-cd 2001.08973).
712    let max_from_state = $crate::state::try_lookup_int("MAX_ERRORS");
713    // Match Perl LaTeXML default of 100 errors before Fatal('too_many_errors').
714    // Past 100 errors a paper has already failed comprehension; continuing
715    // produces noise without information. Override via state for tests
716    // or specific bindings (e.g. tikz_sty raises to 1000, dump-build raises
717    // to 1_000_000).
718    let maxerrors = match max_from_state {
719      // STATE contended: we cannot read the (possibly raised) cap, so skip the
720      // too-many-errors check for *this* error rather than risk a spurious
721      // Fatal from a stale default. The next uncontended error re-applies it.
722      None => usize::MAX,
723      Some(v) if v > 0 => v as usize,
724      Some(_) => 100,
725    };
726    if $crate::common::error::get_status($crate::common::error::LogStatus::Error) > maxerrors {
727      Fatal!(TooManyErrors, MaxLimit(maxerrors), format!("Too many errors (> {maxerrors})!"));
728    }
729    // Runaway-loop early-bail: if the same error signature has fired
730    // MAX_CONSECUTIVE_ERRORS times in a row, we're stuck in a loop
731    // (the canonical witness is plain-TeX `\tabalign` invoked in math
732    // mode → unbounded `\halign` cell loop emitting `\hbox` end-mode
733    // mismatches). Bail before MAX_ERRORS so logs stay short and
734    // post-processing sees a clear cause. The threshold is well above
735    // any legitimate same-error pattern (real papers max out at a few
736    // hundred unique errors).
737    let __consec_key = format!("{}:{}", $category, $object);
738    let __consec = $crate::common::error::note_consecutive_error(&__consec_key);
739    if __consec > $crate::common::error::MAX_CONSECUTIVE_ERRORS {
740      Fatal!(
741        TooManyErrors,
742        MaxLimit($crate::common::error::MAX_CONSECUTIVE_ERRORS),
743        format!(
744          "Runaway: same error '{}' fired {} times in a row (cap = {})",
745          __consec_key, __consec, $crate::common::error::MAX_CONSECUTIVE_ERRORS
746        )
747      );
748    }
749    }
750  }}
751}
752
753// TODO: flesh out the messages
754#[macro_export]
755macro_rules! Fatal {
756  ($target:expr_2021, $category:expr_2021, $message:expr_2021) => {{
757    if $crate::common::error::is_demote_fatals() {
758      // Demoted context (bibliography post-processing): count and log as
759      // an ERROR — the problem is real and must be visible/accounted —
760      // but never latch the document's sticky fatal. The Err return below
761      // still aborts the failing digestion; its caller degrades
762      // gracefully. A document must not be lost to a broken bibliography.
763      $crate::common::error::emit_record(
764        $crate::common::error::LogStatus::Error,
765        "demoted_fatal",
766        &format!("{}", $message),
767      );
768    } else {
769      $crate::common::error::note_status($crate::common::error::LogStatus::Fatal, None);
770    }
771    {
772      use $crate::common::error::{Error as LatexmlError, ErrorCategory::*, ErrorTarget::*};
773      let __fatal_err = LatexmlError {
774        target:   $target,
775        category: $category,
776        message:  $message.to_string(),
777      };
778      // Latch resource-class fatals so layers that flatten errors to strings
779      // (e.g. the marpa semantics boundary) can still recover the STRUCTURED
780      // identity downstream. See `take_last_resource_fatal`.
781      $crate::common::error::record_last_fatal(&__fatal_err);
782      return Err(__fatal_err);
783    }
784  }};
785}
786
787#[macro_export]
788macro_rules! fatal {
789  ($target:expr_2021, $category:expr_2021, $message:expr_2021) => {{
790    use $crate::common::error::{Error as LatexmlError, ErrorCategory::*, ErrorTarget::*};
791    return Err(LatexmlError {
792      target:   $target,
793      category: $category,
794      message:  $message.to_string(),
795    });
796  }};
797}
798
799#[macro_export]
800macro_rules! generate_message {
801  ($message:expr_2021) => {
802    format!(
803      "{}\n\t{}\n\tIn {}:{}:{}\n",
804      $message,
805      $crate::gullet::get_location(),
806      file!(),
807      line!(),
808      column!()
809    )
810  };
811  ($message:expr_2021, $detail:expr_2021) => {
812    format!(
813      "{}\n\t{}\n\t{}\n\tIn {}:{}:{}\n",
814      $message,
815      $crate::gullet::get_location(),
816      $detail,
817      file!(),
818      line!(),
819      column!()
820    )
821  };
822  ($message:expr_2021, $detail:expr_2021, $detail2:expr_2021) => {
823    format!(
824      "{}\n\t{}\n\t{}\n\t{}\n\tIn {}:{}:{}\n",
825      $message,
826      $crate::gullet::get_location(),
827      $detail,
828      $detail2,
829      file!(),
830      line!(),
831      column!()
832    )
833  };
834  ($message:expr_2021, $detail:expr_2021, $detail2:expr_2021) => {
835    format!(
836      "{}\n\t{}\n\t{}\n\t{}\n\tIn {}:{}:{}\n",
837      $message,
838      $crate::gullet::get_location(),
839      $detail,
840      $detail2,
841      file!(),
842      line!(),
843      column!()
844    )
845  };
846  ($message:expr_2021, $detail:expr_2021, $detail2:expr_2021, $location:expr_2021) => {
847    format!(
848      "{}\n\t{}\n\t{}\n\t{}\n\tIn {}:{}:{}\n",
849      $message,
850      $location,
851      $detail,
852      $detail2,
853      file!(),
854      line!(),
855      column!()
856    )
857  };
858}
859
860/// Progress note to BOTH the log and stderr — Perl `Note` (`_printline`): the LOG
861/// always (if a buffer is bound, ANSI-stripped), STDERR only when the verbosity
862/// admits it (`$USE_STDERR && $VERBOSITY>=0` ≈ `max_level >= Info`).
863#[macro_export]
864macro_rules! Note {
865  ($input:expr_2021) => {{
866    let msg = $input;
867    $crate::util::logger::note_to_log(&msg.to_string());
868    if !$crate::common::error::is_log_output_suppressed()
869      && log::max_level() >= log::LevelFilter::Info
870    {
871      $crate::println_stderr!("{msg}");
872      $crate::util::logger::mark_stderr_at_line_start();
873    }
874  }};
875}
876
877/// Progress note to the LOG only — Perl `NoteLog` (`print $LOG … if $LOG`). Always
878/// written to the bound log buffer (the log is the verbose record), never stderr.
879#[macro_export]
880macro_rules! NoteLog {
881  ($input:expr_2021) => {
882    $crate::util::logger::note_to_log(&($input).to_string());
883  };
884}
885
886/// Progress note to STDERR only — Perl `NoteSTDERR` (`if $USE_STDERR &&
887/// $VERBOSITY>=0`). Never touches the log.
888#[macro_export]
889macro_rules! NoteSTDERR {
890  ($input:expr_2021) => {
891    if !$crate::common::error::is_log_output_suppressed()
892      && log::max_level() >= log::LevelFilter::Info
893    {
894      let msg = $input;
895      $crate::println_stderr!("{msg}");
896      $crate::util::logger::mark_stderr_at_line_start();
897    }
898  };
899}
900
901pub type Result<T> = result::Result<T, Error>;
902
903#[derive(Debug)]
904pub struct Error {
905  pub target:   ErrorTarget,
906  pub category: ErrorCategory,
907  pub message:  String,
908}
909impl ErrorTrait for Error {}
910// SAFETY: `Error` contains a `Locator` which embeds a Rc<RefCell<Mouth>> — !Send/!Sync
911// by default. The invariant is the same as for `Stored`: errors propagate within a
912// single thread's conversion pipeline; they never cross thread boundaries at runtime.
913// These impls exist to satisfy `Box<dyn std::error::Error + Send + Sync>` bounds on
914// error return types, which transitively require Send/Sync on all error variants.
915unsafe impl Send for Error {}
916unsafe impl Sync for Error {}
917
918#[derive(Debug)]
919pub enum ErrorCategory {
920  Init,
921  Io(io::Error),
922  NotFound,
923  Unexpected,
924  Expected,
925  Misdefined,
926  Unknown,
927  MissingFile,
928  Malformed,
929  Libxml,
930  Convert,
931  Recursion,
932  EoF,
933  Endgroup,
934  FailedParse,
935  MaxLimit(usize),
936  Generic(Box<dyn ErrorTrait>),
937  Filename(String),
938  ToDo,
939  TokenLimit,
940  PushbackLimit,
941  IfLimit,
942  MemoryBudget,
943}
944
945#[derive(Debug)]
946pub enum ErrorTarget {
947  Package,
948  Parameter,
949  ParamSpec,
950  Prototype,
951  Converter,
952  Mouth,
953  Core,
954  State,
955  Stomach,
956  Codegen,
957  Macro,
958  XMath,
959  MathParser,
960  Document,
961  Definition,
962  TexPool,
963  Internal,
964  TargetUnexpected,
965  TooManyErrors,
966  Timeout,
967}
968
969impl fmt::Display for ErrorCategory {
970  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
971    use ErrorCategory::*;
972    match self {
973      Init => write!(f, "Init"),
974      Io(err) => err.fmt(f),
975      NotFound => write!(f, "No matching cities with a population were found."),
976      MissingFile => write!(f, "missing file"),
977      Misdefined => write!(f, "misdefined"),
978      Unknown => write!(f, "unknown"),
979      Malformed => write!(f, "malformed"),
980      Expected => write!(f, "expected"),
981      Unexpected => write!(f, "unexpected"),
982      Libxml => write!(f, "libxml error"),
983      Recursion => write!(f, "<recursion>"),
984      EoF => write!(f, "<EOF>"),
985      ToDo => write!(f, "TODO"),
986      Convert => write!(f, "conversion"),
987      Endgroup => write!(f, "<endgroup>"),
988      FailedParse => write!(f, "failed to parse"),
989      MaxLimit(num) => write!(f, "{}", num),
990      Generic(err) => err.fmt(f),
991      Filename(name) => write!(f, "file:{name}"),
992      TokenLimit => write!(f, "token_limit"),
993      PushbackLimit => write!(f, "pushback_limit"),
994      IfLimit => write!(f, "if_limit"),
995      MemoryBudget => write!(f, "memory_budget"),
996    }
997  }
998}
999impl fmt::Display for Error {
1000  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
1001    write!(
1002      f,
1003      "Error:{}:{:?} {}",
1004      self.category, self.target, self.message
1005    )
1006  }
1007}
1008
1009impl Error {
1010  pub fn log_fatal(&self) {
1011    // One primitive does both halves: the `Fatal:<target>:<category>` line
1012    // AND the sticky `LogStatus::Fatal` latch. Without the latch,
1013    // `Fatal:Timeout:MemoryBudget` etc. printed but the runtime status_code
1014    // stayed at 0 — canvas would classify the worker as OK with an empty
1015    // HTML output. R35.A.
1016    emit_record(
1017      LogStatus::Fatal,
1018      &s!("Fatal:{:?}:{:?} ", self.target, self.category),
1019      &self.message,
1020    );
1021  }
1022  pub fn todo() -> Self {
1023    Error {
1024      target:   ErrorTarget::Internal,
1025      category: ErrorCategory::ToDo,
1026      message:  String::from(
1027        "This section of the code is not yet implemented / ported over from Perl.",
1028      ),
1029    }
1030  }
1031}
1032
1033#[macro_export]
1034macro_rules! unported {
1035  () => {{ ::latexml_core::common::error::Error::todo() }};
1036}
1037
1038impl From<io::Error> for Error {
1039  fn from(err: io::Error) -> Error {
1040    Error {
1041      target:   ErrorTarget::Mouth,
1042      category: ErrorCategory::Io(err),
1043      message:  s!("IO error"),
1044    }
1045  }
1046}
1047
1048impl From<Box<dyn ErrorTrait>> for Error {
1049  fn from(err: Box<dyn ErrorTrait>) -> Error {
1050    Error {
1051      target:   ErrorTarget::Document,
1052      message:  err.to_string(),
1053      category: ErrorCategory::Generic(err),
1054    }
1055  }
1056}
1057impl From<Box<dyn ErrorTrait + Send + Sync>> for Error {
1058  fn from(err: Box<dyn ErrorTrait + Send + Sync>) -> Error {
1059    Error {
1060      target:   ErrorTarget::Document,
1061      message:  err.to_string(),
1062      category: ErrorCategory::Generic(err),
1063    }
1064  }
1065}
1066
1067impl From<String> for Error {
1068  fn from(err: String) -> Error {
1069    Error {
1070      target:   ErrorTarget::Document,
1071      category: ErrorCategory::Generic(From::from(err.clone())),
1072      message:  err,
1073    }
1074  }
1075}
1076
1077impl<'a> From<&'a str> for Error {
1078  fn from(err: &'a str) -> Error {
1079    Error {
1080      target:   ErrorTarget::Document,
1081      category: ErrorCategory::Generic(From::from(err.to_owned())),
1082      message:  err.to_owned(),
1083    }
1084  }
1085}
1086
1087impl From<()> for Error {
1088  fn from(_e: ()) -> Error {
1089    Error {
1090      target:   ErrorTarget::Document,
1091      category: ErrorCategory::Libxml,
1092      message:  s!("LibXML error"),
1093    }
1094  }
1095}
1096
1097impl From<ParseIntError> for Error {
1098  fn from(err: ParseIntError) -> Error {
1099    Error {
1100      target:   ErrorTarget::Document,
1101      message:  err.to_string(),
1102      category: ErrorCategory::Generic(Box::new(err)),
1103    }
1104  }
1105}
1106
1107impl From<ParseFloatError> for Error {
1108  fn from(err: ParseFloatError) -> Error {
1109    Error {
1110      target:   ErrorTarget::Document,
1111      message:  err.to_string(),
1112      category: ErrorCategory::Generic(Box::new(err)),
1113    }
1114  }
1115}
1116
1117impl From<marpa::error::Error> for Error {
1118  fn from(err: marpa::error::Error) -> Error {
1119    Error {
1120      target:   ErrorTarget::MathParser,
1121      category: ErrorCategory::FailedParse,
1122      message:  err.to_string(),
1123    }
1124  }
1125}
1126
1127//%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
1128// Progress Reporting
1129//**********************************************************************
1130// Progress reporting.
1131
1132/// Advance the progress indicator by one step.
1133///
1134/// Perl `Common/Error.pm:ProgressStep` L430-433 ticks a terminal spinner. This
1135/// port draws no spinner — conversion steps go by faster than a spinner can
1136/// usefully render — so the call is a deliberate no-op, kept as the seam Perl
1137/// bindings call through. The reporting that does reach the log is
1138/// [`note_progress`] and the [`note_begin`]/[`note_end`] pair.
1139pub fn progress_step(_note: &str) {
1140  // should we also do a spinner? It's often too fast to spin
1141  // _spinnerstep(note)
1142}
1143
1144pub fn note_progress(stuff: &str) {
1145  use log::info;
1146  info!(target: "note", "{}", stuff);
1147}
1148
1149// TODO: Rethink this reporting
1150pub fn note_progress_detailed(stuff: &str) {
1151  use log::debug;
1152  debug!(target: "note", "{}", stuff);
1153}
1154
1155/// Open a named progress stage, logging `(stage...`.
1156///
1157/// Perl `Common/Error.pm:ProgressSpinup` L435ff. Pair every call with
1158/// [`note_end`], which closes the parenthesis — the nesting of those
1159/// parentheses is what makes a conversion log readable as a phase tree.
1160/// Perl also stamps a `NOTE_TIMERS` entry here so the close can report elapsed
1161/// time; this port logs the structure without the timing (per-phase wall times
1162/// are the telemetry module's job).
1163pub fn note_begin(stage: &str) {
1164  // $state->assignMapping('NOTE_TIMERS', $stage, [Time::HiRes::gettimeofday]);
1165  use log::info;
1166  info!(target: "note", "\n({}...", stage);
1167}
1168
1169/// Close the progress stage opened by [`note_begin`], logging the matching `)`.
1170///
1171/// Perl `Common/Error.pm:ProgressSpindown` additionally prints the stage's
1172/// elapsed time from its `NOTE_TIMERS` entry; see [`note_begin`] for why this
1173/// port does not.
1174pub fn note_end(_stage: &str) {
1175  // if (my $start = $state && $state->lookupMapping('NOTE_TIMERS', $stage)) {
1176  //   $state->assignMapping('NOTE_TIMERS', $stage, undef);
1177
1178  // my $elapsed = Time::HiRes::tv_interval($start, [Time::HiRes::gettimeofday]);
1179  // info!(target: "note", " %.2f sec)", elapsed);
1180  use log::info;
1181  info!(target: "note", " )");
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186  use super::*;
1187
1188  // These tests share a thread-local `REPORT`, so each test must
1189  // `initialize_report()` first. They must NOT run truly in parallel
1190  // over the same thread, but cargo's default harness only runs tests
1191  // in parallel on separate threads (each with its own thread-local),
1192  // so this is safe.
1193
1194  #[test]
1195  fn initialize_report_clears_state() {
1196    note_status(LogStatus::Warning, None);
1197    initialize_report();
1198    assert_eq!(get_status(LogStatus::Warning), 0);
1199  }
1200
1201  #[test]
1202  fn note_status_increments_counters() {
1203    initialize_report();
1204    note_status(LogStatus::Warning, None);
1205    note_status(LogStatus::Warning, None);
1206    note_status(LogStatus::Error, None);
1207    assert_eq!(get_status(LogStatus::Warning), 2);
1208    assert_eq!(get_status(LogStatus::Error), 1);
1209    assert_eq!(get_status(LogStatus::Fatal), 0);
1210  }
1211
1212  #[test]
1213  fn fatal_macro_latches_resource_fatals() {
1214    // The `Fatal!` macro must record Timeout-target fatals in the
1215    // resource-fatal latch so boundaries that flatten errors to strings
1216    // (marpa semantics) can recover the structured identity (P1-4).
1217    initialize_report();
1218    fn raise() -> Result<()> {
1219      Fatal!(
1220        Timeout,
1221        TokenLimit,
1222        "Token limit of 5 exceeded, infinite loop?"
1223      );
1224    }
1225    let err = raise().unwrap_err();
1226    assert!(matches!(err.target, ErrorTarget::Timeout));
1227    let latched = take_last_resource_fatal().expect("latch must hold the fatal");
1228    assert!(matches!(latched.target, ErrorTarget::Timeout));
1229    assert!(matches!(latched.category, ErrorCategory::TokenLimit));
1230    assert_eq!(latched.message, "Token limit of 5 exceeded, infinite loop?");
1231    // take() clears the latch.
1232    assert!(take_last_resource_fatal().is_none());
1233    // Non-Timeout fatals are NOT latched (the latch serves resource fatals).
1234    fn raise_other() -> Result<()> {
1235      Fatal!(Internal, EoF, "fell off the end");
1236    }
1237    let _ = raise_other().unwrap_err();
1238    assert!(take_last_resource_fatal().is_none());
1239    // initialize_report clears a stale latch.
1240    let _ = raise().unwrap_err();
1241    initialize_report();
1242    assert!(take_last_resource_fatal().is_none());
1243  }
1244
1245  #[test]
1246  fn fatal_status_is_sticky_and_returns_1() {
1247    initialize_report();
1248    note_status(LogStatus::Fatal, None);
1249    note_status(LogStatus::Fatal, None);
1250    // get_status for Fatal returns 0 or 1, not a counter.
1251    assert_eq!(get_status(LogStatus::Fatal), 1);
1252  }
1253
1254  #[test]
1255  fn get_status_code_priority_order() {
1256    initialize_report();
1257    assert_eq!(get_status_code(), 0, "clean state → 0");
1258    note_status(LogStatus::Warning, None);
1259    assert_eq!(get_status_code(), 1, "warning → 1");
1260    note_status(LogStatus::Error, None);
1261    assert_eq!(get_status_code(), 2, "error wins over warning → 2");
1262    note_status(LogStatus::Fatal, None);
1263    assert_eq!(get_status_code(), 3, "fatal wins over error → 3");
1264  }
1265
1266  #[test]
1267  fn status_message_clean_is_no_obvious_problems() {
1268    initialize_report();
1269    assert_eq!(get_status_message(), "No obvious problems");
1270  }
1271
1272  #[test]
1273  fn status_message_plural_warnings() {
1274    initialize_report();
1275    note_status(LogStatus::Warning, None);
1276    let m = get_status_message();
1277    assert_eq!(m, "1 warning", "singular form");
1278
1279    note_status(LogStatus::Warning, None);
1280    let m = get_status_message();
1281    assert_eq!(m, "2 warnings", "plural form");
1282  }
1283
1284  #[test]
1285  fn status_message_multiple_categories_joined() {
1286    initialize_report();
1287    note_status(LogStatus::Warning, None);
1288    note_status(LogStatus::Warning, None);
1289    note_status(LogStatus::Error, None);
1290    let m = get_status_message();
1291    assert!(
1292      m.contains("2 warnings") && m.contains("1 error") && m.contains("; "),
1293      "got {m:?}"
1294    );
1295  }
1296
1297  #[test]
1298  fn suppress_log_output_returns_prior_value() {
1299    let prior = set_suppress_log_output(true);
1300    assert!(is_log_output_suppressed());
1301    let prior2 = set_suppress_log_output(false);
1302    assert!(prior2, "round-trip prior value");
1303    assert!(!is_log_output_suppressed());
1304    // Clean up to original state.
1305    set_suppress_log_output(prior);
1306  }
1307}