Skip to main content

fastapi_output/components/
banner.rs

1//! Startup banner component.
2//!
3//! Displays the server startup information with ASCII art logo,
4//! version info, server URLs, and documentation links.
5
6use crate::mode::OutputMode;
7use crate::themes::FastApiTheme;
8
9const ANSI_RESET: &str = "\x1b[0m";
10const ANSI_BOLD: &str = "\x1b[1m";
11
12/// ASCII art logo for fastapi_rust.
13const LOGO_ASCII: &str = r"
14  ___         _      _   ___ ___   ___         _
15 | __| _ _ __| |_   /_\ | _ \_ _| | _ \_  _ __| |_
16 | _| / _` (_-<  _| / _ \|  _/| |  |   / || (_-<  _|
17 |_|  \__,_/__/\__|/_/ \_\_| |___| |_|_\_,_/__/\__|
18";
19
20/// Simple text logo for plain mode.
21const LOGO_PLAIN: &str = "FastAPI Rust";
22
23/// Server information for the banner.
24#[derive(Debug, Clone)]
25pub struct ServerInfo {
26    /// Server version string.
27    pub version: String,
28    /// Host address.
29    pub host: String,
30    /// Port number.
31    pub port: u16,
32    /// Whether HTTPS is enabled.
33    pub https: bool,
34    /// OpenAPI docs path (if enabled).
35    pub docs_path: Option<String>,
36    /// ReDoc path (if enabled).
37    pub redoc_path: Option<String>,
38    /// OpenAPI JSON path.
39    pub openapi_path: Option<String>,
40}
41
42impl ServerInfo {
43    /// Create a new server info with minimal configuration.
44    #[must_use]
45    pub fn new(version: impl Into<String>, host: impl Into<String>, port: u16) -> Self {
46        Self {
47            version: version.into(),
48            host: host.into(),
49            port,
50            https: false,
51            docs_path: None,
52            redoc_path: None,
53            openapi_path: None,
54        }
55    }
56
57    /// Set whether HTTPS is enabled.
58    #[must_use]
59    pub fn https(mut self, enabled: bool) -> Self {
60        self.https = enabled;
61        self
62    }
63
64    /// Set the OpenAPI docs path.
65    #[must_use]
66    pub fn docs_path(mut self, path: impl Into<String>) -> Self {
67        self.docs_path = Some(path.into());
68        self
69    }
70
71    /// Set the ReDoc path.
72    #[must_use]
73    pub fn redoc_path(mut self, path: impl Into<String>) -> Self {
74        self.redoc_path = Some(path.into());
75        self
76    }
77
78    /// Set the OpenAPI JSON path.
79    #[must_use]
80    pub fn openapi_path(mut self, path: impl Into<String>) -> Self {
81        self.openapi_path = Some(path.into());
82        self
83    }
84
85    /// Get the base URL.
86    #[must_use]
87    pub fn base_url(&self) -> String {
88        let scheme = if self.https { "https" } else { "http" };
89        format!("{}://{}:{}", scheme, self.host, self.port)
90    }
91}
92
93impl Default for ServerInfo {
94    fn default() -> Self {
95        Self::new("0.1.0", "127.0.0.1", 8000)
96    }
97}
98
99/// Configuration for banner display.
100#[derive(Debug, Clone)]
101pub struct BannerConfig {
102    /// Whether to show the ASCII art logo.
103    pub show_logo: bool,
104    /// Whether to show documentation links.
105    pub show_docs: bool,
106    /// Whether to show a border around the banner.
107    pub show_border: bool,
108    /// Custom tagline (if any).
109    pub tagline: Option<String>,
110}
111
112impl Default for BannerConfig {
113    fn default() -> Self {
114        Self {
115            show_logo: true,
116            show_docs: true,
117            show_border: true,
118            tagline: Some("High performance, easy to learn, fast to code".to_string()),
119        }
120    }
121}
122
123/// Startup banner display.
124#[derive(Debug, Clone)]
125pub struct Banner {
126    mode: OutputMode,
127    theme: FastApiTheme,
128    config: BannerConfig,
129}
130
131impl Banner {
132    /// Create a new banner with the specified mode.
133    #[must_use]
134    pub fn new(mode: OutputMode) -> Self {
135        Self {
136            mode,
137            theme: FastApiTheme::default(),
138            config: BannerConfig::default(),
139        }
140    }
141
142    /// Create a banner with custom configuration.
143    #[must_use]
144    pub fn with_config(mode: OutputMode, config: BannerConfig) -> Self {
145        Self {
146            mode,
147            theme: FastApiTheme::default(),
148            config,
149        }
150    }
151
152    /// Set the theme.
153    #[must_use]
154    pub fn theme(mut self, theme: FastApiTheme) -> Self {
155        self.theme = theme;
156        self
157    }
158
159    /// Render the banner to a string.
160    #[must_use]
161    pub fn render(&self, info: &ServerInfo) -> String {
162        match self.mode {
163            OutputMode::Plain => self.render_plain(info),
164            OutputMode::Minimal => self.render_minimal(info),
165            OutputMode::Rich => self.render_rich(info),
166        }
167    }
168
169    fn render_plain(&self, info: &ServerInfo) -> String {
170        let mut lines = Vec::new();
171
172        // Header
173        lines.push(format!("{} v{}", LOGO_PLAIN, info.version));
174
175        if let Some(tagline) = &self.config.tagline {
176            lines.push(tagline.clone());
177        }
178
179        lines.push(String::new());
180
181        // Server info
182        lines.push(format!("Server: {}", info.base_url()));
183
184        // Documentation links
185        if self.config.show_docs {
186            if let Some(docs) = &info.docs_path {
187                lines.push(format!("Docs:   {}{}", info.base_url(), docs));
188            }
189            if let Some(redoc) = &info.redoc_path {
190                lines.push(format!("ReDoc:  {}{}", info.base_url(), redoc));
191            }
192            if let Some(openapi) = &info.openapi_path {
193                lines.push(format!("OpenAPI: {}{}", info.base_url(), openapi));
194            }
195        }
196
197        lines.join("\n")
198    }
199
200    fn render_minimal(&self, info: &ServerInfo) -> String {
201        let mut lines = Vec::new();
202        let primary = self.theme.primary.to_ansi_fg();
203        let muted = self.theme.muted.to_ansi_fg();
204
205        // Header with color
206        lines.push(format!(
207            "{primary}{ANSI_BOLD}{} v{}{ANSI_RESET}",
208            LOGO_PLAIN, info.version
209        ));
210
211        if let Some(tagline) = &self.config.tagline {
212            lines.push(format!("{muted}{tagline}{ANSI_RESET}"));
213        }
214
215        lines.push(String::new());
216
217        // Server info
218        let accent = self.theme.accent.to_ansi_fg();
219        lines.push(format!("Server: {accent}{}{ANSI_RESET}", info.base_url()));
220
221        // Documentation links
222        if self.config.show_docs {
223            if let Some(docs) = &info.docs_path {
224                lines.push(format!(
225                    "Docs:   {accent}{}{}{ANSI_RESET}",
226                    info.base_url(),
227                    docs
228                ));
229            }
230        }
231
232        lines.join("\n")
233    }
234
235    fn render_rich(&self, info: &ServerInfo) -> String {
236        let mut lines = Vec::new();
237        let primary = self.theme.primary.to_ansi_fg();
238        let accent = self.theme.accent.to_ansi_fg();
239        let muted = self.theme.muted.to_ansi_fg();
240        let border = self.theme.border.to_ansi_fg();
241
242        // ASCII art logo
243        if self.config.show_logo {
244            for line in LOGO_ASCII.lines() {
245                if !line.is_empty() {
246                    lines.push(format!("{primary}{line}{ANSI_RESET}"));
247                }
248            }
249        }
250
251        // Version
252        lines.push(format!(
253            "{muted}                                    v{}{ANSI_RESET}",
254            info.version
255        ));
256
257        // Tagline
258        if let Some(tagline) = &self.config.tagline {
259            lines.push(format!("{muted}  {tagline}{ANSI_RESET}"));
260        }
261
262        lines.push(String::new());
263
264        // Border
265        if self.config.show_border {
266            lines.push(format!(
267                "{border}  ─────────────────────────────────────────────{ANSI_RESET}"
268            ));
269        }
270
271        // Server info
272        let success = self.theme.success.to_ansi_fg();
273        lines.push(format!(
274            "  {success}▶{ANSI_RESET} Server running at {accent}{}{ANSI_RESET}",
275            info.base_url()
276        ));
277
278        // Documentation links
279        if self.config.show_docs {
280            if let Some(docs) = &info.docs_path {
281                lines.push(format!(
282                    "  {muted}📖{ANSI_RESET} Interactive docs: {accent}{}{}{ANSI_RESET}",
283                    info.base_url(),
284                    docs
285                ));
286            }
287            if let Some(redoc) = &info.redoc_path {
288                lines.push(format!(
289                    "  {muted}📚{ANSI_RESET} ReDoc:            {accent}{}{}{ANSI_RESET}",
290                    info.base_url(),
291                    redoc
292                ));
293            }
294            if let Some(openapi) = &info.openapi_path {
295                lines.push(format!(
296                    "  {muted}📋{ANSI_RESET} OpenAPI JSON:     {accent}{}{}{ANSI_RESET}",
297                    info.base_url(),
298                    openapi
299                ));
300            }
301        }
302
303        // Footer border
304        if self.config.show_border {
305            lines.push(format!(
306                "{border}  ─────────────────────────────────────────────{ANSI_RESET}"
307            ));
308        }
309
310        lines.join("\n")
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn test_server_info_new() {
320        let info = ServerInfo::new("1.0.0", "localhost", 8080);
321        assert_eq!(info.version, "1.0.0");
322        assert_eq!(info.host, "localhost");
323        assert_eq!(info.port, 8080);
324        assert!(!info.https);
325    }
326
327    #[test]
328    fn test_server_info_builder() {
329        let info = ServerInfo::new("1.0.0", "0.0.0.0", 443)
330            .https(true)
331            .docs_path("/docs")
332            .redoc_path("/redoc")
333            .openapi_path("/openapi.json");
334
335        assert!(info.https);
336        assert_eq!(info.docs_path, Some("/docs".to_string()));
337        assert_eq!(info.redoc_path, Some("/redoc".to_string()));
338        assert_eq!(info.openapi_path, Some("/openapi.json".to_string()));
339    }
340
341    #[test]
342    fn test_server_info_base_url() {
343        let http = ServerInfo::new("1.0.0", "localhost", 8000);
344        assert_eq!(http.base_url(), "http://localhost:8000");
345
346        let https = ServerInfo::new("1.0.0", "example.com", 443).https(true);
347        assert_eq!(https.base_url(), "https://example.com:443");
348    }
349
350    #[test]
351    fn test_banner_plain_contains_essentials() {
352        let banner = Banner::new(OutputMode::Plain);
353        let info = ServerInfo::new("0.1.0", "127.0.0.1", 8000).docs_path("/docs");
354
355        let output = banner.render(&info);
356
357        assert!(output.contains("FastAPI Rust"));
358        assert!(output.contains("v0.1.0"));
359        assert!(output.contains("http://127.0.0.1:8000"));
360        assert!(output.contains("/docs"));
361    }
362
363    #[test]
364    fn test_banner_plain_no_ansi() {
365        let banner = Banner::new(OutputMode::Plain);
366        let info = ServerInfo::default();
367
368        let output = banner.render(&info);
369
370        assert!(!output.contains("\x1b["));
371    }
372
373    #[test]
374    fn test_banner_rich_has_ansi() {
375        let banner = Banner::new(OutputMode::Rich);
376        let info = ServerInfo::default();
377
378        let output = banner.render(&info);
379
380        assert!(output.contains("\x1b["));
381    }
382
383    #[test]
384    fn test_banner_config_no_logo() {
385        let config = BannerConfig {
386            show_logo: false,
387            ..Default::default()
388        };
389        let banner = Banner::with_config(OutputMode::Rich, config);
390        let info = ServerInfo::default();
391
392        let output = banner.render(&info);
393
394        // Should not contain the distinctive ASCII art characters
395        assert!(!output.contains("___"));
396    }
397
398    #[test]
399    fn test_banner_config_no_docs() {
400        let config = BannerConfig {
401            show_docs: false,
402            ..Default::default()
403        };
404        let banner = Banner::with_config(OutputMode::Plain, config);
405        let info = ServerInfo::new("1.0.0", "localhost", 8000).docs_path("/docs");
406
407        let output = banner.render(&info);
408
409        // Should not contain docs link
410        assert!(!output.contains("Docs:"));
411    }
412}