Skip to main content

usage/docs/cli/
mod.rs

1use crate::{Spec, SpecCommand};
2use std::sync::LazyLock;
3use tera::Tera;
4
5mod style;
6pub use style::Style;
7
8pub fn render_help(spec: &Spec, cmd: &SpecCommand, long: bool) -> String {
9    render_help_styled(spec, cmd, long, Style::PLAIN)
10}
11
12/// Render a terminal help page with an explicit colour policy.
13pub fn render_help_styled(spec: &Spec, cmd: &SpecCommand, long: bool, style: Style) -> String {
14    // Convert to docs models to get layout calculations
15    let docs_spec = crate::docs::models::Spec::from(spec);
16    let mut docs_cmd = crate::docs::models::SpecCommand::from(&without_hidden(cmd, long));
17
18    let mut ctx = tera::Context::new();
19    ctx.insert("spec", &docs_spec);
20    ctx.insert("long", &long);
21    // Which page this is. The banner and the program's own description belong to the
22    // program's page; a subcommand's page describes the subcommand, which is the question
23    // that was asked. `full_cmd` is the path a user would type, so the root's is empty.
24    ctx.insert("root", &docs_cmd.full_cmd.is_empty());
25    // Keep this out of the recursively serialized docs command. It controls this page only;
26    // carrying it on every descendant makes rendering a whole command tree pay for the same
27    // boolean at every level.
28    ctx.insert("show_help_subcommand", &!cmd.disable_help_subcommand);
29    // Everything this command inherits: from each ancestor, only what it declared `global` —
30    // the rule the parser follows on the way down. `full_cmd` is the typed path, so walking it
31    // from the root gives the exact ancestry with none of the ambiguity a search would have.
32    //
33    // Listed nowhere before this: `communique generate` accepts `--config` from its root and
34    // its page mentioned none of it — a flag a user can type and cannot discover.
35    let (mut inherited, ancestors_taken) = inherited_flags(spec, cmd, &docs_cmd.full_cmd, long);
36
37    // One column over both lists, so the two sections read as one table with a rule through it
38    // rather than two that happen to be adjacent. The width feeds the wrapping as well as the
39    // padding — a continuation line is indented to sit under the description — so both lists
40    // are laid out again once the width is known.
41    // Last in the command's own section, which is where clap has them: they carry no
42    // `help_heading`, so a CLI that groups its flags gets them at the end of the ungrouped
43    // list rather than inside somebody's section.
44    {
45        let supplied = supplied_flags(spec, cmd, &ancestors_taken, docs_cmd.full_cmd.is_empty());
46        if !supplied.is_empty() {
47            match docs_cmd
48                .flag_groups
49                .iter_mut()
50                .find(|g| g.heading.is_none())
51            {
52                Some(group) => group.items.extend(supplied),
53                // Inserted first, not pushed: `group_by_heading` sorts the unheaded group to
54                // the front and argv's `groups_section` emits it there, so a CLI that heads
55                // every one of its flags would otherwise get `Flags:` *after* the headed
56                // sections here and before them there.
57                None => docs_cmd.flag_groups.insert(
58                    0,
59                    crate::docs::models::Group {
60                        heading: None,
61                        help: None,
62                        help_rendered: None,
63                        items: supplied,
64                    },
65                ),
66            }
67        }
68    }
69
70    // The terminal's width, less the margin the root page reserves for a logo: help wraps to
71    // whatever width it is given, so a page laid out at the full width leaves no margin for
72    // the art to sit in. See `crate::docs::logo::margin`.
73    let terminal = crate::docs::layout::help_width(cmd.term_width, cmd.max_term_width);
74    let logo_margin = docs_cmd
75        .full_cmd
76        .is_empty()
77        .then_some(spec.logo.as_deref())
78        .flatten()
79        .and_then(|logo| crate::docs::logo::margin(logo, terminal));
80    let width = logo_margin.map_or(terminal, |(page, _)| page);
81    ctx.insert("terminal_width", &width);
82    lay_out_group_help(&mut docs_cmd.subcommand_groups, width);
83    lay_out_group_help(&mut docs_cmd.arg_groups, width);
84    lay_out_group_help(&mut docs_cmd.flag_groups, width);
85    let col = crate::docs::layout::usage_column_width(
86        docs_cmd
87            .flag_groups
88            .iter()
89            .flat_map(|g| g.items.iter())
90            .chain(inherited.iter())
91            .map(|f| f.display_usage.as_str()),
92        width,
93    );
94    let flag_column = Column {
95        width,
96        col,
97        long,
98        next_line: cmd.next_line_help,
99    };
100    for group in &mut docs_cmd.flag_groups {
101        lay_out(&mut group.items, flag_column);
102    }
103    lay_out(&mut inherited, flag_column);
104
105    // The arguments get their own column, laid out here for the page being rendered rather than
106    // taken from the model's — which is the long page's, and would put a long description in the
107    // short page's column unwrapped.
108    let arg_col = crate::docs::layout::usage_column_width(
109        docs_cmd.args.iter().map(|a| a.usage.as_str()),
110        width,
111    );
112    for group in &mut docs_cmd.arg_groups {
113        lay_out_args(
114            &mut group.items,
115            Column {
116                width,
117                col: arg_col,
118                long,
119                next_line: cmd.next_line_help,
120            },
121        );
122    }
123
124    // Flattened descendants live on this page, so this page's width governs them. Their own
125    // `term_width` applies when they get a page of their own, not when an ancestor expands them.
126    lay_out_flattened(&mut docs_cmd.flattened_subcommands, width, long);
127
128    // The command list, laid out the way the flag list is: one column for the whole page, the
129    // name in it, and everything else — summary, aliases, deprecation — trailing as text that
130    // wraps under itself. Both pages get the same rows, so `--help` no longer reprints every
131    // child's long help on its parent's page.
132    let help_row = {
133        let show_help_row = !docs_cmd.subcommands.is_empty()
134            && !docs_cmd.flatten_help
135            && !cmd.disable_help_subcommand;
136        let cmd_col = crate::docs::layout::usage_column_width(
137            docs_cmd
138                .subcommand_groups
139                .iter()
140                .flat_map(|g| g.items.iter())
141                .map(|c| c.name.as_str())
142                .chain(show_help_row.then_some(HELP_SUBCOMMAND)),
143            width,
144        );
145        let default_name = docs_cmd
146            .full_cmd
147            .is_empty()
148            .then(|| {
149                spec.default_subcommand.as_deref().and_then(|name| {
150                    spec.cmd
151                        .find_subcommand(name)
152                        .filter(|child| !child.hide)
153                        .map(|child| child.name.as_str())
154                })
155            })
156            .flatten();
157        for group in &mut docs_cmd.subcommand_groups {
158            lay_out_commands(
159                &mut group.items,
160                width,
161                cmd_col,
162                cmd.next_line_help,
163                default_name,
164            );
165        }
166        // Rendered here rather than in the template because it is a row like any other: it
167        // sits in the same column and wraps by the same rule, and neither is something a
168        // template should be working out.
169        show_help_row.then(|| {
170            render_row(
171                HELP_SUBCOMMAND,
172                HELP_SUBCOMMAND_SUMMARY,
173                width,
174                cmd_col,
175                cmd.next_line_help,
176            )
177        })
178    };
179    ctx.insert("help_row", &help_row);
180
181    let arg_has_ungrouped = docs_cmd
182        .arg_groups
183        .iter()
184        .any(|group| group.heading.is_none());
185    let arg_has_grouped = docs_cmd
186        .arg_groups
187        .iter()
188        .any(|group| group.heading.is_some());
189    let flag_has_ungrouped = docs_cmd
190        .flag_groups
191        .iter()
192        .any(|group| group.heading.is_none());
193    let flag_has_grouped = docs_cmd
194        .flag_groups
195        .iter()
196        .any(|group| group.heading.is_some());
197    ctx.insert("arg_has_ungrouped", &arg_has_ungrouped);
198    ctx.insert("arg_has_grouped", &arg_has_grouped);
199    ctx.insert("flag_has_ungrouped", &flag_has_ungrouped);
200    ctx.insert("flag_has_grouped", &flag_has_grouped);
201
202    // Inserted after the layout, not before: the template reads the widths, and a `cmd` put
203    // into the context first would carry the ones computed before the two lists were joined.
204    ctx.insert("cmd", &docs_cmd);
205    ctx.insert("global_flags", &inherited);
206    for (name, mark) in MARKS {
207        ctx.insert(name, &mark);
208    }
209    ctx.insert("mark_grouped_args", &MARK_GROUPED_ARGS);
210    ctx.insert("mark_grouped_flags", &MARK_GROUPED_FLAGS);
211    ctx.insert("mark_global_flags", &MARK_GLOBAL_FLAGS);
212    let template = if long {
213        "spec_template_long.tera"
214    } else {
215        "spec_template_short.tera"
216    };
217    let rendered = TERA.render(template, &ctx).unwrap();
218    let sections = Sections::split(&rendered);
219    let styling = style::Styling::new(
220        &docs_cmd,
221        &inherited,
222        sections.usage,
223        !cmd.disable_help_subcommand,
224    );
225    let page = match spec
226        .help_template
227        .as_deref()
228        .filter(|t| crate::help_template::is_set(t))
229    {
230        Some(template) => {
231            crate::help_template::substitute_with_style(template, style.coloured, |name| {
232                sections
233                    .named(name)
234                    .map(|section| styling.apply(&section, style))
235            })
236        }
237        None => styling.apply(&sections.concatenated(), style),
238    };
239    let page = if style.coloured {
240        std::borrow::Cow::Borrowed(page.as_str())
241    } else {
242        crate::docs::strip_ansi(&page)
243    };
244    let mut page = page.trim().to_string();
245    page.push('\n');
246    // Last, and only on the program's own page. The art is positioned by column, so anything
247    // that trims or re-wraps the page afterwards would move it; and a default subcommand's
248    // page is appended after this, below the logo rather than beside it.
249    if docs_cmd.full_cmd.is_empty() {
250        if let Some(logo) = spec.logo.as_deref() {
251            page = crate::docs::logo::place(
252                &page,
253                logo,
254                spec.logo_style.as_deref(),
255                terminal,
256                logo_margin.map(|(_, column)| column),
257                style.coloured,
258            );
259        }
260    }
261    append_default_command_help(spec, cmd, long, style, page)
262}
263
264fn append_default_command_help(
265    spec: &Spec,
266    cmd: &SpecCommand,
267    long: bool,
268    style: Style,
269    parent: String,
270) -> String {
271    if !cmd.full_cmd.is_empty() || !spec.default_subcommand_help || cmd.flatten_help {
272        return parent;
273    }
274    let Some(child) = spec
275        .default_subcommand
276        .as_deref()
277        .and_then(|name| spec.cmd.find_subcommand(name))
278        .filter(|child| !child.hide)
279    else {
280        return parent;
281    };
282    let child_page = render_help_styled(spec, child, long, style);
283    let mut out = parent.trim_end().to_string();
284    out.push_str("\n\nDefault command: ");
285    out.push_str(&crate::help_template::semantic(
286        "command",
287        &child.name,
288        style.coloured,
289    ));
290    out.push_str("\n  Unmatched words select this command.\n\n");
291    out.push_str(child_page.trim_end());
292    out.push('\n');
293    out
294}
295
296/// Where each section of a rendered page starts, as the templates write it.
297///
298/// The layout lives in the templates, and this is how it stays there: each one emits a marker
299/// at every section boundary, so the boundaries are declared beside the sections rather than
300/// worked out again here. A page with no `help_template` is the marks taken back out, which is
301/// the same string the templates produced before any of this existed — and what the fleet gate
302/// compares byte for byte.
303///
304/// Control characters, because a marker has to be something no help text contains and no
305/// terminal shows if one ever escapes.
306const MARKS: [(&str, &str); 6] = [
307    ("mark_usage", "\u{1}usage\u{1}"),
308    ("mark_commands", "\u{1}commands\u{1}"),
309    ("mark_args", "\u{1}args\u{1}"),
310    ("mark_flags", "\u{1}flags\u{1}"),
311    ("mark_flattened", "\u{1}flattened\u{1}"),
312    ("mark_after_help", "\u{1}after_help\u{1}"),
313];
314const MARK_GROUPED_ARGS: &str = "\u{1}grouped_args\u{1}";
315const MARK_GROUPED_FLAGS: &str = "\u{1}grouped_flags\u{1}";
316const MARK_GLOBAL_FLAGS: &str = "\u{1}global_flags\u{1}";
317
318/// A rendered page cut into the sections a `help_template` may reorder.
319///
320/// The twin of `usage_argv::help`'s `Sections`, down to `flattened` not being a section an
321/// author can name: it is the other half of `commands`, since `flatten_help` replaces a
322/// command list with the subcommands' own bodies, and only one of the two is ever there.
323struct Sections<'a> {
324    about: &'a str,
325    usage: &'a str,
326    commands: &'a str,
327    args: String,
328    flags: String,
329    grouped_args: &'a str,
330    ungrouped_args: &'a str,
331    grouped_flags: &'a str,
332    ungrouped_flags: String,
333    flattened: &'a str,
334    after_help: &'a str,
335}
336
337impl<'a> Sections<'a> {
338    fn split(rendered: &'a str) -> Self {
339        let mut rest = rendered;
340        let mut parts: Vec<&str> = Vec::with_capacity(MARKS.len() + 1);
341        for (_, mark) in MARKS {
342            // A missing marker leaves that section empty rather than swallowing the ones after
343            // it: every one is written at the top level of both templates, so this cannot
344            // happen, and it is not worth a panic in a help renderer if it ever does.
345            match rest.split_once(mark) {
346                Some((before, after)) => {
347                    parts.push(before);
348                    rest = after;
349                }
350                None => parts.push(""),
351            }
352        }
353        parts.push(rest);
354        let (ungrouped_args, grouped_args) = parts[3]
355            .split_once(MARK_GROUPED_ARGS)
356            .unwrap_or((parts[3], ""));
357        let (own_flags, global_flags) = parts[4]
358            .split_once(MARK_GLOBAL_FLAGS)
359            .unwrap_or((parts[4], ""));
360        let (own_ungrouped_flags, grouped_flags) = own_flags
361            .split_once(MARK_GROUPED_FLAGS)
362            .unwrap_or((own_flags, ""));
363        Self {
364            about: parts[0],
365            usage: parts[1],
366            commands: parts[2],
367            args: format!("{ungrouped_args}{grouped_args}"),
368            flags: format!("{own_ungrouped_flags}{grouped_flags}{global_flags}"),
369            grouped_args,
370            ungrouped_args,
371            grouped_flags,
372            ungrouped_flags: format!("{own_ungrouped_flags}{global_flags}"),
373            flattened: parts[5],
374            after_help: parts[6],
375        }
376    }
377
378    /// The default page: every section in the order the templates wrote them.
379    fn concatenated(&self) -> String {
380        [
381            self.about,
382            self.usage,
383            self.commands,
384            self.args.as_str(),
385            self.flags.as_str(),
386            self.flattened,
387            self.after_help,
388        ]
389        .concat()
390    }
391
392    /// One section by name, trimmed, so that a template owns the whitespace between them.
393    fn named(&self, name: &str) -> Option<String> {
394        Some(match name {
395            "about" => self.about.trim().to_string(),
396            "usage" => self.usage.trim().to_string(),
397            "commands" => {
398                let mut out = self.commands.trim().to_string();
399                let flattened = self.flattened.trim();
400                if !flattened.is_empty() {
401                    if !out.is_empty() {
402                        out.push_str("\n\n");
403                    }
404                    out.push_str(flattened);
405                }
406                out
407            }
408            "args" => self.args.trim().to_string(),
409            "flags" => self.flags.trim().to_string(),
410            "grouped_args" => self.grouped_args.trim().to_string(),
411            "ungrouped_args" => self.ungrouped_args.trim().to_string(),
412            "grouped_flags" => self.grouped_flags.trim().to_string(),
413            "ungrouped_flags" => self.ungrouped_flags.trim().to_string(),
414            "after_help" => self.after_help.trim().to_string(),
415            _ => return None,
416        })
417    }
418}
419
420/// The entries for `--help` and `--version`, which the parser supplies and no spec declares.
421///
422/// Listed because help is written for people: a reader looking for how to ask for help should
423/// find it on the page. This reverses the rule these two used to follow — that a page lists
424/// exactly what its spec declares — and the reason is that the spec has its own readers, and
425/// they are not the ones reading this.
426///
427/// `--version` only on the program's own page and only where a version is declared, which is
428/// where a parser accepts one. Each spelling is dropped where the CLI claimed it, since a page
429/// must not describe a flag that something else binds.
430///
431/// The twin of `supplied_entries` in `usage-argv`'s `help` module; the gate over mise's spec is
432/// what says the two agree.
433fn supplied_flags(
434    spec: &Spec,
435    cmd: &SpecCommand,
436    ancestors_taken: &[String],
437    is_root: bool,
438) -> Vec<crate::docs::models::SpecFlag> {
439    // The command's own spellings plus everything in scope above it — the set the inherited
440    // walk built, which counts hidden globals and negations. Rebuilding it from the *visible*
441    // inherited list lost both: a hidden ancestor that binds `--help` would have had the page
442    // offer it anyway.
443    let mut taken: Vec<String> = ancestors_taken.to_vec();
444    for f in &cmd.flags {
445        taken.extend(f.long.iter().map(|l| format!("--{l}")));
446        taken.extend(f.short.iter().map(|s| format!("-{s}")));
447        // Stored with its dashes here, unlike in usage-argv.
448        taken.extend(f.negate.clone());
449    }
450
451    let build = |name: &str, long: &str, short: char, help: &str| {
452        let long_free = !taken.contains(&format!("--{long}"));
453        let short_free = !taken.contains(&format!("-{short}"));
454        if !long_free && !short_free {
455            return None;
456        }
457        // Named after the form it shows: a short-only entry called `help` reads as a renamed
458        // flag and printed `help: -h`.
459        let name = if long_free { name } else { &short.to_string() };
460        let mut flag = crate::SpecFlag {
461            name: name.to_string(),
462            long: if long_free {
463                vec![long.to_string()]
464            } else {
465                vec![]
466            },
467            short: if short_free { vec![short] } else { vec![] },
468            help: Some(help.to_string()),
469            ..Default::default()
470        };
471        flag.usage = flag.usage();
472        Some(crate::docs::models::SpecFlag::from(&flag))
473    };
474
475    let mut out = Vec::new();
476    // `disable_help` turns the parser's answer off — `is_help_arg` refuses the spelling
477    // outright — so a page that still listed it would describe an action nothing performs.
478    // The same rule as a claimed or hidden spelling, with the claim made by the spec itself.
479    //
480    // usage-argv has no equivalent: `disable_help` is a KDL-only word, so no spec that crate
481    // can hold ever carries one, and the two renderers cannot disagree about it.
482    if spec.disable_help != Some(true) && !cmd.disable_help_flag {
483        out.extend(build("help", "help", 'h', "Print help"));
484    }
485    if is_root
486        && (spec.version.is_some() || spec.long_version.is_some())
487        && !cmd.disable_version_flag
488    {
489        out.extend(build("version", "version", 'V', "Print version"));
490    }
491    out
492}
493
494/// Fit a list of flags to a column: how wide their names are, and where their help wraps.
495///
496/// The same pass `SpecCommand::from` makes, run again once the width is known over *both* the
497/// command's own flags and the ones it inherits. The width is not only padding — a wrapped
498/// description is indented to sit under itself — so it cannot be decided per section and then
499/// shared.
500fn lay_out(flags: &mut [crate::docs::models::SpecFlag], column: Column) {
501    for flag in flags {
502        flag.usage_col_width = column.col;
503        flag.help_is_block =
504            !column.can_inline(crate::docs::layout::visible_width(&flag.display_usage));
505        let text = if column.long {
506            flag.help_long
507                .as_deref()
508                .or(flag.help.as_deref())
509                .map(str::to_string)
510        } else if column.next_line {
511            flag.help.clone()
512        } else {
513            with_annotations(flag.help.as_deref(), flag_annotations(flag))
514        };
515        wrap_into(
516            text,
517            column,
518            crate::docs::layout::visible_width(&flag.display_usage),
519            &mut flag.row,
520            &mut flag.help_rendered,
521            &mut flag.help_is_multiline,
522            &mut flag.ann_indent,
523        );
524    }
525}
526
527/// The same pass over a command's arguments.
528///
529/// `SpecCommand::from` already made one, but it made the long page's — the short page prefers
530/// the short description and carries the annotations in the text — so the page it is actually
531/// rendering gets the last word.
532fn lay_out_args(args: &mut [crate::docs::models::SpecArg], column: Column) {
533    for arg in args {
534        arg.usage_col_width = column.col;
535        arg.help_is_block = !column.can_inline(crate::docs::layout::visible_width(&arg.usage));
536        let text = if column.long {
537            arg.help_long
538                .as_deref()
539                .or(arg.help.as_deref())
540                .map(str::to_string)
541        } else if column.next_line {
542            arg.help.clone()
543        } else {
544            with_annotations(arg.help.as_deref(), arg_annotations(arg))
545        };
546        wrap_into(
547            text,
548            column,
549            crate::docs::layout::visible_width(&arg.usage),
550            &mut arg.row,
551            &mut arg.help_rendered,
552            &mut arg.help_is_multiline,
553            &mut arg.ann_indent,
554        );
555    }
556}
557
558fn lay_out_flattened(commands: &mut [crate::docs::models::SpecCommand], width: usize, long: bool) {
559    for command in commands {
560        lay_out_group_help(&mut command.arg_groups, width);
561        lay_out_group_help(&mut command.flag_groups, width);
562        let arg_col = crate::docs::layout::usage_column_width(
563            command
564                .arg_groups
565                .iter()
566                .flat_map(|group| group.items.iter())
567                .map(|arg| arg.usage.as_str()),
568            width,
569        );
570        let arg_column = Column {
571            width,
572            col: arg_col,
573            long,
574            next_line: command.flattened_next_line_help,
575        };
576        for group in &mut command.arg_groups {
577            lay_out_args(&mut group.items, arg_column);
578        }
579
580        let flag_col = crate::docs::layout::usage_column_width(
581            command
582                .flag_groups
583                .iter()
584                .flat_map(|group| group.items.iter())
585                .map(|flag| flag.display_usage.as_str()),
586            width,
587        );
588        let flag_column = Column {
589            width,
590            col: flag_col,
591            long,
592            next_line: command.flattened_next_line_help,
593        };
594        for group in &mut command.flag_groups {
595            lay_out(&mut group.items, flag_column);
596        }
597    }
598}
599
600fn lay_out_group_help<T>(groups: &mut [crate::docs::models::Group<T>], width: usize) {
601    for group in groups {
602        group.help_rendered = group
603            .help
604            .as_deref()
605            .map(|help| crate::docs::layout::render_indented_text(help, width, 2));
606    }
607}
608
609/// Fit one entry's text to the column, and say which layout it wants.
610///
611/// `row` is the text as composed and `help_rendered` the same text wrapped; an empty wrapping
612/// is how [`crate::docs::layout::render_help_text`] says "no room, put it underneath instead",
613/// which is the case the template reads `row` for.
614fn wrap_into(
615    text: Option<String>,
616    column: Column,
617    usage_width: usize,
618    row: &mut Option<String>,
619    help_rendered: &mut Option<String>,
620    help_is_multiline: &mut bool,
621    ann_indent: &mut String,
622) {
623    *row = None;
624    *help_rendered = None;
625    *help_is_multiline = false;
626    // An entry with nothing in the column still has annotations to place, and the column is
627    // where they go: it is the entry's own row that is empty, not the table's.
628    *ann_indent = column.annotation_indent(column.can_inline(usage_width));
629    let Some(text) = text else { return };
630    if !column.can_inline(usage_width) {
631        let indent = column.block_indent();
632        *row = Some(indent_text(
633            &crate::docs::layout::render_indented_text(&text, column.width, indent),
634            indent,
635        ));
636        return;
637    }
638    let first_indent = column.inline_help_start(usage_width);
639    let continuation_indent = column.description_indent();
640    let (rendered, is_multiline) = crate::docs::layout::render_help_text_at(
641        &text,
642        column.width,
643        first_indent,
644        continuation_indent,
645    );
646    // `render_help_text` wraps whatever it is given; whether the page *uses* that is the
647    // template's decision, and on a next-line page it does not. Both have to agree, or the
648    // annotations align to a column the description never entered.
649    *ann_indent = column.annotation_indent(!rendered.is_empty());
650    if !rendered.is_empty() {
651        *help_rendered = Some(rendered);
652        *help_is_multiline = is_multiline;
653    }
654    *row = Some(text);
655}
656
657fn indent_text(text: &str, indent: usize) -> String {
658    let pad = " ".repeat(indent);
659    text.lines()
660        .map(|line| {
661            if line.is_empty() {
662                String::new()
663            } else {
664                format!("{pad}{line}")
665            }
666        })
667        .collect::<Vec<_>>()
668        .join("\n")
669}
670
671/// A short entry's description with its annotations joined on.
672///
673/// The wide layout gives each annotation a line of its own; the narrow one has no room for
674/// that, so they ride along with the description — and they have to be joined *before* it is
675/// wrapped, or an entry with a long description keeps its `[env: …]` out past the column where
676/// the wrapping was supposed to bring the text back.
677fn with_annotations(help: Option<&str>, annotations: Vec<String>) -> Option<String> {
678    let mut parts = Vec::new();
679    if let Some(help) = summarize(help) {
680        parts.push(help.to_string());
681    }
682    parts.extend(annotations);
683    (!parts.is_empty()).then(|| parts.join(" "))
684}
685
686/// What a flag's short entry says about it beyond its description.
687fn flag_annotations(flag: &crate::docs::models::SpecFlag) -> Vec<String> {
688    let mut parts = value_annotations(
689        flag.arg.as_ref().and_then(|arg| arg.choices.as_ref()),
690        flag.hide_possible_values,
691        flag.env.as_deref(),
692        flag.hide_env,
693        &flag.env_fallback,
694        &flag.deprecated_env,
695        &flag.default,
696        flag.hide_default_value,
697    );
698    if let Some(label) = deprecation_label(
699        flag.deprecated.as_deref(),
700        flag.deprecated_warn_at.as_deref(),
701        flag.deprecated_remove_at.as_deref(),
702    ) {
703        parts.push(label);
704    }
705    parts
706}
707
708/// The same for an argument, which carries no deprecation on the narrow page.
709fn arg_annotations(arg: &crate::docs::models::SpecArg) -> Vec<String> {
710    value_annotations(
711        arg.choices.as_ref(),
712        arg.hide_possible_values,
713        arg.env.as_deref(),
714        arg.hide_env,
715        &arg.env_fallback,
716        &arg.deprecated_env,
717        &arg.default,
718        arg.hide_default_value,
719    )
720}
721
722/// What can be said about a value, in the order the narrow page says it.
723#[allow(clippy::too_many_arguments)]
724fn value_annotations(
725    choices: Option<&crate::SpecChoices>,
726    hide_possible_values: bool,
727    env: Option<&str>,
728    hide_env: bool,
729    env_fallback: &[String],
730    deprecated_env: &[String],
731    default: &[String],
732    hide_default_value: bool,
733) -> Vec<String> {
734    let mut parts = Vec::new();
735    if let Some(choices) = choices.filter(|_| !hide_possible_values) {
736        if !choices.choices.is_empty() {
737            parts.push(format!("[{}]", choices.choices.join(", ")));
738        }
739        if let Some(env) = choices.env() {
740            parts.push(format!("[choices env: {env}]"));
741        }
742    }
743    if !hide_env {
744        if let Some(env) = env {
745            parts.push(format!("[env: {env}]"));
746        }
747        parts.extend(
748            env_fallback
749                .iter()
750                .map(|env| format!("[env fallback: {env}]")),
751        );
752        parts.extend(
753            deprecated_env
754                .iter()
755                .map(|env| format!("[deprecated env: {env}]")),
756        );
757    }
758    if !hide_default_value && !default.is_empty() {
759        parts.push(format!("(default: {})", default.join(", ")));
760    }
761    parts
762}
763
764/// What a page is fitting its entries to.
765#[derive(Clone, Copy)]
766struct Column {
767    /// The width the page is laid out for.
768    width: usize,
769    /// How wide the usage column is.
770    col: usize,
771    /// The long page, which prefers the long description and gives each annotation a line.
772    long: bool,
773    /// A page that puts every description under its usage rather than beside it.
774    next_line: bool,
775}
776
777/// The indent a page uses when it cannot align to its column.
778const BLOCK_INDENT: usize = 4;
779
780impl Column {
781    fn usage_overflows(&self, usage_width: usize) -> bool {
782        !self.next_line && usage_width > self.col
783    }
784
785    fn description_indent(&self) -> usize {
786        2 + self.col + 2
787    }
788
789    fn inline_help_start(&self, usage_width: usize) -> usize {
790        if self.usage_overflows(usage_width) {
791            2 + usage_width + 2
792        } else {
793            self.description_indent()
794        }
795    }
796
797    /// Keep an outlier inline only when its spelling leaves a useful amount of prose.
798    fn can_inline(&self, usage_width: usize) -> bool {
799        if self.next_line {
800            return false;
801        }
802        let minimum = if self.usage_overflows(usage_width) {
803            crate::docs::layout::MIN_INLINE_HELP_WIDTH
804        } else {
805            10
806        };
807        self.width
808            .saturating_sub(self.inline_help_start(usage_width))
809            >= minimum
810    }
811
812    /// Where an entry's annotations are indented to.
813    ///
814    /// The description column, when the description reached it — an annotation is a note about
815    /// the same entry and belongs under the text it qualifies, not in the gutter beside a
816    /// column it is ignoring. Where the description is already a block underneath, there is no
817    /// column to align to and the annotations join it there.
818    fn annotation_indent(&self, reached_column: bool) -> String {
819        let indent = if reached_column {
820            self.description_indent()
821        } else {
822            self.block_indent()
823        };
824        " ".repeat(indent)
825    }
826
827    /// Prefer the normal description column for a stacked entry whenever it still leaves a
828    /// useful line. Exceptionally narrow pages retain the compact four-space fallback.
829    fn block_indent(&self) -> usize {
830        let description = self.description_indent();
831        if !self.next_line && self.width.saturating_sub(description) >= 10 {
832            description
833        } else {
834            BLOCK_INDENT
835        }
836    }
837}
838
839/// The entry every command list ends with, unless the CLI turned it off.
840const HELP_SUBCOMMAND: &str = "help";
841const HELP_SUBCOMMAND_SUMMARY: &str = "Print this message or the help of the given subcommand(s)";
842
843/// Fit a list of subcommand summaries to the page's command column.
844///
845/// `lay_out`'s counterpart for the command list: the same column, the same wrapping, the same
846/// "an empty rendering means use the block layout" signal to the template.
847fn lay_out_commands(
848    commands: &mut [crate::docs::models::HelpCommand],
849    terminal_width: usize,
850    col: usize,
851    next_line: bool,
852    default_name: Option<&str>,
853) {
854    let column = Column {
855        width: terminal_width,
856        col,
857        long: false,
858        next_line,
859    };
860    for command in commands {
861        command.usage_col_width = col;
862        command.row = command_row(command, default_name == Some(command.name.as_str()));
863        command.help_rendered = None;
864        command.help_is_multiline = false;
865        let usage_width = crate::docs::layout::visible_width(&command.name);
866        if let Some(row) = command.row.as_deref() {
867            if column.can_inline(usage_width) {
868                let (rendered, is_multiline) = crate::docs::layout::render_help_text_at(
869                    row,
870                    terminal_width,
871                    column.inline_help_start(usage_width),
872                    column.description_indent(),
873                );
874                if !rendered.is_empty() {
875                    command.help_rendered = Some(rendered);
876                    command.help_is_multiline = is_multiline;
877                }
878            } else if let Some(row) = command.row.as_mut() {
879                let indent = column.block_indent();
880                *row = indent_text(
881                    &crate::docs::layout::render_indented_text(row, terminal_width, indent),
882                    indent,
883                );
884            }
885        }
886    }
887}
888
889/// Everything that follows a command's name in its parent's list, as one string.
890///
891/// The name alone occupies the column, so the summaries line up down the page and the syntax a
892/// command takes belongs to that command's own page. What qualifies the command rather than
893/// describing it — the names it also answers to, that it is going away — trails the summary,
894/// where it wraps with the text instead of pushing it out of the column.
895fn command_row(cmd: &crate::docs::models::HelpCommand, is_default: bool) -> Option<String> {
896    let mut parts = Vec::new();
897    // A command that wrote only `help_long` still has a summary: its first line. Both pages
898    // read the same one, so `-h` never says less about a command than `--help` does.
899    let summary = summarize(cmd.help.as_deref()).or_else(|| {
900        summarize(
901            cmd.help_long
902                .as_deref()
903                .and_then(|help| help.lines().next()),
904        )
905    });
906    if let Some(summary) = summary {
907        parts.push(summary.to_string());
908    }
909    if !cmd.aliases.is_empty() {
910        parts.push(format!("[aliases: {}]", cmd.aliases.join(", ")));
911    }
912    if let Some(label) = deprecation_label(
913        cmd.deprecated.as_deref(),
914        cmd.deprecated_warn_at.as_deref(),
915        cmd.deprecated_remove_at.as_deref(),
916    ) {
917        parts.push(label);
918    }
919    if is_default {
920        parts.push("(default)".to_string());
921    }
922    (!parts.is_empty()).then(|| parts.join(" "))
923}
924
925/// A description reduced to what a list can show, or nothing if it says nothing.
926fn summarize(text: Option<&str>) -> Option<&str> {
927    text.map(str::trim_end).filter(|text| !text.is_empty())
928}
929
930/// How a page says something is going away, in the one place both lists read it from.
931fn deprecation_label(
932    message: Option<&str>,
933    warn_at: Option<&str>,
934    remove_at: Option<&str>,
935) -> Option<String> {
936    if message.is_none() && warn_at.is_none() && remove_at.is_none() {
937        return None;
938    }
939    let mut parts = Vec::new();
940    if let Some(message) = message {
941        parts.push(message.to_string());
942    }
943    if let Some(at) = warn_at {
944        parts.push(format!("warns at {at}"));
945    }
946    if let Some(at) = remove_at {
947        parts.push(format!("removed at {at}"));
948    }
949    Some(format!("[deprecated: {}]", parts.join("; ")))
950}
951
952/// One command row, ready to print — the form the synthetic `help` entry takes.
953fn render_row(
954    name: &str,
955    row: &str,
956    terminal_width: usize,
957    col: usize,
958    next_line_help: bool,
959) -> String {
960    if !next_line_help {
961        if crate::docs::layout::visible_width(name) <= col {
962            let (rendered, _) = crate::docs::layout::render_help_text(row, terminal_width, col);
963            if !rendered.is_empty() {
964                return format!("  {name:<col$}  {rendered}");
965            }
966        } else if !row.contains('\n') {
967            return format!(
968                "  {name}\n    {}",
969                crate::docs::layout::render_block_text(row, terminal_width)
970            );
971        }
972    }
973    format!("  {name}\n    {row}")
974}
975
976/// The flags a command inherits, as its page should list them.
977///
978/// Walked down `full_cmd` from the root, which is the path a user would type — so the chain is
979/// exact. Each ancestor contributes only what it declared `global`, and hidden ones are left
980/// out here as they are everywhere else.
981///
982/// The twin of `own_and_global` in `usage-argv`'s `help` module; the two must agree, and the
983/// gate over mise's spec is what says they do.
984fn inherited_flags(
985    spec: &Spec,
986    cmd: &SpecCommand,
987    full_cmd: &[String],
988    long_help: bool,
989) -> (Vec<crate::docs::models::SpecFlag>, Vec<String>) {
990    // Every ancestor, root first, which is the order a reader meets them walking down.
991    let mut ancestors: Vec<&SpecCommand> = Vec::new();
992    let mut at = &spec.cmd;
993    for name in full_cmd.iter().take(full_cmd.len().saturating_sub(1)) {
994        ancestors.push(at);
995        let Some(next) = at.subcommands.get(name) else {
996            return (Vec::new(), Vec::new());
997        };
998        at = next;
999    }
1000    if !full_cmd.is_empty() {
1001        ancestors.push(at);
1002    }
1003
1004    // Shadowing, which the parser does and the page has to agree with: a command's own flags
1005    // are looked up before its ancestors', so `mise use --raw` is *use's* and never the root's.
1006    // Listing both would print two descriptions for one spelling, one of which can never apply.
1007    // Nearest ancestor first for the decision, then emitted root-first.
1008    // Two sets, because the parser has two passes: it resolves a word against every long and
1009    // short in scope before it looks at a negation at all, so *any* long beats *any* negation
1010    // however far away it is. Reading them as one said a nearer negation had taken a spelling
1011    // that a farther long actually wins.
1012    //
1013    // usage-lib stores a negation *with* its dashes — `negate="--no-colour"` reaches the model
1014    // as `--no-colour` — where usage-argv stores it without. Prefixing here produced
1015    // `----no-colour`, which matched nothing, so negations were counted in name only.
1016    let forms = |f: &crate::SpecFlag| -> Vec<String> {
1017        f.long
1018            .iter()
1019            .map(|l| format!("--{l}"))
1020            .chain(f.short.iter().map(|s| format!("-{s}")))
1021            .collect()
1022    };
1023    let every_form: Vec<String> = cmd
1024        .flags
1025        .iter()
1026        .chain(
1027            ancestors
1028                .iter()
1029                .flat_map(|a| a.flags.iter())
1030                .filter(|f| f.global),
1031        )
1032        .flat_map(&forms)
1033        .collect();
1034
1035    let mut taken: Vec<String> = cmd.flags.iter().flat_map(&forms).collect();
1036    let mut taken_negations: Vec<String> =
1037        cmd.flags.iter().filter_map(|f| f.negate.clone()).collect();
1038    let mut keep: Vec<(&crate::SpecFlag, Option<String>, Option<char>, bool)> = Vec::new();
1039    for ancestor in ancestors.iter().rev() {
1040        for f in ancestor.flags.iter().filter(|f| f.global) {
1041            let long = f
1042                .long
1043                .iter()
1044                .find(|l| !f.hidden_aliases.contains(l) && !taken.contains(&format!("--{l}")))
1045                .cloned();
1046            let short = f
1047                .short
1048                .iter()
1049                .find(|s| !f.hidden_short_aliases.contains(s) && !taken.contains(&format!("-{s}")))
1050                .copied();
1051            let mine = forms(f);
1052            let negate = f.negate.as_ref().is_some_and(|n| {
1053                !taken_negations.contains(n) && (!every_form.contains(n) || mine.contains(n))
1054            });
1055            // Reserved whether or not it is shown: a hidden one still binds, and so does one
1056            // whose every spelling something nearer already took.
1057            taken.extend(forms(f));
1058            taken_negations.extend(f.negate.clone());
1059            if f.hide
1060                || if long_help {
1061                    f.hide_long_help
1062                } else {
1063                    f.hide_short_help
1064                }
1065                || (long.is_none() && short.is_none() && !negate)
1066            {
1067                continue;
1068            }
1069            keep.push((f, long, short, negate));
1070        }
1071    }
1072    let shown: Vec<crate::docs::models::SpecFlag> = ancestors
1073        .iter()
1074        .flat_map(|a| a.flags.iter())
1075        .filter_map(|f| {
1076            keep.iter()
1077                .find(|(k, _, _, _)| std::ptr::eq(*k, f))
1078                .map(|(_, l, s, n)| (f, l.clone(), *s, *n))
1079        })
1080        .map(|(f, long, short, negate)| {
1081            // Only the spellings that survived, so the entry offers what the parser would
1082            // actually accept here.
1083            let mut shown = f.clone();
1084            shown.long = long.into_iter().collect();
1085            shown.short = short.into_iter().collect();
1086            if !negate {
1087                shown.negate = None;
1088            }
1089            shown.usage = shown.usage();
1090            crate::docs::models::SpecFlag::from(&shown)
1091        })
1092        .collect();
1093    // The claim set travels with the result, forms and negations together: the supplied
1094    // `--help` and `--version` entries lose to both, since `find_negation` runs before either
1095    // is offered — even though a negation loses to a long.
1096    taken.extend(taken_negations);
1097    (shown, taken)
1098}
1099
1100/// The command without anything marked `hide`.
1101///
1102/// Help showed hidden flags, hidden arguments and hidden subcommands — everything `hide`
1103/// exists to keep out of it. The usage *line* filtered them already, through
1104/// `SpecCommand::usage`, so `ex --help` listed a `--secret` that the line above it did not
1105/// mention. Markdown and manpage rendering filter too; the help templates were the one place
1106/// that did not.
1107///
1108/// Filtered here rather than in the templates, and before the docs model builds its groups, so
1109/// that a heading whose every entry is hidden produces no section — the same rule markdown
1110/// already follows.
1111fn without_hidden(cmd: &SpecCommand, long: bool) -> SpecCommand {
1112    let mut visible = cmd.clone();
1113    visible.flags.retain(|flag| {
1114        !flag.hide
1115            && if long {
1116                !flag.hide_long_help
1117            } else {
1118                !flag.hide_short_help
1119            }
1120    });
1121    visible.args.retain(|arg| {
1122        !arg.hide
1123            && if long {
1124                !arg.hide_long_help
1125            } else {
1126                !arg.hide_short_help
1127            }
1128    });
1129    visible.subcommands.retain(|_, sub| !sub.hide);
1130    // Ordinary help only lists immediate subcommands, so their fields are never
1131    // rendered on this page. Walking and cloning the whole remaining tree here
1132    // makes rendering every page quadratic on a fleet-sized CLI. Flattened help
1133    // is the one mode that renders descendant fields and therefore needs the
1134    // recursive filtering.
1135    if visible.flatten_help {
1136        for sub in visible.subcommands.values_mut() {
1137            *sub = without_hidden(sub, long);
1138        }
1139    }
1140    visible
1141}
1142
1143static TERA: LazyLock<Tera> = LazyLock::new(|| {
1144    let mut tera = Tera::default();
1145
1146    // Register ljust filter for left-justifying text with padding
1147    tera.register_filter(
1148        "ljust",
1149        |value: &tera::Value, args: tera::Kwargs, _: &tera::State| -> tera::TeraResult<String> {
1150            let value = value.as_str().unwrap_or("");
1151            let width = args.get::<u64>("width")?.unwrap_or(0) as usize;
1152            Ok(format!("{:<width$}", value, width = width))
1153        },
1154    );
1155    tera.register_filter(
1156        "default",
1157        |value: &tera::Value,
1158         kwargs: tera::Kwargs,
1159         _: &tera::State|
1160         -> tera::TeraResult<tera::Value> {
1161            let default_val = kwargs.must_get::<tera::Value>("value")?;
1162            let boolean = kwargs.get::<bool>("boolean")?.unwrap_or_default();
1163            if value.is_undefined() || value.is_none() || (boolean && !value.is_truthy()) {
1164                Ok(default_val)
1165            } else {
1166                Ok(value.clone())
1167            }
1168        },
1169    );
1170    tera.register_filter(
1171        "terminal_wrap",
1172        |value: &tera::Value, args: tera::Kwargs, _: &tera::State| -> tera::TeraResult<String> {
1173            let value = value.as_str().unwrap_or("");
1174            let width = args.get::<u64>("width")?.unwrap_or(80) as usize;
1175            let indent = args.get::<u64>("indent")?.unwrap_or(0) as usize;
1176            Ok(crate::docs::layout::render_indented_text(
1177                value, width, indent,
1178            ))
1179        },
1180    );
1181    tera.register_filter(
1182        "terminal_label",
1183        |value: &tera::Value, args: tera::Kwargs, _: &tera::State| -> tera::TeraResult<String> {
1184            let value = value.as_str().unwrap_or("");
1185            let label = args.must_get::<String>("label")?;
1186            let width = args.get::<u64>("width")?.unwrap_or(80) as usize;
1187            let indent = args.get::<u64>("indent")?.unwrap_or(0) as usize;
1188            Ok(crate::docs::layout::render_labelled_text(
1189                &label, value, width, indent,
1190            ))
1191        },
1192    );
1193    tera.register_filter(
1194        "terminal_annotation",
1195        |value: &tera::Value, args: tera::Kwargs, _: &tera::State| -> tera::TeraResult<String> {
1196            let body = if let Some(values) = value.as_array() {
1197                values
1198                    .iter()
1199                    .filter_map(tera::Value::as_str)
1200                    .collect::<Vec<_>>()
1201                    .join(", ")
1202            } else {
1203                value.as_str().unwrap_or("").to_string()
1204            };
1205            let label = args.get::<String>("label")?.unwrap_or_default();
1206            let suffix = args.get::<String>("suffix")?.unwrap_or_default();
1207            let indent = args
1208                .get::<String>("indent")?
1209                .unwrap_or_default()
1210                .chars()
1211                .count();
1212            let width = args.get::<u64>("width")?.unwrap_or(80) as usize;
1213            let rendered = crate::docs::layout::render_indented_text(
1214                &format!("{label}{body}{suffix}"),
1215                width,
1216                indent,
1217            );
1218            Ok(indent_text(&rendered, indent))
1219        },
1220    );
1221    tera.register_filter(
1222        "terminal_deprecation",
1223        |value: &tera::Value, args: tera::Kwargs, _: &tera::State| -> tera::TeraResult<String> {
1224            let field = |name| value.get_from_path(name).and_then(tera::Value::as_str);
1225            let Some(label) = deprecation_label(
1226                field("deprecated"),
1227                field("deprecated_warn_at"),
1228                field("deprecated_remove_at"),
1229            ) else {
1230                return Ok(String::new());
1231            };
1232            let indent = args
1233                .get::<String>("indent")?
1234                .unwrap_or_default()
1235                .chars()
1236                .count();
1237            let width = args.get::<u64>("width")?.unwrap_or(80) as usize;
1238            let rendered = crate::docs::layout::render_indented_text(&label, width, indent);
1239            Ok(indent_text(&rendered, indent))
1240        },
1241    );
1242
1243    #[rustfmt::skip]
1244    tera.add_raw_templates([
1245        ("spec_template_short.tera", include_str!("templates/spec_template_short.tera")),
1246        ("spec_template_long.tera", include_str!("templates/spec_template_long.tera")),
1247    ]).unwrap();
1248
1249    tera
1250});
1251
1252#[cfg(test)]
1253mod tests {
1254    use super::*;
1255    use insta::assert_snapshot;
1256
1257    #[test]
1258    fn flag_aliases_do_not_leak_into_interactive_help() {
1259        let spec = crate::spec! { r#"
1260bin "ex"
1261flag "-t -f --tail --follow" help="Follow output"
1262        "# }
1263        .unwrap();
1264
1265        for long in [false, true] {
1266            let page = super::render_help(&spec, &spec.cmd, long);
1267            assert!(page.contains("-t, --tail"), "long={long}:\n{page}");
1268            assert!(!page.contains("aliases:"), "long={long}:\n{page}");
1269        }
1270    }
1271
1272    #[test]
1273    fn a_hidden_ancestor_claim_keeps_help_off_the_page() {
1274        // `--help` is supplied by the parser, and a hidden global that declares it still binds
1275        // first — `hide` keeps a flag off the page, not out of the parse. Deciding the supplied
1276        // entries from the *visible* inherited list lost exactly that, and the page offered a
1277        // `--help` that does something else.
1278        let spec = crate::spec! { r#"
1279bin "ex"
1280flag "--help" global=#true hide=#true help="the CLI's own, and invisible"
1281cmd inner help="a command" {
1282    flag "--plain" help="its own"
1283}
1284        "# }
1285        .unwrap();
1286
1287        let inner = spec.cmd.subcommands.get("inner").expect("inner");
1288        for long in [false, true] {
1289            let page = super::render_help(&spec, inner, long);
1290            assert!(
1291                !page.contains("--help"),
1292                "long={long}: a hidden ancestor binds this:\n{page}"
1293            );
1294            // The short form is untouched, since nothing claimed it.
1295            assert!(page.contains("-h"), "long={long}:\n{page}");
1296        }
1297    }
1298
1299    #[test]
1300    fn a_long_beats_a_negation_however_far_away_it_is() {
1301        // A negation is stored *with* its dashes here and without them in usage-argv, so the
1302        // spelling was being looked up as `----no-cache` and matched nothing — negations were
1303        // counted in name only. And which one binds is not about distance: a word is resolved
1304        // against every long in scope before any negation is considered, so the root's plain
1305        // `--no-cache` wins over the subcommand's negation and belongs on its page.
1306        let spec = crate::spec! { r#"
1307bin "ex"
1308flag "--no-cache" global=#true help="the root's plain long"
1309flag "--colour" negate="--no-colour" global=#true help="the root's, with a negation"
1310cmd narrow help="a command" {
1311    flag "--cache" negate="--no-cache" help="its own, with a negation"
1312    flag "--tint" negate="--no-colour" help="claims the root's negation"
1313}
1314        "# }
1315        .unwrap();
1316
1317        let narrow = spec.cmd.subcommands.get("narrow").expect("narrow");
1318        for long in [false, true] {
1319            let page = super::render_help(&spec, narrow, long);
1320            assert!(
1321                page.contains("--no-cache"),
1322                "long={long}: a long beats a negation, so this still binds here:\n{page}"
1323            );
1324            // And a negation *is* claimed by a nearer negation — which is what the dashes
1325            // matter for. `--colour` stays; the negation it used to carry does not.
1326            assert!(page.contains("--colour"), "long={long}:\n{page}");
1327            let global = page
1328                .split_once("Global flags:")
1329                .expect("a global section")
1330                .1;
1331            assert!(
1332                !global.contains("--colour / --no-colour"),
1333                "long={long}: the nearer negation owns that spelling:\n{page}"
1334            );
1335        }
1336    }
1337
1338    #[test]
1339    fn a_description_of_only_spaces_is_no_description() {
1340        // `usage-argv` filters a blank description wherever it reads one, and this template
1341        // asked only whether the string was there — so `help="   "` bought a column of padding
1342        // and a line of trailing spaces here and nothing there. Two renderings of one spec.
1343        //
1344        // Asserted on the trailing whitespace rather than by comparing the two renderers, so
1345        // the test says what is wrong with the line rather than only that they disagree.
1346        let spec = crate::spec! { r#"
1347bin "ex"
1348flag "--blank" help="   "
1349flag "--plain" help="plain"
1350        "# }
1351        .unwrap();
1352
1353        for long in [false, true] {
1354            let page = super::render_help(&spec, &spec.cmd, long);
1355            // In the flags section, not the usage line — `Usage: ex [--blank] [--plain]`
1356            // also contains the name and has no padding to get wrong.
1357            let listing = page.split_once("\nFlags:").expect("a flags section").1;
1358            let line = listing
1359                .lines()
1360                .find(|l| l.contains("--blank"))
1361                .unwrap_or_else(|| panic!("long={long}: {page}"));
1362            assert_eq!(
1363                line,
1364                line.trim_end(),
1365                "long={long}: trailing space on {line:?}"
1366            );
1367        }
1368    }
1369
1370    #[test]
1371    fn test_render_help_omits_hidden_entries() {
1372        let spec = crate::spec! { r#"
1373bin "ex"
1374flag "--visible" help="shown"
1375flag "--secret" hide=#true help="hidden"
1376flag "--filtered" hide=#true help="hidden" help_heading="Filtering"
1377arg "[SHOWN]" help="an arg"
1378arg "[HIDDEN]" hide=#true help="a hidden arg"
1379cmd open help="a command"
1380cmd sneaky hide=#true help="a hidden command"
1381        "# }
1382        .unwrap();
1383
1384        // `hide` keeps something out of help. The usage line filtered already — through
1385        // `SpecCommand::usage` — so before this, `ex --help` listed a `--secret` the line
1386        // above it did not mention. A heading whose every entry is hidden produces no
1387        // section, which is the rule markdown rendering already followed.
1388        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1389        Usage: ex [--visible] [SHOWN] [SUBCOMMAND]
1390
1391        Commands:
1392          open  a command
1393          help  Print this message or the help of the given subcommand(s)
1394
1395        Arguments:
1396          [SHOWN]  an arg
1397
1398        Flags:
1399              --visible  shown
1400          -h, --help     Print help
1401        ");
1402    }
1403
1404    #[test]
1405    fn test_render_help_groups_by_heading() {
1406        let spec = crate::spec! { r#"
1407bin "testcli"
1408flag "--verbose" help="Verbose output"
1409flag "--filter <pattern>" help="Only matching" help_heading="Filtering"
1410flag "--exclude <pattern>" help="Skip matching" help_heading="Filtering"
1411flag "--jobs <n>" help="How many at once" help_heading="Performance"
1412arg "<file>" help="The file"
1413arg "<mode>" help="How to run" help_heading="Behaviour"
1414        "# }
1415        .unwrap();
1416
1417        // Unheaded entries keep the default title and come first; each heading
1418        // then gets its own section, in the order the headings first appear.
1419        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1420        Usage: testcli [FLAGS] <file> <mode>
1421
1422        Arguments:
1423          <file>  The file
1424
1425        Behaviour:
1426          <mode>  How to run
1427
1428        Flags:
1429              --verbose            Verbose output
1430          -h, --help               Print help
1431
1432        Filtering:
1433              --filter <pattern>   Only matching
1434              --exclude <pattern>  Skip matching
1435
1436        Performance:
1437              --jobs <n>           How many at once
1438        ");
1439    }
1440
1441    #[test]
1442    fn test_render_help_with_only_headed_flags() {
1443        // No default section when nothing lands in it: a CLI that gives every
1444        // flag a heading should not get an empty "Flags:".
1445        let spec = crate::spec! { r#"
1446bin "testcli"
1447flag "--filter <pattern>" help="Only matching" help_heading="Filtering"
1448        "# }
1449        .unwrap();
1450
1451        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1452        Usage: testcli [--filter <pattern>]
1453
1454        Flags:
1455          -h, --help              Print help
1456
1457        Filtering:
1458              --filter <pattern>  Only matching
1459        ");
1460    }
1461
1462    #[test]
1463    fn test_render_help_with_env() {
1464        let spec = crate::spec! { r#"
1465bin "testcli"
1466flag "--color" env="MYCLI_COLOR" help="Enable color output"
1467flag "--verbose" env="MYCLI_VERBOSE" help="Verbose output"
1468flag "--debug" help="Debug mode"
1469        "# }
1470        .unwrap();
1471
1472        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1473        Usage: testcli [FLAGS]
1474
1475        Flags:
1476              --color    Enable color output [env: MYCLI_COLOR]
1477              --verbose  Verbose output [env: MYCLI_VERBOSE]
1478              --debug    Debug mode
1479          -h, --help     Print help
1480        ");
1481
1482        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1483        Usage: testcli [FLAGS]
1484
1485        Flags:
1486              --color    Enable color output
1487                         [env: MYCLI_COLOR]
1488              --verbose  Verbose output
1489                         [env: MYCLI_VERBOSE]
1490              --debug    Debug mode
1491          -h, --help     Print help
1492        ");
1493    }
1494
1495    #[test]
1496    fn test_render_help_with_arg_env() {
1497        let spec = crate::spec! { r#"
1498bin "testcli"
1499arg "<input>" env="MY_INPUT" help="Input file"
1500arg "<output>" env="MY_OUTPUT" help="Output file"
1501arg "<extra>" help="Extra arg without env"
1502arg "[default]" help="Arg with default value" default="default value"
1503        "# }
1504        .unwrap();
1505
1506        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1507        Usage: testcli <ARGS>…
1508
1509        Arguments:
1510          <input>    Input file [env: MY_INPUT]
1511          <output>   Output file [env: MY_OUTPUT]
1512          <extra>    Extra arg without env
1513          [default]  Arg with default value (default: default value)
1514
1515        Flags:
1516          -h, --help  Print help
1517        ");
1518
1519        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1520        Usage: testcli <ARGS>…
1521
1522        Arguments:
1523          <input>    Input file
1524                     [env: MY_INPUT]
1525          <output>   Output file
1526                     [env: MY_OUTPUT]
1527          <extra>    Extra arg without env
1528          [default]  Arg with default value
1529                     (default: default value)
1530
1531        Flags:
1532          -h, --help  Print help
1533        ");
1534    }
1535
1536    #[test]
1537    fn test_render_help_with_negated_flag() {
1538        let spec = crate::spec! { r#"
1539bin "testcli"
1540flag "--compress" negate="--no-compress" default=#true help="Compress output"
1541flag "--verbose" help="Verbose output"
1542        "# }
1543        .unwrap();
1544
1545        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1546        Usage: testcli [--compress] [--verbose]
1547
1548        Flags:
1549              --compress / --no-compress  Compress output (default: true)
1550              --verbose                   Verbose output
1551          -h, --help                      Print help
1552        ");
1553
1554        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1555        Usage: testcli [--compress] [--verbose]
1556
1557        Flags:
1558              --compress / --no-compress  Compress output
1559                                          (default: true)
1560              --verbose                   Verbose output
1561          -h, --help                      Print help
1562        ");
1563    }
1564
1565    #[test]
1566    fn granular_help_hides_preserve_behavior_but_remove_presentation() {
1567        let spec = crate::spec! { r#"
1568bin "testcli"
1569flag "--mode <mode>" help="Select mode" env="MODE" default="fast" hide_default_value=#true hide_env=#true hide_possible_values=#true {
1570  choices {
1571    choice "fast"
1572    choice "slow"
1573  }
1574}
1575flag "--short-only" help="short" hide_long_help=#true
1576flag "--long-only" help="long" hide_short_help=#true
1577arg "[input]" help="Input" env="INPUT" default="file" hide_default_value=#true hide_env=#true
1578        "# }
1579        .unwrap();
1580
1581        let short = render_help(&spec, &spec.cmd, false);
1582        assert!(short.contains("--mode <mode>"), "{short}");
1583        assert!(short.contains("--short-only"), "{short}");
1584        assert!(!short.contains("--long-only"), "{short}");
1585        assert!(
1586            !short.contains("MODE") && !short.contains("fast, slow"),
1587            "{short}"
1588        );
1589        assert!(
1590            !short.contains("default: fast") && !short.contains("default: file"),
1591            "{short}"
1592        );
1593
1594        let long = render_help(&spec, &spec.cmd, true);
1595        assert!(long.contains("--long-only"), "{long}");
1596        assert!(!long.contains("--short-only"), "{long}");
1597        assert!(
1598            !long.contains("MODE") && !long.contains("possible values"),
1599            "{long}"
1600        );
1601
1602        let rendered = spec.to_string();
1603        let reparsed: crate::Spec = rendered.parse().unwrap();
1604        assert!(reparsed.cmd.flags[0].hide_default_value);
1605        assert!(reparsed.cmd.flags[0].hide_env);
1606        assert!(reparsed.cmd.flags[0].hide_possible_values);
1607    }
1608
1609    #[test]
1610    fn a_help_template_reorders_omits_and_wraps_the_sections() {
1611        // The whole of what a template can do: `{{flags}}` before `{{args}}`, no
1612        // `{{commands}}` at all, and text of the author's own around them. Nothing else is
1613        // substituted, so the layout is the spec's and the sections' contents are not.
1614        let spec = crate::spec! { r#"
1615bin "ex"
1616about "An example"
1617help_template "{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}\n\n-- ask a person --"
1618flag "--force" help="Do it anyway"
1619arg "<file>" help="Which file"
1620cmd "run" help="Run it"
1621        "# }
1622        .unwrap();
1623
1624        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1625        An example
1626
1627        Usage: ex [--force] <file> [SUBCOMMAND]
1628
1629        Flags:
1630              --force  Do it anyway
1631          -h, --help   Print help
1632
1633        Arguments:
1634          <file>  Which file
1635
1636        -- ask a person --
1637        ");
1638    }
1639
1640    #[test]
1641    fn a_template_places_the_sections_a_page_actually_has() {
1642        // A template names every section, and this command has no arguments — the gap
1643        // `{{args}}` would leave closes up rather than pushing the commands down the page.
1644        // What lets one template serve a whole CLI, since most commands are missing most
1645        // sections. Here the version banner and description are last, and `after_help`
1646        // carries them nothing.
1647        let spec = crate::spec! { r#"
1648bin "ex"
1649version "1.2.3"
1650about "An example"
1651after_help "Read the docs."
1652help_template "{{usage}}\n\n{{flags}}\n\n{{args}}\n\n{{commands}}\n\n{{after_help}}\n\n{{about}}"
1653flag "--force" help="Do it anyway"
1654cmd "run" help="Run it"
1655        "# }
1656        .unwrap();
1657
1658        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1659        Usage: ex [--force] [SUBCOMMAND]
1660
1661        Flags:
1662              --force    Do it anyway
1663          -h, --help     Print help
1664          -V, --version  Print version
1665
1666        Commands:
1667          run   Run it
1668          help  Print this message or the help of the given subcommand(s)
1669
1670        Read the docs.
1671
1672        ex 1.2.3
1673        An example
1674        ");
1675    }
1676
1677    #[test]
1678    fn a_flattened_page_puts_its_bodies_where_the_commands_would_go() {
1679        // `flatten_help` replaces a command list with the subcommands' own bodies, so a
1680        // template that places `{{commands}}` places whichever of the two this command has.
1681        let spec = crate::spec! { r#"
1682bin "ex"
1683flatten_help #true
1684help_template "{{usage}}\n\n{{commands}}\n\n{{flags}}"
1685cmd "run" help="Run it" {
1686    flag "--dry-run" help="Only show changes"
1687}
1688        "# }
1689        .unwrap();
1690
1691        let page = render_help(&spec, &spec.cmd, false);
1692        assert!(
1693            page.find("run:").unwrap() < page.find("Flags:").unwrap(),
1694            "{page}"
1695        );
1696        assert!(page.contains("--dry-run"), "{page}");
1697    }
1698
1699    #[test]
1700    fn test_render_help_with_before_after_help() {
1701        let spec = crate::spec! { r#"
1702bin "testcli"
1703before_help "This text appears before the help"
1704after_help "This text appears after the help"
1705flag "--verbose" help="Enable verbose output"
1706        "# }
1707        .unwrap();
1708
1709        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1710        This text appears before the help
1711
1712        Usage: testcli [--verbose]
1713
1714        Flags:
1715              --verbose  Enable verbose output
1716          -h, --help     Print help
1717
1718        This text appears after the help
1719        ");
1720    }
1721
1722    #[test]
1723    fn test_render_help_with_before_after_help_long() {
1724        let spec = crate::spec! { r#"
1725bin "testcli"
1726before_help "short before"
1727before_help_long "This is the long version of before help"
1728after_help "short after"
1729after_help_long "This is the long version of after help"
1730flag "--verbose" help="Enable verbose output"
1731        "# }
1732        .unwrap();
1733
1734        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1735        short before
1736
1737        Usage: testcli [--verbose]
1738
1739        Flags:
1740              --verbose  Enable verbose output
1741          -h, --help     Print help
1742
1743        short after
1744        ");
1745
1746        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1747        This is the long version of before help
1748
1749        Usage: testcli [--verbose]
1750
1751        Flags:
1752              --verbose  Enable verbose output
1753          -h, --help     Print help
1754
1755        This is the long version of after help
1756        ");
1757    }
1758
1759    #[test]
1760    fn root_help_stays_on_the_root_page() {
1761        let spec = crate::spec! { r#"
1762bin "testcli"
1763author "Root Author"
1764license "MIT"
1765before_help "root before"
1766before_help_long "root before long"
1767after_help "root after"
1768after_help_long "root after long"
1769example "testcli --verbose"
1770cmd "run" help="run it" {
1771    after_help "run after"
1772    after_long_help "run after long"
1773    example "testcli run"
1774    cmd "now" help="run it now"
1775}
1776        "# }
1777        .unwrap();
1778        let run = spec.cmd.subcommands.get("run").expect("run command");
1779        let now = run.subcommands.get("now").expect("now command");
1780
1781        for long in [false, true] {
1782            let page = render_help(&spec, now, long);
1783            for root_only in [
1784                "root before",
1785                "root after",
1786                "$ testcli --verbose",
1787                "run after",
1788                "$ testcli run",
1789                "Root Author",
1790                "License: MIT",
1791            ] {
1792                assert!(
1793                    !page.contains(root_only),
1794                    "long={long}, inherited ancestor metadata {root_only:?}:\n{page}"
1795                );
1796            }
1797        }
1798
1799        let short = render_help(&spec, run, false);
1800        let long = render_help(&spec, run, true);
1801        assert!(short.contains("run after"), "{short}");
1802        assert!(short.contains("$ testcli run"), "{short}");
1803        assert!(long.contains("run after long"), "{long}");
1804        assert!(long.contains("$ testcli run"), "{long}");
1805    }
1806
1807    #[test]
1808    fn test_render_help_with_examples() {
1809        let spec = crate::spec! { r#"
1810bin "testcli"
1811flag "--verbose" help="Enable verbose output"
1812example "testcli --verbose" header="Run with verbose output"
1813example "testcli" header="Run normally" help="Just runs the tool"
1814        "# }
1815        .unwrap();
1816
1817        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1818        Usage: testcli [--verbose]
1819
1820        Flags:
1821              --verbose  Enable verbose output
1822          -h, --help     Print help
1823
1824        Examples:
1825          Run with verbose output:
1826            $ testcli --verbose
1827
1828          Run normally:
1829            $ testcli
1830        ");
1831
1832        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1833        Usage: testcli [--verbose]
1834
1835        Flags:
1836              --verbose  Enable verbose output
1837          -h, --help     Print help
1838
1839        Examples:
1840          Run with verbose output:
1841            $ testcli --verbose
1842
1843          Run normally:
1844            Just runs the tool
1845            $ testcli
1846        ");
1847    }
1848
1849    #[test]
1850    fn test_render_help_with_version() {
1851        let spec = crate::spec! { r#"
1852bin "testcli"
1853name "TestCLI"
1854version "1.2.3"
1855flag "--verbose" help="Enable verbose output"
1856        "# }
1857        .unwrap();
1858
1859        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1860        TestCLI 1.2.3
1861        Usage: testcli [--verbose]
1862
1863        Flags:
1864              --verbose  Enable verbose output
1865          -h, --help     Print help
1866          -V, --version  Print version
1867        ");
1868    }
1869
1870    #[test]
1871    fn test_render_help_with_only_long_version() {
1872        let spec = crate::spec! { r#"
1873bin "testcli"
1874long_version "1.2.3\ncommit abc123"
1875flag "--verbose" help="Enable verbose output"
1876        "# }
1877        .unwrap();
1878
1879        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1880        Usage: testcli [--verbose]
1881
1882        Flags:
1883              --verbose  Enable verbose output
1884          -h, --help     Print help
1885          -V, --version  Print version
1886        ");
1887    }
1888
1889    #[test]
1890    fn test_render_help_omits_help_when_disabled() {
1891        // `disable_help` turns the parser's answer off, so the page must not offer it: the same
1892        // rule as a spelling the CLI claimed, with the spec doing the claiming. `--version`
1893        // stays, because nothing disabled that.
1894        let spec = crate::spec! { r#"
1895bin "testcli"
1896version "1.2.3"
1897disable_help #true
1898flag "--verbose" help="Enable verbose output"
1899        "# }
1900        .unwrap();
1901
1902        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1903        testcli 1.2.3
1904        Usage: testcli [--verbose]
1905
1906        Flags:
1907              --verbose  Enable verbose output
1908          -V, --version  Print version
1909        ");
1910    }
1911
1912    #[test]
1913    fn test_render_help_with_author_license() {
1914        let spec = crate::spec! { r#"
1915bin "testcli"
1916author "Test Author"
1917license "MIT"
1918flag "--verbose" help="Enable verbose output"
1919        "# }
1920        .unwrap();
1921
1922        // Short help should not show author/license
1923        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1924        Usage: testcli [--verbose]
1925
1926        Flags:
1927              --verbose  Enable verbose output
1928          -h, --help     Print help
1929        ");
1930
1931        // Long help should show author/license at the bottom
1932        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1933        Usage: testcli [--verbose]
1934
1935        Flags:
1936              --verbose  Enable verbose output
1937          -h, --help     Print help
1938
1939        Author: Test Author
1940        License: MIT
1941        ");
1942    }
1943
1944    #[test]
1945    fn test_render_help_with_deprecated_command() {
1946        // The width is declared because the wrapping is the thing being pinned: a page that
1947        // asked the terminal would wrap this snapshot wherever the developer's window ends.
1948        let spec = crate::spec! { r#"
1949bin "testcli"
1950term_width 80
1951flag "--old" help="Old switch" deprecated="use --new" deprecated_warn_at="6.1" deprecated_remove_at="7.0"
1952cmd "old-cmd" help="Do something" deprecated="use new-cmd instead" deprecated_warn_at="6.2" deprecated_remove_at="7.0"
1953cmd "new-cmd" help="Do something better"
1954        "# }
1955        .unwrap();
1956
1957        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1958        Usage: testcli [--old] [SUBCOMMAND]
1959
1960        Commands:
1961          new-cmd  Do something better
1962          old-cmd  Do something [deprecated: use new-cmd instead; warns at 6.2; removed
1963                   at 7.0]
1964          help     Print this message or the help of the given subcommand(s)
1965
1966        Flags:
1967              --old   Old switch [deprecated: use --new; warns at 6.1; removed at 7.0]
1968          -h, --help  Print help
1969        ");
1970    }
1971
1972    #[test]
1973    fn deprecation_milestones_do_not_need_a_message() {
1974        let spec = crate::spec! { r#"
1975bin "testcli"
1976flag "--old" help="Old switch" deprecated_remove_at="7.0"
1977cmd "old-cmd" help="Do something" deprecated_warn_at="6.2"
1978        "# }
1979        .unwrap();
1980
1981        let page = render_help(&spec, &spec.cmd, false);
1982        assert!(
1983            page.contains("old-cmd  Do something [deprecated: warns at 6.2]"),
1984            "{page}"
1985        );
1986        assert!(page.contains("[deprecated: removed at 7.0]"), "{page}");
1987        assert!(!page.contains("[deprecated:;"), "{page}");
1988    }
1989
1990    #[test]
1991    fn test_render_help_with_subcommand_presentation() {
1992        let spec = crate::spec! { r#"
1993bin "testcli"
1994subcommand_help_heading "Actions"
1995subcommand_value_name "ACTION"
1996cmd "run" help="Run it\n"
1997        "# }
1998        .unwrap();
1999
2000        let page = render_help(&spec, &spec.cmd, false);
2001        assert!(page.contains("Usage: testcli [ACTION]"), "{page}");
2002        assert!(page.contains("\nActions:\n"), "{page}");
2003    }
2004
2005    #[test]
2006    fn test_render_help_honors_explicit_display_order() {
2007        let spec = crate::spec! { r#"
2008bin "testcli"
2009flag "--unset" help="Unordered"
2010flag "--later" help="Later" display_order=20
2011flag "--first" help="First" display_order=10
2012cmd "zulu" help="Unordered"
2013cmd "later" help="Later" display_order=20
2014cmd "first" help="First" display_order=10
2015cmd "alpha" help="Unordered"
2016        "# }
2017        .unwrap();
2018
2019        let page = render_help(&spec, &spec.cmd, false);
2020        let commands = page.split_once("\nCommands:\n").unwrap().1;
2021        assert!(
2022            commands.find("first").unwrap() < commands.find("later").unwrap()
2023                && commands.find("later").unwrap() < commands.find("alpha").unwrap()
2024                && commands.find("alpha").unwrap() < commands.find("zulu").unwrap(),
2025            "{page}"
2026        );
2027        let flags = page.split_once("\nFlags:\n").unwrap().1;
2028        assert!(
2029            flags.find("--first").unwrap() < flags.find("--later").unwrap()
2030                && flags.find("--later").unwrap() < flags.find("--unset").unwrap(),
2031            "{page}"
2032        );
2033    }
2034
2035    #[test]
2036    fn test_render_help_groups_subcommands_by_heading() {
2037        let spec = crate::spec! { r#"
2038bin "testcli"
2039cmd "run" help="Run it" help_heading="Core commands"
2040cmd "clean" help="Remove old state" help_heading="Maintenance"
2041cmd "status" help="Show status" help_heading="Commands"
2042        "# }
2043        .unwrap();
2044
2045        for page in [
2046            render_help(&spec, &spec.cmd, false),
2047            render_help(&spec, &spec.cmd, true),
2048        ] {
2049            let commands = page.find("\nCommands:\n").expect("default command section");
2050            assert_eq!(page.matches("\nCommands:\n").count(), 1, "{page}");
2051            let core = page.find("\nCore commands:\n").expect("core section");
2052            let maintenance = page.find("\nMaintenance:\n").expect("maintenance section");
2053            assert!(commands < core && commands < maintenance, "{page}");
2054            let default_end = core.min(maintenance);
2055            assert!(page[commands..default_end].contains("status"), "{page}");
2056            assert!(page[commands..default_end].contains("help"), "{page}");
2057            assert!(page[core..].contains("run"), "{page}");
2058            assert!(page[maintenance..].contains("clean"), "{page}");
2059        }
2060    }
2061
2062    #[test]
2063    fn test_render_help_with_next_line_layout() {
2064        let spec = crate::spec! { r#"
2065bin "testcli"
2066next_line_help #true
2067arg "<input>" help="Input file" env="INPUT" default="fast" {
2068    choices {
2069        choice "fast"
2070        choice "slow"
2071    }
2072}
2073flag "--verbose" help="Enable verbose output"
2074cmd "run" help="Run it"
2075        "# }
2076        .unwrap();
2077
2078        let short = render_help(&spec, &spec.cmd, false);
2079        assert!(!short.contains("    Run it\n\n  help"), "{short}");
2080        for page in [short, render_help(&spec, &spec.cmd, true)] {
2081            assert!(page.contains("  [input]\n    Input file"), "{page}");
2082            assert!(
2083                page.contains("--verbose\n    Enable verbose output"),
2084                "{page}"
2085            );
2086            assert!(
2087                page.contains(
2088                    "    [possible values: fast, slow]\n    [env: INPUT]\n    (default: fast)"
2089                ),
2090                "{page}"
2091            );
2092            assert!(page.contains("  run\n    Run it"), "{page}");
2093        }
2094    }
2095
2096    #[test]
2097    fn flatten_help_expands_subcommands_instead_of_listing_them() {
2098        let spec = crate::spec! { r#"
2099bin "testcli"
2100flatten_help #true
2101next_line_help #true
2102cmd "run" help="Run it" {
2103    arg "<task>" help="Task name" env="TASK" default="build" {
2104        choices {
2105            choice "build"
2106            choice "test"
2107        }
2108    }
2109    flag "--dry-run" help="Only show changes"
2110    flatten_help #true
2111    cmd "nested" help="Nested operation" {
2112        flag "--deep" help="Deep option"
2113    }
2114}
2115        "# }
2116        .unwrap();
2117
2118        for page in [
2119            render_help(&spec, &spec.cmd, false),
2120            render_help(&spec, &spec.cmd, true),
2121        ] {
2122            assert!(
2123                page.contains("Usage: testcli\n       testcli run"),
2124                "{page}"
2125            );
2126            assert!(!page.contains("\nCommands:\n"), "{page}");
2127            assert!(page.contains("\nrun:\nRun it"), "{page}");
2128            assert!(page.contains("[task]"), "{page}");
2129            assert!(page.contains("--dry-run"), "{page}");
2130            assert!(page.contains("\nrun nested:\nNested operation"), "{page}");
2131            assert!(page.contains("--deep"), "{page}");
2132            assert!(
2133                page.contains(
2134                    "    [possible values: build, test]\n    [env: TASK]\n    (default: build)"
2135                ),
2136                "{page}"
2137            );
2138        }
2139    }
2140
2141    #[test]
2142    fn styled_help_colours_semantics_and_template_styles() {
2143        let mut spec = crate::spec! { r#"
2144bin "testcli"
2145help_template "{$bright-blue}My tool{/$}\n\n{{usage}}\n\n{{commands}}\n\n{{flags}}\n\n{{after_help}}"
2146cmd "build" help="Build it"
2147flag "--output <FILE>" help="Write **the file**"
2148        "# }
2149        .unwrap();
2150        spec.cmd.after_help_long = Some(
2151            "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n\n    \u{1b}[1mtestcli run\u{1b}[22m"
2152                .to_string(),
2153        );
2154
2155        let plain = render_help(&spec, &spec.cmd, true);
2156        assert!(plain.contains("My tool"), "{plain}");
2157        assert!(plain.contains("Examples:\n\n    testcli run"), "{plain}");
2158        assert!(!plain.contains('\u{1b}'), "{plain:?}");
2159
2160        let coloured = render_help_styled(&spec, &spec.cmd, true, Style::COLOURED);
2161        assert!(
2162            coloured.contains("\u{1b}[94mMy tool\u{1b}[0m"),
2163            "{coloured:?}"
2164        );
2165        assert!(
2166            coloured.contains("\u{1b}[1;33mUsage:\u{1b}[0m"),
2167            "{coloured:?}"
2168        );
2169        assert!(
2170            coloured.contains("\u{1b}[1;32m--output\u{1b}[0m"),
2171            "{coloured:?}"
2172        );
2173        assert!(
2174            coloured.contains("\u{1b}[1;35m<FILE>\u{1b}[0m"),
2175            "{coloured:?}"
2176        );
2177        assert!(
2178            coloured.contains("\u{1b}[1;32mbuild\u{1b}[0m  Build it"),
2179            "{coloured:?}"
2180        );
2181        assert!(
2182            coloured.contains(
2183                "\u{1b}[1;32mhelp\u{1b}[0m   Print this message or the help of the given subcommand(s)"
2184            ),
2185            "{coloured:?}"
2186        );
2187        assert!(
2188            coloured.contains("\u{1b}[1mthe file\u{1b}[22m"),
2189            "{coloured:?}"
2190        );
2191        assert!(
2192            coloured.contains("\u{1b}[1m\u{1b}[4mExamples:"),
2193            "{coloured:?}"
2194        );
2195    }
2196
2197    #[test]
2198    fn command_style_does_not_match_command_group_prose() {
2199        let spec = crate::spec! { r#"
2200bin "testcli"
2201heading "Build commands" help="build these projects before publishing"
2202cmd "build" help="Build it" help_heading="Build commands"
2203        "# }
2204        .unwrap();
2205
2206        let coloured = render_help_styled(&spec, &spec.cmd, true, Style::COLOURED);
2207        assert!(
2208            coloured.contains("  build these projects before publishing"),
2209            "{coloured:?}"
2210        );
2211        assert!(
2212            coloured.contains("\u{1b}[1;32mbuild\u{1b}[0m  Build it"),
2213            "{coloured:?}"
2214        );
2215        assert!(
2216            !coloured.contains("\u{1b}[1;32mbuild\u{1b}[0m these projects"),
2217            "{coloured:?}"
2218        );
2219    }
2220
2221    #[test]
2222    fn flattened_help_does_not_apply_command_style() {
2223        let spec = crate::spec! { r#"
2224bin "testcli"
2225flatten_help #true
2226help_template "Intro\n  run  should stay prose\n\n{{commands}}"
2227cmd "run" help="Run it"
2228        "# }
2229        .unwrap();
2230
2231        let coloured = render_help_styled(&spec, &spec.cmd, true, Style::COLOURED);
2232        assert!(
2233            coloured.contains("  run  should stay prose"),
2234            "{coloured:?}"
2235        );
2236        assert!(
2237            !coloured.contains("\u{1b}[1;32mrun\u{1b}[0m"),
2238            "{coloured:?}"
2239        );
2240    }
2241}