#![ cfg( feature = "cli_help_template" ) ]
use cli_fmt::help::*;
fn two_group_data() -> CliHelpData
{
let mut data = CliHelpData::default();
data.binary = "myapp".into();
data.tagline = "example tool".into();
data.groups = vec!
[
CommandGroup
{
name : "Group A".into(),
entries : vec!
[
CommandEntry { name : "cmd-one".into(), desc : "first command".into() },
CommandEntry { name : "cmd-two".into(), desc : "second command".into() },
],
},
CommandGroup
{
name : "Group B".into(),
entries : vec!
[
CommandEntry { name : "cmd-three".into(), desc : "third command".into() },
CommandEntry { name : "cmd-four".into(), desc : "fourth command".into() },
],
},
];
data.options = vec!
[
OptionEntry { name : "format::text|json".into(), desc : "Output format".into() },
OptionEntry { name : "dry::bool".into(), desc : "Dry-run".into() },
];
data.examples = vec!
[
ExampleEntry { invocation : "myapp cmd-one".into(), desc : Some( "run one".into() ) },
ExampleEntry { invocation : "myapp cmd-two".into(), desc : None },
];
data
}
fn no_tty_style() -> CliHelpStyle
{
CliHelpStyle { tty_detect : false, ..CliHelpStyle::default() }
}
#[ test ]
fn test_column_alignment()
{
let tmpl = CliHelpTemplate::new( no_tty_style(), two_group_data() );
let out = tmpl.render();
assert!(
out.contains( "cmd-one " ),
"cmd-one must be padded to 20 chars (cmd_name_width), got:\n{out}",
);
assert!(
out.contains( "dry::bool " ),
"dry::bool must be padded to 18 chars (opt_name_width), got:\n{out}",
);
assert!(
!out.contains( "\x1b[" ),
"no ANSI codes in no-TTY mode, got:\n{out}",
);
}
#[ test ]
fn test_no_ansi_codes()
{
let tmpl = CliHelpTemplate::new( no_tty_style(), two_group_data() );
let out = tmpl.render();
assert!(
!out.contains( "\x1b[" ),
"ANSI escape sequences must be absent with tty_detect=false, got:\n{out}",
);
}
#[ test ]
fn test_explicit_tty_detect_false()
{
let style = CliHelpStyle { tty_detect : false, ..CliHelpStyle::default() };
let tmpl = CliHelpTemplate::new( style, two_group_data() );
let out = tmpl.render();
assert!(
!out.contains( "\x1b[" ),
"explicit tty_detect=false must suppress all ANSI codes, got:\n{out}",
);
assert!( out.contains( "Group A" ), "group header must appear in output, got:\n{out}" );
assert!( out.contains( "cmd-one" ), "command name must appear in output, got:\n{out}" );
}
#[ test ]
fn test_name_not_truncated()
{
let style = CliHelpStyle { cmd_name_width : 10, tty_detect : false, ..CliHelpStyle::default() };
let mut data = CliHelpData::default();
data.binary = "app".into();
data.tagline = "test".into();
data.groups = vec![ CommandGroup
{
name : "G".into(),
entries : vec![ CommandEntry { name : "eleven-char".into(), desc : "desc".into() } ],
}];
let out = CliHelpTemplate::new( style, data ).render();
assert!(
out.contains( "eleven-char" ),
"11-char name must not be truncated when cmd_name_width=10, got:\n{out}",
);
}
#[ test ]
fn test_no_options_section()
{
let mut data = two_group_data();
data.options = vec![];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!(
!out.contains( "Options:" ),
"Options section must be absent when options vec is empty, got:\n{out}",
);
}
#[ test ]
fn test_no_examples_section()
{
let mut data = two_group_data();
data.examples = vec![];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!(
!out.contains( "Examples:" ),
"Examples section must be absent when examples vec is empty, got:\n{out}",
);
}
#[ test ]
fn test_single_group_binary_name()
{
let mut data = CliHelpData::default();
data.binary = "myapp".into();
data.tagline = "a single-group tool".into();
data.groups = vec![ CommandGroup
{
name : "Cmds".into(),
entries : vec![ CommandEntry { name : "run".into(), desc : "run it".into() } ],
}];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!( out.contains( "Usage: myapp" ), "Usage: header with binary must appear, got:\n{out}" );
assert!( out.contains( "Commands:" ), "Commands: header must appear, got:\n{out}" );
assert!( out.contains( "Cmds" ), "group header must appear in output, got:\n{out}" );
assert!( out.contains( "run" ), "command name must appear in output, got:\n{out}" );
assert!(
!out.contains( "\x1b[" ),
"no ANSI codes with tty_detect=false, got:\n{out}",
);
}
#[ test ]
fn test_style_default_fields()
{
let s = CliHelpStyle::default();
assert_eq!( s.cmd_indent, 4 );
assert_eq!( s.cmd_name_width, 20 );
assert_eq!( s.grp_indent, 2 );
assert_eq!( s.opt_indent, 2 );
assert_eq!( s.opt_name_width, 18 );
assert_eq!( s.col_gap, 2 );
assert_eq!( s.example_indent, 2 );
}
#[ test ]
fn test_example_desc_rendered()
{
let out = CliHelpTemplate::new( no_tty_style(), two_group_data() ).render();
let first_line = out.lines()
.find( |l| l.contains( "myapp cmd-one" ) )
.unwrap_or_default();
assert!(
first_line.contains( "# run one" ),
"ExampleEntry with desc=Some must render '# run one' on the invocation line, got:\n{first_line:?}",
);
let second_line = out.lines()
.find( |l| l.contains( "myapp cmd-two" ) )
.unwrap_or_default();
assert!(
!second_line.contains( '#' ),
"ExampleEntry with desc=None must not render '#' on the invocation line, got:\n{second_line:?}",
);
}
#[ test ]
fn test_style_color_defaults()
{
let s = CliHelpStyle::default();
assert_eq!( s.color_tagline, "\x1b[1m", "color_tagline must be bold ANSI code" );
assert_eq!( s.color_group, "\x1b[33m\x1b[1m", "color_group must be yellow+bold ANSI codes" );
assert_eq!( s.color_option, "\x1b[1;36m", "color_option must be bold cyan ANSI code" );
assert_eq!( s.color_example, "\x1b[2m", "color_example must be dim ANSI code" );
assert_eq!( s.color_reset, "\x1b[0m", "color_reset must be ANSI reset sequence" );
assert!( s.tty_detect, "tty_detect must be true by default" );
}
#[ test ]
fn test_empty_groups()
{
let mut data = CliHelpData::default();
data.binary = "app".into();
data.tagline = "test tool".into();
data.options = vec![ OptionEntry { name : "verbose::bool".into(), desc : "Enable verbose".into() } ];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!( !out.is_empty(), "render with empty groups must return non-empty string" );
assert!( out.contains( "app" ), "binary name must appear in output, got:\n{out}" );
assert!( out.contains( "test tool" ), "tagline must appear in output, got:\n{out}" );
}
#[ test ]
fn test_opt_name_not_truncated()
{
let style = CliHelpStyle { opt_name_width : 10, tty_detect : false, ..CliHelpStyle::default() };
let mut data = CliHelpData::default();
data.binary = "app".into();
data.tagline = "test".into();
data.options = vec![ OptionEntry { name : "format::json".into(), desc : "format specifier".into() } ];
let out = CliHelpTemplate::new( style, data ).render();
assert!(
out.contains( "format::json" ),
"12-char option name must not be truncated when opt_name_width=10, got:\n{out}",
);
}
#[ test ]
fn test_tty_detect_true_renders_ansi_iff_tty()
{
use std::io::IsTerminal;
let out = CliHelpTemplate::new( CliHelpStyle::default(), two_group_data() ).render();
let is_tty = std::io::stdout().is_terminal();
if is_tty
{
assert!(
out.contains( "\x1b[" ),
"tty_detect=true in TTY environment must emit ANSI codes, got:\n{out}"
);
}
else
{
assert!(
!out.contains( "\x1b[" ),
"tty_detect=true in non-TTY environment must suppress ANSI codes, got:\n{out}"
);
}
}
#[ test ]
fn test_no_data_fmt_dependency()
{
let cargo = include_str!( "../Cargo.toml" );
assert!(
!cargo.contains( "data_fmt" ),
"cli_fmt must not depend on data_fmt — uses strs_tools primitives only"
);
}
#[ test ]
fn test_usage_lines()
{
let mut data = CliHelpData::default();
data.binary = "clr".into();
data.tagline = "runner".into();
data.usage_lines = vec![ "clr <command>".into() ];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!(
out.contains( " clr <command>" ),
"custom usage_lines must appear indented, got:\n{out}",
);
assert!(
!out.contains( "Usage: clr" ),
"default Usage: header must NOT appear when usage_lines is set, got:\n{out}",
);
let mut data = CliHelpData::default();
data.binary = "clr".into();
data.tagline = "runner".into();
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!(
out.contains( "Usage: clr" ),
"default Usage: header must appear when usage_lines is empty, got:\n{out}",
);
}
#[ test ]
fn test_arguments_section()
{
let mut data = CliHelpData::default();
data.binary = "app".into();
data.tagline = "test".into();
data.arguments = vec![ OptionEntry { name : "<MSG>".into(), desc : "Message to send".into() } ];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!(
out.contains( "Arguments:" ),
"Arguments: header must appear when arguments is non-empty, got:\n{out}",
);
assert!(
out.contains( " <MSG> Message to send" ),
"argument entry must render with content-driven padding, got:\n{out}",
);
let mut data = CliHelpData::default();
data.binary = "app".into();
data.tagline = "test".into();
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!(
!out.contains( "Arguments:" ),
"Arguments: header must NOT appear when arguments is empty, got:\n{out}",
);
}
#[ test ]
fn test_option_groups_render()
{
let mut data = CliHelpData::default();
data.binary = "app".into();
data.tagline = "test".into();
data.option_groups = vec!
[
OptionGroup
{
name : "RUNNER OPTIONS".into(),
entries : vec![ OptionEntry { name : "--flag".into(), desc : "A flag".into() } ],
},
];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!(
out.contains( "RUNNER OPTIONS:" ),
"option group header must appear, got:\n{out}",
);
assert!(
out.contains( " --flag A flag" ),
"option group entry must render with padding, got:\n{out}",
);
}
#[ test ]
fn test_option_groups_empty_backward_compat()
{
let mut data = CliHelpData::default();
data.binary = "app".into();
data.tagline = "test".into();
data.options = vec![ OptionEntry { name : "--opt".into(), desc : "desc".into() } ];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!(
out.contains( "Options:" ),
"legacy Options: section must appear when option_groups is empty, got:\n{out}",
);
assert!(
out.contains( "--opt" ),
"legacy option entry must appear, got:\n{out}",
);
}
#[ test ]
fn test_option_groups_suppresses_options()
{
let mut data = CliHelpData::default();
data.binary = "app".into();
data.tagline = "test".into();
data.options = vec![ OptionEntry { name : "--old".into(), desc : "old opt".into() } ];
data.option_groups = vec!
[
OptionGroup
{
name : "NEW".into(),
entries : vec![ OptionEntry { name : "--new".into(), desc : "new opt".into() } ],
},
];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!(
out.contains( "NEW:" ),
"option group header must appear, got:\n{out}",
);
assert!(
out.contains( " --new new opt" ),
"option group entry must appear, got:\n{out}",
);
assert!(
!out.contains( "--old" ),
"legacy option entry must NOT appear when option_groups is non-empty, got:\n{out}",
);
assert!(
!out.contains( "Options:" ),
"Options: header must NOT appear when option_groups is non-empty, got:\n{out}",
);
}
#[ test ]
fn test_option_groups_independent_padding()
{
let mut data = CliHelpData::default();
data.binary = "app".into();
data.tagline = "test".into();
data.option_groups = vec!
[
OptionGroup
{
name : "GROUP A".into(),
entries : vec!
[
OptionEntry { name : "--aa".into(), desc : "flag a".into() },
OptionEntry { name : "--bb".into(), desc : "flag b".into() },
],
},
OptionGroup
{
name : "GROUP B".into(),
entries : vec!
[
OptionEntry { name : "--longer-name".into(), desc : "a long flag".into() },
],
},
];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!(
out.contains( " --aa flag a" ),
"group A entry must use group A's own max_len (4), not group B's (13), got:\n{out}",
);
assert!(
out.contains( " --bb flag b" ),
"group A second entry must use same group-local padding, got:\n{out}",
);
assert!(
out.contains( " --longer-name a long flag" ),
"group B entry must use group B's own max_len (13), got:\n{out}",
);
}
#[ test ]
fn test_cli_help_data_default()
{
let data = CliHelpData::default();
assert!( data.binary.is_empty(), "binary must be empty" );
assert!( data.tagline.is_empty(), "tagline must be empty" );
assert!( data.groups.is_empty(), "groups must be empty" );
assert!( data.options.is_empty(), "options must be empty" );
assert!( data.examples.is_empty(), "examples must be empty" );
assert!( data.usage_lines.is_empty(), "usage_lines must be empty" );
assert!( data.arguments.is_empty(), "arguments must be empty" );
assert!( data.option_groups.is_empty(), "option_groups must be empty" );
}
#[ test ]
fn test_multiple_usage_lines()
{
let mut data = CliHelpData::default();
data.binary = "app".into();
data.tagline = "tool".into();
data.usage_lines = vec!
[
"app <command>".into(),
"app --help".into(),
"app <command> [options]".into(),
];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!(
out.contains( " app <command>\n" ),
"first custom usage line must render indented, got:\n{out}",
);
assert!(
out.contains( " app --help\n" ),
"second custom usage line must render indented, got:\n{out}",
);
assert!(
out.contains( " app <command> [options]\n" ),
"third custom usage line must render indented, got:\n{out}",
);
assert!(
!out.contains( "Usage: app" ),
"default Usage: header must not appear when usage_lines is non-empty, got:\n{out}",
);
}
#[ test ]
fn test_arguments_multi_entry_padding()
{
let mut data = CliHelpData::default();
data.binary = "app".into();
data.tagline = "test".into();
data.arguments = vec!
[
OptionEntry { name : "<A>".into(), desc : "short arg".into() },
OptionEntry { name : "<LONG-ARGUMENT>".into(), desc : "long arg".into() },
];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!(
out.contains( " <A> short arg" ),
"short argument name must be padded to match longest name (15 chars), got:\n{out}",
);
assert!(
out.contains( " <LONG-ARGUMENT> long arg" ),
"longest argument name must appear with no extra padding, got:\n{out}",
);
}
#[ test ]
fn test_command_group_empty_entries()
{
let mut data = CliHelpData::default();
data.binary = "app".into();
data.tagline = "test".into();
data.groups = vec!
[
CommandGroup { name : "EMPTY GROUP".into(), entries : vec![] },
CommandGroup
{
name : "FULL GROUP".into(),
entries : vec![ CommandEntry { name : "cmd".into(), desc : "a command".into() } ],
},
];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!( out.contains( "EMPTY GROUP" ), "empty-entries group header must appear, got:\n{out}" );
assert!( out.contains( "FULL GROUP" ), "non-empty group header must appear, got:\n{out}" );
assert!( out.contains( "cmd" ), "command from non-empty group must appear, got:\n{out}" );
}
#[ test ]
fn test_render_empty_data_infallible()
{
let data = CliHelpData::default();
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!( !out.is_empty(), "render of empty data must produce non-empty string" );
assert!( out.contains( "Usage:" ), "Usage: header must appear even with empty data, got:\n{out}" );
assert!( out.contains( "Commands:" ), "Commands: header must appear even with empty data, got:\n{out}" );
}
#[ test ]
fn test_example_empty_desc_some_renders_marker()
{
let mut data = CliHelpData::default();
data.binary = "app".into();
data.tagline = "test".into();
data.examples = vec!
[
ExampleEntry { invocation : "app cmd".into(), desc : Some( "".into() ) },
];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
let line = out.lines()
.find( |l| l.contains( "app cmd" ) )
.unwrap_or_default();
assert!(
line.contains( "# " ),
"ExampleEntry with desc=Some(\"\") must render '# ' marker, got:\n{line:?}",
);
}
#[ test ]
fn test_option_group_empty_entries_skipped()
{
let mut data = CliHelpData::default();
data.binary = "app".into();
data.tagline = "test".into();
data.option_groups = vec!
[
OptionGroup { name : "EMPTY SECTION".into(), entries : vec![] },
];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!(
!out.contains( "EMPTY SECTION:" ),
"OptionGroup with empty entries must not emit its header, got:\n{out}",
);
}
#[ test ]
fn test_empty_option_group_suppresses_legacy_options()
{
let mut data = CliHelpData::default();
data.binary = "app".into();
data.tagline = "test".into();
data.options = vec![ OptionEntry { name : "--verbose".into(), desc : "verbosity".into() } ];
data.option_groups = vec![ OptionGroup { name : "EMPTY GROUP".into(), entries : vec![] } ];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!(
!out.contains( "Options:" ),
"Options: must be suppressed when option_groups is non-empty, got:\n{out}",
);
assert!(
!out.contains( "--verbose" ),
"--verbose must not appear when option_groups suppresses legacy options, got:\n{out}",
);
assert!(
!out.contains( "EMPTY GROUP:" ),
"empty-entry OptionGroup header must not appear, got:\n{out}",
);
}
#[ test ]
fn test_arguments_before_groups_in_output()
{
let mut data = CliHelpData::default();
data.binary = "app".into();
data.tagline = "test".into();
data.arguments = vec![ OptionEntry { name : "<FILE>".into(), desc : "input file".into() } ];
data.groups = vec![ CommandGroup
{
name : "FILE OPS".into(),
entries : vec![ CommandEntry { name : "read".into(), desc : "read a file".into() } ],
}];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
let pos_args = out.find( "Arguments:" ).expect( "Arguments: must appear in output" );
let pos_grp = out.find( "FILE OPS" ).expect( "Group header must appear in output" );
assert!(
pos_args < pos_grp,
"Arguments: section must appear before group entries, but got Arguments at {pos_args}, group at {pos_grp}",
);
}
#[ test ]
fn test_examples_compile()
{
let mut data = CliHelpData::default();
data.binary = "mytool".into();
data.tagline = "A sample CLI tool.".into();
data.groups = vec![ CommandGroup
{
name : "Ops".into(),
entries : vec![ CommandEntry { name : "run".into(), desc : "Run it".into() } ],
}];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!( !out.is_empty(), "example construction pattern must produce non-empty output" );
}
#[ test ]
fn test_examples_declaration_order()
{
let mut data = CliHelpData::default();
data.binary = "app".into();
data.tagline = "test".into();
data.examples = vec!
[
ExampleEntry { invocation : "app cmd-a".into(), desc : None },
ExampleEntry { invocation : "app cmd-b".into(), desc : None },
];
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
let pos_a = out.find( "app cmd-a" ).expect( "first example must appear in output" );
let pos_b = out.find( "app cmd-b" ).expect( "second example must appear in output" );
assert!(
pos_a < pos_b,
"first declared example must appear before second in rendered output (pos_a={pos_a}, pos_b={pos_b})"
);
}
#[ test ]
fn test_tagline_blank_line_separator()
{
let mut data = CliHelpData::default();
data.binary = "myapp".into();
data.tagline = "My helpful tool".into();
let out = CliHelpTemplate::new( no_tty_style(), data ).render();
assert!(
out.contains( "My helpful tool" ),
"tagline must appear in rendered output, got:\n{out}"
);
assert!(
out.contains( "\n\n" ),
"a blank line must separate usage line from tagline, got:\n{out:?}"
);
let pos_usage = out.find( "Usage: myapp" ).expect( "Usage: header must appear in output" );
let pos_tagline = out.find( "My helpful tool" ).expect( "tagline must appear in output" );
assert!(
pos_usage < pos_tagline,
"usage line must appear before tagline (pos_usage={pos_usage}, pos_tagline={pos_tagline})"
);
}
#[ test ]
fn test_col_gap_custom()
{
let style = CliHelpStyle { col_gap : 4, cmd_name_width : 7, tty_detect : false, ..CliHelpStyle::default() };
let mut data = CliHelpData::default();
data.groups = vec!
[
CommandGroup
{
name : "CMDS".into(),
entries : vec![ CommandEntry { name : "cmd-one".into(), desc : "do one thing".into() } ],
},
];
let out = CliHelpTemplate::new( style, data ).render();
assert!(
out.contains( " cmd-one do one thing" ),
"FT-31: col_gap=4 must produce 4 spaces between padded name column and description, got:\n{out}",
);
}
#[ test ]
fn test_cmd_indent_custom()
{
let style = CliHelpStyle { cmd_indent : 2, cmd_name_width : 3, tty_detect : false, ..CliHelpStyle::default() };
let mut data = CliHelpData::default();
data.groups = vec!
[
CommandGroup
{
name : "CMDS".into(),
entries : vec![ CommandEntry { name : "run".into(), desc : "run the app".into() } ],
},
];
let out = CliHelpTemplate::new( style, data ).render();
assert!(
out.contains( " run run the app" ),
"FT-32: cmd_indent=2 must produce 2-space leading indent instead of default 4, got:\n{out}",
);
}