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