Skip to main content

usage/docs/cli/
mod.rs

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