turbo_debug_console/
tracefmt.rs1use 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
34fn 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
53fn 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
80fn field_value(v: &Value) -> String {
84 match v {
85 Value::String(s) => s.clone(),
86 other => other.to_string(),
87 }
88}
89
90#[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#[derive(Debug, Default)]
149pub struct TraceRenderer {
150 carry: Vec<u8>,
153}
154
155impl TraceRenderer {
156 #[must_use]
157 pub fn new() -> Self {
158 Self::default()
159 }
160
161 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 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}