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 requested
13//!   (`--help`, `-h`, or the `:help` directive in the REPL) — and outputs
14//!   to the terminal only.
15//! - In the REPL, the application help is followed by the list of
16//!   directives, rendered by [`HelpFormatter::format_directives`]. That
17//!   method has a default implementation, so a custom formatter only needs
18//!   [`HelpFormatter::format_app`] and [`HelpFormatter::format_command`].
19//!
20//! # Extension point
21//!
22//! Users of the framework can supply their own formatter via
23//! [`CliBuilder::help_formatter()`](crate::builder::CliBuilder::help_formatter):
24//!
25//! ```
26//! use dynamic_cli::help::HelpFormatter;
27//! use dynamic_cli::config::schema::CommandsConfig;
28//!
29//! struct MyFormatter;
30//!
31//! impl HelpFormatter for MyFormatter {
32//!     fn format_app(&self, config: &CommandsConfig) -> String {
33//!         format!("Custom help for {}", config.metadata.prompt)
34//!     }
35//!
36//!     fn format_command(&self, config: &CommandsConfig, command: &str) -> String {
37//!         format!("Custom help for command '{command}' in {}", config.metadata.prompt)
38//!     }
39//! }
40//! ```
41
42use crate::config::directive::{DirectiveDefinition, DIRECTIVE_PREFIX};
43use crate::config::schema::{ArgumentType, CommandDefinition, CommandsConfig};
44use colored::Colorize;
45
46// ============================================================================
47// Public trait
48// ============================================================================
49
50/// Generates help text from a runtime configuration.
51///
52/// [`format_app`](Self::format_app) and
53/// [`format_command`](Self::format_command) receive the full
54/// [`CommandsConfig`] so implementations have access to metadata, commands,
55/// and global options. [`format_directives`](Self::format_directives)
56/// receives the REPL directive table and has a default implementation, so
57/// a formatter only needs the first two methods.
58///
59/// # Object safety
60///
61/// This trait has no generic methods and no `Self` return types, so it is
62/// fully `dyn`-compatible. It can be used as `Box<dyn HelpFormatter>`.
63///
64/// # Example
65///
66/// ```
67/// use dynamic_cli::help::{HelpFormatter, DefaultHelpFormatter};
68/// use dynamic_cli::config::schema::{CommandsConfig, Metadata};
69///
70/// let config = CommandsConfig {
71///     metadata: Metadata {
72///         version: "1.0.0".to_string(),
73///         prompt: "myapp".to_string(),
74///         prompt_suffix: " > ".to_string(),
75///     },
76///     commands: vec![],
77///     global_options: vec![],
78///     directives: vec![],
79/// };
80///
81/// let formatter = DefaultHelpFormatter::new();
82/// let help = formatter.format_app(&config);
83/// assert!(help.contains("myapp"));
84/// assert!(help.contains("1.0.0"));
85/// ```
86pub trait HelpFormatter {
87    /// Generate help text for the whole application.
88    ///
89    /// Lists all commands with their descriptions, and prints usage.
90    fn format_app(&self, config: &CommandsConfig) -> String;
91
92    /// Generate help text for a single command.
93    ///
94    /// Looks up `command` by name **or alias** and prints its arguments,
95    /// options, and aliases. If the command is not found, returns an
96    /// informative error string (never panics).
97    fn format_command(&self, config: &CommandsConfig, command: &str) -> String;
98
99    /// Generate the list of REPL directives.
100    ///
101    /// `directives` is the effective table, with the names, aliases and
102    /// descriptions set by the `directives:` configuration section. The
103    /// REPL shows the result after the application help.
104    ///
105    /// Names and aliases are stored without their leading `:`; the output
106    /// is expected to show it, since that is what the user types.
107    ///
108    /// The default implementation renders plain text, without colours:
109    ///
110    /// ```text
111    /// DIRECTIVES:
112    ///     :help [command]      Show the application help, or the help of a command [aliases: :h, :?]
113    ///     :load <path>         Run every line of a script file
114    ///     :quit                Save the session history and leave [aliases: :q]
115    ///     :exit                Leave without saving the session history
116    /// ```
117    ///
118    /// It returns an empty string for an empty table.
119    ///
120    /// # Example
121    ///
122    /// ```
123    /// use dynamic_cli::config::{effective_directives, DirectiveDefinition, ReplDirective};
124    /// use dynamic_cli::config::schema::CommandsConfig;
125    /// use dynamic_cli::help::HelpFormatter;
126    ///
127    /// struct MyFormatter;
128    ///
129    /// impl HelpFormatter for MyFormatter {
130    ///     fn format_app(&self, _config: &CommandsConfig) -> String {
131    ///         String::new()
132    ///     }
133    ///     fn format_command(&self, _config: &CommandsConfig, command: &str) -> String {
134    ///         command.to_string()
135    ///     }
136    /// }
137    ///
138    /// let overrides = vec![DirectiveDefinition {
139    ///     implementation: ReplDirective::Help,
140    ///     name: "aide".to_string(),
141    ///     aliases: vec![],
142    ///     description: "Afficher l'aide".to_string(),
143    /// }];
144    /// let list = MyFormatter.format_directives(&effective_directives(&overrides));
145    /// assert!(list.contains(":aide [command]"));
146    /// assert!(list.contains("Afficher l'aide"));
147    /// ```
148    fn format_directives(&self, directives: &[DirectiveDefinition]) -> String {
149        directive_section(directives, false)
150    }
151}
152
153// ============================================================================
154// Default implementation
155// ============================================================================
156
157/// Default help formatter — colored, aligned, English-only output.
158///
159/// Outputs English text to the terminal. If you need another language,
160/// implement [`HelpFormatter`] and supply it via
161/// [`CliBuilder::help_formatter()`](crate::builder::CliBuilder::help_formatter).
162/// Uses the [`colored`] crate for terminal output. Colours are applied
163/// automatically and disabled when the terminal does not support them.
164///
165/// Column widths are computed dynamically so that descriptions are aligned
166/// regardless of command or argument name lengths.
167///
168/// # Example
169///
170/// ```
171/// use dynamic_cli::help::DefaultHelpFormatter;
172///
173/// let fmt = DefaultHelpFormatter::new();
174/// // or equivalently:
175/// let fmt = DefaultHelpFormatter::default();
176/// ```
177#[derive(Debug, Default)]
178pub struct DefaultHelpFormatter;
179
180impl DefaultHelpFormatter {
181    /// Create a new `DefaultHelpFormatter`.
182    pub fn new() -> Self {
183        Self
184    }
185
186    // -----------------------------------------------------------------------
187    // Private helpers
188    // -----------------------------------------------------------------------
189
190    /// Return the display string for an [`ArgumentType`].
191    fn type_label(t: ArgumentType) -> &'static str {
192        t.as_str()
193    }
194
195    /// Pad `s` to at least `width` characters with trailing spaces.
196    fn pad(s: &str, width: usize) -> String {
197        format!("{:<width$}", s, width = width)
198    }
199
200    /// Resolve a command by name or alias. Returns `None` if not found.
201    fn find_command<'a>(config: &'a CommandsConfig, name: &str) -> Option<&'a CommandDefinition> {
202        config
203            .commands
204            .iter()
205            .find(|cmd| cmd.name == name || cmd.aliases.iter().any(|a| a == name))
206    }
207
208    /// Format the ARGUMENTS section of a command.
209    fn format_arguments(cmd: &CommandDefinition) -> String {
210        if cmd.arguments.is_empty() {
211            return String::new();
212        }
213
214        // Compute column width from the longest argument name.
215        let col_width = cmd
216            .arguments
217            .iter()
218            .map(|a| a.name.len())
219            .max()
220            .unwrap_or(0)
221            + 4; // minimum padding
222
223        let mut out = format!("\n{}\n", "ARGUMENTS:".bold());
224        for arg in &cmd.arguments {
225            let req = if arg.required { "required" } else { "optional" };
226            let label = format!("({}, {req})", Self::type_label(arg.arg_type));
227            out.push_str(&format!(
228                "    {}  {}  {}\n",
229                Self::pad(&arg.name, col_width).green(),
230                label.dimmed(),
231                arg.description
232            ));
233        }
234        out
235    }
236
237    /// Format the OPTIONS section of a command.
238    fn format_options(cmd: &CommandDefinition) -> String {
239        if cmd.options.is_empty() {
240            return String::new();
241        }
242
243        // Build the flag string for each option, e.g. "-v, --verbose".
244        let flags: Vec<String> = cmd
245            .options
246            .iter()
247            .map(|opt| {
248                let short = opt
249                    .short
250                    .as_deref()
251                    .map(|s| format!("-{s}"))
252                    .unwrap_or_default();
253                let long = opt
254                    .long
255                    .as_deref()
256                    .map(|l| format!("--{l}"))
257                    .unwrap_or_default();
258                match (short.is_empty(), long.is_empty()) {
259                    (false, false) => format!("{short}, {long}"),
260                    (false, true) => short,
261                    (true, false) => long,
262                    (true, true) => opt.name.clone(),
263                }
264            })
265            .collect();
266
267        let col_width = flags.iter().map(|f| f.len()).max().unwrap_or(0) + 4;
268
269        let mut out = format!("\n{}\n", "OPTIONS:".bold());
270        for (opt, flag) in cmd.options.iter().zip(flags.iter()) {
271            let type_label = format!("({})", Self::type_label(opt.option_type));
272            let default_note = opt
273                .default
274                .as_deref()
275                .map(|d| format!(" [default: {d}]"))
276                .unwrap_or_default();
277            out.push_str(&format!(
278                "    {}  {}  {}{}\n",
279                Self::pad(flag, col_width).yellow(),
280                type_label.dimmed(),
281                opt.description,
282                default_note.dimmed()
283            ));
284        }
285        out
286    }
287
288    /// Format the ALIASES section of a command.
289    fn format_aliases(cmd: &CommandDefinition) -> String {
290        if cmd.aliases.is_empty() {
291            return String::new();
292        }
293        format!(
294            "\n{}\n    {}\n",
295            "ALIASES:".bold(),
296            cmd.aliases.join(", ").italic()
297        )
298    }
299
300    /// Build the inline usage of a command after its name (e.g.
301    /// `<input> [output] [options]`).
302    ///
303    /// Tokens are joined by single spaces, without leading or trailing
304    /// space; the result is empty for a command with neither arguments nor
305    /// options.
306    fn usage_args(cmd: &CommandDefinition) -> String {
307        let mut tokens: Vec<String> = cmd
308            .arguments
309            .iter()
310            .map(|a| {
311                if a.required {
312                    format!("<{}>", a.name)
313                } else {
314                    format!("[{}]", a.name)
315                }
316            })
317            .collect();
318
319        if !cmd.options.is_empty() {
320            tokens.push("[options]".to_string());
321        }
322
323        tokens.join(" ")
324    }
325}
326
327impl HelpFormatter for DefaultHelpFormatter {
328    /// Format the application-level help (list of all commands).
329    ///
330    /// # Output structure
331    ///
332    /// ```text
333    /// myapp 1.0.0
334    ///
335    /// USAGE:
336    ///     <command> [arguments] [options]
337    ///
338    /// COMMANDS:
339    ///     hello      Say hello to someone
340    ///     process    Process data files
341    /// ```
342    ///
343    /// The output is the same in CLI and REPL mode. How to ask for the help
344    /// of one command differs between them, so the interface appends that
345    /// line itself.
346    fn format_app(&self, config: &CommandsConfig) -> String {
347        let mut out = String::new();
348
349        // Header: "prompt version"
350        out.push_str(&format!(
351            "{} {}\n",
352            config.metadata.prompt.bold().cyan(),
353            config.metadata.version.dimmed()
354        ));
355
356        // USAGE
357        out.push('\n');
358        out.push_str(&format!("{}\n", "USAGE:".bold()));
359        out.push_str(&format!(
360            "    {} [arguments] [options]\n",
361            "<command>".green()
362        ));
363
364        // COMMANDS
365        if !config.commands.is_empty() {
366            out.push('\n');
367            out.push_str(&format!("{}\n", "COMMANDS:".bold()));
368
369            let col_width = config
370                .commands
371                .iter()
372                .map(|c| c.name.len())
373                .max()
374                .unwrap_or(0)
375                + 4;
376
377            for cmd in &config.commands {
378                out.push_str(&format!(
379                    "    {}  {}\n",
380                    Self::pad(&cmd.name, col_width).green(),
381                    cmd.description
382                ));
383            }
384        }
385
386        out
387    }
388
389    /// Format help for a single command, looked up by name or alias.
390    ///
391    /// # Output structure
392    ///
393    /// ```text
394    /// hello — Say hello to someone
395    ///
396    /// USAGE:
397    ///     hello <name> [options]
398    ///
399    /// ARGUMENTS:
400    ///     name    (string, required)  Name to greet
401    ///
402    /// OPTIONS:
403    ///     -l, --loud    (bool)  Use uppercase
404    ///
405    /// ALIASES:
406    ///     hi
407    /// ```
408    ///
409    /// If the command is not found, returns a message listing available commands.
410    fn format_command(&self, config: &CommandsConfig, command: &str) -> String {
411        let Some(cmd) = Self::find_command(config, command) else {
412            // Unknown command — list available names to guide the user.
413            let available = config
414                .commands
415                .iter()
416                .map(|c| c.name.as_str())
417                .collect::<Vec<_>>()
418                .join(", ");
419            return format!(
420                "{} '{}'\n\nAvailable commands: {}\n",
421                "Unknown command:".red().bold(),
422                command,
423                available
424            );
425        };
426
427        let mut out = String::new();
428
429        // Header: "name — description"
430        out.push_str(&format!(
431            "{} — {}\n",
432            cmd.name.bold().cyan(),
433            cmd.description
434        ));
435
436        // USAGE
437        out.push('\n');
438        out.push_str(&format!("{}\n", "USAGE:".bold()));
439        out.push_str(&format!("    {}", cmd.name.green()));
440        let usage = Self::usage_args(cmd);
441        if !usage.is_empty() {
442            out.push(' ');
443            out.push_str(&usage);
444        }
445        out.push('\n');
446
447        // ARGUMENTS, OPTIONS, ALIASES (empty sections are omitted)
448        out.push_str(&Self::format_arguments(cmd));
449        out.push_str(&Self::format_options(cmd));
450        out.push_str(&Self::format_aliases(cmd));
451
452        out
453    }
454
455    /// Format the list of REPL directives, in the layout of the default
456    /// trait implementation with colours.
457    ///
458    /// # Output structure
459    ///
460    /// ```text
461    /// DIRECTIVES:
462    ///     :help [command]      Show the application help, or the help of a command [aliases: :h, :?]
463    ///     :load <path>         Run every line of a script file
464    ///     :quit                Save the session history and leave [aliases: :q]
465    ///     :exit                Leave without saving the session history
466    /// ```
467    fn format_directives(&self, directives: &[DirectiveDefinition]) -> String {
468        directive_section(directives, true)
469    }
470}
471
472// ============================================================================
473// Directive list
474// ============================================================================
475
476/// Render the DIRECTIVES section, coloured when `styled` is set.
477///
478/// Shared by the default [`HelpFormatter::format_directives`] (plain) and
479/// [`DefaultHelpFormatter`] (coloured) so both keep the same layout: one
480/// line per directive with its usage under the effective name, the
481/// description, then the aliases when there are any.
482fn directive_section(directives: &[DirectiveDefinition], styled: bool) -> String {
483    if directives.is_empty() {
484        return String::new();
485    }
486
487    let rows: Vec<(String, String)> = directives
488        .iter()
489        .map(|def| {
490            let aliases = if def.aliases.is_empty() {
491                String::new()
492            } else {
493                let prefixed: Vec<String> = def
494                    .aliases
495                    .iter()
496                    .map(|alias| format!("{}{}", DIRECTIVE_PREFIX, alias))
497                    .collect();
498                format!(" [aliases: {}]", prefixed.join(", "))
499            };
500            (def.usage_line(), aliases)
501        })
502        .collect();
503
504    // Width in characters, as used by the padding: translated names may
505    // be non-ASCII.
506    let col_width = rows
507        .iter()
508        .map(|(usage, _)| usage.chars().count())
509        .max()
510        .unwrap_or(0)
511        + 4;
512
513    let header = "DIRECTIVES:";
514    let mut out = if styled {
515        format!("\n{}\n", header.bold())
516    } else {
517        format!("\n{}\n", header)
518    };
519
520    for (def, (usage, aliases)) in directives.iter().zip(rows) {
521        let usage = DefaultHelpFormatter::pad(&usage, col_width);
522        if styled {
523            out.push_str(&format!(
524                "    {}  {}{}\n",
525                usage.green(),
526                def.description,
527                aliases.dimmed()
528            ));
529        } else {
530            out.push_str(&format!("    {}  {}{}\n", usage, def.description, aliases));
531        }
532    }
533    out
534}
535
536// ============================================================================
537// Interface hints
538// ============================================================================
539
540/// Footer an interface appends after the application help, telling how to
541/// get the help of one command, e.g.
542/// `Type ':help <command>' for more information on a command.`
543///
544/// `lead` is the verb phrase (`"Type"`, `"Run with"`) and `invocation` what
545/// the user enters (`":help <command>"`, `"--help <command>"`). Shared by
546/// both interfaces so the line keeps a single style.
547pub(crate) fn command_help_footer(lead: &str, invocation: &str) -> String {
548    format!(
549        "\n{} '{}' {}\n",
550        lead.dimmed(),
551        invocation.italic(),
552        "for more information on a command.".dimmed()
553    )
554}
555
556// ============================================================================
557// Tests
558// ============================================================================
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use crate::config::directive::{effective_directives, ReplDirective};
564    use crate::config::schema::{
565        ArgumentDefinition, ArgumentType, CommandDefinition, Metadata, OptionDefinition,
566    };
567    use std::collections::HashMap;
568
569    // Disable ANSI codes in tests so assertions work on plain text.
570    fn no_color() {
571        colored::control::set_override(false);
572    }
573
574    // -----------------------------------------------------------------------
575    // Test fixtures
576    // -----------------------------------------------------------------------
577
578    fn make_config() -> CommandsConfig {
579        CommandsConfig {
580            metadata: Metadata {
581                version: "1.0.0".to_string(),
582                prompt: "myapp".to_string(),
583                prompt_suffix: " > ".to_string(),
584            },
585            commands: vec![
586                CommandDefinition {
587                    name: "hello".to_string(),
588                    aliases: vec!["hi".to_string(), "hey".to_string()],
589                    description: "Say hello to someone".to_string(),
590                    required: false,
591                    arguments: vec![ArgumentDefinition {
592                        name: "name".to_string(),
593                        arg_type: ArgumentType::String,
594                        required: true,
595                        description: "Name to greet".to_string(),
596                        validation: vec![],
597                        secure: false,
598                    }],
599                    options: vec![OptionDefinition {
600                        name: "loud".to_string(),
601                        short: Some("l".to_string()),
602                        long: Some("loud".to_string()),
603                        option_type: ArgumentType::Bool,
604                        required: false,
605                        default: None,
606                        description: "Use uppercase".to_string(),
607                        choices: vec![],
608                        repeatable: false,
609                        option_parameters: HashMap::new(),
610                    }],
611                    implementation: "hello_handler".to_string(),
612                    continue_on_failure: false,
613                    requires_success: false,
614                },
615                CommandDefinition {
616                    name: "process".to_string(),
617                    aliases: vec![],
618                    description: "Process data files".to_string(),
619                    required: true,
620                    arguments: vec![],
621                    options: vec![],
622                    implementation: "process_handler".to_string(),
623                    continue_on_failure: false,
624                    requires_success: false,
625                },
626            ],
627            global_options: vec![],
628            directives: vec![],
629        }
630    }
631
632    fn make_formatter() -> DefaultHelpFormatter {
633        DefaultHelpFormatter::new()
634    }
635
636    // -----------------------------------------------------------------------
637    // DefaultHelpFormatter — construction
638    // -----------------------------------------------------------------------
639
640    #[test]
641    // Intentional: this test locks in the public API guarantee that
642    // `::new()` and `::default()` are equivalent construction paths
643    // (documented at the struct's rustdoc, line ~111) — not leftover
644    // test scaffolding, so the `::default()` call must stay.
645    #[allow(clippy::default_constructed_unit_structs)]
646    fn test_new_and_default_are_equivalent() {
647        // Both construction paths compile and produce the same type.
648        let _a = DefaultHelpFormatter::new();
649        let _b = DefaultHelpFormatter::default();
650    }
651
652    // -----------------------------------------------------------------------
653    // format_app
654    // -----------------------------------------------------------------------
655
656    #[test]
657    fn test_format_app_contains_prompt_and_version() {
658        no_color();
659        let config = make_config();
660        let out = make_formatter().format_app(&config);
661
662        assert!(out.contains("myapp"), "should contain prompt");
663        assert!(out.contains("1.0.0"), "should contain version");
664    }
665
666    #[test]
667    fn test_format_app_contains_all_commands() {
668        no_color();
669        let config = make_config();
670        let out = make_formatter().format_app(&config);
671
672        assert!(out.contains("hello"), "should list command 'hello'");
673        assert!(out.contains("process"), "should list command 'process'");
674        assert!(
675            out.contains("Say hello to someone"),
676            "should include description"
677        );
678    }
679
680    #[test]
681    fn test_format_app_contains_usage_and_commands_sections() {
682        no_color();
683        let config = make_config();
684        let out = make_formatter().format_app(&config);
685
686        assert!(out.contains("USAGE:"), "should have USAGE section");
687        assert!(out.contains("COMMANDS:"), "should have COMMANDS section");
688    }
689
690    #[test]
691    fn test_format_app_usage_line_has_no_prompt_prefix() {
692        no_color();
693        let config = make_config();
694        let out = make_formatter().format_app(&config);
695
696        assert!(
697            out.contains("\n    <command> [arguments] [options]\n"),
698            "USAGE line valid in both modes: {out}"
699        );
700        assert!(!out.contains("myapp <command>"), "{out}");
701    }
702
703    #[test]
704    fn test_format_app_has_no_footer() {
705        no_color();
706        let config = make_config();
707        let out = make_formatter().format_app(&config);
708
709        // The interface appends its own footer.
710        assert!(!out.contains("--help"), "{out}");
711        assert!(!out.contains("for more information"), "{out}");
712        assert!(out.ends_with("Process data files\n"), "{out}");
713    }
714
715    #[test]
716    fn test_command_help_footer() {
717        no_color();
718        assert_eq!(
719            command_help_footer("Type", ":aide <command>"),
720            "\nType ':aide <command>' for more information on a command.\n"
721        );
722    }
723
724    #[test]
725    fn test_format_app_empty_commands() {
726        no_color();
727        let mut config = make_config();
728        config.commands.clear();
729        let out = make_formatter().format_app(&config);
730
731        // Should still render without panicking; no COMMANDS section.
732        assert!(out.contains("myapp"));
733        assert!(!out.contains("COMMANDS:"));
734    }
735
736    // -----------------------------------------------------------------------
737    // format_command — known command
738    // -----------------------------------------------------------------------
739
740    #[test]
741    fn test_format_command_by_name() {
742        no_color();
743        let config = make_config();
744        let out = make_formatter().format_command(&config, "hello");
745
746        assert!(out.contains("hello"), "should contain command name");
747        assert!(
748            out.contains("Say hello to someone"),
749            "should contain description"
750        );
751    }
752
753    #[test]
754    fn test_format_command_by_alias() {
755        no_color();
756        let config = make_config();
757        // "hi" is an alias for "hello"
758        let out = make_formatter().format_command(&config, "hi");
759
760        // Resolves to the canonical command
761        assert!(out.contains("hello"));
762        assert!(out.contains("Say hello to someone"));
763    }
764
765    #[test]
766    fn test_format_command_shows_arguments() {
767        no_color();
768        let config = make_config();
769        let out = make_formatter().format_command(&config, "hello");
770
771        assert!(out.contains("ARGUMENTS:"), "should have ARGUMENTS section");
772        assert!(out.contains("name"), "should list argument name");
773        assert!(out.contains("string"), "should show argument type");
774        assert!(out.contains("required"), "should show required status");
775        assert!(out.contains("Name to greet"), "should show description");
776    }
777
778    #[test]
779    fn test_format_command_shows_options() {
780        no_color();
781        let config = make_config();
782        let out = make_formatter().format_command(&config, "hello");
783
784        assert!(out.contains("OPTIONS:"), "should have OPTIONS section");
785        assert!(out.contains("-l"), "should show short flag");
786        assert!(out.contains("--loud"), "should show long flag");
787        assert!(
788            out.contains("Use uppercase"),
789            "should show option description"
790        );
791    }
792
793    #[test]
794    fn test_format_command_shows_aliases() {
795        no_color();
796        let config = make_config();
797        let out = make_formatter().format_command(&config, "hello");
798
799        assert!(out.contains("ALIASES:"), "should have ALIASES section");
800        assert!(out.contains("hi"), "should list alias 'hi'");
801        assert!(out.contains("hey"), "should list alias 'hey'");
802    }
803
804    #[test]
805    fn test_format_command_no_aliases_section_when_empty() {
806        no_color();
807        let config = make_config();
808        // "process" has no aliases
809        let out = make_formatter().format_command(&config, "process");
810
811        assert!(!out.contains("ALIASES:"), "should omit ALIASES section");
812    }
813
814    #[test]
815    fn test_format_command_no_arguments_section_when_empty() {
816        no_color();
817        let config = make_config();
818        let out = make_formatter().format_command(&config, "process");
819
820        assert!(!out.contains("ARGUMENTS:"), "should omit ARGUMENTS section");
821    }
822
823    #[test]
824    fn test_format_command_no_options_section_when_empty() {
825        no_color();
826        let config = make_config();
827        let out = make_formatter().format_command(&config, "process");
828
829        assert!(!out.contains("OPTIONS:"), "should omit OPTIONS section");
830    }
831
832    // -----------------------------------------------------------------------
833    // format_command — unknown command
834    // -----------------------------------------------------------------------
835
836    #[test]
837    fn test_format_command_unknown_returns_error_string() {
838        no_color();
839        let config = make_config();
840        let out = make_formatter().format_command(&config, "nonexistent");
841
842        assert!(
843            out.contains("Unknown command"),
844            "should signal unknown command"
845        );
846        assert!(
847            out.contains("nonexistent"),
848            "should echo the unknown name back"
849        );
850    }
851
852    #[test]
853    fn test_format_command_unknown_lists_available() {
854        no_color();
855        let config = make_config();
856        let out = make_formatter().format_command(&config, "nonexistent");
857
858        // Should list alternatives so the user can self-correct.
859        assert!(
860            out.contains("hello"),
861            "should list available command 'hello'"
862        );
863        assert!(
864            out.contains("process"),
865            "should list available command 'process'"
866        );
867    }
868
869    // -----------------------------------------------------------------------
870    // HelpFormatter trait — object safety check
871    // -----------------------------------------------------------------------
872
873    #[test]
874    fn test_trait_is_dyn_compatible() {
875        no_color();
876        // If this compiles, the trait is object-safe.
877        let formatter: Box<dyn HelpFormatter> = Box::new(DefaultHelpFormatter::new());
878        let config = make_config();
879        let _ = formatter.format_app(&config);
880    }
881
882    // -----------------------------------------------------------------------
883    // Option default display in options section
884    // -----------------------------------------------------------------------
885
886    #[test]
887    fn test_format_command_shows_default_value() {
888        no_color();
889        let mut config = make_config();
890        // Add a default value to the 'loud' option
891        config.commands[0].options[0].default = Some("false".to_string());
892        let out = make_formatter().format_command(&config, "hello");
893
894        assert!(out.contains("false"), "should show default value");
895    }
896
897    // -----------------------------------------------------------------------
898    // Custom HelpFormatter implementation (framework extensibility)
899    // -----------------------------------------------------------------------
900
901    struct MinimalFormatter;
902
903    impl HelpFormatter for MinimalFormatter {
904        fn format_app(&self, config: &CommandsConfig) -> String {
905            config.metadata.prompt.clone()
906        }
907        fn format_command(&self, _config: &CommandsConfig, command: &str) -> String {
908            command.to_string()
909        }
910    }
911
912    #[test]
913    fn test_custom_formatter_via_trait_object() {
914        let config = make_config();
915        let f: Box<dyn HelpFormatter> = Box::new(MinimalFormatter);
916
917        assert_eq!(f.format_app(&config), "myapp");
918        assert_eq!(f.format_command(&config, "hello"), "hello");
919    }
920
921    // -----------------------------------------------------------------------
922    // format_command — USAGE line spacing
923    // -----------------------------------------------------------------------
924
925    /// The USAGE line of `cmd`, as rendered by `format_command`.
926    fn usage_line(config: &CommandsConfig, cmd: &str) -> String {
927        let out = make_formatter().format_command(config, cmd);
928        out.lines()
929            .skip_while(|line| *line != "USAGE:")
930            .nth(1)
931            .expect("USAGE line")
932            .to_string()
933    }
934
935    #[test]
936    fn test_format_command_usage_without_arguments_or_options() {
937        // Regression: the line used to end with a space (`add` in
938        // examples/rpn_calculator.rs).
939        no_color();
940        assert_eq!(usage_line(&make_config(), "process"), "    process");
941    }
942
943    #[test]
944    fn test_format_command_usage_options_without_arguments() {
945        // Regression: two spaces before `[options]` (`list` in
946        // examples/task_runner.rs).
947        no_color();
948        let mut config = make_config();
949        config.commands[1].options = config.commands[0].options.clone();
950        assert_eq!(usage_line(&config, "process"), "    process [options]");
951    }
952
953    #[test]
954    fn test_format_command_usage_arguments_and_options() {
955        no_color();
956        let mut config = make_config();
957        assert_eq!(usage_line(&config, "hello"), "    hello <name> [options]");
958
959        config.commands[0].options.clear();
960        assert_eq!(usage_line(&config, "hello"), "    hello <name>");
961    }
962
963    // -----------------------------------------------------------------------
964    // format_directives
965    // -----------------------------------------------------------------------
966
967    fn directive(
968        implementation: ReplDirective,
969        name: &str,
970        aliases: &[&str],
971        description: &str,
972    ) -> DirectiveDefinition {
973        DirectiveDefinition {
974            implementation,
975            name: name.to_string(),
976            aliases: aliases.iter().map(|a| a.to_string()).collect(),
977            description: description.to_string(),
978        }
979    }
980
981    #[test]
982    fn test_format_directives_default_table() {
983        no_color();
984        let out = make_formatter().format_directives(&effective_directives(&[]));
985        assert_eq!(
986            out,
987            "\nDIRECTIVES:\n\
988             \x20   :help [command]      Show the application help, or the help of a command [aliases: :h, :?]\n\
989             \x20   :load <path>         Run every line of a script file\n\
990             \x20   :quit                Save the session history and leave [aliases: :q]\n\
991             \x20   :exit                Leave without saving the session history\n"
992        );
993    }
994
995    #[test]
996    fn test_format_directives_uses_effective_names() {
997        no_color();
998        let table = effective_directives(&[
999            directive(ReplDirective::Help, "aide", &["a", "?"], "Afficher l'aide"),
1000            directive(ReplDirective::Exit, "sortir", &[], "Sortir"),
1001        ]);
1002        let out = make_formatter().format_directives(&table);
1003
1004        assert!(out.contains(":aide [command]"), "{out}");
1005        assert!(out.contains("Afficher l'aide [aliases: :a, :?]"), "{out}");
1006        assert!(out.contains(":sortir"), "{out}");
1007        assert!(!out.contains(":help"), "{out}");
1008        assert!(!out.contains(":h,"), "{out}");
1009        assert!(!out.contains(":exit"), "{out}");
1010    }
1011
1012    #[test]
1013    fn test_format_directives_aligns_non_ascii_names() {
1014        no_color();
1015        let table =
1016            effective_directives(&[directive(ReplDirective::Load, "chargé", &[], "Charger")]);
1017        let out = make_formatter().format_directives(&table);
1018
1019        // Every description starts at the same character column.
1020        let columns: Vec<usize> = out
1021            .lines()
1022            .skip(2)
1023            .zip(&table)
1024            .map(|(line, def)| {
1025                let byte = line.find(&def.description).expect("description");
1026                line[..byte].chars().count()
1027            })
1028            .collect();
1029        assert!(columns.windows(2).all(|w| w[0] == w[1]), "{out}");
1030    }
1031
1032    #[test]
1033    fn test_format_directives_empty_table() {
1034        assert_eq!(make_formatter().format_directives(&[]), "");
1035        assert_eq!(MinimalFormatter.format_directives(&[]), "");
1036    }
1037
1038    #[test]
1039    fn test_format_directives_trait_default_is_plain() {
1040        // `MinimalFormatter` does not implement `format_directives`.
1041        let table = effective_directives(&[]);
1042        let out = MinimalFormatter.format_directives(&table);
1043
1044        assert!(!out.contains('\x1b'), "{out:?}");
1045        assert_eq!(out, directive_section(&table, false));
1046        for def in &table {
1047            assert!(out.contains(&def.usage_line()), "{out}");
1048            assert!(out.contains(&def.description), "{out}");
1049        }
1050    }
1051
1052    #[test]
1053    fn test_format_directives_default_matches_trait_layout() {
1054        // Same layout; the default formatter only adds colours.
1055        no_color();
1056        let table = effective_directives(&[]);
1057        assert_eq!(
1058            make_formatter().format_directives(&table),
1059            MinimalFormatter.format_directives(&table)
1060        );
1061    }
1062}