1use 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
28const DEFAULT_BODY_PREVIEW_LEN: usize = 512;
30
31#[derive(Debug, Clone)]
33pub struct RequestInfo {
34 pub method: String,
36 pub path: String,
38 pub query: Option<String>,
40 pub http_version: String,
42 pub headers: Vec<(String, String)>,
44 pub body_preview: Option<String>,
46 pub body_size: Option<usize>,
48 pub body_truncated: bool,
50 pub content_type: Option<String>,
52 pub parse_duration: Option<Duration>,
54 pub client_ip: Option<String>,
56 pub request_id: Option<String>,
58}
59
60impl RequestInfo {
61 #[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 #[must_use]
82 pub fn query(mut self, query: impl Into<String>) -> Self {
83 self.query = Some(query.into());
84 self
85 }
86
87 #[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 #[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 #[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 #[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 #[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 #[must_use]
133 pub fn parse_duration(mut self, duration: Duration) -> Self {
134 self.parse_duration = Some(duration);
135 self
136 }
137
138 #[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 #[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#[derive(Debug, Clone)]
155pub struct ResponseInfo {
156 pub status: u16,
158 pub reason: Option<String>,
160 pub headers: Vec<(String, String)>,
162 pub body_preview: Option<String>,
164 pub body_size: Option<usize>,
166 pub body_truncated: bool,
168 pub content_type: Option<String>,
170 pub response_time: Option<Duration>,
172}
173
174impl ResponseInfo {
175 #[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 #[must_use]
192 pub fn reason(mut self, reason: impl Into<String>) -> Self {
193 self.reason = Some(reason.into());
194 self
195 }
196
197 #[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 #[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 #[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 #[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 #[must_use]
236 pub fn response_time(mut self, duration: Duration) -> Self {
237 self.response_time = Some(duration);
238 self
239 }
240
241 #[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#[derive(Debug, Clone)]
279pub struct RequestInspector {
280 mode: OutputMode,
281 theme: FastApiTheme,
282 pub max_body_preview: usize,
284 pub show_all_headers: bool,
286 pub show_timing: bool,
288}
289
290impl RequestInspector {
291 #[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 #[must_use]
305 pub fn theme(mut self, theme: FastApiTheme) -> Self {
306 self.theme = theme;
307 self
308 }
309
310 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone)]
561pub struct ResponseInspector {
562 mode: OutputMode,
563 theme: FastApiTheme,
564 pub max_body_preview: usize,
566 pub show_all_headers: bool,
568 pub show_timing: bool,
570}
571
572impl ResponseInspector {
573 #[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 #[must_use]
587 pub fn theme(mut self, theme: FastApiTheme) -> Self {
588 self.theme = theme;
589 self
590 }
591
592 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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
811fn 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}