Skip to main content

rlg/
tui.rs

1// tui.rs
2// Copyright © 2024-2026 RustLogs (RLG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0
4// SPDX-License-Identifier: MIT
5
6//! Opt-in terminal dashboard for live observability metrics.
7//!
8//! Set `RLG_TUI=1` to spawn a background render thread that paints a
9//! non-clobbering sparkline dashboard at ~60 FPS. Enable the `tui` feature
10//! for automatic terminal size detection; otherwise falls back to 80x24.
11
12use std::io::Write;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
15use std::thread;
16use std::time::Duration;
17
18/// Number of throughput samples in the sparkline ring buffer (one per tick).
19const SPARKLINE_RING_SIZE: usize = 60;
20
21/// TUI render interval in milliseconds (~60 FPS).
22const TUI_TICK_INTERVAL_MS: u64 = 16;
23
24/// Default terminal height when detection fails.
25const DEFAULT_TERMINAL_HEIGHT: u16 = 24;
26
27/// Default terminal width when detection fails.
28const DEFAULT_TERMINAL_WIDTH: u16 = 80;
29
30/// Width of level-distribution bar charts (in block characters).
31const LEVEL_BAR_WIDTH: usize = 10;
32
33#[cfg(all(not(windows), feature = "tui"))]
34/// Returns the terminal height for the given handle, or 24 as fallback.
35///
36/// # Panics
37///
38/// This function does not panic.
39#[must_use]
40pub fn get_terminal_height_of(handle: &impl std::os::fd::AsFd) -> u16 {
41    terminal_size::terminal_size_of(handle).map_or(
42        DEFAULT_TERMINAL_HEIGHT,
43        |(_, terminal_size::Height(h))| h,
44    )
45}
46
47#[cfg(feature = "tui")]
48fn get_terminal_height() -> u16 {
49    terminal_size::terminal_size().map_or(
50        DEFAULT_TERMINAL_HEIGHT,
51        |(_, terminal_size::Height(h))| h,
52    )
53}
54
55#[cfg(not(feature = "tui"))]
56const fn get_terminal_height() -> u16 {
57    DEFAULT_TERMINAL_HEIGHT
58}
59
60/// Atomic counters for the TUI dashboard. Cache-line aligned (`repr(align(64))`).
61#[repr(align(64))]
62#[derive(Debug, Default)]
63pub struct TuiMetrics {
64    /// Total number of log events ingested.
65    pub total_events: AtomicUsize,
66    /// Number of error/fatal events.
67    pub error_count: AtomicUsize,
68    /// Number of active spans (OpenTelemetry style).
69    pub active_spans: AtomicUsize,
70    /// Calculated events per second.
71    pub throughput: AtomicUsize,
72    /// Peak throughput (events per second).
73    pub peak_throughput: AtomicUsize,
74    /// Engine start time (epoch seconds).
75    pub start_epoch_secs: AtomicUsize,
76
77    // Per-level counters
78    /// TRACE-level event count.
79    pub level_trace: AtomicUsize,
80    /// DEBUG-level event count.
81    pub level_debug: AtomicUsize,
82    /// INFO-level event count.
83    pub level_info: AtomicUsize,
84    /// WARN-level event count.
85    pub level_warn: AtomicUsize,
86    /// ERROR-level event count.
87    pub level_error: AtomicUsize,
88    /// FATAL-level event count.
89    pub level_fatal: AtomicUsize,
90    /// CRITICAL-level event count.
91    pub level_critical: AtomicUsize,
92    /// Number of events dropped due to full ring buffer.
93    pub dropped_events: AtomicUsize,
94
95    // Per-format counters
96    /// CLF format count.
97    pub fmt_clf: AtomicUsize,
98    /// JSON format count.
99    pub fmt_json: AtomicUsize,
100    /// CEF format count.
101    pub fmt_cef: AtomicUsize,
102    /// ELF format count.
103    pub fmt_elf: AtomicUsize,
104    /// W3C format count.
105    pub fmt_w3c: AtomicUsize,
106    /// GELF format count.
107    pub fmt_gelf: AtomicUsize,
108    /// Apache Access Log format count.
109    pub fmt_apache: AtomicUsize,
110    /// Logstash format count.
111    pub fmt_logstash: AtomicUsize,
112    /// Log4j XML format count.
113    pub fmt_log4j: AtomicUsize,
114    /// NDJSON format count.
115    pub fmt_ndjson: AtomicUsize,
116    /// MCP format count.
117    pub fmt_mcp: AtomicUsize,
118    /// OTLP format count.
119    pub fmt_otlp: AtomicUsize,
120    /// Logfmt format count.
121    pub fmt_logfmt: AtomicUsize,
122    /// ECS format count.
123    pub fmt_ecs: AtomicUsize,
124}
125
126impl TuiMetrics {
127    /// Increments the total event count.
128    pub fn inc_events(&self) {
129        self.total_events.fetch_add(1, Ordering::Relaxed);
130    }
131
132    /// Increments the error count.
133    pub fn inc_errors(&self) {
134        self.error_count.fetch_add(1, Ordering::Relaxed);
135    }
136
137    /// Increments active spans.
138    pub fn inc_spans(&self) {
139        self.active_spans.fetch_add(1, Ordering::Relaxed);
140    }
141
142    /// Decrements active spans.
143    pub fn dec_spans(&self) {
144        self.active_spans.fetch_sub(1, Ordering::Relaxed);
145    }
146
147    /// Increments the dropped event count.
148    pub fn inc_dropped(&self) {
149        self.dropped_events.fetch_add(1, Ordering::Relaxed);
150    }
151
152    /// Increments the counter for the given log level.
153    pub fn inc_level(&self, level: crate::log_level::LogLevel) {
154        use crate::log_level::LogLevel;
155        match level {
156            LogLevel::TRACE => {
157                self.level_trace.fetch_add(1, Ordering::Relaxed);
158            }
159            LogLevel::DEBUG => {
160                self.level_debug.fetch_add(1, Ordering::Relaxed);
161            }
162            LogLevel::INFO => {
163                self.level_info.fetch_add(1, Ordering::Relaxed);
164            }
165            LogLevel::WARN => {
166                self.level_warn.fetch_add(1, Ordering::Relaxed);
167            }
168            LogLevel::ERROR => {
169                self.level_error.fetch_add(1, Ordering::Relaxed);
170            }
171            LogLevel::FATAL => {
172                self.level_fatal.fetch_add(1, Ordering::Relaxed);
173            }
174            LogLevel::CRITICAL => {
175                self.level_critical.fetch_add(1, Ordering::Relaxed);
176            }
177            _ => {}
178        }
179    }
180
181    /// Increments the counter for the given log format.
182    pub fn inc_format(&self, format: crate::log_format::LogFormat) {
183        use crate::log_format::LogFormat;
184        match format {
185            LogFormat::CLF => {
186                self.fmt_clf.fetch_add(1, Ordering::Relaxed);
187            }
188            LogFormat::JSON => {
189                self.fmt_json.fetch_add(1, Ordering::Relaxed);
190            }
191            LogFormat::CEF => {
192                self.fmt_cef.fetch_add(1, Ordering::Relaxed);
193            }
194            LogFormat::ELF => {
195                self.fmt_elf.fetch_add(1, Ordering::Relaxed);
196            }
197            LogFormat::W3C => {
198                self.fmt_w3c.fetch_add(1, Ordering::Relaxed);
199            }
200            LogFormat::GELF => {
201                self.fmt_gelf.fetch_add(1, Ordering::Relaxed);
202            }
203            LogFormat::ApacheAccessLog => {
204                self.fmt_apache.fetch_add(1, Ordering::Relaxed);
205            }
206            LogFormat::Logstash => {
207                self.fmt_logstash.fetch_add(1, Ordering::Relaxed);
208            }
209            LogFormat::Log4jXML => {
210                self.fmt_log4j.fetch_add(1, Ordering::Relaxed);
211            }
212            LogFormat::NDJSON => {
213                self.fmt_ndjson.fetch_add(1, Ordering::Relaxed);
214            }
215            LogFormat::MCP => {
216                self.fmt_mcp.fetch_add(1, Ordering::Relaxed);
217            }
218            LogFormat::OTLP => {
219                self.fmt_otlp.fetch_add(1, Ordering::Relaxed);
220            }
221            LogFormat::Logfmt => {
222                self.fmt_logfmt.fetch_add(1, Ordering::Relaxed);
223            }
224            LogFormat::ECS => {
225                self.fmt_ecs.fetch_add(1, Ordering::Relaxed);
226            }
227        }
228    }
229}
230
231/// Returns the terminal width, or `DEFAULT_TERMINAL_WIDTH` as fallback.
232#[cfg(feature = "tui")]
233fn get_terminal_width() -> u16 {
234    terminal_size::terminal_size().map_or(
235        DEFAULT_TERMINAL_WIDTH,
236        |(terminal_size::Width(w), _)| w,
237    )
238}
239
240/// Returns the default terminal width when the `tui` feature is disabled.
241#[cfg(not(feature = "tui"))]
242const fn get_terminal_width() -> u16 {
243    DEFAULT_TERMINAL_WIDTH
244}
245
246/// Sparkline characters indexed by intensity (0..=7).
247const SPARK_CHARS: [char; 8] = [
248    '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}',
249    '\u{2586}', '\u{2587}', '\u{2588}',
250];
251
252/// Renders a sparkline string from a circular buffer of throughput samples.
253fn render_sparkline(
254    ring: &[usize; SPARKLINE_RING_SIZE],
255    cursor: usize,
256) -> String {
257    let max_val = ring.iter().copied().max().unwrap_or(1).max(1);
258    let mut out = String::with_capacity(SPARKLINE_RING_SIZE);
259    for i in 0..SPARKLINE_RING_SIZE {
260        let idx = (cursor + i) % SPARKLINE_RING_SIZE;
261        let scaled = (ring[idx] * 7) / max_val;
262        out.push(SPARK_CHARS[scaled.min(7)]);
263    }
264    out
265}
266
267/// Renders a level bar: filled blocks + empty blocks, `LEVEL_BAR_WIDTH` chars wide.
268fn render_level_bar(count: usize, total: usize) -> String {
269    if total == 0 {
270        return "\u{2591}".repeat(LEVEL_BAR_WIDTH);
271    }
272    let filled =
273        ((count * LEVEL_BAR_WIDTH) / total).min(LEVEL_BAR_WIDTH);
274    let mut bar = String::with_capacity(LEVEL_BAR_WIDTH * 3);
275    for _ in 0..filled {
276        bar.push('\u{2588}');
277    }
278    for _ in filled..LEVEL_BAR_WIDTH {
279        bar.push('\u{2591}');
280    }
281    bar
282}
283
284/// Formats an uptime duration as HH:MM:SS.
285fn format_uptime(secs: u64) -> String {
286    let h = secs / 3600;
287    let m = (secs % 3600) / 60;
288    let s = secs % 60;
289    format!("{h:02}:{m:02}:{s:02}")
290}
291
292/// Builds the format counts line from metrics.
293pub fn build_fmt_line(metrics: &TuiMetrics) -> String {
294    let fmt_counts: Vec<(&str, usize)> = [
295        ("CLF", metrics.fmt_clf.load(Ordering::Relaxed)),
296        ("JSON", metrics.fmt_json.load(Ordering::Relaxed)),
297        ("CEF", metrics.fmt_cef.load(Ordering::Relaxed)),
298        ("ELF", metrics.fmt_elf.load(Ordering::Relaxed)),
299        ("W3C", metrics.fmt_w3c.load(Ordering::Relaxed)),
300        ("GELF", metrics.fmt_gelf.load(Ordering::Relaxed)),
301        ("Apache", metrics.fmt_apache.load(Ordering::Relaxed)),
302        ("Logstash", metrics.fmt_logstash.load(Ordering::Relaxed)),
303        ("Log4j", metrics.fmt_log4j.load(Ordering::Relaxed)),
304        ("NDJSON", metrics.fmt_ndjson.load(Ordering::Relaxed)),
305        ("MCP", metrics.fmt_mcp.load(Ordering::Relaxed)),
306        ("OTLP", metrics.fmt_otlp.load(Ordering::Relaxed)),
307        ("Logfmt", metrics.fmt_logfmt.load(Ordering::Relaxed)),
308        ("ECS", metrics.fmt_ecs.load(Ordering::Relaxed)),
309    ]
310    .into_iter()
311    .filter(|(_, c)| *c > 0)
312    .collect();
313
314    let mut fmt_line = String::new();
315    for (i, (name, count)) in fmt_counts.iter().enumerate() {
316        use std::fmt::Write as _;
317        if i > 0 {
318            fmt_line.push_str(" | ");
319        }
320        let _ = write!(fmt_line, "{name}: {count}");
321    }
322    if fmt_line.is_empty() {
323        fmt_line.push_str("(none)");
324    }
325    fmt_line
326}
327
328/// Computes level bar rendering from metrics.
329///
330/// Returns `(info_bar, info_pct, error_bar, error_pct)`.
331pub fn compute_level_bars(
332    metrics: &TuiMetrics,
333) -> (String, usize, String, usize) {
334    let info_c = metrics.level_info.load(Ordering::Relaxed);
335    let warn_c = metrics.level_warn.load(Ordering::Relaxed);
336    let error_c = metrics.level_error.load(Ordering::Relaxed);
337    let debug_c = metrics.level_debug.load(Ordering::Relaxed);
338    let trace_c = metrics.level_trace.load(Ordering::Relaxed);
339
340    let level_total = info_c + warn_c + error_c + debug_c + trace_c;
341
342    let info_bar = render_level_bar(info_c, level_total.max(1));
343    let info_pct = (info_c * 100).checked_div(level_total).unwrap_or(0);
344    let error_bar = render_level_bar(error_c, level_total.max(1));
345    let error_pct =
346        (error_c * 100).checked_div(level_total).unwrap_or(0);
347
348    (info_bar, info_pct, error_bar, error_pct)
349}
350
351/// Performs one tick of the TUI dashboard, returning the ANSI-formatted frame.
352///
353/// This function is extracted from the render loop for testability.
354#[allow(clippy::cast_possible_truncation)]
355pub fn render_tick(
356    metrics: &TuiMetrics,
357    last_total: &mut usize,
358    sparkline_ring: &mut [usize; SPARKLINE_RING_SIZE],
359    spark_cursor: &mut usize,
360) -> String {
361    let total = metrics.total_events.load(Ordering::Relaxed);
362    let errors = metrics.error_count.load(Ordering::Relaxed);
363    let spans = metrics.active_spans.load(Ordering::Relaxed);
364    let dropped = metrics.dropped_events.load(Ordering::Relaxed);
365
366    // Calculate throughput (events per ~16ms tick -> scale to second)
367    let diff = total.saturating_sub(*last_total);
368    *last_total = total;
369    let tps = diff * 60;
370    metrics.throughput.store(tps, Ordering::Relaxed);
371
372    // Track peak throughput
373    let _ = metrics.peak_throughput.fetch_max(tps, Ordering::Relaxed);
374    let peak = metrics.peak_throughput.load(Ordering::Relaxed);
375
376    // Update sparkline ring buffer
377    sparkline_ring[*spark_cursor % SPARKLINE_RING_SIZE] = tps;
378    *spark_cursor = spark_cursor.wrapping_add(1);
379
380    // Uptime
381    let now_secs = std::time::SystemTime::now()
382        .duration_since(std::time::UNIX_EPOCH)
383        .unwrap_or_default()
384        .as_secs() as usize;
385    let uptime_secs = now_secs.saturating_sub(
386        metrics.start_epoch_secs.load(Ordering::Relaxed),
387    );
388    let uptime = format_uptime(uptime_secs as u64);
389
390    // Level bars
391    let (info_bar, info_pct, error_bar, error_pct) =
392        compute_level_bars(metrics);
393
394    // Format counts line
395    let fmt_line = build_fmt_line(metrics);
396
397    let sparkline = render_sparkline(
398        sparkline_ring,
399        *spark_cursor % SPARKLINE_RING_SIZE,
400    );
401
402    let total_fmt = format_with_commas(total);
403
404    let width = get_terminal_width() as usize;
405    let separator: String =
406        "\u{2500}".repeat(width.min(SPARKLINE_RING_SIZE));
407
408    let height = get_terminal_height();
409
410    format!(
411        "\x1b7\x1b[{height};1H\x1b[7A\x1b[J\
412\x1b[38;5;33m[ \x1b[1;37mRLG Liquid Glass Dashboard \x1b[0;38;5;33m]\x1b[0m\n\
413\x1b[1mErrors:\x1b[0m {errors} | \x1b[1mDropped:\x1b[0m {dropped} | \x1b[1mActive Spans:\x1b[0m {spans} | \x1b[1mThroughput:\x1b[0m {tps} ev/s\n\
414\x1b[1mPeak:\x1b[0m {peak} ev/s | \x1b[1mUptime:\x1b[0m {uptime} | \x1b[1mTotal:\x1b[0m {total_fmt}\n\
415\x1b[1mThroughput\x1b[0m {sparkline}\n\
416\x1b[1mLevels:\x1b[0m {info_bar} {info_pct}% INFO  {error_bar} {error_pct}% ERROR\n\
417\x1b[1mFormats:\x1b[0m {fmt_line}\n\
418\x1b[38;5;239m{separator}\x1b[0m\x1b8"
419    )
420}
421
422/// Spawns the background TUI renderer thread.
423///
424/// # Panics
425///
426/// This function panics if the TUI background thread fails to spawn.
427pub fn spawn_tui_thread(
428    metrics: Arc<TuiMetrics>,
429    shutdown_flag: Arc<AtomicBool>,
430) {
431    // Record engine start time
432    #[allow(clippy::cast_possible_truncation)]
433    let start = std::time::SystemTime::now()
434        .duration_since(std::time::UNIX_EPOCH)
435        .unwrap_or_default()
436        .as_secs() as usize;
437    metrics.start_epoch_secs.store(start, Ordering::Relaxed);
438
439    thread::Builder::new()
440        .name("rlg-tui".into())
441        .spawn(move || {
442            let mut last_total: usize = 0;
443            let mut sparkline_ring = [0_usize; SPARKLINE_RING_SIZE];
444            let mut spark_cursor: usize = 0;
445
446            loop {
447                if shutdown_flag.load(Ordering::Relaxed) {
448                    break;
449                }
450
451                thread::sleep(Duration::from_millis(
452                    TUI_TICK_INTERVAL_MS,
453                ));
454
455                let frame = render_tick(
456                    &metrics,
457                    &mut last_total,
458                    &mut sparkline_ring,
459                    &mut spark_cursor,
460                );
461
462                let mut stdout = std::io::stdout();
463                let _ = write!(stdout, "{frame}");
464                let _ = stdout.flush();
465            }
466
467            // Clean up terminal state on exit
468            let _ = write!(std::io::stdout(), "\x1b[r\x1b[J");
469        })
470        .expect("Failed to spawn TUI thread");
471}
472
473/// Formats an integer with comma separators.
474fn format_with_commas(n: usize) -> String {
475    let s = n.to_string();
476    let mut result = String::with_capacity(s.len() + s.len() / 3);
477    for (i, ch) in s.chars().enumerate() {
478        if i > 0 && (s.len() - i).is_multiple_of(3) {
479            result.push(',');
480        }
481        result.push(ch);
482    }
483    result
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    #[test]
491    fn test_format_with_commas_zero() {
492        assert_eq!(format_with_commas(0), "0");
493    }
494
495    #[test]
496    fn test_format_with_commas_small() {
497        assert_eq!(format_with_commas(42), "42");
498        assert_eq!(format_with_commas(999), "999");
499    }
500
501    #[test]
502    fn test_format_with_commas_thousands() {
503        assert_eq!(format_with_commas(1000), "1,000");
504        assert_eq!(format_with_commas(1_234), "1,234");
505        assert_eq!(format_with_commas(999_999), "999,999");
506    }
507
508    #[test]
509    fn test_format_with_commas_millions() {
510        assert_eq!(format_with_commas(1_000_000), "1,000,000");
511        assert_eq!(format_with_commas(1_234_567), "1,234,567");
512    }
513
514    #[test]
515    fn test_format_uptime_zero() {
516        assert_eq!(format_uptime(0), "00:00:00");
517    }
518
519    #[test]
520    fn test_format_uptime_seconds() {
521        assert_eq!(format_uptime(45), "00:00:45");
522    }
523
524    #[test]
525    fn test_format_uptime_minutes() {
526        assert_eq!(format_uptime(125), "00:02:05");
527    }
528
529    #[test]
530    fn test_format_uptime_hours() {
531        assert_eq!(format_uptime(3661), "01:01:01");
532        assert_eq!(format_uptime(86399), "23:59:59");
533    }
534
535    #[test]
536    fn test_render_level_bar_zero_total() {
537        let bar = render_level_bar(0, 0);
538        assert_eq!(bar.chars().count(), 10);
539        // All empty blocks
540        assert!(bar.chars().all(|c| c == '\u{2591}'));
541    }
542
543    #[test]
544    fn test_render_level_bar_full() {
545        let bar = render_level_bar(100, 100);
546        assert_eq!(bar.chars().count(), 10);
547        assert!(bar.chars().all(|c| c == '\u{2588}'));
548    }
549
550    #[test]
551    fn test_render_level_bar_half() {
552        let bar = render_level_bar(50, 100);
553        assert_eq!(bar.chars().count(), 10);
554        let filled = bar.chars().filter(|&c| c == '\u{2588}').count();
555        assert_eq!(filled, 5);
556    }
557
558    #[test]
559    fn test_render_level_bar_empty() {
560        let bar = render_level_bar(0, 100);
561        assert_eq!(bar.chars().count(), 10);
562        assert!(bar.chars().all(|c| c == '\u{2591}'));
563    }
564
565    #[test]
566    fn test_render_sparkline_empty() {
567        let ring = [0_usize; SPARKLINE_RING_SIZE];
568        let sparkline = render_sparkline(&ring, 0);
569        assert_eq!(sparkline.chars().count(), SPARKLINE_RING_SIZE);
570        // All minimum bars since all values are 0
571        assert!(sparkline.chars().all(|c| c == '\u{2581}'));
572    }
573
574    #[test]
575    fn test_render_sparkline_uniform() {
576        let ring = [100_usize; SPARKLINE_RING_SIZE];
577        let sparkline = render_sparkline(&ring, 0);
578        assert_eq!(sparkline.chars().count(), SPARKLINE_RING_SIZE);
579    }
580
581    #[test]
582    fn test_render_sparkline_varied() {
583        let mut ring = [0_usize; SPARKLINE_RING_SIZE];
584        ring[0] = 100;
585        ring[30] = 50;
586        ring[59] = 25;
587        let sparkline = render_sparkline(&ring, 0);
588        assert_eq!(sparkline.chars().count(), SPARKLINE_RING_SIZE);
589    }
590
591    #[test]
592    fn test_render_sparkline_cursor_wrap() {
593        let mut ring = [0_usize; SPARKLINE_RING_SIZE];
594        ring[55] = 10;
595        ring[5] = 20;
596        let sparkline = render_sparkline(&ring, 50);
597        assert_eq!(sparkline.chars().count(), SPARKLINE_RING_SIZE);
598    }
599
600    #[test]
601    fn test_get_terminal_width() {
602        // In test/CI environments this typically returns 80 fallback
603        let w = get_terminal_width();
604        assert!(w > 0);
605    }
606
607    #[test]
608    fn test_tui_metrics_inc_level_all_variants() {
609        let m = TuiMetrics::default();
610
611        m.inc_level(crate::log_level::LogLevel::TRACE);
612        assert_eq!(m.level_trace.load(Ordering::Relaxed), 1);
613
614        m.inc_level(crate::log_level::LogLevel::DEBUG);
615        assert_eq!(m.level_debug.load(Ordering::Relaxed), 1);
616
617        m.inc_level(crate::log_level::LogLevel::INFO);
618        assert_eq!(m.level_info.load(Ordering::Relaxed), 1);
619
620        m.inc_level(crate::log_level::LogLevel::WARN);
621        assert_eq!(m.level_warn.load(Ordering::Relaxed), 1);
622
623        m.inc_level(crate::log_level::LogLevel::ERROR);
624        assert_eq!(m.level_error.load(Ordering::Relaxed), 1);
625
626        m.inc_level(crate::log_level::LogLevel::FATAL);
627        assert_eq!(m.level_fatal.load(Ordering::Relaxed), 1);
628
629        m.inc_level(crate::log_level::LogLevel::CRITICAL);
630        assert_eq!(m.level_critical.load(Ordering::Relaxed), 1);
631
632        // Non-tracked levels should not panic
633        m.inc_level(crate::log_level::LogLevel::ALL);
634        m.inc_level(crate::log_level::LogLevel::NONE);
635    }
636
637    #[test]
638    fn test_tui_metrics_inc_format_all_variants() {
639        let m = TuiMetrics::default();
640
641        m.inc_format(crate::log_format::LogFormat::CLF);
642        assert_eq!(m.fmt_clf.load(Ordering::Relaxed), 1);
643
644        m.inc_format(crate::log_format::LogFormat::JSON);
645        assert_eq!(m.fmt_json.load(Ordering::Relaxed), 1);
646
647        m.inc_format(crate::log_format::LogFormat::CEF);
648        assert_eq!(m.fmt_cef.load(Ordering::Relaxed), 1);
649
650        m.inc_format(crate::log_format::LogFormat::ELF);
651        assert_eq!(m.fmt_elf.load(Ordering::Relaxed), 1);
652
653        m.inc_format(crate::log_format::LogFormat::W3C);
654        assert_eq!(m.fmt_w3c.load(Ordering::Relaxed), 1);
655
656        m.inc_format(crate::log_format::LogFormat::GELF);
657        assert_eq!(m.fmt_gelf.load(Ordering::Relaxed), 1);
658
659        m.inc_format(crate::log_format::LogFormat::ApacheAccessLog);
660        assert_eq!(m.fmt_apache.load(Ordering::Relaxed), 1);
661
662        m.inc_format(crate::log_format::LogFormat::Logstash);
663        assert_eq!(m.fmt_logstash.load(Ordering::Relaxed), 1);
664
665        m.inc_format(crate::log_format::LogFormat::Log4jXML);
666        assert_eq!(m.fmt_log4j.load(Ordering::Relaxed), 1);
667
668        m.inc_format(crate::log_format::LogFormat::NDJSON);
669        assert_eq!(m.fmt_ndjson.load(Ordering::Relaxed), 1);
670
671        m.inc_format(crate::log_format::LogFormat::MCP);
672        assert_eq!(m.fmt_mcp.load(Ordering::Relaxed), 1);
673
674        m.inc_format(crate::log_format::LogFormat::OTLP);
675        assert_eq!(m.fmt_otlp.load(Ordering::Relaxed), 1);
676
677        m.inc_format(crate::log_format::LogFormat::Logfmt);
678        assert_eq!(m.fmt_logfmt.load(Ordering::Relaxed), 1);
679
680        m.inc_format(crate::log_format::LogFormat::ECS);
681        assert_eq!(m.fmt_ecs.load(Ordering::Relaxed), 1);
682    }
683
684    #[test]
685    fn test_tui_metrics_peak_throughput() {
686        let m = TuiMetrics::default();
687        m.peak_throughput.store(100, Ordering::Relaxed);
688        assert_eq!(m.peak_throughput.load(Ordering::Relaxed), 100);
689    }
690
691    #[test]
692    fn test_tui_metrics_start_epoch() {
693        let m = TuiMetrics::default();
694        m.start_epoch_secs.store(1_234_567_890, Ordering::Relaxed);
695        assert_eq!(
696            m.start_epoch_secs.load(Ordering::Relaxed),
697            1_234_567_890
698        );
699    }
700
701    #[test]
702    fn test_spark_chars_length() {
703        assert_eq!(SPARK_CHARS.len(), 8);
704    }
705
706    #[test]
707    fn test_build_fmt_line_empty() {
708        let m = TuiMetrics::default();
709        let line = build_fmt_line(&m);
710        assert_eq!(line, "(none)");
711    }
712
713    #[test]
714    fn test_build_fmt_line_single() {
715        let m = TuiMetrics::default();
716        m.fmt_json.store(42, Ordering::Relaxed);
717        let line = build_fmt_line(&m);
718        assert_eq!(line, "JSON: 42");
719    }
720
721    #[test]
722    fn test_build_fmt_line_multiple() {
723        let m = TuiMetrics::default();
724        m.fmt_json.store(10, Ordering::Relaxed);
725        m.fmt_mcp.store(20, Ordering::Relaxed);
726        m.fmt_otlp.store(5, Ordering::Relaxed);
727        let line = build_fmt_line(&m);
728        assert!(line.contains("JSON: 10"));
729        assert!(line.contains("MCP: 20"));
730        assert!(line.contains("OTLP: 5"));
731        assert!(line.contains(" | "));
732    }
733
734    #[test]
735    fn test_build_fmt_line_all_formats() {
736        let m = TuiMetrics::default();
737        m.fmt_clf.store(1, Ordering::Relaxed);
738        m.fmt_json.store(2, Ordering::Relaxed);
739        m.fmt_cef.store(3, Ordering::Relaxed);
740        m.fmt_elf.store(4, Ordering::Relaxed);
741        m.fmt_w3c.store(5, Ordering::Relaxed);
742        m.fmt_gelf.store(6, Ordering::Relaxed);
743        m.fmt_apache.store(7, Ordering::Relaxed);
744        m.fmt_logstash.store(8, Ordering::Relaxed);
745        m.fmt_log4j.store(9, Ordering::Relaxed);
746        m.fmt_ndjson.store(10, Ordering::Relaxed);
747        m.fmt_mcp.store(11, Ordering::Relaxed);
748        m.fmt_otlp.store(12, Ordering::Relaxed);
749        m.fmt_logfmt.store(13, Ordering::Relaxed);
750        m.fmt_ecs.store(14, Ordering::Relaxed);
751        let line = build_fmt_line(&m);
752        assert!(line.contains("CLF: 1"));
753        assert!(line.contains("ECS: 14"));
754    }
755
756    #[test]
757    fn test_compute_level_bars_empty() {
758        let m = TuiMetrics::default();
759        let (info_bar, info_pct, error_bar, error_pct) =
760            compute_level_bars(&m);
761        assert_eq!(info_pct, 0);
762        assert_eq!(error_pct, 0);
763        assert_eq!(info_bar.chars().count(), 10);
764        assert_eq!(error_bar.chars().count(), 10);
765    }
766
767    #[test]
768    fn test_compute_level_bars_with_data() {
769        let m = TuiMetrics::default();
770        m.level_info.store(80, Ordering::Relaxed);
771        m.level_error.store(20, Ordering::Relaxed);
772        let (info_bar, info_pct, error_bar, error_pct) =
773            compute_level_bars(&m);
774        assert_eq!(info_pct, 80);
775        assert_eq!(error_pct, 20);
776        // info_bar should have 8 filled blocks
777        let filled =
778            info_bar.chars().filter(|&c| c == '\u{2588}').count();
779        assert_eq!(filled, 8);
780        // error_bar should have 2 filled blocks
781        let filled =
782            error_bar.chars().filter(|&c| c == '\u{2588}').count();
783        assert_eq!(filled, 2);
784    }
785
786    #[test]
787    fn test_compute_level_bars_all_levels() {
788        let m = TuiMetrics::default();
789        m.level_info.store(50, Ordering::Relaxed);
790        m.level_warn.store(20, Ordering::Relaxed);
791        m.level_error.store(10, Ordering::Relaxed);
792        m.level_debug.store(15, Ordering::Relaxed);
793        m.level_trace.store(5, Ordering::Relaxed);
794        let (_info_bar, info_pct, _error_bar, error_pct) =
795            compute_level_bars(&m);
796        assert_eq!(info_pct, 50);
797        assert_eq!(error_pct, 10);
798    }
799
800    #[test]
801    fn test_render_tick_basic() {
802        let m = TuiMetrics::default();
803        m.total_events.store(100, Ordering::Relaxed);
804        m.error_count.store(5, Ordering::Relaxed);
805        m.active_spans.store(2, Ordering::Relaxed);
806        m.level_info.store(80, Ordering::Relaxed);
807        m.level_error.store(20, Ordering::Relaxed);
808        m.fmt_json.store(50, Ordering::Relaxed);
809        m.fmt_mcp.store(30, Ordering::Relaxed);
810
811        #[allow(clippy::cast_possible_truncation)]
812        let now = std::time::SystemTime::now()
813            .duration_since(std::time::UNIX_EPOCH)
814            .unwrap_or_default()
815            .as_secs() as usize;
816        m.start_epoch_secs.store(now, Ordering::Relaxed);
817
818        let mut last_total = 0_usize;
819        let mut ring = [0_usize; SPARKLINE_RING_SIZE];
820        let mut cursor = 0_usize;
821
822        let frame =
823            render_tick(&m, &mut last_total, &mut ring, &mut cursor);
824        assert!(frame.contains("RLG Liquid Glass Dashboard"));
825        assert!(frame.contains("Errors:"));
826        assert!(frame.contains("Active Spans:"));
827        assert!(frame.contains("Throughput"));
828        assert!(frame.contains("Peak:"));
829        assert!(frame.contains("Uptime:"));
830        assert!(frame.contains("Levels:"));
831        assert!(frame.contains("Formats:"));
832        assert!(frame.contains("JSON: 50"));
833        assert!(frame.contains("MCP: 30"));
834    }
835
836    #[test]
837    fn test_render_tick_updates_state() {
838        let m = TuiMetrics::default();
839        m.total_events.store(100, Ordering::Relaxed);
840        m.start_epoch_secs.store(0, Ordering::Relaxed);
841
842        let mut last_total = 0_usize;
843        let mut ring = [0_usize; SPARKLINE_RING_SIZE];
844        let mut cursor = 0_usize;
845
846        let _ =
847            render_tick(&m, &mut last_total, &mut ring, &mut cursor);
848
849        // last_total should be updated
850        assert_eq!(last_total, 100);
851        // cursor should be incremented
852        assert_eq!(cursor, 1);
853        // ring[0] should have the throughput value
854        assert_eq!(ring[0], 100 * 60); // diff * 60
855        // throughput metric should be stored
856        assert_eq!(m.throughput.load(Ordering::Relaxed), 100 * 60);
857    }
858
859    #[test]
860    fn test_render_tick_no_diff() {
861        let m = TuiMetrics::default();
862        m.total_events.store(50, Ordering::Relaxed);
863        m.start_epoch_secs.store(0, Ordering::Relaxed);
864
865        let mut last_total = 50_usize;
866        let mut ring = [0_usize; SPARKLINE_RING_SIZE];
867        let mut cursor = 0_usize;
868
869        let _ =
870            render_tick(&m, &mut last_total, &mut ring, &mut cursor);
871        assert_eq!(m.throughput.load(Ordering::Relaxed), 0);
872    }
873
874    #[test]
875    fn test_render_tick_peak_tracking() {
876        let m = TuiMetrics::default();
877        m.start_epoch_secs.store(0, Ordering::Relaxed);
878
879        let mut last_total = 0_usize;
880        let mut ring = [0_usize; SPARKLINE_RING_SIZE];
881        let mut cursor = 0_usize;
882
883        // First tick: 100 events => tps = 6000
884        m.total_events.store(100, Ordering::Relaxed);
885        let _ =
886            render_tick(&m, &mut last_total, &mut ring, &mut cursor);
887        assert_eq!(m.peak_throughput.load(Ordering::Relaxed), 6000);
888
889        // Second tick: 50 more events => tps = 3000
890        m.total_events.store(150, Ordering::Relaxed);
891        let _ =
892            render_tick(&m, &mut last_total, &mut ring, &mut cursor);
893        // Peak should still be 6000
894        assert_eq!(m.peak_throughput.load(Ordering::Relaxed), 6000);
895    }
896
897    #[test]
898    fn test_render_tick_no_formats() {
899        let m = TuiMetrics::default();
900        m.start_epoch_secs.store(0, Ordering::Relaxed);
901
902        let mut last_total = 0_usize;
903        let mut ring = [0_usize; SPARKLINE_RING_SIZE];
904        let mut cursor = 0_usize;
905
906        let frame =
907            render_tick(&m, &mut last_total, &mut ring, &mut cursor);
908        assert!(frame.contains("(none)"));
909    }
910
911    #[test]
912    fn test_get_terminal_height() {
913        let h = get_terminal_height();
914        assert!(h > 0);
915    }
916
917    #[test]
918    fn test_tui_metrics_dropped_events() {
919        let m = TuiMetrics::default();
920        assert_eq!(m.dropped_events.load(Ordering::Relaxed), 0);
921        m.inc_dropped();
922        m.inc_dropped();
923        assert_eq!(m.dropped_events.load(Ordering::Relaxed), 2);
924    }
925
926    #[test]
927    fn test_render_tick_shows_dropped() {
928        let m = TuiMetrics::default();
929        m.start_epoch_secs.store(0, Ordering::Relaxed);
930        m.dropped_events.store(42, Ordering::Relaxed);
931
932        let mut last_total = 0_usize;
933        let mut ring = [0_usize; SPARKLINE_RING_SIZE];
934        let mut cursor = 0_usize;
935
936        let frame =
937            render_tick(&m, &mut last_total, &mut ring, &mut cursor);
938        assert!(frame.contains("Dropped:"));
939        assert!(frame.contains("42"));
940    }
941}