fastapi_output/components/
help_display.rs1use crate::mode::OutputMode;
14use crate::themes::FastApiTheme;
15use std::fmt::Write;
16
17const ANSI_RESET: &str = "\x1b[0m";
18const ANSI_BOLD: &str = "\x1b[1m";
19
20#[derive(Debug, Clone)]
22pub struct ArgInfo {
23 pub short: Option<String>,
25 pub long: Option<String>,
27 pub value: Option<String>,
29 pub description: String,
31 pub default: Option<String>,
33 pub required: bool,
35 pub env_var: Option<String>,
37}
38
39impl ArgInfo {
40 #[must_use]
42 pub fn new(long: impl Into<String>, description: impl Into<String>) -> Self {
43 Self {
44 short: None,
45 long: Some(long.into()),
46 value: None,
47 description: description.into(),
48 default: None,
49 required: false,
50 env_var: None,
51 }
52 }
53
54 #[must_use]
56 pub fn positional(name: impl Into<String>, description: impl Into<String>) -> Self {
57 Self {
58 short: None,
59 long: None,
60 value: Some(name.into()),
61 description: description.into(),
62 default: None,
63 required: true,
64 env_var: None,
65 }
66 }
67
68 #[must_use]
70 pub fn short(mut self, short: impl Into<String>) -> Self {
71 self.short = Some(short.into());
72 self
73 }
74
75 #[must_use]
77 pub fn value(mut self, value: impl Into<String>) -> Self {
78 self.value = Some(value.into());
79 self
80 }
81
82 #[must_use]
84 pub fn default(mut self, default: impl Into<String>) -> Self {
85 self.default = Some(default.into());
86 self
87 }
88
89 #[must_use]
91 pub fn required(mut self, required: bool) -> Self {
92 self.required = required;
93 self
94 }
95
96 #[must_use]
98 pub fn env(mut self, var: impl Into<String>) -> Self {
99 self.env_var = Some(var.into());
100 self
101 }
102
103 #[must_use]
105 pub fn full_name(&self) -> String {
106 let mut parts = Vec::new();
107 if let Some(short) = &self.short {
108 parts.push(short.clone());
109 }
110 if let Some(long) = &self.long {
111 parts.push(long.clone());
112 }
113 if parts.is_empty() {
114 if let Some(value) = &self.value {
115 return value.clone();
116 }
117 }
118 parts.join(", ")
119 }
120}
121
122#[derive(Debug, Clone)]
124pub struct ArgGroup {
125 pub name: String,
127 pub description: Option<String>,
129 pub args: Vec<ArgInfo>,
131}
132
133impl ArgGroup {
134 #[must_use]
136 pub fn new(name: impl Into<String>) -> Self {
137 Self {
138 name: name.into(),
139 description: None,
140 args: Vec::new(),
141 }
142 }
143
144 #[must_use]
146 pub fn description(mut self, desc: impl Into<String>) -> Self {
147 self.description = Some(desc.into());
148 self
149 }
150
151 #[must_use]
153 pub fn arg(mut self, arg: ArgInfo) -> Self {
154 self.args.push(arg);
155 self
156 }
157}
158
159#[derive(Debug, Clone)]
161pub struct CommandInfo {
162 pub name: String,
164 pub description: String,
166 pub aliases: Vec<String>,
168}
169
170impl CommandInfo {
171 #[must_use]
173 pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
174 Self {
175 name: name.into(),
176 description: description.into(),
177 aliases: Vec::new(),
178 }
179 }
180
181 #[must_use]
183 pub fn alias(mut self, alias: impl Into<String>) -> Self {
184 self.aliases.push(alias.into());
185 self
186 }
187}
188
189#[derive(Debug, Clone)]
191pub struct HelpInfo {
192 pub name: String,
194 pub version: Option<String>,
196 pub about: Option<String>,
198 pub description: Option<String>,
200 pub usage: Option<String>,
202 pub groups: Vec<ArgGroup>,
204 pub commands: Vec<CommandInfo>,
206 pub examples: Vec<(String, String)>,
208 pub author: Option<String>,
210 pub notes: Vec<String>,
212}
213
214impl HelpInfo {
215 #[must_use]
217 pub fn new(name: impl Into<String>) -> Self {
218 Self {
219 name: name.into(),
220 version: None,
221 about: None,
222 description: None,
223 usage: None,
224 groups: Vec::new(),
225 commands: Vec::new(),
226 examples: Vec::new(),
227 author: None,
228 notes: Vec::new(),
229 }
230 }
231
232 #[must_use]
234 pub fn version(mut self, version: impl Into<String>) -> Self {
235 self.version = Some(version.into());
236 self
237 }
238
239 #[must_use]
241 pub fn about(mut self, about: impl Into<String>) -> Self {
242 self.about = Some(about.into());
243 self
244 }
245
246 #[must_use]
248 pub fn description(mut self, desc: impl Into<String>) -> Self {
249 self.description = Some(desc.into());
250 self
251 }
252
253 #[must_use]
255 pub fn usage(mut self, usage: impl Into<String>) -> Self {
256 self.usage = Some(usage.into());
257 self
258 }
259
260 #[must_use]
262 pub fn group(mut self, group: ArgGroup) -> Self {
263 self.groups.push(group);
264 self
265 }
266
267 #[must_use]
269 pub fn command(mut self, cmd: CommandInfo) -> Self {
270 self.commands.push(cmd);
271 self
272 }
273
274 #[must_use]
276 pub fn example(mut self, cmd: impl Into<String>, desc: impl Into<String>) -> Self {
277 self.examples.push((cmd.into(), desc.into()));
278 self
279 }
280
281 #[must_use]
283 pub fn author(mut self, author: impl Into<String>) -> Self {
284 self.author = Some(author.into());
285 self
286 }
287
288 #[must_use]
290 pub fn note(mut self, note: impl Into<String>) -> Self {
291 self.notes.push(note.into());
292 self
293 }
294}
295
296#[derive(Debug, Clone)]
298pub struct HelpDisplay {
299 mode: OutputMode,
300 theme: FastApiTheme,
301 pub max_width: usize,
303 pub show_env_vars: bool,
305 pub show_defaults: bool,
307}
308
309impl HelpDisplay {
310 #[must_use]
312 pub fn new(mode: OutputMode) -> Self {
313 Self {
314 mode,
315 theme: FastApiTheme::default(),
316 max_width: 80,
317 show_env_vars: true,
318 show_defaults: true,
319 }
320 }
321
322 #[must_use]
324 pub fn theme(mut self, theme: FastApiTheme) -> Self {
325 self.theme = theme;
326 self
327 }
328
329 #[must_use]
331 pub fn render(&self, help: &HelpInfo) -> String {
332 match self.mode {
333 OutputMode::Plain => self.render_plain(help),
334 OutputMode::Minimal => self.render_minimal(help),
335 OutputMode::Rich => self.render_rich(help),
336 }
337 }
338
339 fn render_plain(&self, help: &HelpInfo) -> String {
340 let mut lines = Vec::new();
341
342 let mut header = help.name.clone();
344 if let Some(version) = &help.version {
345 let _ = write!(header, " {version}");
346 }
347 lines.push(header);
348
349 if let Some(about) = &help.about {
350 lines.push(about.clone());
351 }
352
353 if let Some(usage) = &help.usage {
355 lines.push(String::new());
356 lines.push("USAGE:".to_string());
357 lines.push(format!(" {usage}"));
358 }
359
360 if let Some(desc) = &help.description {
362 lines.push(String::new());
363 for line in Self::wrap_text(desc, self.max_width) {
364 lines.push(line);
365 }
366 }
367
368 for group in &help.groups {
370 lines.push(String::new());
371 lines.push(format!("{}:", group.name.to_uppercase()));
372
373 for arg in &group.args {
374 let name = arg.full_name();
375 let value_part = arg
376 .value
377 .as_ref()
378 .map(|v| format!(" {v}"))
379 .unwrap_or_default();
380
381 let mut line = format!(" {name}{value_part}");
382
383 let padding = 30_usize.saturating_sub(line.len());
385 line.push_str(&" ".repeat(padding));
386 line.push_str(&arg.description);
387
388 if self.show_defaults {
389 if let Some(default) = &arg.default {
390 let _ = write!(line, " [default: {default}]");
391 }
392 }
393
394 if self.show_env_vars {
395 if let Some(env) = &arg.env_var {
396 let _ = write!(line, " [env: {env}]");
397 }
398 }
399
400 lines.push(line);
401 }
402 }
403
404 if !help.commands.is_empty() {
406 lines.push(String::new());
407 lines.push("COMMANDS:".to_string());
408
409 for cmd in &help.commands {
410 let aliases = if cmd.aliases.is_empty() {
411 String::new()
412 } else {
413 format!(" ({})", cmd.aliases.join(", "))
414 };
415
416 let mut line = format!(" {}{aliases}", cmd.name);
417 let padding = 30_usize.saturating_sub(line.len());
418 line.push_str(&" ".repeat(padding));
419 line.push_str(&cmd.description);
420 lines.push(line);
421 }
422 }
423
424 if !help.examples.is_empty() {
426 lines.push(String::new());
427 lines.push("EXAMPLES:".to_string());
428 for (cmd, desc) in &help.examples {
429 lines.push(format!(" $ {cmd}"));
430 lines.push(format!(" {desc}"));
431 lines.push(String::new());
432 }
433 }
434
435 for note in &help.notes {
437 lines.push(String::new());
438 lines.push(format!("NOTE: {note}"));
439 }
440
441 lines.join("\n")
442 }
443
444 fn render_minimal(&self, help: &HelpInfo) -> String {
445 let muted = self.theme.muted.to_ansi_fg();
446 let accent = self.theme.accent.to_ansi_fg();
447 let header = self.theme.header.to_ansi_fg();
448 let success = self.theme.success.to_ansi_fg();
449
450 let mut lines = Vec::new();
451
452 let mut header_line = format!("{header}{ANSI_BOLD}{}{ANSI_RESET}", help.name);
454 if let Some(version) = &help.version {
455 let _ = write!(header_line, " {muted}{version}{ANSI_RESET}");
456 }
457 lines.push(header_line);
458
459 if let Some(about) = &help.about {
460 lines.push(format!("{muted}{about}{ANSI_RESET}"));
461 }
462
463 if let Some(usage) = &help.usage {
465 lines.push(String::new());
466 lines.push(format!("{header}USAGE:{ANSI_RESET}"));
467 lines.push(format!(" {accent}{usage}{ANSI_RESET}"));
468 }
469
470 for group in &help.groups {
472 lines.push(String::new());
473 lines.push(format!(
474 "{header}{}:{ANSI_RESET}",
475 group.name.to_uppercase()
476 ));
477
478 for arg in &group.args {
479 let name = arg.full_name();
480 let value_part = arg
481 .value
482 .as_ref()
483 .map(|v| format!(" {accent}{v}{ANSI_RESET}"))
484 .unwrap_or_default();
485
486 let line = format!(" {success}{name}{ANSI_RESET}{value_part}");
487 lines.push(line);
488 lines.push(format!(" {muted}{}{ANSI_RESET}", arg.description));
489
490 if self.show_defaults {
491 if let Some(default) = &arg.default {
492 lines.push(format!(" {muted}Default: {default}{ANSI_RESET}"));
493 }
494 }
495 }
496 }
497
498 if !help.commands.is_empty() {
500 lines.push(String::new());
501 lines.push(format!("{header}COMMANDS:{ANSI_RESET}"));
502
503 for cmd in &help.commands {
504 lines.push(format!(
505 " {success}{}{ANSI_RESET} {muted}{}{ANSI_RESET}",
506 cmd.name, cmd.description
507 ));
508 }
509 }
510
511 lines.join("\n")
512 }
513
514 #[allow(clippy::too_many_lines)]
515 fn render_rich(&self, help: &HelpInfo) -> String {
516 let muted = self.theme.muted.to_ansi_fg();
517 let accent = self.theme.accent.to_ansi_fg();
518 let border = self.theme.border.to_ansi_fg();
519 let header_style = self.theme.header.to_ansi_fg();
520 let success = self.theme.success.to_ansi_fg();
521 let info = self.theme.info.to_ansi_fg();
522
523 let mut lines = Vec::new();
524
525 let title_width = 60;
527 lines.push(format!("{border}┌{}┐{ANSI_RESET}", "─".repeat(title_width)));
528
529 let mut name_line = format!("{ANSI_BOLD}{}{ANSI_RESET}", help.name);
531 if let Some(version) = &help.version {
532 let _ = write!(name_line, " {muted}v{version}{ANSI_RESET}");
533 }
534 let name_pad =
535 (title_width - help.name.len() - help.version.as_ref().map_or(0, |v| v.len() + 2)) / 2;
536 lines.push(format!(
537 "{border}│{ANSI_RESET}{}{}{}",
538 " ".repeat(name_pad),
539 name_line,
540 " ".repeat(
541 title_width
542 - name_pad
543 - help.name.len()
544 - help.version.as_ref().map_or(0, |v| v.len() + 2)
545 )
546 ));
547
548 if let Some(about) = &help.about {
549 let about_pad = (title_width - about.len()) / 2;
550 lines.push(format!(
551 "{border}│{ANSI_RESET}{}{muted}{about}{ANSI_RESET}{}",
552 " ".repeat(about_pad.max(1)),
553 " ".repeat((title_width - about_pad - about.len()).max(1))
554 ));
555 }
556
557 lines.push(format!("{border}└{}┘{ANSI_RESET}", "─".repeat(title_width)));
558
559 if let Some(usage) = &help.usage {
561 lines.push(String::new());
562 lines.push(format!("{header_style}{ANSI_BOLD}USAGE{ANSI_RESET}"));
563 lines.push(format!(" {accent}${ANSI_RESET} {usage}"));
564 }
565
566 for group in &help.groups {
568 lines.push(String::new());
569 lines.push(format!(
570 "{header_style}{ANSI_BOLD}{}{ANSI_RESET}",
571 group.name.to_uppercase()
572 ));
573
574 for arg in &group.args {
575 let short = arg
576 .short
577 .as_ref()
578 .map(|s| format!("{success}{s}{ANSI_RESET}, "))
579 .unwrap_or_default();
580 let long = arg
581 .long
582 .as_ref()
583 .map(|l| format!("{success}{l}{ANSI_RESET}"))
584 .unwrap_or_default();
585 let value = arg
586 .value
587 .as_ref()
588 .map(|v| format!(" {accent}{v}{ANSI_RESET}"))
589 .unwrap_or_default();
590
591 lines.push(format!(" {short}{long}{value}"));
592 lines.push(format!(" {muted}{}{ANSI_RESET}", arg.description));
593
594 let mut meta_parts = Vec::new();
595 if self.show_defaults {
596 if let Some(default) = &arg.default {
597 meta_parts.push(format!("default: {info}{default}{ANSI_RESET}"));
598 }
599 }
600 if self.show_env_vars {
601 if let Some(env) = &arg.env_var {
602 meta_parts.push(format!("env: {info}{env}{ANSI_RESET}"));
603 }
604 }
605 if !meta_parts.is_empty() {
606 lines.push(format!(
607 " {muted}[{}]{ANSI_RESET}",
608 meta_parts.join(", ")
609 ));
610 }
611 }
612 }
613
614 if !help.commands.is_empty() {
616 lines.push(String::new());
617 lines.push(format!("{header_style}{ANSI_BOLD}COMMANDS{ANSI_RESET}"));
618
619 for cmd in &help.commands {
620 let aliases = if cmd.aliases.is_empty() {
621 String::new()
622 } else {
623 format!(" {muted}({}){ANSI_RESET}", cmd.aliases.join(", "))
624 };
625 lines.push(format!(" {success}{}{ANSI_RESET}{aliases}", cmd.name));
626 lines.push(format!(" {muted}{}{ANSI_RESET}", cmd.description));
627 }
628 }
629
630 if !help.examples.is_empty() {
632 lines.push(String::new());
633 lines.push(format!("{header_style}{ANSI_BOLD}EXAMPLES{ANSI_RESET}"));
634
635 for (cmd, desc) in &help.examples {
636 lines.push(format!(" {accent}${ANSI_RESET} {cmd}"));
637 lines.push(format!(" {muted}{desc}{ANSI_RESET}"));
638 }
639 }
640
641 lines.join("\n")
642 }
643
644 fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
645 let mut lines = Vec::new();
646 let mut current_line = String::new();
647
648 for word in text.split_whitespace() {
649 if current_line.is_empty() {
650 current_line = word.to_string();
651 } else if current_line.len() + 1 + word.len() <= max_width {
652 current_line.push(' ');
653 current_line.push_str(word);
654 } else {
655 lines.push(current_line);
656 current_line = word.to_string();
657 }
658 }
659
660 if !current_line.is_empty() {
661 lines.push(current_line);
662 }
663
664 lines
665 }
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671
672 fn sample_help() -> HelpInfo {
673 HelpInfo::new("myapp")
674 .version("1.0.0")
675 .about("A sample CLI application")
676 .usage("myapp [OPTIONS] <COMMAND>")
677 .group(
678 ArgGroup::new("Options")
679 .arg(
680 ArgInfo::new("--host", "Host to bind to")
681 .short("-h")
682 .value("<HOST>")
683 .default("127.0.0.1")
684 .env("MYAPP_HOST"),
685 )
686 .arg(
687 ArgInfo::new("--port", "Port to listen on")
688 .short("-p")
689 .value("<PORT>")
690 .default("8000")
691 .env("MYAPP_PORT"),
692 )
693 .arg(ArgInfo::new("--verbose", "Enable verbose output").short("-v")),
694 )
695 .command(CommandInfo::new("serve", "Start the server").alias("s"))
696 .command(CommandInfo::new("init", "Initialize configuration"))
697 .example("myapp serve --port 3000", "Start server on port 3000")
698 .example("myapp init", "Create default configuration")
699 }
700
701 #[test]
702 fn test_arg_info_builder() {
703 let arg = ArgInfo::new("--config", "Configuration file path")
704 .short("-c")
705 .value("<FILE>")
706 .default("config.toml")
707 .env("MYAPP_CONFIG");
708
709 assert_eq!(arg.long, Some("--config".to_string()));
710 assert_eq!(arg.short, Some("-c".to_string()));
711 assert_eq!(arg.value, Some("<FILE>".to_string()));
712 }
713
714 #[test]
715 fn test_arg_full_name() {
716 let arg = ArgInfo::new("--verbose", "Enable verbose").short("-v");
717 assert_eq!(arg.full_name(), "-v, --verbose");
718
719 let positional = ArgInfo::positional("<INPUT>", "Input file");
720 assert_eq!(positional.full_name(), "<INPUT>");
721 }
722
723 #[test]
724 fn test_help_display_plain() {
725 let display = HelpDisplay::new(OutputMode::Plain);
726 let output = display.render(&sample_help());
727
728 assert!(output.contains("myapp"));
729 assert!(output.contains("1.0.0"));
730 assert!(output.contains("USAGE:"));
731 assert!(output.contains("--host"));
732 assert!(output.contains("--port"));
733 assert!(output.contains("COMMANDS:"));
734 assert!(output.contains("serve"));
735 assert!(!output.contains("\x1b["));
736 }
737
738 #[test]
739 fn test_help_display_rich_has_ansi() {
740 let display = HelpDisplay::new(OutputMode::Rich);
741 let output = display.render(&sample_help());
742
743 assert!(output.contains("\x1b["));
744 assert!(output.contains("myapp"));
745 }
746
747 #[test]
748 fn test_command_info_builder() {
749 let cmd = CommandInfo::new("build", "Build the project")
750 .alias("b")
751 .alias("compile");
752
753 assert_eq!(cmd.name, "build");
754 assert_eq!(cmd.aliases, vec!["b", "compile"]);
755 }
756}