Skip to main content

turbo_debug_console/
tracefmt.rs

1// Copyright (c) 2026 Enzo Lombardi
2// SPDX-License-Identifier: MIT
3
4//! Renders `tracing-subscriber` JSON-lines records to styled [`Cell`]s.
5//!
6//! This is a separate, self-contained renderer for the `trace` stream kind —
7//! it does not go through `trace-stream`/`Pipeline`; that pipeline is a
8//! markdown/DSML renderer for model token streams and is the wrong tool for
9//! a structured log line.
10//!
11//! One input line (JSON or not) becomes one output line:
12//!
13//! ```text
14//! 12:04:01  WARN  myapp::db   retry  attempt=3
15//! └ dim ┘  └level┘ └ cyan ─┘  └msg┘  └ dim ┘
16//! ```
17//!
18//! A line that fails to parse as JSON is rendered verbatim in the default
19//! attribute rather than dropped or reported as an error — a producer will
20//! eventually emit a panic message or a stray `println!`, and losing it
21//! would be worse than showing it unstyled.
22
23use serde_json::Value;
24use turbo_vision::core::draw::Cell;
25use turbo_vision::core::palette::{Attr, TvColor};
26
27use crate::streamview::StreamView;
28
29const BG: TvColor = TvColor::Black;
30const DEFAULT_FG: TvColor = TvColor::LightGray;
31const DIM_FG: TvColor = TvColor::DarkGray;
32const TARGET_FG: TvColor = TvColor::LightCyan;
33
34/// The color a level renders in. Levels arrive uppercase from
35/// `tracing-subscriber`; accepted case-insensitively anyway.
36fn level_color(level: &str) -> Option<TvColor> {
37    match level.to_ascii_uppercase().as_str() {
38        "ERROR" => Some(TvColor::LightRed),
39        "WARN" => Some(TvColor::Yellow),
40        "INFO" => Some(TvColor::White),
41        "DEBUG" => Some(TvColor::LightGray),
42        "TRACE" => Some(TvColor::DarkGray),
43        _ => None,
44    }
45}
46
47fn cells(text: &str, fg: TvColor) -> Vec<Cell> {
48    text.chars()
49        .map(|c| Cell::new(c, Attr::new(fg, BG)))
50        .collect()
51}
52
53/// If `ts` looks like an RFC3339 timestamp (`YYYY-MM-DDTHH:MM:SS...`),
54/// returns just its time-of-day (`HH:MM:SS`) — a debug console shows a burst
55/// of records seconds apart, and a full date on every line is noise.
56/// Anything else is not recognized, so the caller shows it verbatim.
57fn time_of_day(ts: &str) -> Option<&str> {
58    let b = ts.as_bytes();
59    if b.len() < 19 || b[10] != b'T' {
60        return None;
61    }
62    let digits = |i: usize| b[i].is_ascii_digit();
63    let date_ok = (0..4).all(digits)
64        && b[4] == b'-'
65        && (5..7).all(digits)
66        && b[7] == b'-'
67        && (8..10).all(digits);
68    let time_ok = (11..13).all(digits)
69        && b[13] == b':'
70        && (14..16).all(digits)
71        && b[16] == b':'
72        && (17..19).all(digits);
73    if date_ok && time_ok {
74        Some(&ts[11..19])
75    } else {
76        None
77    }
78}
79
80/// Renders one record's `fields.key=value` value, unquoting a plain string
81/// so it reads as logfmt (`attempt=3`, `user=alice`) rather than
82/// double-quoted JSON.
83fn field_value(v: &Value) -> String {
84    match v {
85        Value::String(s) => s.clone(),
86        other => other.to_string(),
87    }
88}
89
90/// Renders one line of a trace stream: a JSON `tracing-subscriber` record,
91/// or -- if the line is not valid JSON, or lacks a recognized `level` --
92/// verbatim in the default attribute.
93#[must_use]
94pub fn render_line(line: &str) -> Vec<Cell> {
95    render_record(line).unwrap_or_else(|| cells(line, DEFAULT_FG))
96}
97
98fn render_record(line: &str) -> Option<Vec<Cell>> {
99    let value: Value = serde_json::from_str(line).ok()?;
100    let level = value.get("level")?.as_str()?;
101    let level_fg = level_color(level)?;
102
103    let mut out: Vec<Cell> = Vec::new();
104    let push_sep = |out: &mut Vec<Cell>| {
105        if !out.is_empty() {
106            out.extend(cells("  ", DEFAULT_FG));
107        }
108    };
109
110    if let Some(ts) = value.get("timestamp").and_then(Value::as_str) {
111        let shown = time_of_day(ts).unwrap_or(ts);
112        push_sep(&mut out);
113        out.extend(cells(shown, DIM_FG));
114    }
115
116    push_sep(&mut out);
117    out.extend(cells(&level.to_ascii_uppercase(), level_fg));
118
119    if let Some(target) = value.get("target").and_then(Value::as_str) {
120        push_sep(&mut out);
121        out.extend(cells(target, TARGET_FG));
122    }
123
124    if let Some(fields) = value.get("fields").and_then(Value::as_object) {
125        if let Some(message) = fields.get("message").and_then(Value::as_str) {
126            push_sep(&mut out);
127            out.extend(cells(message, DEFAULT_FG));
128        }
129
130        let extra: Vec<String> = fields
131            .iter()
132            .filter(|(k, _)| *k != "message")
133            .map(|(k, v)| format!("{k}={}", field_value(v)))
134            .collect();
135        if !extra.is_empty() {
136            push_sep(&mut out);
137            out.extend(cells(&extra.join(" "), DIM_FG));
138        }
139    }
140
141    Some(out)
142}
143
144/// Buffers a trace session's incoming bytes into lines and renders each one
145/// into the view, exactly as [`crate::pipeline::Pipeline`] does for a
146/// `tokens` session -- but through [`render_line`] instead of
147/// `trace-stream`.
148#[derive(Debug, Default)]
149pub struct TraceRenderer {
150    /// Bytes of the in-progress line (including any incomplete trailing
151    /// UTF-8), not yet terminated by a newline.
152    carry: Vec<u8>,
153}
154
155impl TraceRenderer {
156    #[must_use]
157    pub fn new() -> Self {
158        Self::default()
159    }
160
161    /// Pushes stream bytes into the buffer, emitting a completed line to
162    /// `view` for every `\n` found, and updating the partial line.
163    pub fn feed(&mut self, bytes: &[u8], view: &mut StreamView) {
164        self.carry.extend_from_slice(bytes);
165        while let Some(pos) = self.carry.iter().position(|&b| b == b'\n') {
166            let line_bytes: Vec<u8> = self.carry.drain(..=pos).collect();
167            let line = String::from_utf8_lossy(&line_bytes[..line_bytes.len() - 1]);
168            view.push_line(&render_line(&line));
169        }
170        view.set_partial(&render_line(&String::from_utf8_lossy(&self.carry)));
171    }
172
173    /// Ends the stream: flushes any trailing partial line as a completed one.
174    pub fn finish(&mut self, view: &mut StreamView) {
175        if !self.carry.is_empty() {
176            let line = String::from_utf8_lossy(&self.carry).into_owned();
177            self.carry.clear();
178            view.push_line(&render_line(&line));
179        }
180        view.set_partial(&[]);
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use turbo_vision::core::geometry::Rect;
188
189    fn plain(cells: &[Cell]) -> String {
190        cells.iter().map(|c| c.ch).collect()
191    }
192
193    fn record(json: &str) -> Vec<Cell> {
194        render_line(json)
195    }
196
197    #[test]
198    fn a_full_record_renders_timestamp_level_target_message_and_fields() {
199        let json = r#"{"timestamp":"2024-03-01T12:04:01.123456Z","level":"WARN","fields":{"message":"retry","attempt":3},"target":"myapp::db"}"#;
200        let out = record(json);
201        let text = plain(&out);
202        assert_eq!(text, "12:04:01  WARN  myapp::db  retry  attempt=3");
203    }
204
205    #[test]
206    fn level_colors_are_correct() {
207        for (level, expected) in [
208            ("ERROR", TvColor::LightRed),
209            ("WARN", TvColor::Yellow),
210            ("INFO", TvColor::White),
211            ("DEBUG", TvColor::LightGray),
212            ("TRACE", TvColor::DarkGray),
213        ] {
214            let json = format!(r#"{{"level":"{level}","fields":{{"message":"x"}}}}"#);
215            let out = record(&json);
216            assert_eq!(
217                out[0].attr.fg, expected,
218                "level {level} should render {expected:?}"
219            );
220        }
221    }
222
223    #[test]
224    fn levels_are_accepted_case_insensitively() {
225        let json = r#"{"level":"warn","fields":{"message":"x"}}"#;
226        let out = record(json);
227        assert_eq!(out[0].attr.fg, TvColor::Yellow);
228        assert_eq!(plain(&out[..4]), "WARN");
229    }
230
231    #[test]
232    fn timestamp_is_dim_and_reduced_to_time_of_day() {
233        let json = r#"{"timestamp":"2024-03-01T12:04:01.123456Z","level":"INFO","fields":{"message":"hi"}}"#;
234        let out = record(json);
235        assert_eq!(plain(&out[..8]), "12:04:01");
236        assert!(out[..8].iter().all(|c| c.attr.fg == TvColor::DarkGray));
237    }
238
239    #[test]
240    fn an_unparseable_timestamp_is_shown_verbatim() {
241        let json = r#"{"timestamp":"not-a-time","level":"INFO","fields":{"message":"hi"}}"#;
242        let out = record(json);
243        assert!(plain(&out).starts_with("not-a-time"));
244    }
245
246    #[test]
247    fn missing_timestamp_and_target_still_render() {
248        let json = r#"{"level":"INFO","fields":{"message":"hi"}}"#;
249        let out = record(json);
250        assert_eq!(plain(&out), "INFO  hi");
251    }
252
253    #[test]
254    fn target_is_light_cyan() {
255        let json = r#"{"level":"INFO","target":"myapp::db","fields":{"message":"hi"}}"#;
256        let out = record(json);
257        let target_cell = out
258            .iter()
259            .zip(plain(&out).chars())
260            .find(|(_, c)| *c == 'm')
261            .unwrap()
262            .0;
263        assert_eq!(target_cell.attr.fg, TvColor::LightCyan);
264    }
265
266    #[test]
267    fn extra_structured_fields_appear_dim_after_the_message() {
268        let json = r#"{"level":"INFO","fields":{"message":"retry","attempt":3,"user":"alice"}}"#;
269        let out = record(json);
270        let text = plain(&out);
271        assert!(text.contains("attempt=3"));
272        assert!(text.contains("user=alice"));
273        let dim_start = text.find("attempt=3").unwrap();
274        assert!(
275            out[dim_start..]
276                .iter()
277                .take_while(|c| c.ch != '\0')
278                .all(|c| c.attr.fg == TvColor::DarkGray)
279        );
280    }
281
282    #[test]
283    fn a_non_json_line_renders_verbatim_in_the_default_attribute() {
284        let out = render_line("thread 'main' panicked at src/main.rs:1: boom");
285        assert_eq!(plain(&out), "thread 'main' panicked at src/main.rs:1: boom");
286        assert!(out.iter().all(|c| c.attr.fg == DEFAULT_FG));
287    }
288
289    #[test]
290    fn json_missing_a_recognized_level_renders_verbatim() {
291        let json = r#"{"fields":{"message":"hi"}}"#;
292        let out = render_line(json);
293        assert_eq!(plain(&out), json);
294    }
295
296    #[test]
297    fn trace_renderer_buffers_split_lines() {
298        let mut r = TraceRenderer::new();
299        let mut v = StreamView::new(Rect::new(0, 0, 80, 24));
300        let json = b"{\"level\":\"INFO\",\"fields\":{\"message\":\"hi\"}}\n";
301        r.feed(&json[..10], &mut v);
302        r.feed(&json[10..], &mut v);
303        assert_eq!(v.plain_text(), "INFO  hi");
304    }
305
306    #[test]
307    fn trace_renderer_finish_flushes_a_trailing_partial_line() {
308        let mut r = TraceRenderer::new();
309        let mut v = StreamView::new(Rect::new(0, 0, 80, 24));
310        r.feed(b"no newline here", &mut v);
311        r.finish(&mut v);
312        assert_eq!(v.plain_text(), "no newline here");
313    }
314}