Skip to main content

fastapi_output/components/
routes.rs

1//! Route table display component.
2//!
3//! Displays registered routes in a formatted table with method coloring
4//! and auto-width calculation.
5
6use crate::mode::OutputMode;
7use crate::themes::FastApiTheme;
8use std::fmt::Write;
9
10const ANSI_RESET: &str = "\x1b[0m";
11const ANSI_BOLD: &str = "\x1b[1m";
12
13/// A single route entry for display.
14#[derive(Debug, Clone)]
15pub struct RouteEntry {
16    /// HTTP method.
17    pub method: String,
18    /// Route path pattern.
19    pub path: String,
20    /// Handler name or function.
21    pub handler: Option<String>,
22    /// Tags/groups for the route.
23    pub tags: Vec<String>,
24    /// Whether the route is deprecated.
25    pub deprecated: bool,
26}
27
28impl RouteEntry {
29    /// Create a new route entry.
30    #[must_use]
31    pub fn new(method: impl Into<String>, path: impl Into<String>) -> Self {
32        Self {
33            method: method.into(),
34            path: path.into(),
35            handler: None,
36            tags: Vec::new(),
37            deprecated: false,
38        }
39    }
40
41    /// Set the handler name.
42    #[must_use]
43    pub fn handler(mut self, handler: impl Into<String>) -> Self {
44        self.handler = Some(handler.into());
45        self
46    }
47
48    /// Add a tag.
49    #[must_use]
50    pub fn tag(mut self, tag: impl Into<String>) -> Self {
51        self.tags.push(tag.into());
52        self
53    }
54
55    /// Add multiple tags.
56    #[must_use]
57    pub fn tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
58        self.tags.extend(tags.into_iter().map(Into::into));
59        self
60    }
61
62    /// Mark as deprecated.
63    #[must_use]
64    pub fn deprecated(mut self, deprecated: bool) -> Self {
65        self.deprecated = deprecated;
66        self
67    }
68}
69
70/// Configuration for route table display.
71#[derive(Debug, Clone)]
72pub struct RouteTableConfig {
73    /// Whether to show handler names.
74    pub show_handlers: bool,
75    /// Whether to show tags.
76    pub show_tags: bool,
77    /// Whether to show deprecated routes.
78    pub show_deprecated: bool,
79    /// Maximum terminal width (0 = auto-detect or unlimited).
80    pub max_width: usize,
81    /// Title for the table.
82    pub title: Option<String>,
83}
84
85impl Default for RouteTableConfig {
86    fn default() -> Self {
87        Self {
88            show_handlers: true,
89            show_tags: true,
90            show_deprecated: true,
91            max_width: 0,
92            title: Some("Registered Routes".to_string()),
93        }
94    }
95}
96
97/// Route table display.
98#[derive(Debug, Clone)]
99pub struct RouteDisplay {
100    mode: OutputMode,
101    theme: FastApiTheme,
102    config: RouteTableConfig,
103}
104
105impl RouteDisplay {
106    /// Create a new route display.
107    #[must_use]
108    pub fn new(mode: OutputMode) -> Self {
109        Self {
110            mode,
111            theme: FastApiTheme::default(),
112            config: RouteTableConfig::default(),
113        }
114    }
115
116    /// Create with custom configuration.
117    #[must_use]
118    pub fn with_config(mode: OutputMode, config: RouteTableConfig) -> Self {
119        Self {
120            mode,
121            theme: FastApiTheme::default(),
122            config,
123        }
124    }
125
126    /// Set the theme.
127    #[must_use]
128    pub fn theme(mut self, theme: FastApiTheme) -> Self {
129        self.theme = theme;
130        self
131    }
132
133    /// Render the route table.
134    #[must_use]
135    pub fn render(&self, routes: &[RouteEntry]) -> String {
136        // Filter routes if needed
137        let routes: Vec<_> = if self.config.show_deprecated {
138            routes.to_vec()
139        } else {
140            routes.iter().filter(|r| !r.deprecated).cloned().collect()
141        };
142
143        if routes.is_empty() {
144            return self.render_empty();
145        }
146
147        match self.mode {
148            OutputMode::Plain => self.render_plain(&routes),
149            OutputMode::Minimal => self.render_minimal(&routes),
150            OutputMode::Rich => self.render_rich(&routes),
151        }
152    }
153
154    fn render_empty(&self) -> String {
155        match self.mode {
156            OutputMode::Plain => "No routes registered.".to_string(),
157            OutputMode::Minimal | OutputMode::Rich => {
158                let muted = self.theme.muted.to_ansi_fg();
159                format!("{muted}No routes registered.{ANSI_RESET}")
160            }
161        }
162    }
163
164    fn render_plain(&self, routes: &[RouteEntry]) -> String {
165        let mut lines = Vec::new();
166
167        // Title
168        if let Some(title) = &self.config.title {
169            lines.push(title.clone());
170            lines.push("-".repeat(title.len()));
171        }
172
173        // Calculate column widths
174        let method_width = routes.iter().map(|r| r.method.len()).max().unwrap_or(6);
175        let path_width = routes.iter().map(|r| r.path.len()).max().unwrap_or(10);
176
177        // Header
178        let mut header = format!("{:width$}  Path", "Method", width = method_width);
179        if self.config.show_handlers {
180            header.push_str("  Handler");
181        }
182        if self.config.show_tags {
183            header.push_str("  Tags");
184        }
185        lines.push(header);
186
187        // Routes
188        for route in routes {
189            let mut line = format!(
190                "{:width$}  {}",
191                route.method,
192                route.path,
193                width = method_width
194            );
195
196            if self.config.show_handlers {
197                if let Some(handler) = &route.handler {
198                    // Pad path column
199                    let padding = path_width.saturating_sub(route.path.len());
200                    line.push_str(&" ".repeat(padding));
201                    line.push_str("  ");
202                    line.push_str(handler);
203                }
204            }
205
206            if self.config.show_tags && !route.tags.is_empty() {
207                line.push_str("  [");
208                line.push_str(&route.tags.join(", "));
209                line.push(']');
210            }
211
212            if route.deprecated {
213                line.push_str(" (deprecated)");
214            }
215
216            lines.push(line);
217        }
218
219        // Summary
220        lines.push(String::new());
221        lines.push(format!("Total: {} route(s)", routes.len()));
222
223        lines.join("\n")
224    }
225
226    fn render_minimal(&self, routes: &[RouteEntry]) -> String {
227        let mut lines = Vec::new();
228        let muted = self.theme.muted.to_ansi_fg();
229        let accent = self.theme.accent.to_ansi_fg();
230
231        // Title
232        if let Some(title) = &self.config.title {
233            lines.push(format!("{accent}{title}{ANSI_RESET}"));
234            lines.push(format!("{muted}{}{ANSI_RESET}", "-".repeat(title.len())));
235        }
236
237        // Routes
238        for route in routes {
239            let method_color = self.method_color(&route.method).to_ansi_fg();
240
241            let mut line = format!(
242                "{method_color}{:7}{ANSI_RESET} {}",
243                route.method, route.path
244            );
245
246            if self.config.show_tags && !route.tags.is_empty() {
247                let _ = write!(line, " {muted}[{}]{ANSI_RESET}", route.tags.join(", "));
248            }
249
250            if route.deprecated {
251                let warning = self.theme.warning.to_ansi_fg();
252                let _ = write!(line, " {warning}(deprecated){ANSI_RESET}");
253            }
254
255            lines.push(line);
256        }
257
258        // Summary
259        lines.push(String::new());
260        lines.push(format!(
261            "{muted}Total: {} route(s){ANSI_RESET}",
262            routes.len()
263        ));
264
265        lines.join("\n")
266    }
267
268    fn render_rich(&self, routes: &[RouteEntry]) -> String {
269        let mut lines = Vec::new();
270        let muted = self.theme.muted.to_ansi_fg();
271        let border = self.theme.border.to_ansi_fg();
272        let header_color = self.theme.header.to_ansi_fg();
273
274        // Calculate widths
275        let method_width = routes
276            .iter()
277            .map(|r| r.method.len())
278            .max()
279            .unwrap_or(6)
280            .max(7);
281        let path_width = routes
282            .iter()
283            .map(|r| r.path.len())
284            .max()
285            .unwrap_or(10)
286            .max(20);
287
288        // Top border
289        let table_width = method_width + path_width + 10;
290        lines.push(format!("{border}┌{}┐{ANSI_RESET}", "─".repeat(table_width)));
291
292        // Title
293        if let Some(title) = &self.config.title {
294            let title_pad = (table_width - title.len()) / 2;
295            lines.push(format!(
296                "{border}│{ANSI_RESET}{}{header_color}{ANSI_BOLD}{}{ANSI_RESET}{}{border}│{ANSI_RESET}",
297                " ".repeat(title_pad),
298                title,
299                " ".repeat(table_width - title_pad - title.len())
300            ));
301            lines.push(format!("{border}├{}┤{ANSI_RESET}", "─".repeat(table_width)));
302        }
303
304        // Header row
305        lines.push(format!(
306            "{border}│{ANSI_RESET} {header_color}{:width$}{ANSI_RESET}  {header_color}{:pwidth$}{ANSI_RESET} {border}│{ANSI_RESET}",
307            "Method",
308            "Path",
309            width = method_width,
310            pwidth = path_width + 4
311        ));
312
313        lines.push(format!("{border}├{}┤{ANSI_RESET}", "─".repeat(table_width)));
314
315        // Routes
316        for route in routes {
317            let method_bg = self.method_color(&route.method).to_ansi_bg();
318
319            // Format path with tags
320            let mut path_display = route.path.clone();
321            if self.config.show_tags && !route.tags.is_empty() {
322                use std::fmt::Write;
323                let _ = write!(path_display, " [{}]", route.tags.join(", "));
324            }
325
326            // Truncate if too long
327            if path_display.len() > path_width + 4 {
328                path_display = format!("{}...", &path_display[..=path_width]);
329            }
330
331            let deprecated_marker = if route.deprecated {
332                let warning = self.theme.warning.to_ansi_fg();
333                format!(" {warning}⚠{ANSI_RESET}")
334            } else {
335                String::new()
336            };
337
338            lines.push(format!(
339                "{border}│{ANSI_RESET} {method_bg}{ANSI_BOLD} {:width$} {ANSI_RESET}  {}{}{} {border}│{ANSI_RESET}",
340                route.method,
341                path_display,
342                deprecated_marker,
343                " ".repeat((path_width + 4).saturating_sub(path_display.len() + deprecated_marker.len() / 10)),
344                width = method_width
345            ));
346        }
347
348        // Bottom border
349        lines.push(format!("{border}└{}┘{ANSI_RESET}", "─".repeat(table_width)));
350
351        // Summary
352        let success = self.theme.success.to_ansi_fg();
353        lines.push(format!(
354            "{success}✓{ANSI_RESET} {muted}Total: {} route(s) registered{ANSI_RESET}",
355            routes.len()
356        ));
357
358        lines.join("\n")
359    }
360
361    fn method_color(&self, method: &str) -> crate::themes::Color {
362        match method.to_uppercase().as_str() {
363            "GET" => self.theme.http_get,
364            "POST" => self.theme.http_post,
365            "PUT" => self.theme.http_put,
366            "DELETE" => self.theme.http_delete,
367            "PATCH" => self.theme.http_patch,
368            "OPTIONS" => self.theme.http_options,
369            "HEAD" => self.theme.http_head,
370            _ => self.theme.muted,
371        }
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    fn sample_routes() -> Vec<RouteEntry> {
380        vec![
381            RouteEntry::new("GET", "/api/users")
382                .handler("list_users")
383                .tag("users"),
384            RouteEntry::new("POST", "/api/users")
385                .handler("create_user")
386                .tag("users"),
387            RouteEntry::new("GET", "/api/users/{id}")
388                .handler("get_user")
389                .tag("users"),
390            RouteEntry::new("DELETE", "/api/users/{id}")
391                .handler("delete_user")
392                .tag("users")
393                .deprecated(true),
394        ]
395    }
396
397    #[test]
398    fn test_route_entry_builder() {
399        let route = RouteEntry::new("POST", "/api/items")
400            .handler("create_item")
401            .tags(["items", "v2"])
402            .deprecated(false);
403
404        assert_eq!(route.method, "POST");
405        assert_eq!(route.path, "/api/items");
406        assert_eq!(route.handler, Some("create_item".to_string()));
407        assert_eq!(route.tags, vec!["items", "v2"]);
408        assert!(!route.deprecated);
409    }
410
411    #[test]
412    fn test_route_display_plain() {
413        let display = RouteDisplay::new(OutputMode::Plain);
414        let routes = sample_routes();
415
416        let output = display.render(&routes);
417
418        assert!(output.contains("Registered Routes"));
419        assert!(output.contains("GET"));
420        assert!(output.contains("POST"));
421        assert!(output.contains("/api/users"));
422        assert!(output.contains("list_users"));
423        assert!(output.contains("4 route(s)"));
424        assert!(!output.contains("\x1b["));
425    }
426
427    #[test]
428    fn test_route_display_empty() {
429        let display = RouteDisplay::new(OutputMode::Plain);
430
431        let output = display.render(&[]);
432
433        assert!(output.contains("No routes registered"));
434    }
435
436    #[test]
437    fn test_route_display_rich_has_ansi() {
438        let display = RouteDisplay::new(OutputMode::Rich);
439        let routes = sample_routes();
440
441        let output = display.render(&routes);
442
443        assert!(output.contains("\x1b["));
444        assert!(output.contains("GET"));
445        assert!(output.contains("/api/users"));
446    }
447
448    #[test]
449    fn test_route_display_hide_deprecated() {
450        let config = RouteTableConfig {
451            show_deprecated: false,
452            ..Default::default()
453        };
454        let display = RouteDisplay::with_config(OutputMode::Plain, config);
455        let routes = sample_routes();
456
457        let output = display.render(&routes);
458
459        assert!(output.contains("3 route(s)")); // One deprecated route hidden
460        assert!(!output.contains("deprecated"));
461    }
462
463    #[test]
464    fn test_route_display_no_handlers() {
465        let config = RouteTableConfig {
466            show_handlers: false,
467            ..Default::default()
468        };
469        let display = RouteDisplay::with_config(OutputMode::Plain, config);
470        let routes = sample_routes();
471
472        let output = display.render(&routes);
473
474        assert!(!output.contains("list_users"));
475    }
476
477    #[test]
478    fn test_route_display_no_tags() {
479        let config = RouteTableConfig {
480            show_tags: false,
481            ..Default::default()
482        };
483        let display = RouteDisplay::with_config(OutputMode::Plain, config);
484        let routes = sample_routes();
485
486        let output = display.render(&routes);
487
488        assert!(!output.contains("[users]"));
489    }
490
491    #[test]
492    fn test_route_display_custom_title() {
493        let config = RouteTableConfig {
494            title: Some("API Endpoints".to_string()),
495            ..Default::default()
496        };
497        let display = RouteDisplay::with_config(OutputMode::Plain, config);
498        let routes = sample_routes();
499
500        let output = display.render(&routes);
501
502        assert!(output.contains("API Endpoints"));
503    }
504}