1use crate::mode::OutputMode;
20use crate::themes::FastApiTheme;
21use std::time::Duration;
22
23const ANSI_RESET: &str = "\x1b[0m";
24const ANSI_BOLD: &str = "\x1b[1m";
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum MatchResult {
29 Matched,
31 PathMismatch,
33 MethodMismatch,
35 ParamTypeMismatch {
37 param_name: String,
39 expected_type: String,
41 actual_value: String,
43 },
44 GuardFailed {
46 guard_name: String,
48 },
49}
50
51impl MatchResult {
52 #[must_use]
54 pub fn description(&self) -> String {
55 match self {
56 Self::Matched => "Matched".to_string(),
57 Self::PathMismatch => "Path did not match".to_string(),
58 Self::MethodMismatch => "Method not allowed".to_string(),
59 Self::ParamTypeMismatch {
60 param_name,
61 expected_type,
62 actual_value,
63 } => {
64 format!("Parameter '{param_name}' expected {expected_type}, got '{actual_value}'")
65 }
66 Self::GuardFailed { guard_name } => format!("Guard '{guard_name}' failed"),
67 }
68 }
69
70 #[must_use]
72 pub fn is_match(&self) -> bool {
73 matches!(self, Self::Matched)
74 }
75}
76
77#[derive(Debug, Clone)]
79pub struct CandidateRoute {
80 pub pattern: String,
82 pub methods: Vec<String>,
84 pub handler: Option<String>,
86 pub result: MatchResult,
88 pub partial_match: bool,
90}
91
92impl CandidateRoute {
93 #[must_use]
95 pub fn new(pattern: impl Into<String>, result: MatchResult) -> Self {
96 Self {
97 pattern: pattern.into(),
98 methods: Vec::new(),
99 handler: None,
100 result,
101 partial_match: false,
102 }
103 }
104
105 #[must_use]
107 pub fn methods(mut self, methods: impl IntoIterator<Item = impl Into<String>>) -> Self {
108 self.methods = methods.into_iter().map(Into::into).collect();
109 self
110 }
111
112 #[must_use]
114 pub fn handler(mut self, handler: impl Into<String>) -> Self {
115 self.handler = Some(handler.into());
116 self
117 }
118
119 #[must_use]
121 pub fn partial_match(mut self, partial: bool) -> Self {
122 self.partial_match = partial;
123 self
124 }
125}
126
127#[derive(Debug, Clone)]
129pub struct ExtractedParams {
130 pub params: Vec<(String, String)>,
132}
133
134impl ExtractedParams {
135 #[must_use]
137 pub fn new() -> Self {
138 Self { params: Vec::new() }
139 }
140
141 #[must_use]
143 pub fn param(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
144 self.params.push((name.into(), value.into()));
145 self
146 }
147
148 #[must_use]
150 pub fn is_empty(&self) -> bool {
151 self.params.is_empty()
152 }
153}
154
155impl Default for ExtractedParams {
156 fn default() -> Self {
157 Self::new()
158 }
159}
160
161#[derive(Debug, Clone)]
163pub struct MiddlewareInfo {
164 pub name: String,
166 pub route_specific: bool,
168 pub order: usize,
170}
171
172impl MiddlewareInfo {
173 #[must_use]
175 pub fn new(name: impl Into<String>, order: usize) -> Self {
176 Self {
177 name: name.into(),
178 route_specific: false,
179 order,
180 }
181 }
182
183 #[must_use]
185 pub fn route_specific(mut self, specific: bool) -> Self {
186 self.route_specific = specific;
187 self
188 }
189}
190
191#[derive(Debug, Clone)]
193pub struct RoutingDebugInfo {
194 pub request_path: String,
196 pub request_method: String,
198 pub candidates: Vec<CandidateRoute>,
200 pub matched_route: Option<String>,
202 pub extracted_params: ExtractedParams,
204 pub middleware: Vec<MiddlewareInfo>,
206 pub routing_time: Option<Duration>,
208 pub has_partial_matches: bool,
210}
211
212impl RoutingDebugInfo {
213 #[must_use]
215 pub fn new(path: impl Into<String>, method: impl Into<String>) -> Self {
216 Self {
217 request_path: path.into(),
218 request_method: method.into(),
219 candidates: Vec::new(),
220 matched_route: None,
221 extracted_params: ExtractedParams::new(),
222 middleware: Vec::new(),
223 routing_time: None,
224 has_partial_matches: false,
225 }
226 }
227
228 #[must_use]
230 pub fn candidate(mut self, candidate: CandidateRoute) -> Self {
231 if candidate.partial_match {
232 self.has_partial_matches = true;
233 }
234 if candidate.result.is_match() {
235 self.matched_route = Some(candidate.pattern.clone());
236 }
237 self.candidates.push(candidate);
238 self
239 }
240
241 #[must_use]
243 pub fn params(mut self, params: ExtractedParams) -> Self {
244 self.extracted_params = params;
245 self
246 }
247
248 #[must_use]
250 pub fn middleware(mut self, mw: MiddlewareInfo) -> Self {
251 self.middleware.push(mw);
252 self
253 }
254
255 #[must_use]
257 pub fn routing_time(mut self, duration: Duration) -> Self {
258 self.routing_time = Some(duration);
259 self
260 }
261
262 #[must_use]
264 pub fn is_matched(&self) -> bool {
265 self.matched_route.is_some()
266 }
267}
268
269#[derive(Debug, Clone)]
271pub struct RoutingDebug {
272 mode: OutputMode,
273 theme: FastApiTheme,
274 pub show_all_candidates: bool,
276 pub show_middleware: bool,
278}
279
280impl RoutingDebug {
281 #[must_use]
283 pub fn new(mode: OutputMode) -> Self {
284 Self {
285 mode,
286 theme: FastApiTheme::default(),
287 show_all_candidates: true,
288 show_middleware: true,
289 }
290 }
291
292 #[must_use]
294 pub fn theme(mut self, theme: FastApiTheme) -> Self {
295 self.theme = theme;
296 self
297 }
298
299 #[must_use]
301 pub fn format(&self, info: &RoutingDebugInfo) -> String {
302 match self.mode {
303 OutputMode::Plain => self.format_plain(info),
304 OutputMode::Minimal => self.format_minimal(info),
305 OutputMode::Rich => self.format_rich(info),
306 }
307 }
308
309 fn format_plain(&self, info: &RoutingDebugInfo) -> String {
310 let mut lines = Vec::new();
311
312 lines.push("=== Routing Debug ===".to_string());
314 lines.push(format!(
315 "Request: {} {}",
316 info.request_method, info.request_path
317 ));
318
319 if let Some(duration) = info.routing_time {
320 lines.push(format!("Routing time: {}", format_duration(duration)));
321 }
322
323 lines.push(String::new());
325 if let Some(matched) = &info.matched_route {
326 lines.push(format!("Result: MATCHED -> {matched}"));
327 } else if info.has_partial_matches {
328 lines.push("Result: 405 Method Not Allowed".to_string());
329 } else {
330 lines.push("Result: 404 Not Found".to_string());
331 }
332
333 if self.show_all_candidates && !info.candidates.is_empty() {
335 lines.push(String::new());
336 lines.push("Candidates considered:".to_string());
337 for candidate in &info.candidates {
338 let status = if candidate.result.is_match() {
339 "[MATCH]"
340 } else if candidate.partial_match {
341 "[PARTIAL]"
342 } else {
343 "[SKIP]"
344 };
345 let methods = candidate.methods.join(", ");
346 lines.push(format!(" {status} {} [{methods}]", candidate.pattern));
347 if !candidate.result.is_match() {
348 lines.push(format!(
349 " Reason: {}",
350 candidate.result.description()
351 ));
352 }
353 }
354 }
355
356 if !info.extracted_params.is_empty() {
358 lines.push(String::new());
359 lines.push("Extracted parameters:".to_string());
360 for (name, value) in &info.extracted_params.params {
361 lines.push(format!(" {name}: {value}"));
362 }
363 }
364
365 if self.show_middleware && !info.middleware.is_empty() {
367 lines.push(String::new());
368 lines.push("Middleware stack:".to_string());
369 for mw in &info.middleware {
370 let scope = if mw.route_specific {
371 "(route)"
372 } else {
373 "(global)"
374 };
375 lines.push(format!(" {}. {} {scope}", mw.order, mw.name));
376 }
377 }
378
379 lines.push("=====================".to_string());
380 lines.join("\n")
381 }
382
383 fn format_minimal(&self, info: &RoutingDebugInfo) -> String {
384 let muted = self.theme.muted.to_ansi_fg();
385 let accent = self.theme.accent.to_ansi_fg();
386 let success = self.theme.success.to_ansi_fg();
387 let error = self.theme.error.to_ansi_fg();
388 let warning = self.theme.warning.to_ansi_fg();
389
390 let mut lines = Vec::new();
391
392 lines.push(format!("{muted}=== Routing Debug ==={ANSI_RESET}"));
394 let method_color = self.method_color(&info.request_method).to_ansi_fg();
395 lines.push(format!(
396 "{method_color}{}{ANSI_RESET} {accent}{}{ANSI_RESET}",
397 info.request_method, info.request_path
398 ));
399
400 if let Some(matched) = &info.matched_route {
402 lines.push(format!("{success}✓ Matched:{ANSI_RESET} {matched}"));
403 } else if info.has_partial_matches {
404 lines.push(format!("{warning}⚠ 405 Method Not Allowed{ANSI_RESET}"));
405 } else {
406 lines.push(format!("{error}✗ 404 Not Found{ANSI_RESET}"));
407 }
408
409 if let Some(duration) = info.routing_time {
411 lines.push(format!(
412 "{muted}Routed in {}{ANSI_RESET}",
413 format_duration(duration)
414 ));
415 }
416
417 if !info.extracted_params.is_empty() {
419 lines.push(format!("{muted}Parameters:{ANSI_RESET}"));
420 for (name, value) in &info.extracted_params.params {
421 lines.push(format!(" {accent}{name}{ANSI_RESET}: {value}"));
422 }
423 }
424
425 lines.push(format!("{muted}=================={ANSI_RESET}"));
426 lines.join("\n")
427 }
428
429 #[allow(clippy::too_many_lines)]
430 fn format_rich(&self, info: &RoutingDebugInfo) -> String {
431 let muted = self.theme.muted.to_ansi_fg();
432 let accent = self.theme.accent.to_ansi_fg();
433 let success = self.theme.success.to_ansi_fg();
434 let error = self.theme.error.to_ansi_fg();
435 let warning = self.theme.warning.to_ansi_fg();
436 let border = self.theme.border.to_ansi_fg();
437 let header_style = self.theme.header.to_ansi_fg();
438
439 let mut lines = Vec::new();
440
441 lines.push(format!(
443 "{border}┌─────────────────────────────────────────────┐{ANSI_RESET}"
444 ));
445 lines.push(format!(
446 "{border}│{ANSI_RESET} {header_style}{ANSI_BOLD}Routing Debug{ANSI_RESET} {border}│{ANSI_RESET}"
447 ));
448 lines.push(format!(
449 "{border}├─────────────────────────────────────────────┤{ANSI_RESET}"
450 ));
451
452 let method_bg = self.method_color(&info.request_method).to_ansi_bg();
454 lines.push(format!(
455 "{border}│{ANSI_RESET} {method_bg}{ANSI_BOLD} {} {ANSI_RESET} {accent}{}{ANSI_RESET}",
456 info.request_method, info.request_path
457 ));
458
459 lines.push(format!(
461 "{border}├─────────────────────────────────────────────┤{ANSI_RESET}"
462 ));
463 if let Some(matched) = &info.matched_route {
464 lines.push(format!(
465 "{border}│{ANSI_RESET} {success}✓ Matched{ANSI_RESET} → {matched}"
466 ));
467 } else if info.has_partial_matches {
468 lines.push(format!(
469 "{border}│{ANSI_RESET} {warning}⚠ 405 Method Not Allowed{ANSI_RESET}"
470 ));
471 let allowed: Vec<_> = info
473 .candidates
474 .iter()
475 .filter(|c| c.partial_match)
476 .flat_map(|c| c.methods.iter())
477 .collect();
478 if !allowed.is_empty() {
479 lines.push(format!(
480 "{border}│{ANSI_RESET} {muted}Allowed:{ANSI_RESET} {}",
481 allowed.into_iter().cloned().collect::<Vec<_>>().join(", ")
482 ));
483 }
484 } else {
485 lines.push(format!(
486 "{border}│{ANSI_RESET} {error}✗ 404 Not Found{ANSI_RESET}"
487 ));
488 }
489
490 if let Some(duration) = info.routing_time {
492 lines.push(format!(
493 "{border}│{ANSI_RESET} {muted}Routed in {}{ANSI_RESET}",
494 format_duration(duration)
495 ));
496 }
497
498 if self.show_all_candidates && !info.candidates.is_empty() {
500 lines.push(format!(
501 "{border}├─────────────────────────────────────────────┤{ANSI_RESET}"
502 ));
503 lines.push(format!(
504 "{border}│{ANSI_RESET} {header_style}Candidates{ANSI_RESET} {muted}({}){ANSI_RESET}",
505 info.candidates.len()
506 ));
507
508 for candidate in &info.candidates {
509 let (icon, color) = if candidate.result.is_match() {
510 ("✓", &success)
511 } else if candidate.partial_match {
512 ("◐", &warning)
513 } else {
514 ("○", &muted)
515 };
516 let methods = candidate.methods.join("|");
517 lines.push(format!(
518 "{border}│{ANSI_RESET} {color}{icon}{ANSI_RESET} {}{} {muted}[{methods}]{ANSI_RESET}",
519 candidate.pattern,
520 if let Some(h) = &candidate.handler {
521 format!(" {muted}→ {h}{ANSI_RESET}")
522 } else {
523 String::new()
524 }
525 ));
526 if !candidate.result.is_match()
527 && !matches!(candidate.result, MatchResult::PathMismatch)
528 {
529 lines.push(format!(
530 "{border}│{ANSI_RESET} {muted}{}{ANSI_RESET}",
531 candidate.result.description()
532 ));
533 }
534 }
535 }
536
537 if !info.extracted_params.is_empty() {
539 lines.push(format!(
540 "{border}├─────────────────────────────────────────────┤{ANSI_RESET}"
541 ));
542 lines.push(format!(
543 "{border}│{ANSI_RESET} {header_style}Extracted Parameters{ANSI_RESET}"
544 ));
545 for (name, value) in &info.extracted_params.params {
546 lines.push(format!(
547 "{border}│{ANSI_RESET} {accent}{name}{ANSI_RESET}: {value}"
548 ));
549 }
550 }
551
552 if self.show_middleware && !info.middleware.is_empty() {
554 lines.push(format!(
555 "{border}├─────────────────────────────────────────────┤{ANSI_RESET}"
556 ));
557 lines.push(format!(
558 "{border}│{ANSI_RESET} {header_style}Middleware Stack{ANSI_RESET}"
559 ));
560 for mw in &info.middleware {
561 let scope = if mw.route_specific {
562 format!("{accent}(route){ANSI_RESET}")
563 } else {
564 format!("{muted}(global){ANSI_RESET}")
565 };
566 lines.push(format!(
567 "{border}│{ANSI_RESET} {muted}{}→{ANSI_RESET} {} {scope}",
568 mw.order, mw.name
569 ));
570 }
571 }
572
573 lines.push(format!(
575 "{border}└─────────────────────────────────────────────┘{ANSI_RESET}"
576 ));
577
578 lines.join("\n")
579 }
580
581 fn method_color(&self, method: &str) -> crate::themes::Color {
582 match method.to_uppercase().as_str() {
583 "GET" => self.theme.http_get,
584 "POST" => self.theme.http_post,
585 "PUT" => self.theme.http_put,
586 "DELETE" => self.theme.http_delete,
587 "PATCH" => self.theme.http_patch,
588 "OPTIONS" => self.theme.http_options,
589 "HEAD" => self.theme.http_head,
590 _ => self.theme.muted,
591 }
592 }
593}
594
595fn format_duration(duration: Duration) -> String {
597 let micros = duration.as_micros();
598 if micros < 1000 {
599 format!("{micros}µs")
600 } else if micros < 1_000_000 {
601 let whole = micros / 1000;
602 let frac = (micros % 1000) / 10;
603 format!("{whole}.{frac:02}ms")
604 } else {
605 let whole = micros / 1_000_000;
606 let frac = (micros % 1_000_000) / 10_000;
607 format!("{whole}.{frac:02}s")
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614
615 fn sample_successful_routing() -> RoutingDebugInfo {
616 RoutingDebugInfo::new("/api/users/42", "GET")
617 .candidate(
618 CandidateRoute::new("/api/health", MatchResult::PathMismatch)
619 .methods(["GET"])
620 .handler("health_check"),
621 )
622 .candidate(
623 CandidateRoute::new("/api/users", MatchResult::PathMismatch)
624 .methods(["GET", "POST"])
625 .handler("list_users"),
626 )
627 .candidate(
628 CandidateRoute::new("/api/users/{id}", MatchResult::Matched)
629 .methods(["GET", "PUT", "DELETE"])
630 .handler("get_user"),
631 )
632 .params(ExtractedParams::new().param("id", "42"))
633 .middleware(MiddlewareInfo::new("RequestLogger", 1))
634 .middleware(MiddlewareInfo::new("Auth", 2))
635 .middleware(MiddlewareInfo::new("RateLimit", 3).route_specific(true))
636 .routing_time(Duration::from_micros(45))
637 }
638
639 fn sample_404_routing() -> RoutingDebugInfo {
640 RoutingDebugInfo::new("/api/nonexistent", "GET")
641 .candidate(
642 CandidateRoute::new("/api/users", MatchResult::PathMismatch)
643 .methods(["GET"])
644 .handler("list_users"),
645 )
646 .candidate(
647 CandidateRoute::new("/api/items", MatchResult::PathMismatch)
648 .methods(["GET"])
649 .handler("list_items"),
650 )
651 .routing_time(Duration::from_micros(12))
652 }
653
654 fn sample_405_routing() -> RoutingDebugInfo {
655 RoutingDebugInfo::new("/api/users", "DELETE")
656 .candidate(
657 CandidateRoute::new("/api/users", MatchResult::MethodMismatch)
658 .methods(["GET", "POST"])
659 .handler("list_users")
660 .partial_match(true),
661 )
662 .routing_time(Duration::from_micros(8))
663 }
664
665 #[test]
666 fn test_match_result_description() {
667 assert_eq!(MatchResult::Matched.description(), "Matched");
668 assert_eq!(
669 MatchResult::PathMismatch.description(),
670 "Path did not match"
671 );
672 assert_eq!(
673 MatchResult::ParamTypeMismatch {
674 param_name: "id".to_string(),
675 expected_type: "int".to_string(),
676 actual_value: "abc".to_string(),
677 }
678 .description(),
679 "Parameter 'id' expected int, got 'abc'"
680 );
681 }
682
683 #[test]
684 fn test_routing_debug_plain_success() {
685 let debug = RoutingDebug::new(OutputMode::Plain);
686 let output = debug.format(&sample_successful_routing());
687
688 assert!(output.contains("Routing Debug"));
689 assert!(output.contains("GET /api/users/42"));
690 assert!(output.contains("MATCHED"));
691 assert!(output.contains("/api/users/{id}"));
692 assert!(output.contains("id: 42"));
693 assert!(output.contains("RequestLogger"));
694 assert!(!output.contains("\x1b["));
695 }
696
697 #[test]
698 fn test_routing_debug_plain_404() {
699 let debug = RoutingDebug::new(OutputMode::Plain);
700 let output = debug.format(&sample_404_routing());
701
702 assert!(output.contains("404 Not Found"));
703 assert!(!output.contains("MATCHED"));
704 }
705
706 #[test]
707 fn test_routing_debug_plain_405() {
708 let debug = RoutingDebug::new(OutputMode::Plain);
709 let output = debug.format(&sample_405_routing());
710
711 assert!(output.contains("405 Method Not Allowed"));
712 assert!(output.contains("[PARTIAL]"));
713 }
714
715 #[test]
716 fn test_routing_debug_rich_has_ansi() {
717 let debug = RoutingDebug::new(OutputMode::Rich);
718 let output = debug.format(&sample_successful_routing());
719
720 assert!(output.contains("\x1b["));
721 assert!(output.contains("✓ Matched"));
722 }
723
724 #[test]
725 fn test_extracted_params_builder() {
726 let params = ExtractedParams::new()
727 .param("id", "42")
728 .param("name", "alice");
729
730 assert_eq!(params.params.len(), 2);
731 assert!(!params.is_empty());
732 }
733
734 #[test]
735 fn test_candidate_route_builder() {
736 let candidate = CandidateRoute::new("/api/users/{id}", MatchResult::Matched)
737 .methods(["GET", "PUT"])
738 .handler("get_user")
739 .partial_match(false);
740
741 assert_eq!(candidate.pattern, "/api/users/{id}");
742 assert_eq!(candidate.methods, vec!["GET", "PUT"]);
743 assert!(candidate.result.is_match());
744 }
745
746 #[test]
747 fn test_middleware_info_builder() {
748 let mw = MiddlewareInfo::new("Auth", 1).route_specific(true);
749
750 assert_eq!(mw.name, "Auth");
751 assert_eq!(mw.order, 1);
752 assert!(mw.route_specific);
753 }
754}