1use std::fmt;
2use std::sync::{Arc, Mutex, RwLock};
3use std::time::SystemTime;
4
5use humantime::format_rfc3339_seconds;
6use once_cell::sync::Lazy;
7use ratatui::{
8 style::{Color, Modifier, Style},
9 text::{Line, Span, Text},
10};
11use syntect::easy::HighlightLines;
12use syntect::highlighting::Theme;
13use syntect::parsing::SyntaxReference;
14use tokio::sync::mpsc::UnboundedSender;
15use tracing::{Event, Level, Subscriber, field::Visit};
16use tracing_subscriber::layer::{Context, Layer};
17use tracing_subscriber::registry::LookupSpan;
18
19use crate::ui::syntax_highlight::{
20 default_theme_name, find_syntax_by_extension, find_syntax_by_name, find_syntax_plain_text,
21 load_theme, syntax_set,
22};
23
24#[derive(Debug, Clone)]
25pub struct LogEntry {
26 pub formatted: Arc<str>,
27 pub timestamp: Arc<str>,
28 pub level: Level,
29 pub target: Arc<str>,
30 pub message: Arc<str>,
31}
32
33#[derive(Default)]
34struct LogForwarder {
35 sender: Mutex<Option<UnboundedSender<LogEntry>>>,
36}
37
38impl LogForwarder {
39 fn set_sender(&self, sender: UnboundedSender<LogEntry>) {
40 match self.sender.lock() {
41 Ok(mut guard) => {
42 *guard = Some(sender);
43 }
44 Err(err) => {
45 tracing::warn!("failed to set TUI log sender; lock poisoned: {err}");
46 }
47 }
48 }
49
50 fn clear_sender(&self) {
51 match self.sender.lock() {
52 Ok(mut guard) => {
53 *guard = None;
54 }
55 Err(err) => {
56 tracing::warn!("failed to clear TUI log sender; lock poisoned: {err}");
57 }
58 }
59 }
60
61 fn send(&self, entry: LogEntry) {
62 match self.sender.lock() {
63 Ok(guard) => {
64 if let Some(sender) = guard.as_ref() {
65 let _ = sender.send(entry);
66 }
67 }
68 Err(err) => {
69 tracing::warn!("failed to forward TUI log entry; lock poisoned: {err}");
70 }
71 }
72 }
73}
74
75static LOG_FORWARDER: Lazy<Arc<LogForwarder>> = Lazy::new(|| Arc::new(LogForwarder::default()));
76
77static LOG_THEME_NAME: Lazy<RwLock<Option<String>>> = Lazy::new(|| RwLock::new(None));
78static LOG_THEME_CACHE: Lazy<RwLock<Option<(String, Theme)>>> = Lazy::new(|| RwLock::new(None));
79
80#[derive(Default)]
81struct FieldVisitor {
82 message: Option<String>,
83 extras: Vec<(String, String)>,
84}
85
86impl Visit for FieldVisitor {
87 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) {
88 let rendered = format!("{:?}", value);
89 if field.name() == "message" {
90 self.message = Some(rendered);
91 } else {
92 self.extras.push((field.name().to_string(), rendered));
93 }
94 }
95
96 fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
97 if field.name() == "message" {
98 self.message = Some(value.to_string());
99 } else {
100 self.extras
101 .push((field.name().to_string(), value.to_string()));
102 }
103 }
104}
105
106pub struct TuiLogLayer {
107 forwarder: Arc<LogForwarder>,
108}
109
110impl TuiLogLayer {
111 pub fn new() -> Self {
112 Self {
113 forwarder: LOG_FORWARDER.clone(),
114 }
115 }
116}
117
118impl Default for TuiLogLayer {
119 fn default() -> Self {
120 Self::new()
121 }
122}
123
124impl TuiLogLayer {
125 pub fn set_sender(sender: UnboundedSender<LogEntry>) {
126 LOG_FORWARDER.set_sender(sender);
127 }
128
129 pub fn clear_sender() {
130 LOG_FORWARDER.clear_sender();
131 }
132}
133
134impl<S> Layer<S> for TuiLogLayer
135where
136 S: Subscriber + for<'span> LookupSpan<'span>,
137{
138 fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
139 let level = *event.metadata().level();
140
141 if level == tracing::Level::TRACE {
143 return;
144 }
145
146 if level == tracing::Level::DEBUG && !crate::ui::tui::panic_hook::is_debug_mode() {
148 return;
149 }
150
151 if level == tracing::Level::ERROR && !crate::ui::tui::panic_hook::show_diagnostics() {
154 return;
155 }
156
157 let mut visitor = FieldVisitor::default();
158 event.record(&mut visitor);
159
160 let message = visitor
161 .message
162 .unwrap_or_else(|| "(no message)".to_string());
163 let extras = if visitor.extras.is_empty() {
164 String::new()
165 } else {
166 let rendered = visitor
167 .extras
168 .into_iter()
169 .map(|(key, value)| format!("{key}={value}"))
170 .collect::<Vec<_>>()
171 .join(" ");
172 format!(" {rendered}")
173 };
174
175 let combined_message = format!("{}{}", message, extras);
177 if should_filter_log_message(&combined_message) {
178 return;
179 }
180
181 let timestamp = format_rfc3339_seconds(SystemTime::now()).to_string();
182 let line = format!(
183 "{} {:<5} {} {}{}",
184 timestamp,
185 event.metadata().level(),
186 event.metadata().target(),
187 message,
188 extras
189 );
190
191 let log_target = Arc::from(event.metadata().target().to_string().into_boxed_str());
192 let message_with_extras = Arc::from(format!("{message}{extras}").into_boxed_str());
193 self.forwarder.send(LogEntry {
194 formatted: Arc::from(line.into_boxed_str()),
195 timestamp: Arc::from(timestamp.into_boxed_str()),
196 level: *event.metadata().level(),
197 target: log_target,
198 message: message_with_extras,
199 });
200 }
201}
202
203fn should_filter_log_message(message: &str) -> bool {
205 let lower_message = message.to_lowercase();
206
207 let malloc_filters = [
209 "mallocstacklogging",
210 "malscollogging",
211 "no such file or directory",
212 "can't turn off malloc stack logging",
213 "could not tag msl-related memory",
214 "those pages will be included in process footprint",
215 "process is not in a debuggable environment",
216 "unsetting mallocstackloggingdirectory environment variable",
217 ];
218
219 malloc_filters
220 .iter()
221 .any(|&filter| lower_message.contains(&filter.to_lowercase()))
222}
223
224pub fn make_tui_log_layer() -> TuiLogLayer {
225 TuiLogLayer::new()
226}
227
228pub fn register_tui_log_sender(sender: UnboundedSender<LogEntry>) {
229 TuiLogLayer::set_sender(sender);
230}
231
232pub fn clear_tui_log_sender() {
233 TuiLogLayer::clear_sender();
234}
235
236pub fn set_log_theme_name(theme: Option<String>) {
237 let Ok(mut slot) = LOG_THEME_NAME.write() else {
238 tracing::warn!("failed to set TUI log theme name; theme lock poisoned");
239 return;
240 };
241 *slot = theme
242 .map(|t| t.trim().to_string())
243 .filter(|t| !t.is_empty());
244 drop(slot);
245 if let Ok(mut cache) = LOG_THEME_CACHE.write() {
246 *cache = None;
247 } else {
248 tracing::warn!("failed to clear TUI log theme cache; cache lock poisoned");
249 }
250}
251
252fn theme_for_current_config() -> Theme {
253 let theme_name = {
254 let Ok(slot) = LOG_THEME_NAME.read() else {
255 tracing::warn!("failed to read TUI log theme name; falling back to default theme");
256 return load_theme(&default_theme_name(), true);
257 };
258 slot.clone()
259 };
260 let resolved_name = theme_name.clone().unwrap_or_else(default_theme_name);
261 {
262 if let Ok(cache) = LOG_THEME_CACHE.read()
263 && let Some((cached_name, cached)) = &*cache
264 && *cached_name == resolved_name
265 {
266 return cached.clone();
267 }
268 }
269
270 let theme = load_theme(&resolved_name, true);
271 if let Ok(mut cache) = LOG_THEME_CACHE.write() {
272 *cache = Some((resolved_name, theme.clone()));
273 }
274 theme
275}
276
277fn syntect_to_ratatui_style(style: syntect::highlighting::Style) -> Style {
278 let fg = style.foreground;
279 Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b))
280}
281
282fn highlight_lines_to_text<'a>(
283 lines: impl Iterator<Item = &'a str>,
284 syntax: &SyntaxReference,
285) -> Text<'static> {
286 let theme = theme_for_current_config();
287 let ss = syntax_set();
288 let mut highlighter = HighlightLines::new(syntax, &theme);
289 let mut result_lines = Vec::new();
290
291 for line in lines {
292 match highlighter.highlight_line(line, ss) {
293 Ok(ranges) => {
294 let spans: Vec<Span<'static>> = ranges
295 .into_iter()
296 .map(|(style, text)| {
297 Span::styled(text.to_owned(), syntect_to_ratatui_style(style))
298 })
299 .collect();
300 result_lines.push(Line::from(spans));
301 }
302 Err(_) => {
303 result_lines.push(Line::raw(line.to_owned()));
304 }
305 }
306 }
307
308 Text::from(result_lines)
309}
310
311fn log_level_style(level: &Level) -> Style {
312 match *level {
313 Level::ERROR => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
314 Level::WARN => Style::default()
315 .fg(Color::Yellow)
316 .add_modifier(Modifier::BOLD),
317 Level::INFO => Style::default().fg(Color::Green),
318 Level::DEBUG => Style::default().fg(Color::Blue),
319 Level::TRACE => Style::default().fg(Color::DarkGray),
320 }
321}
322
323fn log_prefix(entry: &LogEntry) -> Vec<Span<'static>> {
324 let timestamp_style = Style::default().fg(Color::DarkGray);
325 vec![
326 Span::styled(format!("[{}]", entry.timestamp), timestamp_style),
327 Span::raw(" "),
328 Span::styled(format!("{:<5}", entry.level), log_level_style(&entry.level)),
329 Span::raw(" "),
330 Span::styled(entry.target.to_string(), Style::default().fg(Color::Gray)),
331 Span::raw(" "),
332 ]
333}
334
335fn prepend_metadata(text: &mut Text<'static>, entry: &LogEntry) {
336 let mut prefix = log_prefix(entry);
337 if let Some(first) = text.lines.first_mut() {
338 let mut merged = Vec::with_capacity(prefix.len() + first.spans.len());
339 merged.append(&mut prefix);
340 merged.append(&mut first.spans);
341 first.spans = merged;
342 } else {
343 text.lines.push(Line::from(prefix));
344 }
345}
346
347fn select_syntax(message: &str) -> &'static SyntaxReference {
348 let trimmed = message.trim_start();
349 if !trimmed.is_empty() {
350 if (trimmed.starts_with('{') || trimmed.starts_with('['))
351 && let Some(json) =
352 find_syntax_by_name("JSON").or_else(|| find_syntax_by_extension("json"))
353 {
354 return json;
355 }
356
357 if (trimmed.contains('$') || trimmed.contains(';'))
358 && let Some(shell) =
359 find_syntax_by_name("Bash").or_else(|| find_syntax_by_extension("sh"))
360 {
361 return shell;
362 }
363 }
364
365 find_syntax_by_name("Rust")
366 .or_else(|| find_syntax_by_extension("rs"))
367 .unwrap_or_else(find_syntax_plain_text)
368}
369
370pub fn highlight_log_entry(entry: &LogEntry) -> Text<'static> {
371 let ss = syntax_set();
372 if ss.syntaxes().is_empty() {
373 let mut text = Text::raw(entry.formatted.as_ref().to_string());
374 prepend_metadata(&mut text, entry);
375 return text;
376 }
377
378 let syntax = select_syntax(entry.message.as_ref());
379 let mut highlighted = highlight_lines_to_text(entry.message.lines(), syntax);
380 prepend_metadata(&mut highlighted, entry);
381 highlighted
382}