Skip to main content

fastmcp_console/logging/
formatter.rs

1//! Rich log formatting for tracing events.
2//!
3//! This module provides `RichLogFormatter` which transforms tracing events
4//! into beautifully styled console output using rich_rust.
5
6use crate::detection::DisplayContext;
7use crate::theme::FastMcpTheme;
8use rich_rust::prelude::*;
9
10/// Formats tracing events into rich, styled output.
11///
12/// The formatter is aware of the display context and will produce plain
13/// text output when running in agent mode (machine parsing) vs rich
14/// styled output when running in human mode (interactive terminal).
15#[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    /// Create a new formatter with the given theme and context.
27    #[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    /// Create a formatter that auto-detects the context.
40    #[must_use]
41    pub fn detect() -> Self {
42        Self::new(crate::theme::theme(), DisplayContext::detect())
43    }
44
45    /// Set whether to show the target/module path.
46    #[must_use]
47    pub fn with_target(mut self, show: bool) -> Self {
48        self.show_target = show;
49        self
50    }
51
52    /// Set whether to show timestamps.
53    #[must_use]
54    pub fn with_timestamp(mut self, show: bool) -> Self {
55        self.show_timestamp = show;
56        self
57    }
58
59    /// Set whether to show file:line information.
60    #[must_use]
61    pub fn with_file_line(mut self, show: bool) -> Self {
62        self.show_file_line = show;
63        self
64    }
65
66    /// Set maximum width for message/target truncation.
67    #[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    /// Check if rich output should be used.
74    #[must_use]
75    pub fn should_use_rich(&self) -> bool {
76        self.context.is_human()
77    }
78
79    /// Get the style for a given log level.
80    #[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    /// Format a level badge (e.g., `[ERROR]`, `[INFO ]`).
92    #[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    /// Format a timestamp.
111    #[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    /// Format a target/module path.
131    #[must_use]
132    pub fn format_target(&self, target: &str) -> Option<String> {
133        if !self.show_target {
134            return None;
135        }
136
137        // Strip "fastmcp_rust::" prefix for cleaner output
138        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    /// Format structured fields (key=value pairs).
155    #[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    /// Format a complete log event.
183    #[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    /// Format a log event to a single line string.
217    #[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/// Log level enum (mirrors tracing levels).
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum LogLevel {
252    Error,
253    Warn,
254    Info,
255    Debug,
256    Trace,
257}
258
259impl LogLevel {
260    /// Get the string representation.
261    #[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/// A log event to be formatted.
298#[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    /// Create a new log event.
311    #[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    /// Set the target.
325    #[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    /// Set the timestamp.
332    #[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    /// Set the file location.
339    #[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    /// Set the line number.
346    #[must_use]
347    pub fn with_line(mut self, line: u32) -> Self {
348        self.line = Some(line);
349        self
350    }
351
352    /// Add a field.
353    #[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/// Formatted log output ready for rendering.
361#[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    /// Convert to a single line string.
372    #[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        // Rich badges contain markup
423        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        // Should strip fastmcp_rust:: prefix
460        let target = formatter.format_target("fastmcp_rust::server::router");
461        assert_eq!(target, Some("server::router".to_string()));
462
463        // Non-fastmcp targets remain unchanged
464        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        // Should work without panicking
635        let _ = formatter.format_level_badge(LogLevel::Info);
636    }
637
638    // =========================================================================
639    // Additional coverage tests (bd-m32k)
640    // =========================================================================
641
642    #[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        // Exactly at max → no truncation
648        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        // max <= 3 takes first max chars (no ellipsis)
668        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        // Just verify each level returns a style without panicking
689        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        // Debug and Trace should return the same muted style
695        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        // No timestamp, no target, no fields → just "badge message"
704        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        // as_str
734        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        // Debug
741        let debug = format!("{:?}", LogLevel::Error);
742        assert!(debug.contains("Error"));
743
744        // Clone + Copy
745        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        // file:line should be in fields
784        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        // Should contain markup and be truncated
795        assert!(target.contains("[/]"));
796        assert!(target.contains("..."));
797    }
798}