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(¬e[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(¬e));
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}