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"))]
56fn 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"))]
242fn 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 = if level_total > 0 {
344        (info_c * 100) / level_total
345    } else {
346        0
347    };
348    let error_bar = render_level_bar(error_c, level_total.max(1));
349    let error_pct = if level_total > 0 {
350        (error_c * 100) / level_total
351    } else {
352        0
353    };
354
355    (info_bar, info_pct, error_bar, error_pct)
356}
357
358/// Performs one tick of the TUI dashboard, returning the ANSI-formatted frame.
359///
360/// This function is extracted from the render loop for testability.
361#[allow(clippy::cast_possible_truncation)]
362pub fn render_tick(
363    metrics: &TuiMetrics,
364    last_total: &mut usize,
365    sparkline_ring: &mut [usize; SPARKLINE_RING_SIZE],
366    spark_cursor: &mut usize,
367) -> String {
368    let total = metrics.total_events.load(Ordering::Relaxed);
369    let errors = metrics.error_count.load(Ordering::Relaxed);
370    let spans = metrics.active_spans.load(Ordering::Relaxed);
371    let dropped = metrics.dropped_events.load(Ordering::Relaxed);
372
373    // Calculate throughput (events per ~16ms tick -> scale to second)
374    let diff = total.saturating_sub(*last_total);
375    *last_total = total;
376    let tps = diff * 60;
377    metrics.throughput.store(tps, Ordering::Relaxed);
378
379    // Track peak throughput
380    let _ = metrics.peak_throughput.fetch_max(tps, Ordering::Relaxed);
381    let peak = metrics.peak_throughput.load(Ordering::Relaxed);
382
383    // Update sparkline ring buffer
384    sparkline_ring[*spark_cursor % SPARKLINE_RING_SIZE] = tps;
385    *spark_cursor = spark_cursor.wrapping_add(1);
386
387    // Uptime
388    let now_secs = std::time::SystemTime::now()
389        .duration_since(std::time::UNIX_EPOCH)
390        .unwrap_or_default()
391        .as_secs() as usize;
392    let uptime_secs = now_secs.saturating_sub(
393        metrics.start_epoch_secs.load(Ordering::Relaxed),
394    );
395    let uptime = format_uptime(uptime_secs as u64);
396
397    // Level bars
398    let (info_bar, info_pct, error_bar, error_pct) =
399        compute_level_bars(metrics);
400
401    // Format counts line
402    let fmt_line = build_fmt_line(metrics);
403
404    let sparkline = render_sparkline(
405        sparkline_ring,
406        *spark_cursor % SPARKLINE_RING_SIZE,
407    );
408
409    let total_fmt = format_with_commas(total);
410
411    let width = get_terminal_width() as usize;
412    let separator: String =
413        "\u{2500}".repeat(width.min(SPARKLINE_RING_SIZE));
414
415    let height = get_terminal_height();
416
417    format!(
418        "\x1b7\x1b[{height};1H\x1b[7A\x1b[J\
419\x1b[38;5;33m[ \x1b[1;37mRLG Liquid Glass Dashboard \x1b[0;38;5;33m]\x1b[0m\n\
420\x1b[1mErrors:\x1b[0m {errors} | \x1b[1mDropped:\x1b[0m {dropped} | \x1b[1mActive Spans:\x1b[0m {spans} | \x1b[1mThroughput:\x1b[0m {tps} ev/s\n\
421\x1b[1mPeak:\x1b[0m {peak} ev/s | \x1b[1mUptime:\x1b[0m {uptime} | \x1b[1mTotal:\x1b[0m {total_fmt}\n\
422\x1b[1mThroughput\x1b[0m {sparkline}\n\
423\x1b[1mLevels:\x1b[0m {info_bar} {info_pct}% INFO  {error_bar} {error_pct}% ERROR\n\
424\x1b[1mFormats:\x1b[0m {fmt_line}\n\
425\x1b[38;5;239m{separator}\x1b[0m\x1b8"
426    )
427}
428
429/// Spawns the background TUI renderer thread.
430///
431/// # Panics
432///
433/// This function panics if the TUI background thread fails to spawn.
434pub fn spawn_tui_thread(
435    metrics: Arc<TuiMetrics>,
436    shutdown_flag: Arc<AtomicBool>,
437) {
438    // Record engine start time
439    #[allow(clippy::cast_possible_truncation)]
440    let start = std::time::SystemTime::now()
441        .duration_since(std::time::UNIX_EPOCH)
442        .unwrap_or_default()
443        .as_secs() as usize;
444    metrics.start_epoch_secs.store(start, Ordering::Relaxed);
445
446    thread::Builder::new()
447        .name("rlg-tui".into())
448        .spawn(move || {
449            let mut last_total: usize = 0;
450            let mut sparkline_ring = [0_usize; SPARKLINE_RING_SIZE];
451            let mut spark_cursor: usize = 0;
452
453            loop {
454                if shutdown_flag.load(Ordering::Relaxed) {
455                    break;
456                }
457
458                thread::sleep(Duration::from_millis(
459                    TUI_TICK_INTERVAL_MS,
460                ));
461
462                let frame = render_tick(
463                    &metrics,
464                    &mut last_total,
465                    &mut sparkline_ring,
466                    &mut spark_cursor,
467                );
468
469                let mut stdout = std::io::stdout();
470                let _ = write!(stdout, "{frame}");
471                let _ = stdout.flush();
472            }
473
474            // Clean up terminal state on exit
475            let _ = write!(std::io::stdout(), "\x1b[r\x1b[J");
476        })
477        .expect("Failed to spawn TUI thread");
478}
479
480/// Formats an integer with comma separators.
481fn format_with_commas(n: usize) -> String {
482    let s = n.to_string();
483    let mut result = String::with_capacity(s.len() + s.len() / 3);
484    for (i, ch) in s.chars().enumerate() {
485        if i > 0 && (s.len() - i).is_multiple_of(3) {
486            result.push(',');
487        }
488        result.push(ch);
489    }
490    result
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn test_format_with_commas_zero() {
499        assert_eq!(format_with_commas(0), "0");
500    }
501
502    #[test]
503    fn test_format_with_commas_small() {
504        assert_eq!(format_with_commas(42), "42");
505        assert_eq!(format_with_commas(999), "999");
506    }
507
508    #[test]
509    fn test_format_with_commas_thousands() {
510        assert_eq!(format_with_commas(1000), "1,000");
511        assert_eq!(format_with_commas(1_234), "1,234");
512        assert_eq!(format_with_commas(999_999), "999,999");
513    }
514
515    #[test]
516    fn test_format_with_commas_millions() {
517        assert_eq!(format_with_commas(1_000_000), "1,000,000");
518        assert_eq!(format_with_commas(1_234_567), "1,234,567");
519    }
520
521    #[test]
522    fn test_format_uptime_zero() {
523        assert_eq!(format_uptime(0), "00:00:00");
524    }
525
526    #[test]
527    fn test_format_uptime_seconds() {
528        assert_eq!(format_uptime(45), "00:00:45");
529    }
530
531    #[test]
532    fn test_format_uptime_minutes() {
533        assert_eq!(format_uptime(125), "00:02:05");
534    }
535
536    #[test]
537    fn test_format_uptime_hours() {
538        assert_eq!(format_uptime(3661), "01:01:01");
539        assert_eq!(format_uptime(86399), "23:59:59");
540    }
541
542    #[test]
543    fn test_render_level_bar_zero_total() {
544        let bar = render_level_bar(0, 0);
545        assert_eq!(bar.chars().count(), 10);
546        // All empty blocks
547        assert!(bar.chars().all(|c| c == '\u{2591}'));
548    }
549
550    #[test]
551    fn test_render_level_bar_full() {
552        let bar = render_level_bar(100, 100);
553        assert_eq!(bar.chars().count(), 10);
554        assert!(bar.chars().all(|c| c == '\u{2588}'));
555    }
556
557    #[test]
558    fn test_render_level_bar_half() {
559        let bar = render_level_bar(50, 100);
560        assert_eq!(bar.chars().count(), 10);
561        let filled = bar.chars().filter(|&c| c == '\u{2588}').count();
562        assert_eq!(filled, 5);
563    }
564
565    #[test]
566    fn test_render_level_bar_empty() {
567        let bar = render_level_bar(0, 100);
568        assert_eq!(bar.chars().count(), 10);
569        assert!(bar.chars().all(|c| c == '\u{2591}'));
570    }
571
572    #[test]
573    fn test_render_sparkline_empty() {
574        let ring = [0_usize; SPARKLINE_RING_SIZE];
575        let sparkline = render_sparkline(&ring, 0);
576        assert_eq!(sparkline.chars().count(), SPARKLINE_RING_SIZE);
577        // All minimum bars since all values are 0
578        assert!(sparkline.chars().all(|c| c == '\u{2581}'));
579    }
580
581    #[test]
582    fn test_render_sparkline_uniform() {
583        let ring = [100_usize; SPARKLINE_RING_SIZE];
584        let sparkline = render_sparkline(&ring, 0);
585        assert_eq!(sparkline.chars().count(), SPARKLINE_RING_SIZE);
586    }
587
588    #[test]
589    fn test_render_sparkline_varied() {
590        let mut ring = [0_usize; SPARKLINE_RING_SIZE];
591        ring[0] = 100;
592        ring[30] = 50;
593        ring[59] = 25;
594        let sparkline = render_sparkline(&ring, 0);
595        assert_eq!(sparkline.chars().count(), SPARKLINE_RING_SIZE);
596    }
597
598    #[test]
599    fn test_render_sparkline_cursor_wrap() {
600        let mut ring = [0_usize; SPARKLINE_RING_SIZE];
601        ring[55] = 10;
602        ring[5] = 20;
603        let sparkline = render_sparkline(&ring, 50);
604        assert_eq!(sparkline.chars().count(), SPARKLINE_RING_SIZE);
605    }
606
607    #[test]
608    fn test_get_terminal_width() {
609        // In test/CI environments this typically returns 80 fallback
610        let w = get_terminal_width();
611        assert!(w > 0);
612    }
613
614    #[test]
615    fn test_tui_metrics_inc_level_all_variants() {
616        let m = TuiMetrics::default();
617
618        m.inc_level(crate::log_level::LogLevel::TRACE);
619        assert_eq!(m.level_trace.load(Ordering::Relaxed), 1);
620
621        m.inc_level(crate::log_level::LogLevel::DEBUG);
622        assert_eq!(m.level_debug.load(Ordering::Relaxed), 1);
623
624        m.inc_level(crate::log_level::LogLevel::INFO);
625        assert_eq!(m.level_info.load(Ordering::Relaxed), 1);
626
627        m.inc_level(crate::log_level::LogLevel::WARN);
628        assert_eq!(m.level_warn.load(Ordering::Relaxed), 1);
629
630        m.inc_level(crate::log_level::LogLevel::ERROR);
631        assert_eq!(m.level_error.load(Ordering::Relaxed), 1);
632
633        m.inc_level(crate::log_level::LogLevel::FATAL);
634        assert_eq!(m.level_fatal.load(Ordering::Relaxed), 1);
635
636        m.inc_level(crate::log_level::LogLevel::CRITICAL);
637        assert_eq!(m.level_critical.load(Ordering::Relaxed), 1);
638
639        // Non-tracked levels should not panic
640        m.inc_level(crate::log_level::LogLevel::ALL);
641        m.inc_level(crate::log_level::LogLevel::NONE);
642    }
643
644    #[test]
645    fn test_tui_metrics_inc_format_all_variants() {
646        let m = TuiMetrics::default();
647
648        m.inc_format(crate::log_format::LogFormat::CLF);
649        assert_eq!(m.fmt_clf.load(Ordering::Relaxed), 1);
650
651        m.inc_format(crate::log_format::LogFormat::JSON);
652        assert_eq!(m.fmt_json.load(Ordering::Relaxed), 1);
653
654        m.inc_format(crate::log_format::LogFormat::CEF);
655        assert_eq!(m.fmt_cef.load(Ordering::Relaxed), 1);
656
657        m.inc_format(crate::log_format::LogFormat::ELF);
658        assert_eq!(m.fmt_elf.load(Ordering::Relaxed), 1);
659
660        m.inc_format(crate::log_format::LogFormat::W3C);
661        assert_eq!(m.fmt_w3c.load(Ordering::Relaxed), 1);
662
663        m.inc_format(crate::log_format::LogFormat::GELF);
664        assert_eq!(m.fmt_gelf.load(Ordering::Relaxed), 1);
665
666        m.inc_format(crate::log_format::LogFormat::ApacheAccessLog);
667        assert_eq!(m.fmt_apache.load(Ordering::Relaxed), 1);
668
669        m.inc_format(crate::log_format::LogFormat::Logstash);
670        assert_eq!(m.fmt_logstash.load(Ordering::Relaxed), 1);
671
672        m.inc_format(crate::log_format::LogFormat::Log4jXML);
673        assert_eq!(m.fmt_log4j.load(Ordering::Relaxed), 1);
674
675        m.inc_format(crate::log_format::LogFormat::NDJSON);
676        assert_eq!(m.fmt_ndjson.load(Ordering::Relaxed), 1);
677
678        m.inc_format(crate::log_format::LogFormat::MCP);
679        assert_eq!(m.fmt_mcp.load(Ordering::Relaxed), 1);
680
681        m.inc_format(crate::log_format::LogFormat::OTLP);
682        assert_eq!(m.fmt_otlp.load(Ordering::Relaxed), 1);
683
684        m.inc_format(crate::log_format::LogFormat::Logfmt);
685        assert_eq!(m.fmt_logfmt.load(Ordering::Relaxed), 1);
686
687        m.inc_format(crate::log_format::LogFormat::ECS);
688        assert_eq!(m.fmt_ecs.load(Ordering::Relaxed), 1);
689    }
690
691    #[test]
692    fn test_tui_metrics_peak_throughput() {
693        let m = TuiMetrics::default();
694        m.peak_throughput.store(100, Ordering::Relaxed);
695        assert_eq!(m.peak_throughput.load(Ordering::Relaxed), 100);
696    }
697
698    #[test]
699    fn test_tui_metrics_start_epoch() {
700        let m = TuiMetrics::default();
701        m.start_epoch_secs.store(1_234_567_890, Ordering::Relaxed);
702        assert_eq!(
703            m.start_epoch_secs.load(Ordering::Relaxed),
704            1_234_567_890
705        );
706    }
707
708    #[test]
709    fn test_spark_chars_length() {
710        assert_eq!(SPARK_CHARS.len(), 8);
711    }
712
713    #[test]
714    fn test_build_fmt_line_empty() {
715        let m = TuiMetrics::default();
716        let line = build_fmt_line(&m);
717        assert_eq!(line, "(none)");
718    }
719
720    #[test]
721    fn test_build_fmt_line_single() {
722        let m = TuiMetrics::default();
723        m.fmt_json.store(42, Ordering::Relaxed);
724        let line = build_fmt_line(&m);
725        assert_eq!(line, "JSON: 42");
726    }
727
728    #[test]
729    fn test_build_fmt_line_multiple() {
730        let m = TuiMetrics::default();
731        m.fmt_json.store(10, Ordering::Relaxed);
732        m.fmt_mcp.store(20, Ordering::Relaxed);
733        m.fmt_otlp.store(5, Ordering::Relaxed);
734        let line = build_fmt_line(&m);
735        assert!(line.contains("JSON: 10"));
736        assert!(line.contains("MCP: 20"));
737        assert!(line.contains("OTLP: 5"));
738        assert!(line.contains(" | "));
739    }
740
741    #[test]
742    fn test_build_fmt_line_all_formats() {
743        let m = TuiMetrics::default();
744        m.fmt_clf.store(1, Ordering::Relaxed);
745        m.fmt_json.store(2, Ordering::Relaxed);
746        m.fmt_cef.store(3, Ordering::Relaxed);
747        m.fmt_elf.store(4, Ordering::Relaxed);
748        m.fmt_w3c.store(5, Ordering::Relaxed);
749        m.fmt_gelf.store(6, Ordering::Relaxed);
750        m.fmt_apache.store(7, Ordering::Relaxed);
751        m.fmt_logstash.store(8, Ordering::Relaxed);
752        m.fmt_log4j.store(9, Ordering::Relaxed);
753        m.fmt_ndjson.store(10, Ordering::Relaxed);
754        m.fmt_mcp.store(11, Ordering::Relaxed);
755        m.fmt_otlp.store(12, Ordering::Relaxed);
756        m.fmt_logfmt.store(13, Ordering::Relaxed);
757        m.fmt_ecs.store(14, Ordering::Relaxed);
758        let line = build_fmt_line(&m);
759        assert!(line.contains("CLF: 1"));
760        assert!(line.contains("ECS: 14"));
761    }
762
763    #[test]
764    fn test_compute_level_bars_empty() {
765        let m = TuiMetrics::default();
766        let (info_bar, info_pct, error_bar, error_pct) =
767            compute_level_bars(&m);
768        assert_eq!(info_pct, 0);
769        assert_eq!(error_pct, 0);
770        assert_eq!(info_bar.chars().count(), 10);
771        assert_eq!(error_bar.chars().count(), 10);
772    }
773
774    #[test]
775    fn test_compute_level_bars_with_data() {
776        let m = TuiMetrics::default();
777        m.level_info.store(80, Ordering::Relaxed);
778        m.level_error.store(20, Ordering::Relaxed);
779        let (info_bar, info_pct, error_bar, error_pct) =
780            compute_level_bars(&m);
781        assert_eq!(info_pct, 80);
782        assert_eq!(error_pct, 20);
783        // info_bar should have 8 filled blocks
784        let filled =
785            info_bar.chars().filter(|&c| c == '\u{2588}').count();
786        assert_eq!(filled, 8);
787        // error_bar should have 2 filled blocks
788        let filled =
789            error_bar.chars().filter(|&c| c == '\u{2588}').count();
790        assert_eq!(filled, 2);
791    }
792
793    #[test]
794    fn test_compute_level_bars_all_levels() {
795        let m = TuiMetrics::default();
796        m.level_info.store(50, Ordering::Relaxed);
797        m.level_warn.store(20, Ordering::Relaxed);
798        m.level_error.store(10, Ordering::Relaxed);
799        m.level_debug.store(15, Ordering::Relaxed);
800        m.level_trace.store(5, Ordering::Relaxed);
801        let (_info_bar, info_pct, _error_bar, error_pct) =
802            compute_level_bars(&m);
803        assert_eq!(info_pct, 50);
804        assert_eq!(error_pct, 10);
805    }
806
807    #[test]
808    fn test_render_tick_basic() {
809        let m = TuiMetrics::default();
810        m.total_events.store(100, Ordering::Relaxed);
811        m.error_count.store(5, Ordering::Relaxed);
812        m.active_spans.store(2, Ordering::Relaxed);
813        m.level_info.store(80, Ordering::Relaxed);
814        m.level_error.store(20, Ordering::Relaxed);
815        m.fmt_json.store(50, Ordering::Relaxed);
816        m.fmt_mcp.store(30, Ordering::Relaxed);
817
818        #[allow(clippy::cast_possible_truncation)]
819        let now = std::time::SystemTime::now()
820            .duration_since(std::time::UNIX_EPOCH)
821            .unwrap_or_default()
822            .as_secs() as usize;
823        m.start_epoch_secs.store(now, Ordering::Relaxed);
824
825        let mut last_total = 0_usize;
826        let mut ring = [0_usize; SPARKLINE_RING_SIZE];
827        let mut cursor = 0_usize;
828
829        let frame =
830            render_tick(&m, &mut last_total, &mut ring, &mut cursor);
831        assert!(frame.contains("RLG Liquid Glass Dashboard"));
832        assert!(frame.contains("Errors:"));
833        assert!(frame.contains("Active Spans:"));
834        assert!(frame.contains("Throughput"));
835        assert!(frame.contains("Peak:"));
836        assert!(frame.contains("Uptime:"));
837        assert!(frame.contains("Levels:"));
838        assert!(frame.contains("Formats:"));
839        assert!(frame.contains("JSON: 50"));
840        assert!(frame.contains("MCP: 30"));
841    }
842
843    #[test]
844    fn test_render_tick_updates_state() {
845        let m = TuiMetrics::default();
846        m.total_events.store(100, Ordering::Relaxed);
847        m.start_epoch_secs.store(0, Ordering::Relaxed);
848
849        let mut last_total = 0_usize;
850        let mut ring = [0_usize; SPARKLINE_RING_SIZE];
851        let mut cursor = 0_usize;
852
853        let _ =
854            render_tick(&m, &mut last_total, &mut ring, &mut cursor);
855
856        // last_total should be updated
857        assert_eq!(last_total, 100);
858        // cursor should be incremented
859        assert_eq!(cursor, 1);
860        // ring[0] should have the throughput value
861        assert_eq!(ring[0], 100 * 60); // diff * 60
862        // throughput metric should be stored
863        assert_eq!(m.throughput.load(Ordering::Relaxed), 100 * 60);
864    }
865
866    #[test]
867    fn test_render_tick_no_diff() {
868        let m = TuiMetrics::default();
869        m.total_events.store(50, Ordering::Relaxed);
870        m.start_epoch_secs.store(0, Ordering::Relaxed);
871
872        let mut last_total = 50_usize;
873        let mut ring = [0_usize; SPARKLINE_RING_SIZE];
874        let mut cursor = 0_usize;
875
876        let _ =
877            render_tick(&m, &mut last_total, &mut ring, &mut cursor);
878        assert_eq!(m.throughput.load(Ordering::Relaxed), 0);
879    }
880
881    #[test]
882    fn test_render_tick_peak_tracking() {
883        let m = TuiMetrics::default();
884        m.start_epoch_secs.store(0, Ordering::Relaxed);
885
886        let mut last_total = 0_usize;
887        let mut ring = [0_usize; SPARKLINE_RING_SIZE];
888        let mut cursor = 0_usize;
889
890        // First tick: 100 events => tps = 6000
891        m.total_events.store(100, Ordering::Relaxed);
892        let _ =
893            render_tick(&m, &mut last_total, &mut ring, &mut cursor);
894        assert_eq!(m.peak_throughput.load(Ordering::Relaxed), 6000);
895
896        // Second tick: 50 more events => tps = 3000
897        m.total_events.store(150, Ordering::Relaxed);
898        let _ =
899            render_tick(&m, &mut last_total, &mut ring, &mut cursor);
900        // Peak should still be 6000
901        assert_eq!(m.peak_throughput.load(Ordering::Relaxed), 6000);
902    }
903
904    #[test]
905    fn test_render_tick_no_formats() {
906        let m = TuiMetrics::default();
907        m.start_epoch_secs.store(0, Ordering::Relaxed);
908
909        let mut last_total = 0_usize;
910        let mut ring = [0_usize; SPARKLINE_RING_SIZE];
911        let mut cursor = 0_usize;
912
913        let frame =
914            render_tick(&m, &mut last_total, &mut ring, &mut cursor);
915        assert!(frame.contains("(none)"));
916    }
917
918    #[test]
919    fn test_get_terminal_height() {
920        let h = get_terminal_height();
921        assert!(h > 0);
922    }
923
924    #[test]
925    fn test_tui_metrics_dropped_events() {
926        let m = TuiMetrics::default();
927        assert_eq!(m.dropped_events.load(Ordering::Relaxed), 0);
928        m.inc_dropped();
929        m.inc_dropped();
930        assert_eq!(m.dropped_events.load(Ordering::Relaxed), 2);
931    }
932
933    #[test]
934    fn test_render_tick_shows_dropped() {
935        let m = TuiMetrics::default();
936        m.start_epoch_secs.store(0, Ordering::Relaxed);
937        m.dropped_events.store(42, Ordering::Relaxed);
938
939        let mut last_total = 0_usize;
940        let mut ring = [0_usize; SPARKLINE_RING_SIZE];
941        let mut cursor = 0_usize;
942
943        let frame =
944            render_tick(&m, &mut last_total, &mut ring, &mut cursor);
945        assert!(frame.contains("Dropped:"));
946        assert!(frame.contains("42"));
947    }
948}