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