Skip to main content

dynamic_cli/help/
mod.rs

1//! Dynamic help generation for CLI applications
2//!
3//! This module provides the [`HelpFormatter`] trait and its default
4//! implementation [`DefaultHelpFormatter`], which generate help text
5//! from a [`CommandsConfig`] at runtime.
6//!
7//! # Design
8//!
9//! - The trait is public and `dyn`-compatible so users can supply custom implementations.
10//! - The default implementation outputs English-only text; custom implementations
11//!   choose their own language.
12//! - The formatter is instantiated lazily — only when `--help` is detected —
13//!   and outputs to the terminal only.
14//!
15//! # Extension point
16//!
17//! Users of the framework can supply their own formatter via
18//! [`CliBuilder::help_formatter()`](crate::builder::CliBuilder::help_formatter):
19//!
20//! ```
21//! use dynamic_cli::help::HelpFormatter;
22//! use dynamic_cli::config::schema::CommandsConfig;
23//!
24//! struct MyFormatter;
25//!
26//! impl HelpFormatter for MyFormatter {
27//!     fn format_app(&self, config: &CommandsConfig) -> String {
28//!         format!("Custom help for {}", config.metadata.prompt)
29//!     }
30//!
31//!     fn format_command(&self, config: &CommandsConfig, command: &str) -> String {
32//!         format!("Custom help for command '{command}' in {}", config.metadata.prompt)
33//!     }
34//! }
35//! ```
36
37use crate::config::schema::{ArgumentType, CommandDefinition, CommandsConfig};
38use colored::Colorize;
39
40// ============================================================================
41// Public trait
42// ============================================================================
43
44/// Generates help text from a runtime configuration.
45///
46/// Both methods receive the full [`CommandsConfig`] so implementations
47/// have access to metadata, commands, and global options.
48///
49/// # Object safety
50///
51/// This trait has no generic methods and no `Self` return types, so it is
52/// fully `dyn`-compatible. It can be used as `Box<dyn HelpFormatter>`.
53///
54/// # Example
55///
56/// ```
57/// use dynamic_cli::help::{HelpFormatter, DefaultHelpFormatter};
58/// use dynamic_cli::config::schema::{CommandsConfig, Metadata};
59///
60/// let config = CommandsConfig {
61///     metadata: Metadata {
62///         version: "1.0.0".to_string(),
63///         prompt: "myapp".to_string(),
64///         prompt_suffix: " > ".to_string(),
65///     },
66///     commands: vec![],
67///     global_options: vec![],
68/// };
69///
70/// let formatter = DefaultHelpFormatter::new();
71/// let help = formatter.format_app(&config);
72/// assert!(help.contains("myapp"));
73/// assert!(help.contains("1.0.0"));
74/// ```
75pub trait HelpFormatter {
76    /// Generate help text for the whole application.
77    ///
78    /// Lists all commands with their descriptions, and prints usage.
79    fn format_app(&self, config: &CommandsConfig) -> String;
80
81    /// Generate help text for a single command.
82    ///
83    /// Looks up `command` by name **or alias** and prints its arguments,
84    /// options, and aliases. If the command is not found, returns an
85    /// informative error string (never panics).
86    fn format_command(&self, config: &CommandsConfig, command: &str) -> String;
87}
88
89// ============================================================================
90// Default implementation
91// ============================================================================
92
93/// Default help formatter — colored, aligned, English-only output.
94///
95/// Outputs English text to the terminal. If you need another language,
96/// implement [`HelpFormatter`] and supply it via
97/// [`CliBuilder::help_formatter()`](crate::builder::CliBuilder::help_formatter).
98/// Uses the [`colored`] crate for terminal output. Colours are applied
99/// automatically and disabled when the terminal does not support them.
100///
101/// Column widths are computed dynamically so that descriptions are aligned
102/// regardless of command or argument name lengths.
103///
104/// # Example
105///
106/// ```
107/// use dynamic_cli::help::DefaultHelpFormatter;
108///
109/// let fmt = DefaultHelpFormatter::new();
110/// // or equivalently:
111/// let fmt = DefaultHelpFormatter::default();
112/// ```
113#[derive(Debug, Default)]
114pub struct DefaultHelpFormatter;
115
116impl DefaultHelpFormatter {
117    /// Create a new `DefaultHelpFormatter`.
118    pub fn new() -> Self {
119        Self
120    }
121
122    // -----------------------------------------------------------------------
123    // Private helpers
124    // -----------------------------------------------------------------------
125
126    /// Return the display string for an [`ArgumentType`].
127    fn type_label(t: ArgumentType) -> &'static str {
128        t.as_str()
129    }
130
131    /// Pad `s` to at least `width` characters with trailing spaces.
132    fn pad(s: &str, width: usize) -> String {
133        format!("{:<width$}", s, width = width)
134    }
135
136    /// Resolve a command by name or alias. Returns `None` if not found.
137    fn find_command<'a>(config: &'a CommandsConfig, name: &str) -> Option<&'a CommandDefinition> {
138        config
139            .commands
140            .iter()
141            .find(|cmd| cmd.name == name || cmd.aliases.iter().any(|a| a == name))
142    }
143
144    /// Format the ARGUMENTS section of a command.
145    fn format_arguments(cmd: &CommandDefinition) -> String {
146        if cmd.arguments.is_empty() {
147            return String::new();
148        }
149
150        // Compute column width from the longest argument name.
151        let col_width = cmd
152            .arguments
153            .iter()
154            .map(|a| a.name.len())
155            .max()
156            .unwrap_or(0)
157            + 4; // minimum padding
158
159        let mut out = format!("\n{}\n", "ARGUMENTS:".bold());
160        for arg in &cmd.arguments {
161            let req = if arg.required { "required" } else { "optional" };
162            let label = format!("({}, {req})", Self::type_label(arg.arg_type));
163            out.push_str(&format!(
164                "    {}  {}  {}\n",
165                Self::pad(&arg.name, col_width).green(),
166                label.dimmed(),
167                arg.description
168            ));
169        }
170        out
171    }
172
173    /// Format the OPTIONS section of a command.
174    fn format_options(cmd: &CommandDefinition) -> String {
175        if cmd.options.is_empty() {
176            return String::new();
177        }
178
179        // Build the flag string for each option, e.g. "-v, --verbose".
180        let flags: Vec<String> = cmd
181            .options
182            .iter()
183            .map(|opt| {
184                let short = opt
185                    .short
186                    .as_deref()
187                    .map(|s| format!("-{s}"))
188                    .unwrap_or_default();
189                let long = opt
190                    .long
191                    .as_deref()
192                    .map(|l| format!("--{l}"))
193                    .unwrap_or_default();
194                match (short.is_empty(), long.is_empty()) {
195                    (false, false) => format!("{short}, {long}"),
196                    (false, true) => short,
197                    (true, false) => long,
198                    (true, true) => opt.name.clone(),
199                }
200            })
201            .collect();
202
203        let col_width = flags.iter().map(|f| f.len()).max().unwrap_or(0) + 4;
204
205        let mut out = format!("\n{}\n", "OPTIONS:".bold());
206        for (opt, flag) in cmd.options.iter().zip(flags.iter()) {
207            let type_label = format!("({})", Self::type_label(opt.option_type));
208            let default_note = opt
209                .default
210                .as_deref()
211                .map(|d| format!(" [default: {d}]"))
212                .unwrap_or_default();
213            out.push_str(&format!(
214                "    {}  {}  {}{}\n",
215                Self::pad(flag, col_width).yellow(),
216                type_label.dimmed(),
217                opt.description,
218                default_note.dimmed()
219            ));
220        }
221        out
222    }
223
224    /// Format the ALIASES section of a command.
225    fn format_aliases(cmd: &CommandDefinition) -> String {
226        if cmd.aliases.is_empty() {
227            return String::new();
228        }
229        format!(
230            "\n{}\n    {}\n",
231            "ALIASES:".bold(),
232            cmd.aliases.join(", ").italic()
233        )
234    }
235
236    /// Build the inline usage token for a command (e.g. `<input> [output]`).
237    fn usage_args(cmd: &CommandDefinition) -> String {
238        let args: String = cmd
239            .arguments
240            .iter()
241            .map(|a| {
242                if a.required {
243                    format!("<{}>", a.name)
244                } else {
245                    format!("[{}]", a.name)
246                }
247            })
248            .collect::<Vec<_>>()
249            .join(" ");
250
251        let opts = if cmd.options.is_empty() {
252            String::new()
253        } else {
254            " [options]".to_string()
255        };
256
257        format!("{args}{opts}")
258    }
259}
260
261impl HelpFormatter for DefaultHelpFormatter {
262    /// Format the application-level help (list of all commands).
263    ///
264    /// # Output structure
265    ///
266    /// ```text
267    /// myapp 1.0.0
268    ///
269    /// USAGE:
270    ///     myapp <command> [arguments] [options]
271    ///
272    /// COMMANDS:
273    ///     hello      Say hello to someone
274    ///     process    Process data files
275    ///
276    /// Run 'myapp --help <command>' for more information on a command.
277    /// ```
278    fn format_app(&self, config: &CommandsConfig) -> String {
279        let mut out = String::new();
280
281        // Header: "prompt version"
282        out.push_str(&format!(
283            "{} {}\n",
284            config.metadata.prompt.bold().cyan(),
285            config.metadata.version.dimmed()
286        ));
287
288        // USAGE
289        out.push('\n');
290        out.push_str(&format!("{}\n", "USAGE:".bold()));
291        out.push_str(&format!(
292            "    {} {} [arguments] [options]\n",
293            config.metadata.prompt,
294            "<command>".green()
295        ));
296
297        // COMMANDS
298        if !config.commands.is_empty() {
299            out.push('\n');
300            out.push_str(&format!("{}\n", "COMMANDS:".bold()));
301
302            let col_width = config
303                .commands
304                .iter()
305                .map(|c| c.name.len())
306                .max()
307                .unwrap_or(0)
308                + 4;
309
310            for cmd in &config.commands {
311                out.push_str(&format!(
312                    "    {}  {}\n",
313                    Self::pad(&cmd.name, col_width).green(),
314                    cmd.description
315                ));
316            }
317        }
318
319        // Footer hint
320        out.push('\n');
321        out.push_str(&format!(
322            "{} '{}' {}\n",
323            "Run".dimmed(),
324            format!("{} --help <command>", config.metadata.prompt).italic(),
325            "for more information on a command.".dimmed()
326        ));
327
328        out
329    }
330
331    /// Format help for a single command, looked up by name or alias.
332    ///
333    /// # Output structure
334    ///
335    /// ```text
336    /// hello — Say hello to someone
337    ///
338    /// USAGE:
339    ///     hello <name> [options]
340    ///
341    /// ARGUMENTS:
342    ///     name    (string, required)  Name to greet
343    ///
344    /// OPTIONS:
345    ///     -l, --loud    (bool)  Use uppercase
346    ///
347    /// ALIASES:
348    ///     hi
349    /// ```
350    ///
351    /// If the command is not found, returns a message listing available commands.
352    fn format_command(&self, config: &CommandsConfig, command: &str) -> String {
353        let Some(cmd) = Self::find_command(config, command) else {
354            // Unknown command — list available names to guide the user.
355            let available = config
356                .commands
357                .iter()
358                .map(|c| c.name.as_str())
359                .collect::<Vec<_>>()
360                .join(", ");
361            return format!(
362                "{} '{}'\n\nAvailable commands: {}\n",
363                "Unknown command:".red().bold(),
364                command,
365                available
366            );
367        };
368
369        let mut out = String::new();
370
371        // Header: "name — description"
372        out.push_str(&format!(
373            "{} — {}\n",
374            cmd.name.bold().cyan(),
375            cmd.description
376        ));
377
378        // USAGE
379        out.push('\n');
380        out.push_str(&format!("{}\n", "USAGE:".bold()));
381        out.push_str(&format!(
382            "    {} {}\n",
383            cmd.name.green(),
384            Self::usage_args(cmd)
385        ));
386
387        // ARGUMENTS, OPTIONS, ALIASES (empty sections are omitted)
388        out.push_str(&Self::format_arguments(cmd));
389        out.push_str(&Self::format_options(cmd));
390        out.push_str(&Self::format_aliases(cmd));
391
392        out
393    }
394}
395
396// ============================================================================
397// Tests
398// ============================================================================
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use crate::config::schema::{
404        ArgumentDefinition, ArgumentType, CommandDefinition, Metadata, OptionDefinition,
405    };
406
407    // Disable ANSI codes in tests so assertions work on plain text.
408    fn no_color() {
409        colored::control::set_override(false);
410    }
411
412    // -----------------------------------------------------------------------
413    // Test fixtures
414    // -----------------------------------------------------------------------
415
416    fn make_config() -> CommandsConfig {
417        CommandsConfig {
418            metadata: Metadata {
419                version: "1.0.0".to_string(),
420                prompt: "myapp".to_string(),
421                prompt_suffix: " > ".to_string(),
422            },
423            commands: vec![
424                CommandDefinition {
425                    name: "hello".to_string(),
426                    aliases: vec!["hi".to_string(), "hey".to_string()],
427                    description: "Say hello to someone".to_string(),
428                    required: false,
429                    arguments: vec![ArgumentDefinition {
430                        name: "name".to_string(),
431                        arg_type: ArgumentType::String,
432                        required: true,
433                        description: "Name to greet".to_string(),
434                        validation: vec![],
435                        secure: false,
436                    }],
437                    options: vec![OptionDefinition {
438                        name: "loud".to_string(),
439                        short: Some("l".to_string()),
440                        long: Some("loud".to_string()),
441                        option_type: ArgumentType::Bool,
442                        required: false,
443                        default: None,
444                        description: "Use uppercase".to_string(),
445                        choices: vec![],
446                    }],
447                    implementation: "hello_handler".to_string(),
448                },
449                CommandDefinition {
450                    name: "process".to_string(),
451                    aliases: vec![],
452                    description: "Process data files".to_string(),
453                    required: true,
454                    arguments: vec![],
455                    options: vec![],
456                    implementation: "process_handler".to_string(),
457                },
458            ],
459            global_options: vec![],
460        }
461    }
462
463    fn make_formatter() -> DefaultHelpFormatter {
464        DefaultHelpFormatter::new()
465    }
466
467    // -----------------------------------------------------------------------
468    // DefaultHelpFormatter — construction
469    // -----------------------------------------------------------------------
470
471    #[test]
472    fn test_new_and_default_are_equivalent() {
473        // Both construction paths compile and produce the same type.
474        let _a = DefaultHelpFormatter::new();
475        let _b = DefaultHelpFormatter::default();
476    }
477
478    // -----------------------------------------------------------------------
479    // format_app
480    // -----------------------------------------------------------------------
481
482    #[test]
483    fn test_format_app_contains_prompt_and_version() {
484        no_color();
485        let config = make_config();
486        let out = make_formatter().format_app(&config);
487
488        assert!(out.contains("myapp"), "should contain prompt");
489        assert!(out.contains("1.0.0"), "should contain version");
490    }
491
492    #[test]
493    fn test_format_app_contains_all_commands() {
494        no_color();
495        let config = make_config();
496        let out = make_formatter().format_app(&config);
497
498        assert!(out.contains("hello"), "should list command 'hello'");
499        assert!(out.contains("process"), "should list command 'process'");
500        assert!(
501            out.contains("Say hello to someone"),
502            "should include description"
503        );
504    }
505
506    #[test]
507    fn test_format_app_contains_usage_and_footer() {
508        no_color();
509        let config = make_config();
510        let out = make_formatter().format_app(&config);
511
512        assert!(out.contains("USAGE:"), "should have USAGE section");
513        assert!(out.contains("COMMANDS:"), "should have COMMANDS section");
514        assert!(
515            out.contains("--help <command>"),
516            "should hint at per-command help"
517        );
518    }
519
520    #[test]
521    fn test_format_app_empty_commands() {
522        no_color();
523        let mut config = make_config();
524        config.commands.clear();
525        let out = make_formatter().format_app(&config);
526
527        // Should still render without panicking; no COMMANDS section.
528        assert!(out.contains("myapp"));
529        assert!(!out.contains("COMMANDS:"));
530    }
531
532    // -----------------------------------------------------------------------
533    // format_command — known command
534    // -----------------------------------------------------------------------
535
536    #[test]
537    fn test_format_command_by_name() {
538        no_color();
539        let config = make_config();
540        let out = make_formatter().format_command(&config, "hello");
541
542        assert!(out.contains("hello"), "should contain command name");
543        assert!(
544            out.contains("Say hello to someone"),
545            "should contain description"
546        );
547    }
548
549    #[test]
550    fn test_format_command_by_alias() {
551        no_color();
552        let config = make_config();
553        // "hi" is an alias for "hello"
554        let out = make_formatter().format_command(&config, "hi");
555
556        // Resolves to the canonical command
557        assert!(out.contains("hello"));
558        assert!(out.contains("Say hello to someone"));
559    }
560
561    #[test]
562    fn test_format_command_shows_arguments() {
563        no_color();
564        let config = make_config();
565        let out = make_formatter().format_command(&config, "hello");
566
567        assert!(out.contains("ARGUMENTS:"), "should have ARGUMENTS section");
568        assert!(out.contains("name"), "should list argument name");
569        assert!(out.contains("string"), "should show argument type");
570        assert!(out.contains("required"), "should show required status");
571        assert!(out.contains("Name to greet"), "should show description");
572    }
573
574    #[test]
575    fn test_format_command_shows_options() {
576        no_color();
577        let config = make_config();
578        let out = make_formatter().format_command(&config, "hello");
579
580        assert!(out.contains("OPTIONS:"), "should have OPTIONS section");
581        assert!(out.contains("-l"), "should show short flag");
582        assert!(out.contains("--loud"), "should show long flag");
583        assert!(
584            out.contains("Use uppercase"),
585            "should show option description"
586        );
587    }
588
589    #[test]
590    fn test_format_command_shows_aliases() {
591        no_color();
592        let config = make_config();
593        let out = make_formatter().format_command(&config, "hello");
594
595        assert!(out.contains("ALIASES:"), "should have ALIASES section");
596        assert!(out.contains("hi"), "should list alias 'hi'");
597        assert!(out.contains("hey"), "should list alias 'hey'");
598    }
599
600    #[test]
601    fn test_format_command_no_aliases_section_when_empty() {
602        no_color();
603        let config = make_config();
604        // "process" has no aliases
605        let out = make_formatter().format_command(&config, "process");
606
607        assert!(!out.contains("ALIASES:"), "should omit ALIASES section");
608    }
609
610    #[test]
611    fn test_format_command_no_arguments_section_when_empty() {
612        no_color();
613        let config = make_config();
614        let out = make_formatter().format_command(&config, "process");
615
616        assert!(!out.contains("ARGUMENTS:"), "should omit ARGUMENTS section");
617    }
618
619    #[test]
620    fn test_format_command_no_options_section_when_empty() {
621        no_color();
622        let config = make_config();
623        let out = make_formatter().format_command(&config, "process");
624
625        assert!(!out.contains("OPTIONS:"), "should omit OPTIONS section");
626    }
627
628    // -----------------------------------------------------------------------
629    // format_command — unknown command
630    // -----------------------------------------------------------------------
631
632    #[test]
633    fn test_format_command_unknown_returns_error_string() {
634        no_color();
635        let config = make_config();
636        let out = make_formatter().format_command(&config, "nonexistent");
637
638        assert!(
639            out.contains("Unknown command"),
640            "should signal unknown command"
641        );
642        assert!(
643            out.contains("nonexistent"),
644            "should echo the unknown name back"
645        );
646    }
647
648    #[test]
649    fn test_format_command_unknown_lists_available() {
650        no_color();
651        let config = make_config();
652        let out = make_formatter().format_command(&config, "nonexistent");
653
654        // Should list alternatives so the user can self-correct.
655        assert!(
656            out.contains("hello"),
657            "should list available command 'hello'"
658        );
659        assert!(
660            out.contains("process"),
661            "should list available command 'process'"
662        );
663    }
664
665    // -----------------------------------------------------------------------
666    // HelpFormatter trait — object safety check
667    // -----------------------------------------------------------------------
668
669    #[test]
670    fn test_trait_is_dyn_compatible() {
671        no_color();
672        // If this compiles, the trait is object-safe.
673        let formatter: Box<dyn HelpFormatter> = Box::new(DefaultHelpFormatter::new());
674        let config = make_config();
675        let _ = formatter.format_app(&config);
676    }
677
678    // -----------------------------------------------------------------------
679    // Option default display in options section
680    // -----------------------------------------------------------------------
681
682    #[test]
683    fn test_format_command_shows_default_value() {
684        no_color();
685        let mut config = make_config();
686        // Add a default value to the 'loud' option
687        config.commands[0].options[0].default = Some("false".to_string());
688        let out = make_formatter().format_command(&config, "hello");
689
690        assert!(out.contains("false"), "should show default value");
691    }
692
693    // -----------------------------------------------------------------------
694    // Custom HelpFormatter implementation (framework extensibility)
695    // -----------------------------------------------------------------------
696
697    struct MinimalFormatter;
698
699    impl HelpFormatter for MinimalFormatter {
700        fn format_app(&self, config: &CommandsConfig) -> String {
701            config.metadata.prompt.clone()
702        }
703        fn format_command(&self, _config: &CommandsConfig, command: &str) -> String {
704            command.to_string()
705        }
706    }
707
708    #[test]
709    fn test_custom_formatter_via_trait_object() {
710        let config = make_config();
711        let f: Box<dyn HelpFormatter> = Box::new(MinimalFormatter);
712
713        assert_eq!(f.format_app(&config), "myapp");
714        assert_eq!(f.format_command(&config, "hello"), "hello");
715    }
716}