Skip to main content

fastapi_output/components/
http_inspector.rs

1//! HTTP request and response inspector component.
2//!
3//! Provides detailed visual inspection of HTTP requests and responses
4//! for debugging purposes. Unlike the simple one-line logging in `logging.rs`,
5//! this module provides comprehensive multi-line output showing headers,
6//! body previews, and timing information.
7//!
8//! # Feature Gating
9//!
10//! This module is designed for debug output. In production, inspectors
11//! should only be called when debug mode is explicitly enabled.
12//!
13//! ```rust,ignore
14//! if output.is_debug_enabled() {
15//!     let inspector = RequestInspector::new(OutputMode::Rich);
16//!     println!("{}", inspector.inspect(&request_info));
17//! }
18//! ```
19
20use crate::mode::OutputMode;
21use crate::themes::FastApiTheme;
22use std::time::Duration;
23
24const ANSI_RESET: &str = "\x1b[0m";
25const ANSI_BOLD: &str = "\x1b[1m";
26const ANSI_DIM: &str = "\x1b[2m";
27
28/// Maximum body preview length in bytes.
29const DEFAULT_BODY_PREVIEW_LEN: usize = 512;
30
31/// HTTP request information for inspection.
32#[derive(Debug, Clone)]
33pub struct RequestInfo {
34    /// HTTP method (GET, POST, etc.).
35    pub method: String,
36    /// Request path.
37    pub path: String,
38    /// Query string (without leading ?).
39    pub query: Option<String>,
40    /// HTTP version (e.g., "HTTP/1.1").
41    pub http_version: String,
42    /// Request headers as key-value pairs.
43    pub headers: Vec<(String, String)>,
44    /// Body preview (may be truncated).
45    pub body_preview: Option<String>,
46    /// Total body size in bytes.
47    pub body_size: Option<usize>,
48    /// Whether the body was truncated.
49    pub body_truncated: bool,
50    /// Content-Type header value.
51    pub content_type: Option<String>,
52    /// Parse duration (time to parse the request).
53    pub parse_duration: Option<Duration>,
54    /// Client IP address.
55    pub client_ip: Option<String>,
56    /// Request ID.
57    pub request_id: Option<String>,
58}
59
60impl RequestInfo {
61    /// Create a new request info with minimal data.
62    #[must_use]
63    pub fn new(method: impl Into<String>, path: impl Into<String>) -> Self {
64        Self {
65            method: method.into(),
66            path: path.into(),
67            query: None,
68            http_version: "HTTP/1.1".to_string(),
69            headers: Vec::new(),
70            body_preview: None,
71            body_size: None,
72            body_truncated: false,
73            content_type: None,
74            parse_duration: None,
75            client_ip: None,
76            request_id: None,
77        }
78    }
79
80    /// Set the query string.
81    #[must_use]
82    pub fn query(mut self, query: impl Into<String>) -> Self {
83        self.query = Some(query.into());
84        self
85    }
86
87    /// Set the HTTP version.
88    #[must_use]
89    pub fn http_version(mut self, version: impl Into<String>) -> Self {
90        self.http_version = version.into();
91        self
92    }
93
94    /// Add a header.
95    #[must_use]
96    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
97        self.headers.push((name.into(), value.into()));
98        self
99    }
100
101    /// Set headers from an iterator.
102    #[must_use]
103    pub fn headers(
104        mut self,
105        headers: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
106    ) -> Self {
107        self.headers = headers
108            .into_iter()
109            .map(|(k, v)| (k.into(), v.into()))
110            .collect();
111        self
112    }
113
114    /// Set the body preview.
115    #[must_use]
116    pub fn body_preview(mut self, preview: impl Into<String>, total_size: usize) -> Self {
117        let preview_str = preview.into();
118        self.body_truncated = preview_str.len() < total_size;
119        self.body_preview = Some(preview_str);
120        self.body_size = Some(total_size);
121        self
122    }
123
124    /// Set the content type.
125    #[must_use]
126    pub fn content_type(mut self, content_type: impl Into<String>) -> Self {
127        self.content_type = Some(content_type.into());
128        self
129    }
130
131    /// Set the parse duration.
132    #[must_use]
133    pub fn parse_duration(mut self, duration: Duration) -> Self {
134        self.parse_duration = Some(duration);
135        self
136    }
137
138    /// Set the client IP.
139    #[must_use]
140    pub fn client_ip(mut self, ip: impl Into<String>) -> Self {
141        self.client_ip = Some(ip.into());
142        self
143    }
144
145    /// Set the request ID.
146    #[must_use]
147    pub fn request_id(mut self, id: impl Into<String>) -> Self {
148        self.request_id = Some(id.into());
149        self
150    }
151}
152
153/// HTTP response information for inspection.
154#[derive(Debug, Clone)]
155pub struct ResponseInfo {
156    /// HTTP status code.
157    pub status: u16,
158    /// Status reason phrase (e.g., "OK", "Not Found").
159    pub reason: Option<String>,
160    /// Response headers.
161    pub headers: Vec<(String, String)>,
162    /// Body preview (may be truncated).
163    pub body_preview: Option<String>,
164    /// Total body size in bytes.
165    pub body_size: Option<usize>,
166    /// Whether the body was truncated.
167    pub body_truncated: bool,
168    /// Content-Type header value.
169    pub content_type: Option<String>,
170    /// Total response time.
171    pub response_time: Option<Duration>,
172}
173
174impl ResponseInfo {
175    /// Create a new response info.
176    #[must_use]
177    pub fn new(status: u16) -> Self {
178        Self {
179            status,
180            reason: None,
181            headers: Vec::new(),
182            body_preview: None,
183            body_size: None,
184            body_truncated: false,
185            content_type: None,
186            response_time: None,
187        }
188    }
189
190    /// Set the reason phrase.
191    #[must_use]
192    pub fn reason(mut self, reason: impl Into<String>) -> Self {
193        self.reason = Some(reason.into());
194        self
195    }
196
197    /// Add a header.
198    #[must_use]
199    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
200        self.headers.push((name.into(), value.into()));
201        self
202    }
203
204    /// Set headers from an iterator.
205    #[must_use]
206    pub fn headers(
207        mut self,
208        headers: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
209    ) -> Self {
210        self.headers = headers
211            .into_iter()
212            .map(|(k, v)| (k.into(), v.into()))
213            .collect();
214        self
215    }
216
217    /// Set the body preview.
218    #[must_use]
219    pub fn body_preview(mut self, preview: impl Into<String>, total_size: usize) -> Self {
220        let preview_str = preview.into();
221        self.body_truncated = preview_str.len() < total_size;
222        self.body_preview = Some(preview_str);
223        self.body_size = Some(total_size);
224        self
225    }
226
227    /// Set the content type.
228    #[must_use]
229    pub fn content_type(mut self, content_type: impl Into<String>) -> Self {
230        self.content_type = Some(content_type.into());
231        self
232    }
233
234    /// Set the response time.
235    #[must_use]
236    pub fn response_time(mut self, duration: Duration) -> Self {
237        self.response_time = Some(duration);
238        self
239    }
240
241    /// Get the default reason phrase for the status code.
242    #[must_use]
243    pub fn default_reason(&self) -> &'static str {
244        match self.status {
245            100 => "Continue",
246            101 => "Switching Protocols",
247            200 => "OK",
248            201 => "Created",
249            202 => "Accepted",
250            204 => "No Content",
251            301 => "Moved Permanently",
252            302 => "Found",
253            304 => "Not Modified",
254            307 => "Temporary Redirect",
255            308 => "Permanent Redirect",
256            400 => "Bad Request",
257            401 => "Unauthorized",
258            403 => "Forbidden",
259            404 => "Not Found",
260            405 => "Method Not Allowed",
261            409 => "Conflict",
262            413 => "Payload Too Large",
263            415 => "Unsupported Media Type",
264            422 => "Unprocessable Entity",
265            429 => "Too Many Requests",
266            500 => "Internal Server Error",
267            502 => "Bad Gateway",
268            503 => "Service Unavailable",
269            504 => "Gateway Timeout",
270            _ => "Unknown",
271        }
272    }
273}
274
275/// HTTP request inspector.
276///
277/// Provides detailed visual display of HTTP requests for debugging.
278#[derive(Debug, Clone)]
279pub struct RequestInspector {
280    mode: OutputMode,
281    theme: FastApiTheme,
282    /// Maximum body preview length.
283    pub max_body_preview: usize,
284    /// Whether to show all headers.
285    pub show_all_headers: bool,
286    /// Whether to show timing information.
287    pub show_timing: bool,
288}
289
290impl RequestInspector {
291    /// Create a new request inspector.
292    #[must_use]
293    pub fn new(mode: OutputMode) -> Self {
294        Self {
295            mode,
296            theme: FastApiTheme::default(),
297            max_body_preview: DEFAULT_BODY_PREVIEW_LEN,
298            show_all_headers: true,
299            show_timing: true,
300        }
301    }
302
303    /// Set the theme.
304    #[must_use]
305    pub fn theme(mut self, theme: FastApiTheme) -> Self {
306        self.theme = theme;
307        self
308    }
309
310    /// Inspect a request and return formatted output.
311    #[must_use]
312    pub fn inspect(&self, info: &RequestInfo) -> String {
313        match self.mode {
314            OutputMode::Plain => self.inspect_plain(info),
315            OutputMode::Minimal => self.inspect_minimal(info),
316            OutputMode::Rich => self.inspect_rich(info),
317        }
318    }
319
320    fn inspect_plain(&self, info: &RequestInfo) -> String {
321        let mut lines = Vec::new();
322
323        // Request line
324        lines.push("=== HTTP Request ===".to_string());
325        let full_path = match &info.query {
326            Some(q) => format!("{}?{}", info.path, q),
327            None => info.path.clone(),
328        };
329        lines.push(format!(
330            "{} {} {}",
331            info.method, full_path, info.http_version
332        ));
333
334        // Metadata
335        if let Some(ip) = &info.client_ip {
336            lines.push(format!("Client: {ip}"));
337        }
338        if let Some(id) = &info.request_id {
339            lines.push(format!("Request-ID: {id}"));
340        }
341        if self.show_timing {
342            if let Some(duration) = info.parse_duration {
343                lines.push(format!("Parse time: {}", format_duration(duration)));
344            }
345        }
346
347        // Headers
348        if !info.headers.is_empty() {
349            lines.push(String::new());
350            lines.push("Headers:".to_string());
351            for (name, value) in &info.headers {
352                lines.push(format!("  {name}: {value}"));
353            }
354        }
355
356        // Body
357        if let Some(preview) = &info.body_preview {
358            lines.push(String::new());
359            let size_info = match info.body_size {
360                Some(size) if info.body_truncated => format!(" ({size} bytes, truncated)"),
361                Some(size) => format!(" ({size} bytes)"),
362                None => String::new(),
363            };
364            lines.push(format!("Body{size_info}:"));
365            lines.push(format!("  {preview}"));
366        }
367
368        lines.push("====================".to_string());
369        lines.join("\n")
370    }
371
372    fn inspect_minimal(&self, info: &RequestInfo) -> String {
373        let method_color = self.method_color(&info.method).to_ansi_fg();
374        let muted = self.theme.muted.to_ansi_fg();
375        let accent = self.theme.accent.to_ansi_fg();
376
377        let mut lines = Vec::new();
378
379        // Request line with color
380        lines.push(format!("{muted}=== HTTP Request ==={ANSI_RESET}"));
381        let full_path = match &info.query {
382            Some(q) => format!("{}?{}", info.path, q),
383            None => info.path.clone(),
384        };
385        lines.push(format!(
386            "{method_color}{}{ANSI_RESET} {full_path} {muted}{}{ANSI_RESET}",
387            info.method, info.http_version
388        ));
389
390        // Metadata
391        if let Some(id) = &info.request_id {
392            lines.push(format!(
393                "{muted}Request-ID:{ANSI_RESET} {accent}{id}{ANSI_RESET}"
394            ));
395        }
396        if self.show_timing {
397            if let Some(duration) = info.parse_duration {
398                lines.push(format!(
399                    "{muted}Parse time:{ANSI_RESET} {}",
400                    format_duration(duration)
401                ));
402            }
403        }
404
405        // Headers (condensed)
406        if !info.headers.is_empty() {
407            lines.push(format!(
408                "{muted}Headers ({}):{ANSI_RESET}",
409                info.headers.len()
410            ));
411            for (name, value) in &info.headers {
412                lines.push(format!("  {accent}{name}:{ANSI_RESET} {value}"));
413            }
414        }
415
416        lines.push(format!("{muted}=================={ANSI_RESET}"));
417        lines.join("\n")
418    }
419
420    #[allow(clippy::too_many_lines)]
421    fn inspect_rich(&self, info: &RequestInfo) -> String {
422        let method_color = self.method_color(&info.method);
423        let muted = self.theme.muted.to_ansi_fg();
424        let accent = self.theme.accent.to_ansi_fg();
425        let border = self.theme.border.to_ansi_fg();
426        let header_style = self.theme.header.to_ansi_fg();
427
428        let mut lines = Vec::new();
429
430        // Top border with title
431        lines.push(format!(
432            "{border}┌─────────────────────────────────────────────┐{ANSI_RESET}"
433        ));
434        lines.push(format!(
435            "{border}│{ANSI_RESET} {header_style}{ANSI_BOLD}HTTP Request{ANSI_RESET}                                 {border}│{ANSI_RESET}"
436        ));
437        lines.push(format!(
438            "{border}├─────────────────────────────────────────────┤{ANSI_RESET}"
439        ));
440
441        // Request line with method badge
442        let method_bg = method_color.to_ansi_bg();
443        let full_path = match &info.query {
444            Some(q) => format!(
445                "{}{}?{q}{ANSI_RESET}",
446                info.path,
447                self.theme.accent.to_ansi_fg()
448            ),
449            None => info.path.clone(),
450        };
451        lines.push(format!(
452            "{border}│{ANSI_RESET} {method_bg}{ANSI_BOLD} {} {ANSI_RESET} {full_path}",
453            info.method
454        ));
455
456        // Metadata row
457        let mut meta_parts = Vec::new();
458        if let Some(ip) = &info.client_ip {
459            meta_parts.push(format!("Client: {ip}"));
460        }
461        if let Some(id) = &info.request_id {
462            meta_parts.push(format!("ID: {id}"));
463        }
464        if self.show_timing {
465            if let Some(duration) = info.parse_duration {
466                meta_parts.push(format!("Parsed: {}", format_duration(duration)));
467            }
468        }
469        if !meta_parts.is_empty() {
470            lines.push(format!(
471                "{border}│{ANSI_RESET} {muted}{}{ANSI_RESET}",
472                meta_parts.join(" │ ")
473            ));
474        }
475
476        // Headers section
477        if !info.headers.is_empty() {
478            lines.push(format!(
479                "{border}├─────────────────────────────────────────────┤{ANSI_RESET}"
480            ));
481            lines.push(format!(
482                "{border}│{ANSI_RESET} {header_style}Headers{ANSI_RESET} {muted}({}){ANSI_RESET}",
483                info.headers.len()
484            ));
485
486            // Find max header name length for alignment
487            let max_name_len = info
488                .headers
489                .iter()
490                .map(|(n, _)| n.len())
491                .max()
492                .unwrap_or(0)
493                .min(20);
494
495            for (name, value) in &info.headers {
496                let truncated_name = if name.len() > max_name_len {
497                    format!("{}...", &name[..max_name_len - 3])
498                } else {
499                    name.clone()
500                };
501                lines.push(format!(
502                    "{border}│{ANSI_RESET}   {accent}{truncated_name:max_name_len$}{ANSI_RESET}: {value}",
503                ));
504            }
505        }
506
507        // Body section
508        if let Some(preview) = &info.body_preview {
509            lines.push(format!(
510                "{border}├─────────────────────────────────────────────┤{ANSI_RESET}"
511            ));
512            let size_info = match info.body_size {
513                Some(size) if info.body_truncated => {
514                    format!("{muted}({size} bytes, truncated){ANSI_RESET}")
515                }
516                Some(size) => format!("{muted}({size} bytes){ANSI_RESET}"),
517                None => String::new(),
518            };
519            lines.push(format!(
520                "{border}│{ANSI_RESET} {header_style}Body{ANSI_RESET} {size_info}"
521            ));
522            // Wrap long body preview
523            for line in preview.lines().take(5) {
524                let truncated = if line.len() > 40 {
525                    format!("{}...", &line[..37])
526                } else {
527                    line.to_string()
528                };
529                lines.push(format!(
530                    "{border}│{ANSI_RESET}   {ANSI_DIM}{truncated}{ANSI_RESET}"
531                ));
532            }
533        }
534
535        // Bottom border
536        lines.push(format!(
537            "{border}└─────────────────────────────────────────────┘{ANSI_RESET}"
538        ));
539
540        lines.join("\n")
541    }
542
543    fn method_color(&self, method: &str) -> crate::themes::Color {
544        match method.to_uppercase().as_str() {
545            "GET" => self.theme.http_get,
546            "POST" => self.theme.http_post,
547            "PUT" => self.theme.http_put,
548            "DELETE" => self.theme.http_delete,
549            "PATCH" => self.theme.http_patch,
550            "OPTIONS" => self.theme.http_options,
551            "HEAD" => self.theme.http_head,
552            _ => self.theme.muted,
553        }
554    }
555}
556
557/// HTTP response inspector.
558///
559/// Provides detailed visual display of HTTP responses for debugging.
560#[derive(Debug, Clone)]
561pub struct ResponseInspector {
562    mode: OutputMode,
563    theme: FastApiTheme,
564    /// Maximum body preview length.
565    pub max_body_preview: usize,
566    /// Whether to show all headers.
567    pub show_all_headers: bool,
568    /// Whether to show timing information.
569    pub show_timing: bool,
570}
571
572impl ResponseInspector {
573    /// Create a new response inspector.
574    #[must_use]
575    pub fn new(mode: OutputMode) -> Self {
576        Self {
577            mode,
578            theme: FastApiTheme::default(),
579            max_body_preview: DEFAULT_BODY_PREVIEW_LEN,
580            show_all_headers: true,
581            show_timing: true,
582        }
583    }
584
585    /// Set the theme.
586    #[must_use]
587    pub fn theme(mut self, theme: FastApiTheme) -> Self {
588        self.theme = theme;
589        self
590    }
591
592    /// Inspect a response and return formatted output.
593    #[must_use]
594    pub fn inspect(&self, info: &ResponseInfo) -> String {
595        match self.mode {
596            OutputMode::Plain => self.inspect_plain(info),
597            OutputMode::Minimal => self.inspect_minimal(info),
598            OutputMode::Rich => self.inspect_rich(info),
599        }
600    }
601
602    fn inspect_plain(&self, info: &ResponseInfo) -> String {
603        let mut lines = Vec::new();
604
605        // Status line
606        lines.push("=== HTTP Response ===".to_string());
607        let reason = info.reason.as_deref().unwrap_or(info.default_reason());
608        lines.push(format!("HTTP/1.1 {} {reason}", info.status));
609
610        // Timing
611        if self.show_timing {
612            if let Some(duration) = info.response_time {
613                lines.push(format!("Response time: {}", format_duration(duration)));
614            }
615        }
616
617        // Headers
618        if !info.headers.is_empty() {
619            lines.push(String::new());
620            lines.push("Headers:".to_string());
621            for (name, value) in &info.headers {
622                lines.push(format!("  {name}: {value}"));
623            }
624        }
625
626        // Body
627        if let Some(preview) = &info.body_preview {
628            lines.push(String::new());
629            let size_info = match info.body_size {
630                Some(size) if info.body_truncated => format!(" ({size} bytes, truncated)"),
631                Some(size) => format!(" ({size} bytes)"),
632                None => String::new(),
633            };
634            lines.push(format!("Body{size_info}:"));
635            lines.push(format!("  {preview}"));
636        }
637
638        lines.push("=====================".to_string());
639        lines.join("\n")
640    }
641
642    fn inspect_minimal(&self, info: &ResponseInfo) -> String {
643        let status_color = self.status_color(info.status).to_ansi_fg();
644        let muted = self.theme.muted.to_ansi_fg();
645        let accent = self.theme.accent.to_ansi_fg();
646
647        let mut lines = Vec::new();
648
649        // Status line with color
650        lines.push(format!("{muted}=== HTTP Response ==={ANSI_RESET}"));
651        let reason = info.reason.as_deref().unwrap_or(info.default_reason());
652        let icon = self.status_icon(info.status);
653        lines.push(format!(
654            "{status_color}{icon} {} {reason}{ANSI_RESET}",
655            info.status
656        ));
657
658        // Timing
659        if self.show_timing {
660            if let Some(duration) = info.response_time {
661                lines.push(format!(
662                    "{muted}Response time:{ANSI_RESET} {}",
663                    format_duration(duration)
664                ));
665            }
666        }
667
668        // Headers (condensed)
669        if !info.headers.is_empty() {
670            lines.push(format!(
671                "{muted}Headers ({}):{ANSI_RESET}",
672                info.headers.len()
673            ));
674            for (name, value) in &info.headers {
675                lines.push(format!("  {accent}{name}:{ANSI_RESET} {value}"));
676            }
677        }
678
679        lines.push(format!("{muted}=================={ANSI_RESET}"));
680        lines.join("\n")
681    }
682
683    fn inspect_rich(&self, info: &ResponseInfo) -> String {
684        let status_color = self.status_color(info.status);
685        let muted = self.theme.muted.to_ansi_fg();
686        let accent = self.theme.accent.to_ansi_fg();
687        let border = self.theme.border.to_ansi_fg();
688        let header_style = self.theme.header.to_ansi_fg();
689
690        let mut lines = Vec::new();
691
692        // Top border
693        lines.push(format!(
694            "{border}┌─────────────────────────────────────────────┐{ANSI_RESET}"
695        ));
696        lines.push(format!(
697            "{border}│{ANSI_RESET} {header_style}{ANSI_BOLD}HTTP Response{ANSI_RESET}                                {border}│{ANSI_RESET}"
698        ));
699        lines.push(format!(
700            "{border}├─────────────────────────────────────────────┤{ANSI_RESET}"
701        ));
702
703        // Status line with badge
704        let status_bg = status_color.to_ansi_bg();
705        let reason = info.reason.as_deref().unwrap_or(info.default_reason());
706        let icon = self.status_icon(info.status);
707        lines.push(format!(
708            "{border}│{ANSI_RESET} {status_bg}{ANSI_BOLD} {icon} {} {ANSI_RESET} {reason}",
709            info.status
710        ));
711
712        // Timing
713        if self.show_timing {
714            if let Some(duration) = info.response_time {
715                lines.push(format!(
716                    "{border}│{ANSI_RESET} {muted}Response time: {}{ANSI_RESET}",
717                    format_duration(duration)
718                ));
719            }
720        }
721
722        // Headers section
723        if !info.headers.is_empty() {
724            lines.push(format!(
725                "{border}├─────────────────────────────────────────────┤{ANSI_RESET}"
726            ));
727            lines.push(format!(
728                "{border}│{ANSI_RESET} {header_style}Headers{ANSI_RESET} {muted}({}){ANSI_RESET}",
729                info.headers.len()
730            ));
731
732            let max_name_len = info
733                .headers
734                .iter()
735                .map(|(n, _)| n.len())
736                .max()
737                .unwrap_or(0)
738                .min(20);
739
740            for (name, value) in &info.headers {
741                let truncated_name = if name.len() > max_name_len {
742                    format!("{}...", &name[..max_name_len - 3])
743                } else {
744                    name.clone()
745                };
746                lines.push(format!(
747                    "{border}│{ANSI_RESET}   {accent}{truncated_name:max_name_len$}{ANSI_RESET}: {value}",
748                ));
749            }
750        }
751
752        // Body section
753        if let Some(preview) = &info.body_preview {
754            lines.push(format!(
755                "{border}├─────────────────────────────────────────────┤{ANSI_RESET}"
756            ));
757            let size_info = match info.body_size {
758                Some(size) if info.body_truncated => {
759                    format!("{muted}({size} bytes, truncated){ANSI_RESET}")
760                }
761                Some(size) => format!("{muted}({size} bytes){ANSI_RESET}"),
762                None => String::new(),
763            };
764            lines.push(format!(
765                "{border}│{ANSI_RESET} {header_style}Body{ANSI_RESET} {size_info}"
766            ));
767            for line in preview.lines().take(5) {
768                let truncated = if line.len() > 40 {
769                    format!("{}...", &line[..37])
770                } else {
771                    line.to_string()
772                };
773                lines.push(format!(
774                    "{border}│{ANSI_RESET}   {ANSI_DIM}{truncated}{ANSI_RESET}"
775                ));
776            }
777        }
778
779        // Bottom border
780        lines.push(format!(
781            "{border}└─────────────────────────────────────────────┘{ANSI_RESET}"
782        ));
783
784        lines.join("\n")
785    }
786
787    fn status_color(&self, status: u16) -> crate::themes::Color {
788        match status {
789            100..=199 => self.theme.status_1xx,
790            200..=299 => self.theme.status_2xx,
791            300..=399 => self.theme.status_3xx,
792            400..=499 => self.theme.status_4xx,
793            500..=599 => self.theme.status_5xx,
794            _ => self.theme.muted,
795        }
796    }
797
798    #[allow(clippy::unused_self)]
799    fn status_icon(&self, status: u16) -> &'static str {
800        match status {
801            100..=199 => "ℹ",
802            200..=299 => "✓",
803            300..=399 => "→",
804            400..=499 => "⚠",
805            500..=599 => "✗",
806            _ => "?",
807        }
808    }
809}
810
811/// Format a duration in human-readable form.
812fn format_duration(duration: Duration) -> String {
813    let micros = duration.as_micros();
814    if micros < 1000 {
815        format!("{micros}µs")
816    } else if micros < 1_000_000 {
817        let whole = micros / 1000;
818        let frac = (micros % 1000) / 10;
819        format!("{whole}.{frac:02}ms")
820    } else {
821        let whole = micros / 1_000_000;
822        let frac = (micros % 1_000_000) / 10_000;
823        format!("{whole}.{frac:02}s")
824    }
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830
831    fn sample_request() -> RequestInfo {
832        RequestInfo::new("POST", "/api/users")
833            .query("version=2")
834            .http_version("HTTP/1.1")
835            .header("Content-Type", "application/json")
836            .header("Authorization", "Bearer token123")
837            .header("X-Request-ID", "req-abc-123")
838            .body_preview(r#"{"name": "Alice", "email": "alice@example.com"}"#, 48)
839            .content_type("application/json")
840            .parse_duration(Duration::from_micros(150))
841            .client_ip("192.168.1.100")
842            .request_id("req-abc-123")
843    }
844
845    fn sample_response() -> ResponseInfo {
846        ResponseInfo::new(201)
847            .reason("Created")
848            .header("Content-Type", "application/json")
849            .header("X-Request-ID", "req-abc-123")
850            .header("Location", "/api/users/42")
851            .body_preview(r#"{"id": 42, "name": "Alice"}"#, 27)
852            .content_type("application/json")
853            .response_time(Duration::from_millis(45))
854    }
855
856    #[test]
857    fn test_request_info_builder() {
858        let info = sample_request();
859        assert_eq!(info.method, "POST");
860        assert_eq!(info.path, "/api/users");
861        assert_eq!(info.query, Some("version=2".to_string()));
862        assert_eq!(info.headers.len(), 3);
863        assert!(info.body_preview.is_some());
864    }
865
866    #[test]
867    fn test_response_info_builder() {
868        let info = sample_response();
869        assert_eq!(info.status, 201);
870        assert_eq!(info.reason, Some("Created".to_string()));
871        assert_eq!(info.headers.len(), 3);
872    }
873
874    #[test]
875    fn test_request_inspector_plain() {
876        let inspector = RequestInspector::new(OutputMode::Plain);
877        let output = inspector.inspect(&sample_request());
878
879        assert!(output.contains("HTTP Request"));
880        assert!(output.contains("POST"));
881        assert!(output.contains("/api/users?version=2"));
882        assert!(output.contains("Content-Type: application/json"));
883        assert!(output.contains("Authorization: Bearer"));
884        assert!(!output.contains("\x1b["));
885    }
886
887    #[test]
888    fn test_request_inspector_rich_has_ansi() {
889        let inspector = RequestInspector::new(OutputMode::Rich);
890        let output = inspector.inspect(&sample_request());
891
892        assert!(output.contains("\x1b["));
893        assert!(output.contains("POST"));
894    }
895
896    #[test]
897    fn test_response_inspector_plain() {
898        let inspector = ResponseInspector::new(OutputMode::Plain);
899        let output = inspector.inspect(&sample_response());
900
901        assert!(output.contains("HTTP Response"));
902        assert!(output.contains("201"));
903        assert!(output.contains("Created"));
904        assert!(output.contains("Content-Type: application/json"));
905        assert!(!output.contains("\x1b["));
906    }
907
908    #[test]
909    fn test_response_inspector_rich_has_ansi() {
910        let inspector = ResponseInspector::new(OutputMode::Rich);
911        let output = inspector.inspect(&sample_response());
912
913        assert!(output.contains("\x1b["));
914        assert!(output.contains("201"));
915    }
916
917    #[test]
918    fn test_response_default_reason() {
919        let info = ResponseInfo::new(404);
920        assert_eq!(info.default_reason(), "Not Found");
921
922        let info = ResponseInfo::new(500);
923        assert_eq!(info.default_reason(), "Internal Server Error");
924    }
925
926    #[test]
927    fn test_format_duration() {
928        assert_eq!(format_duration(Duration::from_micros(500)), "500µs");
929        assert_eq!(format_duration(Duration::from_micros(1500)), "1.50ms");
930        assert_eq!(format_duration(Duration::from_secs(2)), "2.00s");
931    }
932}