Skip to main content

fastapi_output/components/
help_display.rs

1//! Help and usage display component.
2//!
3//! Provides beautiful help text and usage information display with
4//! consistent formatting across output modes.
5//!
6//! # Features
7//!
8//! - Command-line argument help
9//! - Configuration option display
10//! - Quick reference guides
11//! - Version and about information
12
13use 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/// A command-line argument or option.
21#[derive(Debug, Clone)]
22pub struct ArgInfo {
23    /// Short name (e.g., "-h").
24    pub short: Option<String>,
25    /// Long name (e.g., "--help").
26    pub long: Option<String>,
27    /// Value placeholder (e.g., "\<PORT\>").
28    pub value: Option<String>,
29    /// Description.
30    pub description: String,
31    /// Default value.
32    pub default: Option<String>,
33    /// Whether required.
34    pub required: bool,
35    /// Environment variable name.
36    pub env_var: Option<String>,
37}
38
39impl ArgInfo {
40    /// Create a new argument with long name.
41    #[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    /// Create a positional argument.
55    #[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    /// Set short name.
69    #[must_use]
70    pub fn short(mut self, short: impl Into<String>) -> Self {
71        self.short = Some(short.into());
72        self
73    }
74
75    /// Set value placeholder.
76    #[must_use]
77    pub fn value(mut self, value: impl Into<String>) -> Self {
78        self.value = Some(value.into());
79        self
80    }
81
82    /// Set default value.
83    #[must_use]
84    pub fn default(mut self, default: impl Into<String>) -> Self {
85        self.default = Some(default.into());
86        self
87    }
88
89    /// Mark as required.
90    #[must_use]
91    pub fn required(mut self, required: bool) -> Self {
92        self.required = required;
93        self
94    }
95
96    /// Set environment variable.
97    #[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    /// Get the full argument name for display.
104    #[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/// A group of related arguments.
123#[derive(Debug, Clone)]
124pub struct ArgGroup {
125    /// Group name.
126    pub name: String,
127    /// Group description.
128    pub description: Option<String>,
129    /// Arguments in this group.
130    pub args: Vec<ArgInfo>,
131}
132
133impl ArgGroup {
134    /// Create a new argument group.
135    #[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    /// Set description.
145    #[must_use]
146    pub fn description(mut self, desc: impl Into<String>) -> Self {
147        self.description = Some(desc.into());
148        self
149    }
150
151    /// Add an argument.
152    #[must_use]
153    pub fn arg(mut self, arg: ArgInfo) -> Self {
154        self.args.push(arg);
155        self
156    }
157}
158
159/// Command information for help display.
160#[derive(Debug, Clone)]
161pub struct CommandInfo {
162    /// Command name.
163    pub name: String,
164    /// Command description.
165    pub description: String,
166    /// Alias(es) for the command.
167    pub aliases: Vec<String>,
168}
169
170impl CommandInfo {
171    /// Create a new command.
172    #[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    /// Add an alias.
182    #[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/// Help information for display.
190#[derive(Debug, Clone)]
191pub struct HelpInfo {
192    /// Program name.
193    pub name: String,
194    /// Program version.
195    pub version: Option<String>,
196    /// Short description.
197    pub about: Option<String>,
198    /// Longer description.
199    pub description: Option<String>,
200    /// Usage string.
201    pub usage: Option<String>,
202    /// Argument groups.
203    pub groups: Vec<ArgGroup>,
204    /// Subcommands.
205    pub commands: Vec<CommandInfo>,
206    /// Examples.
207    pub examples: Vec<(String, String)>,
208    /// Author information.
209    pub author: Option<String>,
210    /// Additional notes.
211    pub notes: Vec<String>,
212}
213
214impl HelpInfo {
215    /// Create new help info.
216    #[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    /// Set version.
233    #[must_use]
234    pub fn version(mut self, version: impl Into<String>) -> Self {
235        self.version = Some(version.into());
236        self
237    }
238
239    /// Set about text.
240    #[must_use]
241    pub fn about(mut self, about: impl Into<String>) -> Self {
242        self.about = Some(about.into());
243        self
244    }
245
246    /// Set description.
247    #[must_use]
248    pub fn description(mut self, desc: impl Into<String>) -> Self {
249        self.description = Some(desc.into());
250        self
251    }
252
253    /// Set usage string.
254    #[must_use]
255    pub fn usage(mut self, usage: impl Into<String>) -> Self {
256        self.usage = Some(usage.into());
257        self
258    }
259
260    /// Add an argument group.
261    #[must_use]
262    pub fn group(mut self, group: ArgGroup) -> Self {
263        self.groups.push(group);
264        self
265    }
266
267    /// Add a command.
268    #[must_use]
269    pub fn command(mut self, cmd: CommandInfo) -> Self {
270        self.commands.push(cmd);
271        self
272    }
273
274    /// Add an example.
275    #[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    /// Set author.
282    #[must_use]
283    pub fn author(mut self, author: impl Into<String>) -> Self {
284        self.author = Some(author.into());
285        self
286    }
287
288    /// Add a note.
289    #[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/// Help display formatter.
297#[derive(Debug, Clone)]
298pub struct HelpDisplay {
299    mode: OutputMode,
300    theme: FastApiTheme,
301    /// Maximum width for wrapping.
302    pub max_width: usize,
303    /// Show environment variables.
304    pub show_env_vars: bool,
305    /// Show default values.
306    pub show_defaults: bool,
307}
308
309impl HelpDisplay {
310    /// Create a new help display.
311    #[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    /// Set the theme.
323    #[must_use]
324    pub fn theme(mut self, theme: FastApiTheme) -> Self {
325        self.theme = theme;
326        self
327    }
328
329    /// Render help information.
330    #[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        // Header
343        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        // Usage
354        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        // Description
361        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        // Argument groups
369        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                // Pad to align descriptions
384                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        // Subcommands
405        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        // Examples
425        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        // Notes
436        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        // Header
453        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        // Usage
464        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        // Argument groups
471        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        // Commands
499        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        // Title box
526        let title_width = 60;
527        lines.push(format!("{border}┌{}┐{ANSI_RESET}", "─".repeat(title_width)));
528
529        // Name and version
530        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        // Usage section
560        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        // Arguments
567        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        // Commands
615        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        // Examples
631        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}