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}