Skip to main content

latexml_core/util/
logger.rs

1use std::{
2  cell::RefCell,
3  sync::atomic::{AtomicBool, Ordering},
4};
5
6use log::{Level, LevelFilter, Metadata, Record, SetLoggerError, max_level};
7
8// ANSI SGR escape sequences (drop-in replacement for the
9// unmaintained `ansi_term` crate; bytes match `ansi_term::Colour::*.paint(...)`).
10const ANSI_RESET: &str = "\x1b[0m";
11const ANSI_GREEN: &str = "\x1b[32m";
12const ANSI_YELLOW: &str = "\x1b[33m";
13const ANSI_RED: &str = "\x1b[31m";
14const ANSI_WHITE: &str = "\x1b[37m";
15
16fn paint(color: &str, text: &str) -> String { format!("{color}{text}{ANSI_RESET}") }
17
18/// Whether to emit ANSI color escapes on stderr. Colors are a convenience for
19/// an interactive terminal ONLY; when stderr is redirected to a file or pipe
20/// (the canvas/auto-upgrade path: `cortex_worker ... > log.txt 2>&1`) they are
21/// noise that breaks line-anchored error parsing — a naive `grep '^Error:'`
22/// matches `\x1b[31mError:` ZERO times and silently reports "0 errors" on a
23/// failed paper (the false-negative that masked real Rust-only regressions; see
24/// CLAUDE.md "canvas signal integrity"). So: colorize iff stderr is a TTY and
25/// `NO_COLOR` is unset. Cached once — stderr's terminal-ness can't change
26/// mid-process. Note the captured LOG_BUFFER (`.latexml.log`) is already
27/// ANSI-stripped independently; this makes the *redirected stderr* match it.
28fn stderr_use_color() -> bool {
29  use std::{io::IsTerminal, sync::OnceLock};
30  static USE_COLOR: OnceLock<bool> = OnceLock::new();
31  *USE_COLOR
32    .get_or_init(|| std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none())
33}
34
35struct LatexmlLogger;
36static LOGGER: LatexmlLogger = LatexmlLogger;
37
38/// Thread-local log capture buffer. When enabled, log messages are
39/// appended here (without ANSI colors) in addition to stderr.
40#[thread_local]
41static LOG_BUFFER: RefCell<Option<String>> = RefCell::new(None);
42
43/// Does stderr currently sit at the start of a line?
44///
45/// A diagnostic record must begin on a fresh line so CorTeX's line-anchored
46/// parser (`^Error:`/`^Warning:`/`^Info:`) matches and the record never glues
47/// onto an in-flight progress note like `(Loading "foo.sty"… )`, which is
48/// written WITHOUT a trailing newline. That guarantee used to be bought with an
49/// unconditional leading `\n` on every record — which emits a BLANK line
50/// whenever stderr was already at line start, i.e. almost always.
51///
52/// Measured on the 131 MB witness: **1,440,571 of 3,142,509 captured stderr
53/// lines (45.8%) were blank**, matching the record count almost exactly. The
54/// `LOG_BUFFER` path never had this bug — it tests `ends_with('\n')` — so the
55/// on-disk `.latexml.log` and the captured stderr disagreed on line count.
56///
57/// Tracking line-start state reproduces the guarantee at half the bytes.
58///
59/// **Process-global, not `#[thread_local]`, and mutated under the stderr lock.**
60/// stderr is one shared descriptor: `cortex_worker --pool-size N` runs N
61/// conversion threads in one process, and `logger::capture` exists precisely to
62/// run conversions on worker threads. A per-thread flag would let thread A skip
63/// its leading newline believing the cursor is at line start while thread B had
64/// left it mid-line — gluing a record onto B's output, where CorTeX's
65/// `^Error:`/`^Warning:` anchor then misses it entirely. That is a silent
66/// error-count loss, the exact failure this project's signal-integrity rule
67/// forbids (false negatives hide regressions). The unconditional leading `\n`
68/// was robust to that by construction, so the replacement has to be too:
69/// check-write-set happens as one critical section holding the stderr lock.
70static STDERR_AT_LINE_START: AtomicBool = AtomicBool::new(true);
71
72/// Tell the logger that stderr is back at line start. For the `Note!` /
73/// `NoteLog!` macros, which write to stderr directly via `println_stderr!`
74/// rather than through `log::Log` — without this the next diagnostic record
75/// could emit a spurious leading newline.
76pub fn mark_stderr_at_line_start() { STDERR_AT_LINE_START.store(true, Ordering::Release); }
77
78/// Start capturing log output into the buffer (Perl: bind_log).
79pub fn bind_log() { *LOG_BUFFER.borrow_mut() = Some(String::new()); }
80
81/// Flush and return the captured log output, stopping capture (Perl: flush_log).
82pub fn flush_log() -> String { LOG_BUFFER.borrow_mut().take().unwrap_or_default() }
83
84/// Append a progress note to the captured log (`.latexml.log`) only — the LOG
85/// half of Perl `Note`/`NoteLog` (`Common/Error.pm`: `print $LOG _freshline($LOG),
86/// strip_ansi($message), "\n" if $LOG`). ANSI is stripped and the note lands on
87/// its own fresh line with a single trailing newline, so CorTeX's line-anchored
88/// parser and the `.latexml.log` stay clean (mirrors the diagnostic-record
89/// freshline path below). No-op when no buffer is bound or output is suppressed —
90/// unlike a `log::info!` record it is NOT gated on the stderr verbosity, because
91/// the log is the verbose record (Perl writes it regardless of `$VERBOSITY`).
92pub fn note_to_log(msg: &str) {
93  if crate::common::error::is_log_output_suppressed() {
94    return;
95  }
96  if let Ok(mut buf) = LOG_BUFFER.try_borrow_mut()
97    && let Some(ref mut log) = *buf
98  {
99    let clean = strip_ansi(msg);
100    if !log.is_empty() && !log.ends_with('\n') {
101      log.push('\n');
102    }
103    log.push_str(&clean);
104    if !log.ends_with('\n') {
105      log.push('\n');
106    }
107  }
108}
109
110/// Diagnostics captured from a worker thread by [`capture`], for the main thread
111/// to fold back in via [`replay_captured`]. Carries both the already-formatted
112/// log text AND the `REPORT` count deltas — `LOG_BUFFER` and `REPORT` are BOTH
113/// `#[thread_local]`, so forwarding only the text would still leave
114/// `status_code` blind to a worker's failures.
115pub struct CapturedDiagnostics {
116  pub log:    String,
117  pub counts: crate::common::error::ReportCounts,
118}
119
120/// Run `f` on the CURRENT (worker) thread with diagnostic capture. Binds a fresh
121/// thread-local log buffer for the duration so any `Error!`/`Warn!`/`Info!` `f`
122/// emits (directly or deep inside a conversion helper) is recorded instead of
123/// lost, and snapshots the worker's `REPORT` counters afterward. The returned
124/// [`CapturedDiagnostics`] is replayed on the main thread by [`replay_captured`]
125/// after the worker is joined, so the messages reach the bound `cortex.log` and
126/// the failures register in `status_code`.
127///
128/// Assumes the worker thread has no pre-bound buffer (the spawned post-processing
129/// pool threads start clean); it does not save/restore a prior binding.
130///
131/// INVARIANTS (unenforced by types; guard the fleet's canonical signal):
132/// - one `capture` per thread lifetime — `bind_log()` CLOBBERS any pre-bound
133///   buffer, and `snapshot_report_counts` does not reset, so reusing capture
134///   on a pooled thread would drop earlier text and double-merge counts;
135/// - callers must `replay_captured` the result on the MAIN thread exactly
136///   once (a panicking worker never returns, losing its pre-panic capture —
137///   the real-time stderr echo retains it, and the caller's worker_panicked
138///   Error keeps status from reading clean).
139pub fn capture<R>(f: impl FnOnce() -> R) -> (R, CapturedDiagnostics) {
140  debug_assert!(
141    LOG_BUFFER
142      .try_borrow()
143      .map(|b| b.is_none())
144      .unwrap_or(false),
145    "logger::capture on a thread with a pre-bound buffer — pooled-thread reuse?"
146  );
147  bind_log();
148  let result = f();
149  let log = flush_log();
150  let counts = crate::common::error::snapshot_report_counts();
151  (result, CapturedDiagnostics { log, counts })
152}
153
154/// Fold worker-thread diagnostics (from [`capture`]) into the main thread: append
155/// the captured log text to the bound `LOG_BUFFER` and merge the count deltas
156/// into the main `REPORT`. Call on the MAIN thread, in a deterministic order
157/// (e.g. worker/job order), after the workers join. The worker already echoed
158/// each line to the shared stderr fd in real time, so this does NOT re-print to
159/// stderr — it only repairs the captured log + status tally.
160pub fn replay_captured(d: CapturedDiagnostics) {
161  debug_assert!(
162    LOG_BUFFER.try_borrow().is_ok(),
163    "replay_captured: LOG_BUFFER contended — captured text would be dropped"
164  );
165  if !d.log.is_empty()
166    && let Ok(mut buf) = LOG_BUFFER.try_borrow_mut()
167    && let Some(ref mut log) = *buf
168  {
169    // The captured text is already per-record newline-terminated; just make
170    // sure it starts on a fresh line so it can't glue onto an in-flight note.
171    if !log.is_empty() && !log.ends_with('\n') {
172      log.push('\n');
173    }
174    log.push_str(&d.log);
175  }
176  crate::common::error::merge_report_counts(d.counts);
177}
178
179/// Strip ANSI escape sequences from a string for log file output.
180fn strip_ansi(s: &str) -> String {
181  // Match ESC[ ... m sequences
182  let mut result = String::with_capacity(s.len());
183  let mut in_escape = false;
184  for c in s.chars() {
185    if in_escape {
186      if c == 'm' {
187        in_escape = false;
188      }
189    } else if c == '\x1b' {
190      in_escape = true;
191    } else {
192      result.push(c);
193    }
194  }
195  result
196}
197
198/// Append a progress note to the capture buffer as INLINE flowing text — the
199/// faithful Perl LaTeXML / tex.web terminal-progress format. `note_begin`
200/// carries a leading '\n' so each stage opens on a fresh line; `note_end`
201/// (` )`) and `note_progress` (`[1][2]…`, `N formulae …`) append inline so a
202/// load's closing paren and the page markers stay on the SAME line as their
203/// opener, and nested closes chain (`… 0.00 sec) 0.05 sec)`). A leading '\n'
204/// is collapsed against an existing trailing '\n' (or buffer start) so we never
205/// emit a blank line. Replaces the old unconditional `push('\n')` per note,
206/// which put every `)` on its own line and doubled newlines into blank lines
207/// (the reported `.latexml.log` noise on corpora.latexml.rs).
208fn append_note(buf: &mut String, note: &str) {
209  if note.starts_with('\n') && (buf.is_empty() || buf.ends_with('\n')) {
210    buf.push_str(&note[1..]);
211  } else {
212    buf.push_str(note);
213  }
214}
215
216/// prints a single line to STDERR
217#[macro_export]
218macro_rules! println_stderr(
219    ($($arg:tt)*) => ({
220      use std::io::Write;
221      match writeln!(&mut ::std::io::stderr(), $($arg)* ) {
222        Ok(_) => {},
223        Err(x) => panic!("Unable to write to stderr: {}", x),
224      }
225    })
226);
227
228/// prints a to STDERR without a line break
229#[macro_export]
230macro_rules! print_stderr(
231    ($($arg:tt)*) => ({
232      use std::io::Write;
233      match write!(&mut ::std::io::stderr(), $($arg)* ) {
234        Ok(_) => {},
235        Err(x) => panic!("Unable to write to stderr: {}", x),
236      }
237    })
238);
239
240impl log::Log for LatexmlLogger {
241  fn enabled(&self, metadata: &Metadata) -> bool { metadata.level() <= max_level() }
242
243  fn log(&self, record: &Record) {
244    if self.enabled(record.metadata()) {
245      let record_target = record.target();
246      let details = record.args();
247      if record_target == "note" {
248        let note = details.to_string();
249        // A note (e.g. `(Loading foo.sty… )`) is a live progress indicator — but when a capture
250        // buffer is active it must ALSO land there so it reaches the flushed `cortex.log` and
251        // CorTeX's `loaded_file` parser, which anchors on `^(Loading …`. `append_note` keeps it
252        // INLINE (Perl-faithful: `(Loading X… )` on one line, `[1][2]…` chained) while preserving
253        // the `^(Loading` anchor — every `note_begin` carries a leading '\n', so each load still
254        // opens at line start. The following `Info:/Warning:` record re-asserts its own line break
255        // (see the diagnostic-record path below), so the note can't glue onto its anchor.
256        if let Ok(mut buf) = LOG_BUFFER.try_borrow_mut()
257          && let Some(ref mut log) = *buf
258        {
259          append_note(log, &strip_ansi(&note));
260        }
261        // Write and publish the cursor state under ONE stderr lock, so a
262        // concurrent diagnostic record cannot observe a stale flag.
263        {
264          use std::io::Write;
265          let mut err = std::io::stderr().lock();
266          let _ = err.write_all(note.as_bytes());
267          let _ = err.flush();
268          // A note carries no trailing newline of its own unless its text ends
269          // in one (`note_begin` opens with a leading '\n'), so record where it
270          // left the cursor for the next diagnostic record.
271          STDERR_AT_LINE_START.store(note.ends_with('\n'), Ordering::Release);
272        }
273        return;
274      }
275      let category_object = if record_target.is_empty() {
276        "" // "unknown:unknown" ???
277      } else {
278        record_target
279      };
280      // Following the reporting syntax at: https://math.nist.gov/~BMiller/LaTeXML/manual/errorcodes/
281      // The severity word is the FULL Perl LaTeXML token (Info/Warning/Error/Fatal) — consumers
282      // (CorTeX's log parser, the --server LSP) key on it, so it must match Perl exactly. In
283      // particular WARN must serialize as `Warning` (not the abbreviated `Warn`): CorTeX maps an
284      // unrecognized severity to Info, so `Warn:` silently misfiled every warning (see
285      // LaTeXML/lib/LaTeXML/Common/Error.pm: `"Warning:" . $category . …`).
286      let severity = if category_object.starts_with("Fatal:") {
287        ""
288      } else {
289        match record.level() {
290          Level::Info => "Info",
291          Level::Warn => "Warning",
292          Level::Error => "Error",
293          Level::Debug => "Debug",
294          Level::Trace => "Trace",
295        }
296      };
297
298      // Tally every printed diagnostic record that was NOT emitted by a
299      // counting macro (those count at raise time, even under output
300      // suppression — the MAX_ERRORS cap depends on that). This is the
301      // lossless half of the tally contract: any `Warning:`/`Error:` line
302      // this backend prints from a raw `log::warn!`/`log::error!` call now
303      // registers in `REPORT`, so the final "Conversion complete: N
304      // warnings" agrees with what a reader greps from the log. Witness:
305      // the 131 MB witness logged 12,105 `Warning:` lines (12,103 of them
306      // the math parser's raw `log_math_warn!`) and reported "2 warnings".
307      {
308        use crate::common::error::{LogStatus, note_status_from_logger};
309        let status = if category_object.starts_with("Fatal:") {
310          Some(LogStatus::Fatal)
311        } else {
312          match record.level() {
313            Level::Warn => Some(LogStatus::Warning),
314            Level::Error => Some(LogStatus::Error),
315            Level::Info => Some(LogStatus::Info),
316            Level::Debug => Some(LogStatus::Debug),
317            // No Trace counter exists; Trace records stay uncounted.
318            Level::Trace => None,
319          }
320        };
321        if let Some(status) = status {
322          note_status_from_logger(status);
323        }
324      }
325      let message = if severity.is_empty() {
326        s!("{} ", category_object)
327      } else {
328        s!("{}:{} ", severity, category_object)
329      };
330      let painted_message = match record.level() {
331        Level::Info => message,
332        Level::Warn => paint(ANSI_YELLOW, &message),
333        Level::Error => paint(ANSI_RED, &message),
334        Level::Debug => paint(ANSI_GREEN, &message),
335        _ => paint(ANSI_WHITE, &message),
336      } + &details.to_string();
337
338      // Capture to log buffer if active (strip ANSI for clean log text).
339      // A diagnostic record (Info/Warning/Error/Fatal) must start on a fresh
340      // line so CorTeX's line-anchored parser (^Error:/^Warning:/^Info:)
341      // matches and the record never glues onto an in-flight progress note
342      // (notes no longer force a trailing newline — see append_note).
343      if let Ok(mut buf) = LOG_BUFFER.try_borrow_mut()
344        && let Some(ref mut log) = *buf
345      {
346        if !log.is_empty() && !log.ends_with('\n') {
347          log.push('\n');
348        }
349        log.push_str(&strip_ansi(&painted_message));
350        // Exactly one trailing newline — a multi-detail message (e.g.
351        // `Info:…loaded …\n\tat …\n\tIn …`) already ends with '\n', so an
352        // unconditional push would double it into a blank line before the next
353        // `(Loading …` note.
354        if !log.ends_with('\n') {
355          log.push('\n');
356        }
357      }
358
359      // Use `\n` (not `\r`) to guarantee each log line starts on a fresh
360      // line in both TTY and file output. The previous `\r` prefix made
361      // log lines visually overlay any in-flight progress indicator like
362      // `(Loading "foo.sty" definitions... )` — convenient in a terminal
363      // but produced `(...)<CR>Error:...` byte sequences in log files,
364      // breaking line-anchored counts in canvas harnesses
365      // (`grep -cE '^...Error:'` silently returned 0 even when errors
366      // were present). Trade-off: progress indicators in a TTY no longer
367      // get overwritten, but they were not really self-erasing anyway
368      // (they always emitted ` )` to close their parens), so the visual
369      // change is small.
370      // Colorize for an interactive terminal only; when stderr is redirected
371      // to a file/pipe, emit the ANSI-stripped text so on-disk logs stay
372      // grep-clean (matches the captured `.latexml.log` buffer above).
373      // Break onto a fresh line ONLY when a note left the cursor mid-line —
374      // an unconditional leading '\n' was 45.8% of the witness's log (see
375      // STDERR_AT_LINE_START). One critical section: read the flag, write, and
376      // republish it while holding the stderr lock, so the "record starts at
377      // line start" guarantee survives concurrent loggers.
378      {
379        use std::io::Write;
380        let text = if stderr_use_color() {
381          painted_message
382        } else {
383          strip_ansi(&painted_message)
384        };
385        let mut err = std::io::stderr().lock();
386        if !STDERR_AT_LINE_START.load(Ordering::Acquire) {
387          let _ = err.write_all(b"\n");
388        }
389        let _ = err.write_all(text.as_bytes());
390        let _ = err.write_all(b"\n");
391        let _ = err.flush();
392        STDERR_AT_LINE_START.store(true, Ordering::Release);
393      }
394    }
395  }
396
397  fn flush(&self) {}
398}
399
400/// initialize the logger at a given verbosity `level`
401///
402/// Returns the underlying `SetLoggerError` if another `log` global logger
403/// is already installed (e.g. an embedder set up `tracing-log` first).
404/// Callers can decide whether to ignore that — the in-process `bind_log` /
405/// `flush_log` buffers are independent of the `log` crate sink and keep
406/// working either way.
407pub fn init(level: LevelFilter) -> Result<(), SetLoggerError> {
408  log::set_logger(&LOGGER)?;
409  log::set_max_level(level);
410  Ok(())
411}
412
413#[cfg(test)]
414mod tests {
415  use super::*;
416
417  #[test]
418  fn append_note_inline_and_no_blank_lines() {
419    // note_begin opens a fresh line; note_end / note_progress stay inline.
420    let mut b = String::new();
421    append_note(&mut b, "\n(Loading keyval.sty..."); // note_begin (buffer empty: no leading blank)
422    append_note(&mut b, " )"); // note_end inline
423    assert_eq!(b, "(Loading keyval.sty... )");
424    append_note(&mut b, "\n(Loading graphics.sty..."); // mid-line: keep the break
425    append_note(&mut b, " )");
426    assert_eq!(b, "(Loading keyval.sty... )\n(Loading graphics.sty... )");
427    // page markers chain inline
428    append_note(&mut b, "\n410 formulae ...");
429    append_note(&mut b, "[1]");
430    append_note(&mut b, "[2]");
431    assert!(b.ends_with("410 formulae ...[1][2]"));
432    // a leading '\n' note after a buffer already at line-start collapses (no blank line)
433    let mut c = String::from("Info:foo\n");
434    append_note(&mut c, "\n(Building...");
435    assert_eq!(c, "Info:foo\n(Building...");
436  }
437
438  /// The lossless-tally contract (user directive 2026-08-02): every printed
439  /// diagnostic record counts exactly once, whether it came from a counting
440  /// macro (raise-time count, logger skips) or a raw `log::warn!`-family call
441  /// (logger counts). Defect this pins: the 131 MB witness logged 12,105
442  /// `Warning:` lines — 12,103 from the math parser's raw `log_math_warn!` —
443  /// and the final verdict said "2 warnings".
444  ///
445  /// One test, not several: the global logger and the thread-local `REPORT`
446  /// are both process/thread state, and cargo runs sibling `#[test]`s on
447  /// separate threads — but a SINGLE test body sees one thread and one
448  /// deterministic sequence.
449  #[test]
450  fn raw_log_records_count_and_macro_records_do_not_double_count() {
451    use crate::common::error::{
452      LogStatus, get_status, initialize_report, macro_diag_guard, note_status,
453      note_status_from_logger,
454    };
455    // Another test may have installed the logger already; counting rides
456    // note_status_from_logger either way, so drive it exactly as
457    // `LatexmlLogger::log` does rather than through the global sink.
458    initialize_report();
459
460    // Raw record: the logger's tally path counts it.
461    note_status_from_logger(LogStatus::Warning);
462    assert_eq!(get_status(LogStatus::Warning), 1, "raw warning must count");
463
464    // Macro-emitted record: raise-time count happens under the guard, and
465    // the logger's observation of the same record must be a no-op.
466    {
467      let _g = macro_diag_guard();
468      note_status(LogStatus::Warning, None); // what the macro does
469      note_status_from_logger(LogStatus::Warning); // what the logger then sees
470    }
471    assert_eq!(
472      get_status(LogStatus::Warning),
473      2,
474      "a macro warning counts once, not twice"
475    );
476
477    // Nested guards (Error! escalating into Fatal!) keep the marker set.
478    {
479      let _outer = macro_diag_guard();
480      {
481        let _inner = macro_diag_guard();
482      }
483      note_status_from_logger(LogStatus::Error);
484    }
485    assert_eq!(
486      get_status(LogStatus::Error),
487      0,
488      "the logger stays muted for the whole macro emission, even after a \
489       nested guard drops"
490    );
491
492    // Raw error and a raw Fatal-target record (log_fatal's shape when some
493    // future caller bypasses it): the error counts, the fatal latches.
494    note_status_from_logger(LogStatus::Error);
495    note_status_from_logger(LogStatus::Fatal);
496    assert_eq!(get_status(LogStatus::Error), 1);
497    assert_eq!(get_status(LogStatus::Fatal), 1, "raw fatal latches sticky");
498    initialize_report();
499  }
500
501  /// Suppression semantics (user decision 2026-08-03): Debug/Info/Warning
502  /// records are muted under suppression, but Error and Fatal EMIT
503  /// UNCONDITIONALLY — cortex-class frameworks aggregate success rates from
504  /// `Error:`/`Fatal:` lines, and a suppressed error would vanish from that
505  /// measurement while still counting locally. Counts accrue either way.
506  #[test]
507  fn suppression_never_mutes_error_or_fatal() {
508    use crate::common::error::{
509      LogStatus, emit_record, get_status, initialize_report, set_suppress_log_output,
510    };
511    initialize_report();
512    let _ = log::set_logger(&LOGGER); // ok if another test installed it
513    log::set_max_level(LevelFilter::Warn);
514    let prev = set_suppress_log_output(true);
515
516    bind_log();
517    emit_record(LogStatus::Warning, "quiet:warn", "muted warning");
518    emit_record(LogStatus::Error, "loud:error", "unmutable error");
519    emit_record(LogStatus::Fatal, "Fatal:loud:fatal ", "unmutable fatal");
520    let captured = flush_log();
521
522    set_suppress_log_output(prev);
523    assert!(
524      !captured.contains("muted warning"),
525      "suppression must mute Warning records, captured:\n{captured}"
526    );
527    assert!(
528      captured.contains("Error:loud:error unmutable error"),
529      "Error must emit under suppression, captured:\n{captured}"
530    );
531    // (Fatal targets carry their own trailing space, so the formatter's
532    // separator makes it two — match the pieces, not the join.)
533    assert!(
534      captured.contains("Fatal:loud:fatal") && captured.contains("unmutable fatal"),
535      "Fatal must emit under suppression, captured:\n{captured}"
536    );
537    // Counts accrue regardless of emission.
538    assert_eq!(get_status(LogStatus::Warning), 1);
539    assert_eq!(get_status(LogStatus::Error), 1);
540    assert_eq!(get_status(LogStatus::Fatal), 1);
541    initialize_report();
542  }
543
544  #[test]
545  fn strip_ansi_removes_color_codes() {
546    // ESC [ ... m should vanish; other content preserved.
547    let red = "\x1b[31mhello\x1b[0m";
548    assert_eq!(strip_ansi(red), "hello");
549  }
550
551  #[test]
552  fn strip_ansi_noop_on_plain() {
553    assert_eq!(strip_ansi("plain text"), "plain text");
554    assert_eq!(strip_ansi(""), "");
555  }
556
557  #[test]
558  fn strip_ansi_multiple_sequences() {
559    let s = "\x1b[31merror:\x1b[0m \x1b[33mwarning\x1b[0m";
560    assert_eq!(strip_ansi(s), "error: warning");
561  }
562
563  #[test]
564  fn strip_ansi_preserves_unicode() {
565    let s = "\x1b[31mαβγ\x1b[0m";
566    assert_eq!(strip_ansi(s), "αβγ");
567  }
568
569  #[test]
570  fn strip_ansi_handles_incomplete_escape() {
571    // Unterminated ESC[ sequence — we should not hang.
572    // Current impl: scan until 'm' is found. If never found, consumes
573    // the rest of the input. Document that behavior.
574    let s = "\x1b[1;31m hello";
575    // The scan consumes characters until 'm' is found → the 'm' in the
576    // escape closes, then " hello" remains.
577    assert_eq!(strip_ansi(s), " hello");
578  }
579
580  #[test]
581  fn bind_log_and_flush_log_roundtrip() {
582    // Before bind_log, flush_log returns empty.
583    // After bind_log, the buffer is active but empty until a log
584    // message arrives. Since we can't easily exercise the Log impl
585    // without initializing a global logger, just verify the
586    // capture-buffer lifecycle primitives.
587    let before = flush_log();
588    assert!(before.is_empty(), "no active buffer → empty flush");
589
590    bind_log();
591    let after = flush_log();
592    assert!(
593      after.is_empty(),
594      "empty buffer is still empty after bind/flush with no log traffic"
595    );
596  }
597}