Skip to main content

usage/docs/cli/
mod.rs

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