fastapi_output/components/
routes.rs1use 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#[derive(Debug, Clone)]
15pub struct RouteEntry {
16 pub method: String,
18 pub path: String,
20 pub handler: Option<String>,
22 pub tags: Vec<String>,
24 pub deprecated: bool,
26}
27
28impl RouteEntry {
29 #[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 #[must_use]
43 pub fn handler(mut self, handler: impl Into<String>) -> Self {
44 self.handler = Some(handler.into());
45 self
46 }
47
48 #[must_use]
50 pub fn tag(mut self, tag: impl Into<String>) -> Self {
51 self.tags.push(tag.into());
52 self
53 }
54
55 #[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 #[must_use]
64 pub fn deprecated(mut self, deprecated: bool) -> Self {
65 self.deprecated = deprecated;
66 self
67 }
68}
69
70#[derive(Debug, Clone)]
72pub struct RouteTableConfig {
73 pub show_handlers: bool,
75 pub show_tags: bool,
77 pub show_deprecated: bool,
79 pub max_width: usize,
81 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#[derive(Debug, Clone)]
99pub struct RouteDisplay {
100 mode: OutputMode,
101 theme: FastApiTheme,
102 config: RouteTableConfig,
103}
104
105impl RouteDisplay {
106 #[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 #[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 #[must_use]
128 pub fn theme(mut self, theme: FastApiTheme) -> Self {
129 self.theme = theme;
130 self
131 }
132
133 #[must_use]
135 pub fn render(&self, routes: &[RouteEntry]) -> String {
136 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 if let Some(title) = &self.config.title {
169 lines.push(title.clone());
170 lines.push("-".repeat(title.len()));
171 }
172
173 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 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 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 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 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 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 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 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 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 let table_width = method_width + path_width + 10;
290 lines.push(format!("{border}┌{}┐{ANSI_RESET}", "─".repeat(table_width)));
291
292 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 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 for route in routes {
317 let method_bg = self.method_color(&route.method).to_ansi_bg();
318
319 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 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 lines.push(format!("{border}└{}┘{ANSI_RESET}", "─".repeat(table_width)));
350
351 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)")); 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}