fastapi_output/components/
banner.rs1use crate::mode::OutputMode;
7use crate::themes::FastApiTheme;
8
9const ANSI_RESET: &str = "\x1b[0m";
10const ANSI_BOLD: &str = "\x1b[1m";
11
12const LOGO_ASCII: &str = r"
14 ___ _ _ ___ ___ ___ _
15 | __| _ _ __| |_ /_\ | _ \_ _| | _ \_ _ __| |_
16 | _| / _` (_-< _| / _ \| _/| | | / || (_-< _|
17 |_| \__,_/__/\__|/_/ \_\_| |___| |_|_\_,_/__/\__|
18";
19
20const LOGO_PLAIN: &str = "FastAPI Rust";
22
23#[derive(Debug, Clone)]
25pub struct ServerInfo {
26 pub version: String,
28 pub host: String,
30 pub port: u16,
32 pub https: bool,
34 pub docs_path: Option<String>,
36 pub redoc_path: Option<String>,
38 pub openapi_path: Option<String>,
40}
41
42impl ServerInfo {
43 #[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 #[must_use]
59 pub fn https(mut self, enabled: bool) -> Self {
60 self.https = enabled;
61 self
62 }
63
64 #[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 #[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 #[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 #[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#[derive(Debug, Clone)]
101pub struct BannerConfig {
102 pub show_logo: bool,
104 pub show_docs: bool,
106 pub show_border: bool,
108 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#[derive(Debug, Clone)]
125pub struct Banner {
126 mode: OutputMode,
127 theme: FastApiTheme,
128 config: BannerConfig,
129}
130
131impl Banner {
132 #[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 #[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 #[must_use]
154 pub fn theme(mut self, theme: FastApiTheme) -> Self {
155 self.theme = theme;
156 self
157 }
158
159 #[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 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 lines.push(format!("Server: {}", info.base_url()));
183
184 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 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 let accent = self.theme.accent.to_ansi_fg();
219 lines.push(format!("Server: {accent}{}{ANSI_RESET}", info.base_url()));
220
221 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 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 lines.push(format!(
253 "{muted} v{}{ANSI_RESET}",
254 info.version
255 ));
256
257 if let Some(tagline) = &self.config.tagline {
259 lines.push(format!("{muted} {tagline}{ANSI_RESET}"));
260 }
261
262 lines.push(String::new());
263
264 if self.config.show_border {
266 lines.push(format!(
267 "{border} ─────────────────────────────────────────────{ANSI_RESET}"
268 ));
269 }
270
271 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 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 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 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 assert!(!output.contains("Docs:"));
411 }
412}