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/// Would a `Debug`-status record actually reach the log right now? True only
611/// when the global `log` level admits `Debug` (default is `Info`; `--verbose`/
612/// `--debug` raise it — `util/logger.rs::init`) AND output is not suppressed.
613/// The `Debug!` macro gates *message construction* on this: the 2026-08-23
614/// audit measured up to ~26% of a build-bound conversion spent serializing
615/// `node_to_string` subtrees into `Debug!` messages that `emit_record` then
616/// discarded (PERFORMANCE.md, Open levers P0). An atomic load + thread-local
617/// read — cheap enough for token-frequency call sites.
618#[inline]
619pub fn debug_record_enabled() -> bool {
620  log::max_level() >= log::LevelFilter::Debug && !is_log_output_suppressed()
621}
622
623/// Feature-gated debug logging — Perl's `Debug(...) if $LaTeXML::DEBUG{feature}`.
624/// Usage: `DebugFeature!("frontmatter", "FRONT Add {}", entry)`.
625/// Logs with the feature name as the `log` target (so output matches the
626/// previous `log::debug!(target: "frontmatter", ...)` form) and counts a
627/// Debug in the status report, like `Debug!`. NB deliberately does NOT
628/// forward to `Debug!` — its 3-expr `(category, object, message)` arm
629/// would mis-capture a format string with two arguments.
630#[macro_export]
631macro_rules! DebugFeature {
632  ($feature:literal, $($arg:tt)*) => {{
633    if $crate::common::error::debug_enabled($feature) {
634      let __diag_guard = $crate::common::error::macro_diag_guard();
635      $crate::common::error::note_status(
636        $crate::common::error::LogStatus::Debug, None);
637      use log::debug;
638      debug!(target: $feature, $($arg)*);
639    }
640  }};
641}
642
643/// Debug-status diagnostics. **Lazy**: the argument expressions — which at
644/// several sites build whole `node_to_string` subtree serializations — are
645/// evaluated only when [`debug_record_enabled`] says the record would actually
646/// be logged (2026-08-23 audit, PERFORMANCE.md Open levers P0). The Debug
647/// status tally (`note_status`) is preserved unconditionally, so status counts
648/// are identical to the eager form at every verbosity.
649#[macro_export]
650macro_rules! Debug {
651  ($category:expr_2021, $object:expr_2021, $message:expr_2021) => {{
652    if $crate::common::error::debug_record_enabled() {
653      $crate::common::error::emit_record(
654        $crate::common::error::LogStatus::Debug,
655        &format!("{}:{}", $category, $object),
656        &$crate::generate_message!($message))
657    } else {
658      $crate::common::error::note_status(
659        $crate::common::error::LogStatus::Debug, None);
660    }
661  }};
662 ($category:expr_2021, $object:expr_2021, $message:expr_2021, $($details:expr_2021),*) => {{
663    if $crate::common::error::debug_record_enabled() {
664      $crate::common::error::emit_record(
665        $crate::common::error::LogStatus::Debug,
666        &format!("{}:{}", $category, $object),
667        &$crate::generate_message!($message, $($details),*))
668    } else {
669      $crate::common::error::note_status(
670        $crate::common::error::LogStatus::Debug, None);
671    }
672  }};
673  ($($simple:expr_2021),*) => {{
674    $crate::common::error::note_status(
675      $crate::common::error::LogStatus::Debug, None);
676    if $crate::common::error::debug_record_enabled() {
677      let __diag_guard = $crate::common::error::macro_diag_guard();
678      use log::debug;
679      debug!($($simple),*);
680    }
681  }};
682
683}
684
685#[macro_export]
686macro_rules! Info {
687  ($category:expr_2021, $object:expr_2021, $message:expr_2021) => {{
688    $crate::common::error::emit_info(
689      &format!("{}", $category), &format!("{}", $object),
690      &$crate::generate_message!($message))
691  }};
692 ($category:expr_2021, $object:expr_2021, $message:expr_2021, $($details:expr_2021),*) => {{
693    $crate::common::error::emit_info(
694      &format!("{}", $category), &format!("{}", $object),
695      &$crate::generate_message!($message, $($details),*))
696  }};
697  ($($simple:expr_2021),*) => {{
698    let __diag_guard = $crate::common::error::macro_diag_guard();
699    $crate::common::error::note_status(
700      $crate::common::error::LogStatus::Info, None);
701    use log::info;
702    info!($($simple),*);
703  }};
704
705}
706
707#[macro_export]
708macro_rules! Warn {
709  ($category:expr_2021, $object:expr_2021, $message:expr_2021) => {{
710    $crate::common::error::emit_warn(
711      &format!("{}", $category), &format!("{}", $object),
712      &$crate::generate_message!($message))
713  }};
714 ($category:expr_2021, $object:expr_2021, $message:expr_2021, $($details:expr_2021),*) => {{
715    $crate::common::error::emit_warn(
716      &format!("{}", $category), &format!("{}", $object),
717      &$crate::generate_message!($message, $($details),*))
718  }}
719}
720
721#[macro_export]
722macro_rules! Error {
723  ($category:expr_2021, $object:expr_2021, $message:expr_2021) => {{
724    $crate::Error!($category,$object,$message,"")
725  }};
726 ($category:expr_2021, $object:expr_2021, $message:expr_2021, $($details:expr_2021),*) => {{
727    $crate::common::error::emit_record(
728      $crate::common::error::LogStatus::Error,
729      &format!("{}:{}", $category, $object),
730      &$crate::generate_message!($message, $($details),*),
731    );
732    // In the fatal-demotion scope (bibliography post-processing) the
733    // too-many/consecutive-error escalations are SKIPPED: their Fatal!
734    // would demote back into an Error, turning the circuit-breaker into
735    // an error multiplier (run-233 follow-up: 470 self-feeding
736    // "Too many errors" lines on 2605.02213). The bib interpreter has its
737    // own bounded failure latch instead.
738    if !$crate::common::error::is_demote_fatals() {
739    // Borrow-safe read: an Error! can be raised from inside a `state_mut()`
740    // scope (e.g. push_value's BUG branch, a constructor's after_digest),
741    // where a plain `lookup_int` would panic "RefCell already mutably
742    // borrowed" and abort the conversion (tikz-cd 2001.08973).
743    let max_from_state = $crate::state::try_lookup_int("MAX_ERRORS");
744    // Match Perl LaTeXML default of 100 errors before Fatal('too_many_errors').
745    // Past 100 errors a paper has already failed comprehension; continuing
746    // produces noise without information. Override via state for tests
747    // or specific bindings (e.g. tikz_sty raises to 1000, dump-build raises
748    // to 1_000_000).
749    let maxerrors = match max_from_state {
750      // STATE contended: we cannot read the (possibly raised) cap, so skip the
751      // too-many-errors check for *this* error rather than risk a spurious
752      // Fatal from a stale default. The next uncontended error re-applies it.
753      None => usize::MAX,
754      Some(v) if v > 0 => v as usize,
755      Some(_) => 100,
756    };
757    if $crate::common::error::get_status($crate::common::error::LogStatus::Error) > maxerrors {
758      Fatal!(TooManyErrors, MaxLimit(maxerrors), format!("Too many errors (> {maxerrors})!"));
759    }
760    // Runaway-loop early-bail: if the same error signature has fired
761    // MAX_CONSECUTIVE_ERRORS times in a row, we're stuck in a loop
762    // (the canonical witness is plain-TeX `\tabalign` invoked in math
763    // mode → unbounded `\halign` cell loop emitting `\hbox` end-mode
764    // mismatches). Bail before MAX_ERRORS so logs stay short and
765    // post-processing sees a clear cause. The threshold is well above
766    // any legitimate same-error pattern (real papers max out at a few
767    // hundred unique errors).
768    let __consec_key = format!("{}:{}", $category, $object);
769    let __consec = $crate::common::error::note_consecutive_error(&__consec_key);
770    if __consec > $crate::common::error::MAX_CONSECUTIVE_ERRORS {
771      Fatal!(
772        TooManyErrors,
773        MaxLimit($crate::common::error::MAX_CONSECUTIVE_ERRORS),
774        format!(
775          "Runaway: same error '{}' fired {} times in a row (cap = {})",
776          __consec_key, __consec, $crate::common::error::MAX_CONSECUTIVE_ERRORS
777        )
778      );
779    }
780    }
781  }}
782}
783
784// TODO: flesh out the messages
785#[macro_export]
786macro_rules! Fatal {
787  ($target:expr_2021, $category:expr_2021, $message:expr_2021) => {{
788    if $crate::common::error::is_demote_fatals() {
789      // Demoted context (bibliography post-processing): count and log as
790      // an ERROR — the problem is real and must be visible/accounted —
791      // but never latch the document's sticky fatal. The Err return below
792      // still aborts the failing digestion; its caller degrades
793      // gracefully. A document must not be lost to a broken bibliography.
794      $crate::common::error::emit_record(
795        $crate::common::error::LogStatus::Error,
796        "demoted_fatal",
797        &format!("{}", $message),
798      );
799    } else {
800      $crate::common::error::note_status($crate::common::error::LogStatus::Fatal, None);
801    }
802    {
803      use $crate::common::error::{Error as LatexmlError, ErrorCategory::*, ErrorTarget::*};
804      let __fatal_err = LatexmlError {
805        target:   $target,
806        category: $category,
807        message:  $message.to_string(),
808      };
809      // Latch resource-class fatals so layers that flatten errors to strings
810      // (e.g. the marpa semantics boundary) can still recover the STRUCTURED
811      // identity downstream. See `take_last_resource_fatal`.
812      $crate::common::error::record_last_fatal(&__fatal_err);
813      return Err(__fatal_err);
814    }
815  }};
816}
817
818#[macro_export]
819macro_rules! fatal {
820  ($target:expr_2021, $category:expr_2021, $message:expr_2021) => {{
821    use $crate::common::error::{Error as LatexmlError, ErrorCategory::*, ErrorTarget::*};
822    return Err(LatexmlError {
823      target:   $target,
824      category: $category,
825      message:  $message.to_string(),
826    });
827  }};
828}
829
830#[macro_export]
831macro_rules! generate_message {
832  ($message:expr_2021) => {
833    format!(
834      "{}\n\t{}\n\tIn {}:{}:{}\n",
835      $message,
836      $crate::gullet::get_location(),
837      file!(),
838      line!(),
839      column!()
840    )
841  };
842  ($message:expr_2021, $detail:expr_2021) => {
843    format!(
844      "{}\n\t{}\n\t{}\n\tIn {}:{}:{}\n",
845      $message,
846      $crate::gullet::get_location(),
847      $detail,
848      file!(),
849      line!(),
850      column!()
851    )
852  };
853  ($message:expr_2021, $detail:expr_2021, $detail2:expr_2021) => {
854    format!(
855      "{}\n\t{}\n\t{}\n\t{}\n\tIn {}:{}:{}\n",
856      $message,
857      $crate::gullet::get_location(),
858      $detail,
859      $detail2,
860      file!(),
861      line!(),
862      column!()
863    )
864  };
865  ($message:expr_2021, $detail:expr_2021, $detail2:expr_2021) => {
866    format!(
867      "{}\n\t{}\n\t{}\n\t{}\n\tIn {}:{}:{}\n",
868      $message,
869      $crate::gullet::get_location(),
870      $detail,
871      $detail2,
872      file!(),
873      line!(),
874      column!()
875    )
876  };
877  ($message:expr_2021, $detail:expr_2021, $detail2:expr_2021, $location:expr_2021) => {
878    format!(
879      "{}\n\t{}\n\t{}\n\t{}\n\tIn {}:{}:{}\n",
880      $message,
881      $location,
882      $detail,
883      $detail2,
884      file!(),
885      line!(),
886      column!()
887    )
888  };
889}
890
891/// Progress note to BOTH the log and stderr — Perl `Note` (`_printline`): the LOG
892/// always (if a buffer is bound, ANSI-stripped), STDERR only when the verbosity
893/// admits it (`$USE_STDERR && $VERBOSITY>=0`). The STDERR gate is the decoupled
894/// console verbosity ([`crate::util::logger::stderr_shows_info`]), NOT `max_level`
895/// — under `--quiet` the log-file floor keeps `max_level` at `Info`, but the
896/// console note must still be silenced (issue #763).
897#[macro_export]
898macro_rules! Note {
899  ($input:expr_2021) => {{
900    let msg = $input;
901    $crate::util::logger::note_to_log(&msg.to_string());
902    if !$crate::common::error::is_log_output_suppressed()
903      && $crate::util::logger::stderr_shows_info()
904    {
905      $crate::println_stderr!("{msg}");
906      $crate::util::logger::mark_stderr_at_line_start();
907    }
908  }};
909}
910
911/// Progress note to the LOG only — Perl `NoteLog` (`print $LOG … if $LOG`). Always
912/// written to the bound log buffer (the log is the verbose record), never stderr.
913#[macro_export]
914macro_rules! NoteLog {
915  ($input:expr_2021) => {
916    $crate::util::logger::note_to_log(&($input).to_string());
917  };
918}
919
920/// Progress note to STDERR only — Perl `NoteSTDERR` (`if $USE_STDERR &&
921/// $VERBOSITY>=0`). Never touches the log. Gated on the decoupled console
922/// verbosity ([`crate::util::logger::stderr_shows_info`]), not `max_level`
923/// (issue #763).
924#[macro_export]
925macro_rules! NoteSTDERR {
926  ($input:expr_2021) => {
927    if !$crate::common::error::is_log_output_suppressed()
928      && $crate::util::logger::stderr_shows_info()
929    {
930      let msg = $input;
931      $crate::println_stderr!("{msg}");
932      $crate::util::logger::mark_stderr_at_line_start();
933    }
934  };
935}
936
937pub type Result<T> = result::Result<T, Error>;
938
939#[derive(Debug)]
940pub struct Error {
941  pub target:   ErrorTarget,
942  pub category: ErrorCategory,
943  pub message:  String,
944}
945impl ErrorTrait for Error {}
946// SAFETY: `Error` contains a `Locator` which embeds a Rc<RefCell<Mouth>> — !Send/!Sync
947// by default. The invariant is the same as for `Stored`: errors propagate within a
948// single thread's conversion pipeline; they never cross thread boundaries at runtime.
949// These impls exist to satisfy `Box<dyn std::error::Error + Send + Sync>` bounds on
950// error return types, which transitively require Send/Sync on all error variants.
951unsafe impl Send for Error {}
952unsafe impl Sync for Error {}
953
954#[derive(Debug)]
955pub enum ErrorCategory {
956  Init,
957  Io(io::Error),
958  NotFound,
959  Unexpected,
960  Expected,
961  Misdefined,
962  Unknown,
963  MissingFile,
964  Malformed,
965  Libxml,
966  Convert,
967  Recursion,
968  EoF,
969  Endgroup,
970  FailedParse,
971  MaxLimit(usize),
972  Generic(Box<dyn ErrorTrait>),
973  Filename(String),
974  ToDo,
975  TokenLimit,
976  PushbackLimit,
977  IfLimit,
978  MemoryBudget,
979}
980
981#[derive(Debug)]
982pub enum ErrorTarget {
983  Package,
984  Parameter,
985  ParamSpec,
986  Prototype,
987  Converter,
988  Mouth,
989  Core,
990  State,
991  Stomach,
992  Codegen,
993  Macro,
994  XMath,
995  MathParser,
996  Document,
997  Definition,
998  TexPool,
999  Internal,
1000  TargetUnexpected,
1001  TooManyErrors,
1002  Timeout,
1003}
1004
1005impl fmt::Display for ErrorCategory {
1006  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
1007    use ErrorCategory::*;
1008    match self {
1009      Init => write!(f, "Init"),
1010      Io(err) => err.fmt(f),
1011      NotFound => write!(f, "No matching cities with a population were found."),
1012      MissingFile => write!(f, "missing file"),
1013      Misdefined => write!(f, "misdefined"),
1014      Unknown => write!(f, "unknown"),
1015      Malformed => write!(f, "malformed"),
1016      Expected => write!(f, "expected"),
1017      Unexpected => write!(f, "unexpected"),
1018      Libxml => write!(f, "libxml error"),
1019      Recursion => write!(f, "<recursion>"),
1020      EoF => write!(f, "<EOF>"),
1021      ToDo => write!(f, "TODO"),
1022      Convert => write!(f, "conversion"),
1023      Endgroup => write!(f, "<endgroup>"),
1024      FailedParse => write!(f, "failed to parse"),
1025      MaxLimit(num) => write!(f, "{}", num),
1026      Generic(err) => err.fmt(f),
1027      Filename(name) => write!(f, "file:{name}"),
1028      TokenLimit => write!(f, "token_limit"),
1029      PushbackLimit => write!(f, "pushback_limit"),
1030      IfLimit => write!(f, "if_limit"),
1031      MemoryBudget => write!(f, "memory_budget"),
1032    }
1033  }
1034}
1035impl fmt::Display for Error {
1036  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
1037    write!(
1038      f,
1039      "Error:{}:{:?} {}",
1040      self.category, self.target, self.message
1041    )
1042  }
1043}
1044
1045impl Error {
1046  pub fn log_fatal(&self) {
1047    // One primitive does both halves: the `Fatal:<target>:<category>` line
1048    // AND the sticky `LogStatus::Fatal` latch. Without the latch,
1049    // `Fatal:Timeout:MemoryBudget` etc. printed but the runtime status_code
1050    // stayed at 0 — canvas would classify the worker as OK with an empty
1051    // HTML output. R35.A.
1052    emit_record(
1053      LogStatus::Fatal,
1054      &s!("Fatal:{:?}:{:?} ", self.target, self.category),
1055      &self.message,
1056    );
1057  }
1058  pub fn todo() -> Self {
1059    Error {
1060      target:   ErrorTarget::Internal,
1061      category: ErrorCategory::ToDo,
1062      message:  String::from(
1063        "This section of the code is not yet implemented / ported over from Perl.",
1064      ),
1065    }
1066  }
1067}
1068
1069#[macro_export]
1070macro_rules! unported {
1071  () => {{ ::latexml_core::common::error::Error::todo() }};
1072}
1073
1074impl From<io::Error> for Error {
1075  fn from(err: io::Error) -> Error {
1076    Error {
1077      target:   ErrorTarget::Mouth,
1078      category: ErrorCategory::Io(err),
1079      message:  s!("IO error"),
1080    }
1081  }
1082}
1083
1084impl From<Box<dyn ErrorTrait>> for Error {
1085  fn from(err: Box<dyn ErrorTrait>) -> Error {
1086    Error {
1087      target:   ErrorTarget::Document,
1088      message:  err.to_string(),
1089      category: ErrorCategory::Generic(err),
1090    }
1091  }
1092}
1093impl From<Box<dyn ErrorTrait + Send + Sync>> for Error {
1094  fn from(err: Box<dyn ErrorTrait + Send + Sync>) -> Error {
1095    Error {
1096      target:   ErrorTarget::Document,
1097      message:  err.to_string(),
1098      category: ErrorCategory::Generic(err),
1099    }
1100  }
1101}
1102
1103impl From<String> for Error {
1104  fn from(err: String) -> Error {
1105    Error {
1106      target:   ErrorTarget::Document,
1107      category: ErrorCategory::Generic(From::from(err.clone())),
1108      message:  err,
1109    }
1110  }
1111}
1112
1113impl<'a> From<&'a str> for Error {
1114  fn from(err: &'a str) -> Error {
1115    Error {
1116      target:   ErrorTarget::Document,
1117      category: ErrorCategory::Generic(From::from(err.to_owned())),
1118      message:  err.to_owned(),
1119    }
1120  }
1121}
1122
1123impl From<()> for Error {
1124  fn from(_e: ()) -> Error {
1125    Error {
1126      target:   ErrorTarget::Document,
1127      category: ErrorCategory::Libxml,
1128      message:  s!("LibXML error"),
1129    }
1130  }
1131}
1132
1133impl From<ParseIntError> for Error {
1134  fn from(err: ParseIntError) -> Error {
1135    Error {
1136      target:   ErrorTarget::Document,
1137      message:  err.to_string(),
1138      category: ErrorCategory::Generic(Box::new(err)),
1139    }
1140  }
1141}
1142
1143impl From<ParseFloatError> for Error {
1144  fn from(err: ParseFloatError) -> Error {
1145    Error {
1146      target:   ErrorTarget::Document,
1147      message:  err.to_string(),
1148      category: ErrorCategory::Generic(Box::new(err)),
1149    }
1150  }
1151}
1152
1153impl From<marpa::error::Error> for Error {
1154  fn from(err: marpa::error::Error) -> Error {
1155    Error {
1156      target:   ErrorTarget::MathParser,
1157      category: ErrorCategory::FailedParse,
1158      message:  err.to_string(),
1159    }
1160  }
1161}
1162
1163//%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
1164// Progress Reporting
1165//**********************************************************************
1166// Progress reporting.
1167
1168/// Advance the progress indicator by one step.
1169///
1170/// Perl `Common/Error.pm:ProgressStep` L430-433 ticks a terminal spinner. This
1171/// port draws no spinner — conversion steps go by faster than a spinner can
1172/// usefully render — so the call is a deliberate no-op, kept as the seam Perl
1173/// bindings call through. The reporting that does reach the log is
1174/// [`note_progress`] and the [`note_begin`]/[`note_end`] pair.
1175pub fn progress_step(_note: &str) {
1176  // should we also do a spinner? It's often too fast to spin
1177  // _spinnerstep(note)
1178}
1179
1180pub fn note_progress(stuff: &str) {
1181  use log::info;
1182  info!(target: "note", "{}", stuff);
1183}
1184
1185// TODO: Rethink this reporting
1186pub fn note_progress_detailed(stuff: &str) {
1187  use log::debug;
1188  debug!(target: "note", "{}", stuff);
1189}
1190
1191/// Open a named progress stage, logging `(stage...`.
1192///
1193/// Perl `Common/Error.pm:ProgressSpinup` L435ff. Pair every call with
1194/// [`note_end`], which closes the parenthesis — the nesting of those
1195/// parentheses is what makes a conversion log readable as a phase tree.
1196/// Perl also stamps a `NOTE_TIMERS` entry here so the close can report elapsed
1197/// time; this port logs the structure without the timing (per-phase wall times
1198/// are the telemetry module's job).
1199pub fn note_begin(stage: &str) {
1200  // $state->assignMapping('NOTE_TIMERS', $stage, [Time::HiRes::gettimeofday]);
1201  use log::info;
1202  info!(target: "note", "\n({}...", stage);
1203}
1204
1205/// Close the progress stage opened by [`note_begin`], logging the matching `)`.
1206///
1207/// Perl `Common/Error.pm:ProgressSpindown` additionally prints the stage's
1208/// elapsed time from its `NOTE_TIMERS` entry; see [`note_begin`] for why this
1209/// port does not.
1210pub fn note_end(_stage: &str) {
1211  // if (my $start = $state && $state->lookupMapping('NOTE_TIMERS', $stage)) {
1212  //   $state->assignMapping('NOTE_TIMERS', $stage, undef);
1213
1214  // my $elapsed = Time::HiRes::tv_interval($start, [Time::HiRes::gettimeofday]);
1215  // info!(target: "note", " %.2f sec)", elapsed);
1216  use log::info;
1217  info!(target: "note", " )");
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222  use super::*;
1223
1224  // These tests share a thread-local `REPORT`, so each test must
1225  // `initialize_report()` first. They must NOT run truly in parallel
1226  // over the same thread, but cargo's default harness only runs tests
1227  // in parallel on separate threads (each with its own thread-local),
1228  // so this is safe.
1229
1230  #[test]
1231  fn initialize_report_clears_state() {
1232    note_status(LogStatus::Warning, None);
1233    initialize_report();
1234    assert_eq!(get_status(LogStatus::Warning), 0);
1235  }
1236
1237  #[test]
1238  fn note_status_increments_counters() {
1239    initialize_report();
1240    note_status(LogStatus::Warning, None);
1241    note_status(LogStatus::Warning, None);
1242    note_status(LogStatus::Error, None);
1243    assert_eq!(get_status(LogStatus::Warning), 2);
1244    assert_eq!(get_status(LogStatus::Error), 1);
1245    assert_eq!(get_status(LogStatus::Fatal), 0);
1246  }
1247
1248  #[test]
1249  fn fatal_macro_latches_resource_fatals() {
1250    // The `Fatal!` macro must record Timeout-target fatals in the
1251    // resource-fatal latch so boundaries that flatten errors to strings
1252    // (marpa semantics) can recover the structured identity (P1-4).
1253    initialize_report();
1254    fn raise() -> Result<()> {
1255      Fatal!(
1256        Timeout,
1257        TokenLimit,
1258        "Token limit of 5 exceeded, infinite loop?"
1259      );
1260    }
1261    let err = raise().unwrap_err();
1262    assert!(matches!(err.target, ErrorTarget::Timeout));
1263    let latched = take_last_resource_fatal().expect("latch must hold the fatal");
1264    assert!(matches!(latched.target, ErrorTarget::Timeout));
1265    assert!(matches!(latched.category, ErrorCategory::TokenLimit));
1266    assert_eq!(latched.message, "Token limit of 5 exceeded, infinite loop?");
1267    // take() clears the latch.
1268    assert!(take_last_resource_fatal().is_none());
1269    // Non-Timeout fatals are NOT latched (the latch serves resource fatals).
1270    fn raise_other() -> Result<()> {
1271      Fatal!(Internal, EoF, "fell off the end");
1272    }
1273    let _ = raise_other().unwrap_err();
1274    assert!(take_last_resource_fatal().is_none());
1275    // initialize_report clears a stale latch.
1276    let _ = raise().unwrap_err();
1277    initialize_report();
1278    assert!(take_last_resource_fatal().is_none());
1279  }
1280
1281  #[test]
1282  fn fatal_status_is_sticky_and_returns_1() {
1283    initialize_report();
1284    note_status(LogStatus::Fatal, None);
1285    note_status(LogStatus::Fatal, None);
1286    // get_status for Fatal returns 0 or 1, not a counter.
1287    assert_eq!(get_status(LogStatus::Fatal), 1);
1288  }
1289
1290  #[test]
1291  fn get_status_code_priority_order() {
1292    initialize_report();
1293    assert_eq!(get_status_code(), 0, "clean state → 0");
1294    note_status(LogStatus::Warning, None);
1295    assert_eq!(get_status_code(), 1, "warning → 1");
1296    note_status(LogStatus::Error, None);
1297    assert_eq!(get_status_code(), 2, "error wins over warning → 2");
1298    note_status(LogStatus::Fatal, None);
1299    assert_eq!(get_status_code(), 3, "fatal wins over error → 3");
1300  }
1301
1302  #[test]
1303  fn status_message_clean_is_no_obvious_problems() {
1304    initialize_report();
1305    assert_eq!(get_status_message(), "No obvious problems");
1306  }
1307
1308  #[test]
1309  fn status_message_plural_warnings() {
1310    initialize_report();
1311    note_status(LogStatus::Warning, None);
1312    let m = get_status_message();
1313    assert_eq!(m, "1 warning", "singular form");
1314
1315    note_status(LogStatus::Warning, None);
1316    let m = get_status_message();
1317    assert_eq!(m, "2 warnings", "plural form");
1318  }
1319
1320  #[test]
1321  fn status_message_multiple_categories_joined() {
1322    initialize_report();
1323    note_status(LogStatus::Warning, None);
1324    note_status(LogStatus::Warning, None);
1325    note_status(LogStatus::Error, None);
1326    let m = get_status_message();
1327    assert!(
1328      m.contains("2 warnings") && m.contains("1 error") && m.contains("; "),
1329      "got {m:?}"
1330    );
1331  }
1332
1333  #[test]
1334  fn suppress_log_output_returns_prior_value() {
1335    let prior = set_suppress_log_output(true);
1336    assert!(is_log_output_suppressed());
1337    let prior2 = set_suppress_log_output(false);
1338    assert!(prior2, "round-trip prior value");
1339    assert!(!is_log_output_suppressed());
1340    // Clean up to original state.
1341    set_suppress_log_output(prior);
1342  }
1343}