1use crate::detection::DisplayContext;
7use crate::theme::FastMcpTheme;
8use rich_rust::prelude::*;
9
10#[derive(Debug)]
16pub struct RichLogFormatter {
17 theme: &'static FastMcpTheme,
18 context: DisplayContext,
19 show_target: bool,
20 show_timestamp: bool,
21 show_file_line: bool,
22 max_message_width: Option<usize>,
23}
24
25impl RichLogFormatter {
26 #[must_use]
28 pub fn new(theme: &'static FastMcpTheme, context: DisplayContext) -> Self {
29 Self {
30 theme,
31 context,
32 show_target: true,
33 show_timestamp: true,
34 show_file_line: false,
35 max_message_width: None,
36 }
37 }
38
39 #[must_use]
41 pub fn detect() -> Self {
42 Self::new(crate::theme::theme(), DisplayContext::detect())
43 }
44
45 #[must_use]
47 pub fn with_target(mut self, show: bool) -> Self {
48 self.show_target = show;
49 self
50 }
51
52 #[must_use]
54 pub fn with_timestamp(mut self, show: bool) -> Self {
55 self.show_timestamp = show;
56 self
57 }
58
59 #[must_use]
61 pub fn with_file_line(mut self, show: bool) -> Self {
62 self.show_file_line = show;
63 self
64 }
65
66 #[must_use]
68 pub fn with_max_width(mut self, width: Option<usize>) -> Self {
69 self.max_message_width = width;
70 self
71 }
72
73 #[must_use]
75 pub fn should_use_rich(&self) -> bool {
76 self.context.is_human()
77 }
78
79 #[must_use]
81 pub fn style_for_level(&self, level: LogLevel) -> &Style {
82 match level {
83 LogLevel::Error => &self.theme.error_style,
84 LogLevel::Warn => &self.theme.warning_style,
85 LogLevel::Info => &self.theme.info_style,
86 LogLevel::Debug => &self.theme.muted_style,
87 LogLevel::Trace => &self.theme.muted_style,
88 }
89 }
90
91 #[must_use]
93 pub fn format_level_badge(&self, level: LogLevel) -> String {
94 let text = format!("{:5}", level.as_str());
95
96 if self.should_use_rich() {
97 let style = self.style_for_level(level);
98 let color_hex = style
99 .color
100 .as_ref()
101 .and_then(|c| c.triplet)
102 .map(|t| t.hex())
103 .unwrap_or_default();
104 format!("[{color_hex}]{text}[/]")
105 } else {
106 format!("[{text}]")
107 }
108 }
109
110 #[must_use]
112 pub fn format_timestamp(&self, timestamp: &str) -> Option<String> {
113 if !self.show_timestamp {
114 return None;
115 }
116
117 if self.should_use_rich() {
118 let dim_hex = self
119 .theme
120 .text_dim
121 .triplet
122 .map(|t| t.hex())
123 .unwrap_or_default();
124 Some(format!("[{dim_hex}]{timestamp}[/]"))
125 } else {
126 Some(timestamp.to_string())
127 }
128 }
129
130 #[must_use]
132 pub fn format_target(&self, target: &str) -> Option<String> {
133 if !self.show_target {
134 return None;
135 }
136
137 let target = target.strip_prefix("fastmcp_rust::").unwrap_or(target);
139 let target = self.truncate_text(target);
140
141 if self.should_use_rich() {
142 let muted_hex = self
143 .theme
144 .text_muted
145 .triplet
146 .map(|t| t.hex())
147 .unwrap_or_default();
148 Some(format!("[{muted_hex}]{target}[/]"))
149 } else {
150 Some(target.to_string())
151 }
152 }
153
154 #[must_use]
156 pub fn format_fields(&self, fields: &[(String, String)]) -> String {
157 if fields.is_empty() {
158 return String::new();
159 }
160
161 if self.should_use_rich() {
162 let dim_hex = self
163 .theme
164 .text_dim
165 .triplet
166 .map(|t| t.hex())
167 .unwrap_or_default();
168 fields
169 .iter()
170 .map(|(k, v)| format!("[{dim_hex}]{k}[/]={v}"))
171 .collect::<Vec<_>>()
172 .join(" ")
173 } else {
174 fields
175 .iter()
176 .map(|(k, v)| format!("{k}={v}"))
177 .collect::<Vec<_>>()
178 .join(" ")
179 }
180 }
181
182 #[must_use]
184 pub fn format_event(&self, event: &LogEvent) -> FormattedLog {
185 let level_badge = self.format_level_badge(event.level);
186 let timestamp = event
187 .timestamp
188 .as_deref()
189 .and_then(|ts| self.format_timestamp(ts));
190 let target = event.target.as_deref().and_then(|t| self.format_target(t));
191 let message = self.truncate_text(&event.message);
192
193 let mut fields = event.fields.clone();
194 if self.show_file_line {
195 if let Some(file) = event.file.as_deref() {
196 let file_line = if let Some(line) = event.line {
197 format!("{file}:{line}")
198 } else {
199 file.to_string()
200 };
201 fields.push(("file".to_string(), file_line));
202 }
203 }
204
205 let fields = self.format_fields(&fields);
206
207 FormattedLog {
208 level_badge,
209 timestamp,
210 target,
211 message,
212 fields,
213 }
214 }
215
216 #[must_use]
218 pub fn format_line(&self, event: &LogEvent) -> String {
219 let formatted = self.format_event(event);
220 formatted.to_line()
221 }
222
223 fn truncate_text(&self, text: &str) -> String {
224 let Some(max) = self.max_message_width else {
225 return text.to_string();
226 };
227
228 let len = text.chars().count();
229 if len <= max {
230 return text.to_string();
231 }
232
233 if max <= 3 {
234 return text.chars().take(max).collect();
235 }
236
237 let mut truncated: String = text.chars().take(max - 3).collect();
238 truncated.push_str("...");
239 truncated
240 }
241}
242
243impl Default for RichLogFormatter {
244 fn default() -> Self {
245 Self::detect()
246 }
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum LogLevel {
252 Error,
253 Warn,
254 Info,
255 Debug,
256 Trace,
257}
258
259impl LogLevel {
260 #[must_use]
262 pub fn as_str(&self) -> &'static str {
263 match self {
264 Self::Error => "ERROR",
265 Self::Warn => "WARN",
266 Self::Info => "INFO",
267 Self::Debug => "DEBUG",
268 Self::Trace => "TRACE",
269 }
270 }
271}
272
273impl From<log::Level> for LogLevel {
274 fn from(level: log::Level) -> Self {
275 match level {
276 log::Level::Error => Self::Error,
277 log::Level::Warn => Self::Warn,
278 log::Level::Info => Self::Info,
279 log::Level::Debug => Self::Debug,
280 log::Level::Trace => Self::Trace,
281 }
282 }
283}
284
285impl From<tracing::Level> for LogLevel {
286 fn from(level: tracing::Level) -> Self {
287 match level {
288 tracing::Level::ERROR => Self::Error,
289 tracing::Level::WARN => Self::Warn,
290 tracing::Level::INFO => Self::Info,
291 tracing::Level::DEBUG => Self::Debug,
292 tracing::Level::TRACE => Self::Trace,
293 }
294 }
295}
296
297#[derive(Debug, Clone)]
299pub struct LogEvent {
300 pub level: LogLevel,
301 pub message: String,
302 pub target: Option<String>,
303 pub timestamp: Option<String>,
304 pub file: Option<String>,
305 pub line: Option<u32>,
306 pub fields: Vec<(String, String)>,
307}
308
309impl LogEvent {
310 #[must_use]
312 pub fn new(level: LogLevel, message: impl Into<String>) -> Self {
313 Self {
314 level,
315 message: message.into(),
316 target: None,
317 timestamp: None,
318 file: None,
319 line: None,
320 fields: Vec::new(),
321 }
322 }
323
324 #[must_use]
326 pub fn with_target(mut self, target: impl Into<String>) -> Self {
327 self.target = Some(target.into());
328 self
329 }
330
331 #[must_use]
333 pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
334 self.timestamp = Some(timestamp.into());
335 self
336 }
337
338 #[must_use]
340 pub fn with_file(mut self, file: impl Into<String>) -> Self {
341 self.file = Some(file.into());
342 self
343 }
344
345 #[must_use]
347 pub fn with_line(mut self, line: u32) -> Self {
348 self.line = Some(line);
349 self
350 }
351
352 #[must_use]
354 pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
355 self.fields.push((key.into(), value.into()));
356 self
357 }
358}
359
360#[derive(Debug, Clone)]
362pub struct FormattedLog {
363 pub level_badge: String,
364 pub timestamp: Option<String>,
365 pub target: Option<String>,
366 pub message: String,
367 pub fields: String,
368}
369
370impl FormattedLog {
371 #[must_use]
373 pub fn to_line(&self) -> String {
374 let mut parts = Vec::with_capacity(5);
375
376 if let Some(ref ts) = self.timestamp {
377 parts.push(ts.as_str());
378 }
379
380 parts.push(&self.level_badge);
381
382 if let Some(ref target) = self.target {
383 parts.push(target.as_str());
384 }
385
386 parts.push(&self.message);
387
388 if !self.fields.is_empty() {
389 parts.push(&self.fields);
390 }
391
392 parts.join(" ")
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 fn test_formatter_agent() -> RichLogFormatter {
401 RichLogFormatter::new(crate::theme::theme(), DisplayContext::new_agent())
402 }
403
404 fn test_formatter_human() -> RichLogFormatter {
405 RichLogFormatter::new(crate::theme::theme(), DisplayContext::new_human())
406 }
407
408 #[test]
409 fn test_level_badge_formatting_plain() {
410 let formatter = test_formatter_agent();
411 assert_eq!(formatter.format_level_badge(LogLevel::Error), "[ERROR]");
412 assert_eq!(formatter.format_level_badge(LogLevel::Warn), "[WARN ]");
413 assert_eq!(formatter.format_level_badge(LogLevel::Info), "[INFO ]");
414 assert_eq!(formatter.format_level_badge(LogLevel::Debug), "[DEBUG]");
415 assert_eq!(formatter.format_level_badge(LogLevel::Trace), "[TRACE]");
416 }
417
418 #[test]
419 fn test_level_badge_formatting_rich() {
420 let formatter = test_formatter_human();
421 let badge = formatter.format_level_badge(LogLevel::Error);
422 assert!(badge.contains("[/]"));
424 assert!(badge.contains("ERROR"));
425 }
426
427 #[test]
428 fn test_level_badge_formatting_rich_all_levels() {
429 let formatter = test_formatter_human();
430 for level in [
431 LogLevel::Warn,
432 LogLevel::Info,
433 LogLevel::Debug,
434 LogLevel::Trace,
435 ] {
436 let badge = formatter.format_level_badge(level);
437 assert!(badge.contains("[/]"));
438 assert!(badge.contains(level.as_str().trim()));
439 }
440 }
441
442 #[test]
443 fn test_timestamp_formatting() {
444 let formatter = test_formatter_agent();
445 let ts = formatter.format_timestamp("2026-01-21 12:00:00");
446 assert_eq!(ts, Some("2026-01-21 12:00:00".to_string()));
447
448 let formatter_no_ts = formatter.with_timestamp(false);
449 assert_eq!(
450 formatter_no_ts.format_timestamp("2026-01-21 12:00:00"),
451 None
452 );
453 }
454
455 #[test]
456 fn test_target_formatting() {
457 let formatter = test_formatter_agent();
458
459 let target = formatter.format_target("fastmcp_rust::server::router");
461 assert_eq!(target, Some("server::router".to_string()));
462
463 let target = formatter.format_target("tokio::runtime");
465 assert_eq!(target, Some("tokio::runtime".to_string()));
466 }
467
468 #[test]
469 fn test_timestamp_and_target_formatting_rich() {
470 let formatter = test_formatter_human();
471 let ts = formatter
472 .format_timestamp("2026-01-21 12:00:00")
473 .expect("timestamp should be present");
474 assert!(ts.contains("[/]"));
475 assert!(ts.contains("2026-01-21 12:00:00"));
476
477 let target = formatter
478 .format_target("fastmcp_rust::server::router")
479 .expect("target should be present");
480 assert!(target.contains("[/]"));
481 assert!(target.contains("server::router"));
482 }
483
484 #[test]
485 fn test_target_disabled() {
486 let formatter = test_formatter_agent().with_target(false);
487 assert_eq!(formatter.format_target("any::target"), None);
488 }
489
490 #[test]
491 fn test_target_truncation() {
492 let formatter = test_formatter_agent().with_max_width(Some(8));
493 let target = formatter.format_target("fastmcp_rust::server::router");
494 assert_eq!(target, Some("serve...".to_string()));
495 }
496
497 #[test]
498 fn test_fields_formatting_plain() {
499 let formatter = test_formatter_agent();
500 let fields = vec![
501 ("request_id".to_string(), "123".to_string()),
502 ("method".to_string(), "GET".to_string()),
503 ];
504 assert_eq!(
505 formatter.format_fields(&fields),
506 "request_id=123 method=GET"
507 );
508 }
509
510 #[test]
511 fn test_fields_formatting_rich() {
512 let formatter = test_formatter_human();
513 let fields = vec![
514 ("request_id".to_string(), "123".to_string()),
515 ("method".to_string(), "GET".to_string()),
516 ];
517
518 let rendered = formatter.format_fields(&fields);
519 assert!(rendered.contains("[/]"));
520 assert!(rendered.contains("request_id"));
521 assert!(rendered.contains("method"));
522 }
523
524 #[test]
525 fn test_fields_empty() {
526 let formatter = test_formatter_agent();
527 assert_eq!(formatter.format_fields(&[]), "");
528 }
529
530 #[test]
531 fn test_format_event_plain() {
532 let formatter = test_formatter_agent();
533 let event = LogEvent::new(LogLevel::Info, "Server started")
534 .with_target("fastmcp_rust::server")
535 .with_timestamp("12:00:00");
536
537 let formatted = formatter.format_event(&event);
538 assert_eq!(formatted.level_badge, "[INFO ]");
539 assert_eq!(formatted.target, Some("server".to_string()));
540 assert_eq!(formatted.message, "Server started");
541 }
542
543 #[test]
544 fn test_file_line_field() {
545 let formatter = test_formatter_agent().with_file_line(true);
546 let event = LogEvent::new(LogLevel::Info, "File info")
547 .with_file("src/main.rs")
548 .with_line(42);
549
550 let formatted = formatter.format_event(&event);
551 assert!(formatted.fields.contains("file=src/main.rs:42"));
552 }
553
554 #[test]
555 fn test_file_field_without_line_number() {
556 let formatter = test_formatter_agent().with_file_line(true);
557 let event = LogEvent::new(LogLevel::Info, "File info").with_file("src/main.rs");
558
559 let formatted = formatter.format_event(&event);
560 assert!(formatted.fields.contains("file=src/main.rs"));
561 assert!(!formatted.fields.contains("src/main.rs:"));
562 }
563
564 #[test]
565 fn test_message_truncation() {
566 let formatter = test_formatter_agent().with_max_width(Some(8));
567 let event = LogEvent::new(LogLevel::Info, "HelloWorld");
568 let formatted = formatter.format_event(&event);
569 assert_eq!(formatted.message, "Hello...");
570 }
571
572 #[test]
573 fn test_message_truncation_for_tiny_width() {
574 let formatter = test_formatter_agent().with_max_width(Some(3));
575 let event = LogEvent::new(LogLevel::Info, "HelloWorld");
576 let formatted = formatter.format_event(&event);
577 assert_eq!(formatted.message, "Hel");
578 }
579
580 #[test]
581 fn test_format_line() {
582 let formatter = test_formatter_agent();
583 let event = LogEvent::new(LogLevel::Error, "Connection failed")
584 .with_target("fastmcp_rust::transport")
585 .with_timestamp("12:00:00")
586 .with_field("error", "timeout");
587
588 let line = formatter.format_line(&event);
589 assert!(line.contains("[ERROR]"));
590 assert!(line.contains("Connection failed"));
591 assert!(line.contains("transport"));
592 assert!(line.contains("error=timeout"));
593 }
594
595 #[test]
596 fn test_log_level_from_log_crate() {
597 assert_eq!(LogLevel::from(log::Level::Error), LogLevel::Error);
598 assert_eq!(LogLevel::from(log::Level::Warn), LogLevel::Warn);
599 assert_eq!(LogLevel::from(log::Level::Info), LogLevel::Info);
600 assert_eq!(LogLevel::from(log::Level::Debug), LogLevel::Debug);
601 assert_eq!(LogLevel::from(log::Level::Trace), LogLevel::Trace);
602 }
603
604 #[test]
605 fn test_log_level_from_tracing_crate() {
606 assert_eq!(LogLevel::from(tracing::Level::ERROR), LogLevel::Error);
607 assert_eq!(LogLevel::from(tracing::Level::WARN), LogLevel::Warn);
608 assert_eq!(LogLevel::from(tracing::Level::INFO), LogLevel::Info);
609 assert_eq!(LogLevel::from(tracing::Level::DEBUG), LogLevel::Debug);
610 assert_eq!(LogLevel::from(tracing::Level::TRACE), LogLevel::Trace);
611 }
612
613 #[test]
614 fn test_log_event_builder() {
615 let event = LogEvent::new(LogLevel::Info, "test")
616 .with_target("target")
617 .with_timestamp("ts")
618 .with_file("file.rs")
619 .with_line(42)
620 .with_field("key", "value");
621
622 assert_eq!(event.level, LogLevel::Info);
623 assert_eq!(event.message, "test");
624 assert_eq!(event.target, Some("target".to_string()));
625 assert_eq!(event.timestamp, Some("ts".to_string()));
626 assert_eq!(event.file, Some("file.rs".to_string()));
627 assert_eq!(event.line, Some(42));
628 assert_eq!(event.fields, vec![("key".to_string(), "value".to_string())]);
629 }
630
631 #[test]
632 fn test_formatter_default() {
633 let formatter = RichLogFormatter::default();
634 let _ = formatter.format_level_badge(LogLevel::Info);
636 }
637
638 #[test]
643 fn truncate_text_exact_max_no_truncation() {
644 let formatter = test_formatter_agent().with_max_width(Some(5));
645 let event = LogEvent::new(LogLevel::Info, "Hello");
646 let formatted = formatter.format_event(&event);
647 assert_eq!(formatted.message, "Hello");
649 }
650
651 #[test]
652 fn truncate_text_no_max_returns_full() {
653 let formatter = test_formatter_agent().with_max_width(None);
654 let event = LogEvent::new(
655 LogLevel::Info,
656 "A long message that should not be truncated at all",
657 );
658 let formatted = formatter.format_event(&event);
659 assert_eq!(
660 formatted.message,
661 "A long message that should not be truncated at all"
662 );
663 }
664
665 #[test]
666 fn truncate_text_width_one_and_two() {
667 let f1 = test_formatter_agent().with_max_width(Some(1));
669 let e = LogEvent::new(LogLevel::Info, "Hello");
670 assert_eq!(f1.format_event(&e).message, "H");
671
672 let f2 = test_formatter_agent().with_max_width(Some(2));
673 assert_eq!(f2.format_event(&e).message, "He");
674 }
675
676 #[test]
677 fn should_use_rich_agent_vs_human() {
678 let agent = test_formatter_agent();
679 assert!(!agent.should_use_rich());
680
681 let human = test_formatter_human();
682 assert!(human.should_use_rich());
683 }
684
685 #[test]
686 fn style_for_level_all_levels() {
687 let formatter = test_formatter_agent();
688 let _ = formatter.style_for_level(LogLevel::Error);
690 let _ = formatter.style_for_level(LogLevel::Warn);
691 let _ = formatter.style_for_level(LogLevel::Info);
692 let _ = formatter.style_for_level(LogLevel::Debug);
693 let _ = formatter.style_for_level(LogLevel::Trace);
694 assert!(std::ptr::eq(
696 formatter.style_for_level(LogLevel::Debug),
697 formatter.style_for_level(LogLevel::Trace)
698 ));
699 }
700
701 #[test]
702 fn formatted_log_to_line_minimal() {
703 let log = FormattedLog {
705 level_badge: "[INFO ]".to_string(),
706 timestamp: None,
707 target: None,
708 message: "hello".to_string(),
709 fields: String::new(),
710 };
711 assert_eq!(log.to_line(), "[INFO ] hello");
712 }
713
714 #[test]
715 fn formatted_log_debug_and_clone() {
716 let log = FormattedLog {
717 level_badge: "[INFO ]".to_string(),
718 timestamp: Some("12:00:00".to_string()),
719 target: Some("server".to_string()),
720 message: "msg".to_string(),
721 fields: "k=v".to_string(),
722 };
723 let debug = format!("{log:?}");
724 assert!(debug.contains("FormattedLog"));
725
726 let cloned = log.clone();
727 assert_eq!(cloned.message, "msg");
728 assert_eq!(cloned.to_line(), log.to_line());
729 }
730
731 #[test]
732 fn log_level_as_str_and_traits() {
733 assert_eq!(LogLevel::Error.as_str(), "ERROR");
735 assert_eq!(LogLevel::Warn.as_str(), "WARN");
736 assert_eq!(LogLevel::Info.as_str(), "INFO");
737 assert_eq!(LogLevel::Debug.as_str(), "DEBUG");
738 assert_eq!(LogLevel::Trace.as_str(), "TRACE");
739
740 let debug = format!("{:?}", LogLevel::Error);
742 assert!(debug.contains("Error"));
743
744 let level = LogLevel::Warn;
746 let copied = level;
747 assert_eq!(level, copied);
748 }
749
750 #[test]
751 fn log_event_debug_and_clone() {
752 let event = LogEvent::new(LogLevel::Info, "test")
753 .with_target("t")
754 .with_field("k", "v");
755
756 let debug = format!("{event:?}");
757 assert!(debug.contains("LogEvent"));
758 assert!(debug.contains("test"));
759
760 let cloned = event.clone();
761 assert_eq!(cloned.message, "test");
762 assert_eq!(cloned.target, Some("t".to_string()));
763 assert_eq!(cloned.fields.len(), 1);
764 }
765
766 #[test]
767 fn format_event_with_all_options_enabled() {
768 let formatter = test_formatter_agent()
769 .with_file_line(true)
770 .with_max_width(Some(50));
771
772 let event = LogEvent::new(LogLevel::Error, "Connection failed")
773 .with_target("fastmcp_rust::transport::http")
774 .with_timestamp("2026-01-01T00:00:00Z")
775 .with_file("src/transport/http.rs")
776 .with_line(42)
777 .with_field("peer", "127.0.0.1");
778
779 let formatted = formatter.format_event(&event);
780 assert_eq!(formatted.level_badge, "[ERROR]");
781 assert!(formatted.timestamp.is_some());
782 assert!(formatted.target.is_some());
783 assert!(formatted.fields.contains("file=src/transport/http.rs:42"));
785 assert!(formatted.fields.contains("peer=127.0.0.1"));
786 }
787
788 #[test]
789 fn format_target_rich_with_truncation() {
790 let formatter = test_formatter_human().with_max_width(Some(10));
791 let target = formatter
792 .format_target("fastmcp_rust::server::router::handler")
793 .unwrap();
794 assert!(target.contains("[/]"));
796 assert!(target.contains("..."));
797 }
798}