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