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