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.clone());
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                        items: supplied,
54                    },
55                ),
56            }
57        }
58    }
59
60    let width = crate::docs::layout::help_width(cmd.term_width, cmd.max_term_width);
61    let col = crate::docs::layout::max_usage_width(
62        docs_cmd
63            .flag_groups
64            .iter()
65            .flat_map(|g| g.items.iter())
66            .chain(inherited.iter())
67            .map(|f| f.display_usage.as_str()),
68    );
69    for group in &mut docs_cmd.flag_groups {
70        lay_out(&mut group.items, width, col);
71    }
72    lay_out(&mut inherited, width, col);
73
74    // Inserted after the layout, not before: the template reads the widths, and a `cmd` put
75    // into the context first would carry the ones computed before the two lists were joined.
76    ctx.insert("cmd", &docs_cmd);
77    ctx.insert("global_flags", &inherited);
78    for (name, mark) in MARKS {
79        ctx.insert(name, &mark);
80    }
81    let template = if long {
82        "spec_template_long.tera"
83    } else {
84        "spec_template_short.tera"
85    };
86    let rendered = TERA.render(template, &ctx).unwrap();
87    let sections = Sections::split(&rendered);
88    let page = match spec
89        .help_template
90        .as_deref()
91        .filter(|t| crate::help_template::is_set(t))
92    {
93        Some(template) => crate::help_template::substitute(template, |name| sections.named(name)),
94        None => sections.concatenated(),
95    };
96    page.trim().to_string() + "\n"
97}
98
99/// Where each section of a rendered page starts, as the templates write it.
100///
101/// The layout lives in the templates, and this is how it stays there: each one emits a marker
102/// at every section boundary, so the boundaries are declared beside the sections rather than
103/// worked out again here. A page with no `help_template` is the marks taken back out, which is
104/// the same string the templates produced before any of this existed — and what the fleet gate
105/// compares byte for byte.
106///
107/// Control characters, because a marker has to be something no help text contains and no
108/// terminal shows if one ever escapes.
109const MARKS: [(&str, &str); 6] = [
110    ("mark_usage", "\u{1}usage\u{1}"),
111    ("mark_commands", "\u{1}commands\u{1}"),
112    ("mark_args", "\u{1}args\u{1}"),
113    ("mark_flags", "\u{1}flags\u{1}"),
114    ("mark_flattened", "\u{1}flattened\u{1}"),
115    ("mark_after_help", "\u{1}after_help\u{1}"),
116];
117
118/// A rendered page cut into the sections a `help_template` may reorder.
119///
120/// The twin of `usage_argv::help`'s `Sections`, down to `flattened` not being a section an
121/// author can name: it is the other half of `commands`, since `flatten_help` replaces a
122/// command list with the subcommands' own bodies, and only one of the two is ever there.
123struct Sections<'a> {
124    about: &'a str,
125    usage: &'a str,
126    commands: &'a str,
127    args: &'a str,
128    flags: &'a str,
129    flattened: &'a str,
130    after_help: &'a str,
131}
132
133impl<'a> Sections<'a> {
134    fn split(rendered: &'a str) -> Self {
135        let mut rest = rendered;
136        let mut parts: Vec<&str> = Vec::with_capacity(MARKS.len() + 1);
137        for (_, mark) in MARKS {
138            // A missing marker leaves that section empty rather than swallowing the ones after
139            // it: every one is written at the top level of both templates, so this cannot
140            // happen, and it is not worth a panic in a help renderer if it ever does.
141            match rest.split_once(mark) {
142                Some((before, after)) => {
143                    parts.push(before);
144                    rest = after;
145                }
146                None => parts.push(""),
147            }
148        }
149        parts.push(rest);
150        Self {
151            about: parts[0],
152            usage: parts[1],
153            commands: parts[2],
154            args: parts[3],
155            flags: parts[4],
156            flattened: parts[5],
157            after_help: parts[6],
158        }
159    }
160
161    /// The default page: every section in the order the templates wrote them.
162    fn concatenated(&self) -> String {
163        [
164            self.about,
165            self.usage,
166            self.commands,
167            self.args,
168            self.flags,
169            self.flattened,
170            self.after_help,
171        ]
172        .concat()
173    }
174
175    /// One section by name, trimmed, so that a template owns the whitespace between them.
176    fn named(&self, name: &str) -> Option<String> {
177        Some(match name {
178            "about" => self.about.trim().to_string(),
179            "usage" => self.usage.trim().to_string(),
180            "commands" => {
181                let mut out = self.commands.trim().to_string();
182                let flattened = self.flattened.trim();
183                if !flattened.is_empty() {
184                    if !out.is_empty() {
185                        out.push_str("\n\n");
186                    }
187                    out.push_str(flattened);
188                }
189                out
190            }
191            "args" => self.args.trim().to_string(),
192            "flags" => self.flags.trim().to_string(),
193            "after_help" => self.after_help.trim().to_string(),
194            _ => return None,
195        })
196    }
197}
198
199/// The entries for `--help` and `--version`, which the parser supplies and no spec declares.
200///
201/// Listed because help is written for people: a reader looking for how to ask for help should
202/// find it on the page. This reverses the rule these two used to follow — that a page lists
203/// exactly what its spec declares — and the reason is that the spec has its own readers, and
204/// they are not the ones reading this.
205///
206/// `--version` only on the program's own page and only where a version is declared, which is
207/// where a parser accepts one. Each spelling is dropped where the CLI claimed it, since a page
208/// must not describe a flag that something else binds.
209///
210/// The twin of `supplied_entries` in `usage-argv`'s `help` module; the gate over mise's spec is
211/// what says the two agree.
212fn supplied_flags(
213    spec: &Spec,
214    cmd: &SpecCommand,
215    ancestors_taken: &[String],
216    is_root: bool,
217) -> Vec<crate::docs::models::SpecFlag> {
218    // The command's own spellings plus everything in scope above it — the set the inherited
219    // walk built, which counts hidden globals and negations. Rebuilding it from the *visible*
220    // inherited list lost both: a hidden ancestor that binds `--help` would have had the page
221    // offer it anyway.
222    let mut taken: Vec<String> = ancestors_taken.to_vec();
223    for f in &cmd.flags {
224        taken.extend(f.long.iter().map(|l| format!("--{l}")));
225        taken.extend(f.short.iter().map(|s| format!("-{s}")));
226        // Stored with its dashes here, unlike in usage-argv.
227        taken.extend(f.negate.clone());
228    }
229
230    let build = |name: &str, long: &str, short: char, help: &str| {
231        let long_free = !taken.contains(&format!("--{long}"));
232        let short_free = !taken.contains(&format!("-{short}"));
233        if !long_free && !short_free {
234            return None;
235        }
236        // Named after the form it shows: a short-only entry called `help` reads as a renamed
237        // flag and printed `help: -h`.
238        let name = if long_free { name } else { &short.to_string() };
239        let mut flag = crate::SpecFlag {
240            name: name.to_string(),
241            long: if long_free {
242                vec![long.to_string()]
243            } else {
244                vec![]
245            },
246            short: if short_free { vec![short] } else { vec![] },
247            help: Some(help.to_string()),
248            ..Default::default()
249        };
250        flag.usage = flag.usage();
251        Some(crate::docs::models::SpecFlag::from(&flag))
252    };
253
254    let mut out = Vec::new();
255    // `disable_help` turns the parser's answer off — `is_help_arg` refuses the spelling
256    // outright — so a page that still listed it would describe an action nothing performs.
257    // The same rule as a claimed or hidden spelling, with the claim made by the spec itself.
258    //
259    // usage-argv has no equivalent: `disable_help` is a KDL-only word, so no spec that crate
260    // can hold ever carries one, and the two renderers cannot disagree about it.
261    if spec.disable_help != Some(true) && !cmd.disable_help_flag {
262        out.extend(build("help", "help", 'h', "Print help"));
263    }
264    if is_root
265        && (spec.version.is_some() || spec.long_version.is_some())
266        && !cmd.disable_version_flag
267    {
268        out.extend(build("version", "version", 'V', "Print version"));
269    }
270    out
271}
272
273/// Fit a list of flags to a column: how wide their names are, and where their help wraps.
274///
275/// The same pass `SpecCommand::from` makes, run again once the width is known over *both* the
276/// command's own flags and the ones it inherits. The width is not only padding — a wrapped
277/// description is indented to sit under itself — so it cannot be decided per section and then
278/// shared.
279fn lay_out(flags: &mut [crate::docs::models::SpecFlag], terminal_width: usize, col: usize) {
280    for flag in flags {
281        flag.usage_col_width = col;
282        flag.help_rendered = None;
283        flag.help_is_multiline = false;
284        let help = flag.help_long.as_deref().or(flag.help.as_deref());
285        if let Some(help) = help {
286            let (rendered, is_multiline) =
287                crate::docs::layout::render_help_text(help, terminal_width, col);
288            // An empty rendering is how this says "use the block layout instead".
289            if !rendered.is_empty() {
290                flag.help_rendered = Some(rendered);
291                flag.help_is_multiline = is_multiline;
292            }
293        }
294    }
295}
296
297/// The flags a command inherits, as its page should list them.
298///
299/// Walked down `full_cmd` from the root, which is the path a user would type — so the chain is
300/// exact. Each ancestor contributes only what it declared `global`, and hidden ones are left
301/// out here as they are everywhere else.
302///
303/// The twin of `own_and_global` in `usage-argv`'s `help` module; the two must agree, and the
304/// gate over mise's spec is what says they do.
305fn inherited_flags(
306    spec: &Spec,
307    cmd: &SpecCommand,
308    full_cmd: &[String],
309    long_help: bool,
310) -> (Vec<crate::docs::models::SpecFlag>, Vec<String>) {
311    // Every ancestor, root first, which is the order a reader meets them walking down.
312    let mut ancestors: Vec<&SpecCommand> = Vec::new();
313    let mut at = &spec.cmd;
314    for name in full_cmd.iter().take(full_cmd.len().saturating_sub(1)) {
315        ancestors.push(at);
316        let Some(next) = at.subcommands.get(name) else {
317            return (Vec::new(), Vec::new());
318        };
319        at = next;
320    }
321    if !full_cmd.is_empty() {
322        ancestors.push(at);
323    }
324
325    // Shadowing, which the parser does and the page has to agree with: a command's own flags
326    // are looked up before its ancestors', so `mise use --raw` is *use's* and never the root's.
327    // Listing both would print two descriptions for one spelling, one of which can never apply.
328    // Nearest ancestor first for the decision, then emitted root-first.
329    // Two sets, because the parser has two passes: it resolves a word against every long and
330    // short in scope before it looks at a negation at all, so *any* long beats *any* negation
331    // however far away it is. Reading them as one said a nearer negation had taken a spelling
332    // that a farther long actually wins.
333    //
334    // usage-lib stores a negation *with* its dashes — `negate="--no-colour"` reaches the model
335    // as `--no-colour` — where usage-argv stores it without. Prefixing here produced
336    // `----no-colour`, which matched nothing, so negations were counted in name only.
337    let forms = |f: &crate::SpecFlag| -> Vec<String> {
338        f.long
339            .iter()
340            .map(|l| format!("--{l}"))
341            .chain(f.short.iter().map(|s| format!("-{s}")))
342            .collect()
343    };
344    let every_form: Vec<String> = cmd
345        .flags
346        .iter()
347        .chain(
348            ancestors
349                .iter()
350                .flat_map(|a| a.flags.iter())
351                .filter(|f| f.global),
352        )
353        .flat_map(&forms)
354        .collect();
355
356    let mut taken: Vec<String> = cmd.flags.iter().flat_map(&forms).collect();
357    let mut taken_negations: Vec<String> =
358        cmd.flags.iter().filter_map(|f| f.negate.clone()).collect();
359    let mut keep: Vec<(&crate::SpecFlag, Option<String>, Option<char>, bool)> = Vec::new();
360    for ancestor in ancestors.iter().rev() {
361        for f in ancestor.flags.iter().filter(|f| f.global) {
362            let long = f
363                .long
364                .iter()
365                .find(|l| !f.hidden_aliases.contains(l) && !taken.contains(&format!("--{l}")))
366                .cloned();
367            let short = f
368                .short
369                .iter()
370                .find(|s| !f.hidden_short_aliases.contains(s) && !taken.contains(&format!("-{s}")))
371                .copied();
372            let mine = forms(f);
373            let negate = f.negate.as_ref().is_some_and(|n| {
374                !taken_negations.contains(n) && (!every_form.contains(n) || mine.contains(n))
375            });
376            // Reserved whether or not it is shown: a hidden one still binds, and so does one
377            // whose every spelling something nearer already took.
378            taken.extend(forms(f));
379            taken_negations.extend(f.negate.clone());
380            if f.hide
381                || if long_help {
382                    f.hide_long_help
383                } else {
384                    f.hide_short_help
385                }
386                || (long.is_none() && short.is_none() && !negate)
387            {
388                continue;
389            }
390            keep.push((f, long, short, negate));
391        }
392    }
393    let shown: Vec<crate::docs::models::SpecFlag> = ancestors
394        .iter()
395        .flat_map(|a| a.flags.iter())
396        .filter_map(|f| {
397            keep.iter()
398                .find(|(k, _, _, _)| std::ptr::eq(*k, f))
399                .map(|(_, l, s, n)| (f, l.clone(), *s, *n))
400        })
401        .map(|(f, long, short, negate)| {
402            // Only the spellings that survived, so the entry offers what the parser would
403            // actually accept here.
404            let mut shown = f.clone();
405            shown.long = long.into_iter().collect();
406            shown.short = short.into_iter().collect();
407            if !negate {
408                shown.negate = None;
409            }
410            shown.usage = shown.usage();
411            crate::docs::models::SpecFlag::from(&shown)
412        })
413        .collect();
414    // The claim set travels with the result, forms and negations together: the supplied
415    // `--help` and `--version` entries lose to both, since `find_negation` runs before either
416    // is offered — even though a negation loses to a long.
417    taken.extend(taken_negations);
418    (shown, taken)
419}
420
421/// The command without anything marked `hide`.
422///
423/// Help showed hidden flags, hidden arguments and hidden subcommands — everything `hide`
424/// exists to keep out of it. The usage *line* filtered them already, through
425/// `SpecCommand::usage`, so `ex --help` listed a `--secret` that the line above it did not
426/// mention. Markdown and manpage rendering filter too; the help templates were the one place
427/// that did not.
428///
429/// Filtered here rather than in the templates, and before the docs model builds its groups, so
430/// that a heading whose every entry is hidden produces no section — the same rule markdown
431/// already follows.
432fn without_hidden(cmd: &SpecCommand, long: bool) -> SpecCommand {
433    let mut visible = cmd.clone();
434    visible.flags.retain(|flag| {
435        !flag.hide
436            && if long {
437                !flag.hide_long_help
438            } else {
439                !flag.hide_short_help
440            }
441    });
442    visible.args.retain(|arg| {
443        !arg.hide
444            && if long {
445                !arg.hide_long_help
446            } else {
447                !arg.hide_short_help
448            }
449    });
450    visible.subcommands.retain(|_, sub| !sub.hide);
451    // Ordinary help only lists immediate subcommands, so their fields are never
452    // rendered on this page. Walking and cloning the whole remaining tree here
453    // makes rendering every page quadratic on a fleet-sized CLI. Flattened help
454    // is the one mode that renders descendant fields and therefore needs the
455    // recursive filtering.
456    if visible.flatten_help {
457        for sub in visible.subcommands.values_mut() {
458            *sub = without_hidden(sub, long);
459        }
460    }
461    visible
462}
463
464static TERA: LazyLock<Tera> = LazyLock::new(|| {
465    let mut tera = Tera::default();
466
467    // Register ljust filter for left-justifying text with padding
468    tera.register_filter(
469        "ljust",
470        |value: &tera::Value, args: tera::Kwargs, _: &tera::State| -> tera::TeraResult<String> {
471            let value = value.as_str().unwrap_or("");
472            let width = args.get::<u64>("width")?.unwrap_or(0) as usize;
473            Ok(format!("{:<width$}", value, width = width))
474        },
475    );
476    tera.register_filter(
477        "default",
478        |value: &tera::Value,
479         kwargs: tera::Kwargs,
480         _: &tera::State|
481         -> tera::TeraResult<tera::Value> {
482            let default_val = kwargs.must_get::<tera::Value>("value")?;
483            let boolean = kwargs.get::<bool>("boolean")?.unwrap_or_default();
484            if value.is_undefined() || value.is_none() || (boolean && !value.is_truthy()) {
485                Ok(default_val)
486            } else {
487                Ok(value.clone())
488            }
489        },
490    );
491
492    #[rustfmt::skip]
493    tera.add_raw_templates([
494        ("spec_template_short.tera", include_str!("templates/spec_template_short.tera")),
495        ("spec_template_long.tera", include_str!("templates/spec_template_long.tera")),
496    ]).unwrap();
497
498    tera
499});
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use insta::assert_snapshot;
505
506    #[test]
507    fn a_hidden_ancestor_claim_keeps_help_off_the_page() {
508        // `--help` is supplied by the parser, and a hidden global that declares it still binds
509        // first — `hide` keeps a flag off the page, not out of the parse. Deciding the supplied
510        // entries from the *visible* inherited list lost exactly that, and the page offered a
511        // `--help` that does something else.
512        let spec = crate::spec! { r#"
513bin "ex"
514flag "--help" global=#true hide=#true help="the CLI's own, and invisible"
515cmd inner help="a command" {
516    flag "--plain" help="its own"
517}
518        "# }
519        .unwrap();
520
521        let inner = spec.cmd.subcommands.get("inner").expect("inner");
522        for long in [false, true] {
523            let page = super::render_help(&spec, inner, long);
524            assert!(
525                !page.contains("--help"),
526                "long={long}: a hidden ancestor binds this:\n{page}"
527            );
528            // The short form is untouched, since nothing claimed it.
529            assert!(page.contains("-h"), "long={long}:\n{page}");
530        }
531    }
532
533    #[test]
534    fn a_long_beats_a_negation_however_far_away_it_is() {
535        // A negation is stored *with* its dashes here and without them in usage-argv, so the
536        // spelling was being looked up as `----no-cache` and matched nothing — negations were
537        // counted in name only. And which one binds is not about distance: a word is resolved
538        // against every long in scope before any negation is considered, so the root's plain
539        // `--no-cache` wins over the subcommand's negation and belongs on its page.
540        let spec = crate::spec! { r#"
541bin "ex"
542flag "--no-cache" global=#true help="the root's plain long"
543flag "--colour" negate="--no-colour" global=#true help="the root's, with a negation"
544cmd narrow help="a command" {
545    flag "--cache" negate="--no-cache" help="its own, with a negation"
546    flag "--tint" negate="--no-colour" help="claims the root's negation"
547}
548        "# }
549        .unwrap();
550
551        let narrow = spec.cmd.subcommands.get("narrow").expect("narrow");
552        for long in [false, true] {
553            let page = super::render_help(&spec, narrow, long);
554            assert!(
555                page.contains("--no-cache"),
556                "long={long}: a long beats a negation, so this still binds here:\n{page}"
557            );
558            // And a negation *is* claimed by a nearer negation — which is what the dashes
559            // matter for. `--colour` stays; the negation it used to carry does not.
560            assert!(page.contains("--colour"), "long={long}:\n{page}");
561            let global = page
562                .split_once("Global flags:")
563                .expect("a global section")
564                .1;
565            assert!(
566                !global.contains("--colour / --no-colour"),
567                "long={long}: the nearer negation owns that spelling:\n{page}"
568            );
569        }
570    }
571
572    #[test]
573    fn a_description_of_only_spaces_is_no_description() {
574        // `usage-argv` filters a blank description wherever it reads one, and this template
575        // asked only whether the string was there — so `help="   "` bought a column of padding
576        // and a line of trailing spaces here and nothing there. Two renderings of one spec.
577        //
578        // Asserted on the trailing whitespace rather than by comparing the two renderers, so
579        // the test says what is wrong with the line rather than only that they disagree.
580        let spec = crate::spec! { r#"
581bin "ex"
582flag "--blank" help="   "
583flag "--plain" help="plain"
584        "# }
585        .unwrap();
586
587        for long in [false, true] {
588            let page = super::render_help(&spec, &spec.cmd, long);
589            // In the flags section, not the usage line — `Usage: ex [--blank] [--plain]`
590            // also contains the name and has no padding to get wrong.
591            let listing = page.split_once("\nFlags:").expect("a flags section").1;
592            let line = listing
593                .lines()
594                .find(|l| l.contains("--blank"))
595                .unwrap_or_else(|| panic!("long={long}: {page}"));
596            assert_eq!(
597                line,
598                line.trim_end(),
599                "long={long}: trailing space on {line:?}"
600            );
601        }
602    }
603
604    #[test]
605    fn test_render_help_omits_hidden_entries() {
606        let spec = crate::spec! { r#"
607bin "ex"
608flag "--visible" help="shown"
609flag "--secret" hide=#true help="hidden"
610flag "--filtered" hide=#true help="hidden" help_heading="Filtering"
611arg "[SHOWN]" help="an arg"
612arg "[HIDDEN]" hide=#true help="a hidden arg"
613cmd open help="a command"
614cmd sneaky hide=#true help="a hidden command"
615        "# }
616        .unwrap();
617
618        // `hide` keeps something out of help. The usage line filtered already — through
619        // `SpecCommand::usage` — so before this, `ex --help` listed a `--secret` the line
620        // above it did not mention. A heading whose every entry is hidden produces no
621        // section, which is the rule markdown rendering already followed.
622        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
623        Usage: ex [--visible] [SHOWN] <SUBCOMMAND>
624
625        Commands:
626          open  a command
627          help  Print this message or the help of the given subcommand(s)
628
629        Arguments:
630          [SHOWN]  an arg
631
632        Flags:
633              --visible  shown
634          -h, --help     Print help
635        ");
636    }
637
638    #[test]
639    fn test_render_help_groups_by_heading() {
640        let spec = crate::spec! { r#"
641bin "testcli"
642flag "--verbose" help="Verbose output"
643flag "--filter <pattern>" help="Only matching" help_heading="Filtering"
644flag "--exclude <pattern>" help="Skip matching" help_heading="Filtering"
645flag "--jobs <n>" help="How many at once" help_heading="Performance"
646arg "<file>" help="The file"
647arg "<mode>" help="How to run" help_heading="Behaviour"
648        "# }
649        .unwrap();
650
651        // Unheaded entries keep the default title and come first; each heading
652        // then gets its own section, in the order the headings first appear.
653        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
654        Usage: testcli [FLAGS] <file> <mode>
655
656        Arguments:
657          <file>  The file
658
659        Behaviour:
660          <mode>  How to run
661
662        Flags:
663              --verbose            Verbose output
664          -h, --help               Print help
665
666        Filtering:
667              --filter <pattern>   Only matching
668              --exclude <pattern>  Skip matching
669
670        Performance:
671              --jobs <n>           How many at once
672        ");
673    }
674
675    #[test]
676    fn test_render_help_with_only_headed_flags() {
677        // No default section when nothing lands in it: a CLI that gives every
678        // flag a heading should not get an empty "Flags:".
679        let spec = crate::spec! { r#"
680bin "testcli"
681flag "--filter <pattern>" help="Only matching" help_heading="Filtering"
682        "# }
683        .unwrap();
684
685        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
686        Usage: testcli [--filter <pattern>]
687
688        Flags:
689          -h, --help              Print help
690
691        Filtering:
692              --filter <pattern>  Only matching
693        ");
694    }
695
696    #[test]
697    fn test_render_help_with_env() {
698        let spec = crate::spec! { r#"
699bin "testcli"
700flag "--color" env="MYCLI_COLOR" help="Enable color output"
701flag "--verbose" env="MYCLI_VERBOSE" help="Verbose output"
702flag "--debug" help="Debug mode"
703        "# }
704        .unwrap();
705
706        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
707        Usage: testcli [FLAGS]
708
709        Flags:
710              --color    Enable color output [env: MYCLI_COLOR]
711              --verbose  Verbose output [env: MYCLI_VERBOSE]
712              --debug    Debug mode
713          -h, --help     Print help
714        ");
715
716        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
717        Usage: testcli [FLAGS]
718
719        Flags:
720              --color    Enable color output
721            [env: MYCLI_COLOR]
722              --verbose  Verbose output
723            [env: MYCLI_VERBOSE]
724              --debug    Debug mode
725          -h, --help     Print help
726        ");
727    }
728
729    #[test]
730    fn test_render_help_with_arg_env() {
731        let spec = crate::spec! { r#"
732bin "testcli"
733arg "<input>" env="MY_INPUT" help="Input file"
734arg "<output>" env="MY_OUTPUT" help="Output file"
735arg "<extra>" help="Extra arg without env"
736arg "[default]" help="Arg with default value" default="default value"
737        "# }
738        .unwrap();
739
740        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
741        Usage: testcli <ARGS>…
742
743        Arguments:
744          <input>    Input file [env: MY_INPUT]
745          <output>   Output file [env: MY_OUTPUT]
746          <extra>    Extra arg without env
747          [default]  Arg with default value (default: default value)
748
749        Flags:
750          -h, --help  Print help
751        ");
752
753        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
754        Usage: testcli <ARGS>…
755
756        Arguments:
757          <input>    Input file
758            [env: MY_INPUT]
759          <output>   Output file
760            [env: MY_OUTPUT]
761          <extra>    Extra arg without env
762          [default]  Arg with default value
763            (default: default value)
764
765        Flags:
766          -h, --help  Print help
767        ");
768    }
769
770    #[test]
771    fn test_render_help_with_negated_flag() {
772        let spec = crate::spec! { r#"
773bin "testcli"
774flag "--compress" negate="--no-compress" default=#true help="Compress output"
775flag "--verbose" help="Verbose output"
776        "# }
777        .unwrap();
778
779        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
780        Usage: testcli [--compress] [--verbose]
781
782        Flags:
783              --compress / --no-compress  Compress output (default: true)
784              --verbose                   Verbose output
785          -h, --help                      Print help
786        ");
787
788        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
789        Usage: testcli [--compress] [--verbose]
790
791        Flags:
792              --compress / --no-compress  Compress output
793            (default: true)
794              --verbose                   Verbose output
795          -h, --help                      Print help
796        ");
797    }
798
799    #[test]
800    fn granular_help_hides_preserve_behavior_but_remove_presentation() {
801        let spec = crate::spec! { r#"
802bin "testcli"
803flag "--mode <mode>" help="Select mode" env="MODE" default="fast" hide_default_value=#true hide_env=#true hide_possible_values=#true {
804  choices {
805    choice "fast"
806    choice "slow"
807  }
808}
809flag "--short-only" help="short" hide_long_help=#true
810flag "--long-only" help="long" hide_short_help=#true
811arg "[input]" help="Input" env="INPUT" default="file" hide_default_value=#true hide_env=#true
812        "# }
813        .unwrap();
814
815        let short = render_help(&spec, &spec.cmd, false);
816        assert!(short.contains("--mode <mode>"), "{short}");
817        assert!(short.contains("--short-only"), "{short}");
818        assert!(!short.contains("--long-only"), "{short}");
819        assert!(
820            !short.contains("MODE") && !short.contains("fast, slow"),
821            "{short}"
822        );
823        assert!(
824            !short.contains("default: fast") && !short.contains("default: file"),
825            "{short}"
826        );
827
828        let long = render_help(&spec, &spec.cmd, true);
829        assert!(long.contains("--long-only"), "{long}");
830        assert!(!long.contains("--short-only"), "{long}");
831        assert!(
832            !long.contains("MODE") && !long.contains("possible values"),
833            "{long}"
834        );
835
836        let rendered = spec.to_string();
837        let reparsed: crate::Spec = rendered.parse().unwrap();
838        assert!(reparsed.cmd.flags[0].hide_default_value);
839        assert!(reparsed.cmd.flags[0].hide_env);
840        assert!(reparsed.cmd.flags[0].hide_possible_values);
841    }
842
843    #[test]
844    fn a_help_template_reorders_omits_and_wraps_the_sections() {
845        // The whole of what a template can do: `{{flags}}` before `{{args}}`, no
846        // `{{commands}}` at all, and text of the author's own around them. Nothing else is
847        // substituted, so the layout is the spec's and the sections' contents are not.
848        let spec = crate::spec! { r#"
849bin "ex"
850about "An example"
851help_template "{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}\n\n-- ask a person --"
852flag "--force" help="Do it anyway"
853arg "<file>" help="Which file"
854cmd "run" help="Run it"
855        "# }
856        .unwrap();
857
858        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
859        An example
860
861        Usage: ex [--force] <file> <SUBCOMMAND>
862
863        Flags:
864              --force  Do it anyway
865          -h, --help   Print help
866
867        Arguments:
868          <file>  Which file
869
870        -- ask a person --
871        ");
872    }
873
874    #[test]
875    fn a_template_places_the_sections_a_page_actually_has() {
876        // A template names every section, and this command has no arguments — the gap
877        // `{{args}}` would leave closes up rather than pushing the commands down the page.
878        // What lets one template serve a whole CLI, since most commands are missing most
879        // sections. Here the version banner and description are last, and `after_help`
880        // carries them nothing.
881        let spec = crate::spec! { r#"
882bin "ex"
883version "1.2.3"
884about "An example"
885after_help "Read the docs."
886help_template "{{usage}}\n\n{{flags}}\n\n{{args}}\n\n{{commands}}\n\n{{after_help}}\n\n{{about}}"
887flag "--force" help="Do it anyway"
888cmd "run" help="Run it"
889        "# }
890        .unwrap();
891
892        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
893        Usage: ex [--force] <SUBCOMMAND>
894
895        Flags:
896              --force    Do it anyway
897          -h, --help     Print help
898          -V, --version  Print version
899
900        Commands:
901          run
902            Run it
903
904          help
905            Print this message or the help of the given subcommand(s)
906
907        Read the docs.
908
909        ex 1.2.3
910        An example
911        ");
912    }
913
914    #[test]
915    fn a_flattened_page_puts_its_bodies_where_the_commands_would_go() {
916        // `flatten_help` replaces a command list with the subcommands' own bodies, so a
917        // template that places `{{commands}}` places whichever of the two this command has.
918        let spec = crate::spec! { r#"
919bin "ex"
920flatten_help #true
921help_template "{{usage}}\n\n{{commands}}\n\n{{flags}}"
922cmd "run" help="Run it" {
923    flag "--dry-run" help="Only show changes"
924}
925        "# }
926        .unwrap();
927
928        let page = render_help(&spec, &spec.cmd, false);
929        assert!(
930            page.find("run:").unwrap() < page.find("Flags:").unwrap(),
931            "{page}"
932        );
933        assert!(page.contains("--dry-run"), "{page}");
934    }
935
936    #[test]
937    fn test_render_help_with_before_after_help() {
938        let spec = crate::spec! { r#"
939bin "testcli"
940before_help "This text appears before the help"
941after_help "This text appears after the help"
942flag "--verbose" help="Enable verbose output"
943        "# }
944        .unwrap();
945
946        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
947        This text appears before the help
948
949        Usage: testcli [--verbose]
950
951        Flags:
952              --verbose  Enable verbose output
953          -h, --help     Print help
954
955        This text appears after the help
956        ");
957    }
958
959    #[test]
960    fn test_render_help_with_before_after_help_long() {
961        let spec = crate::spec! { r#"
962bin "testcli"
963before_help "short before"
964before_help_long "This is the long version of before help"
965after_help "short after"
966after_help_long "This is the long version of after help"
967flag "--verbose" help="Enable verbose output"
968        "# }
969        .unwrap();
970
971        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
972        short before
973
974        Usage: testcli [--verbose]
975
976        Flags:
977              --verbose  Enable verbose output
978          -h, --help     Print help
979
980        short after
981        ");
982
983        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
984        This is the long version of before help
985
986        Usage: testcli [--verbose]
987
988        Flags:
989              --verbose  Enable verbose output
990          -h, --help     Print help
991
992        This is the long version of after help
993        ");
994    }
995
996    #[test]
997    fn test_render_help_with_examples() {
998        let spec = crate::spec! { r#"
999bin "testcli"
1000flag "--verbose" help="Enable verbose output"
1001example "testcli --verbose" header="Run with verbose output"
1002example "testcli" header="Run normally" help="Just runs the tool"
1003        "# }
1004        .unwrap();
1005
1006        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1007        Usage: testcli [--verbose]
1008
1009        Flags:
1010              --verbose  Enable verbose output
1011          -h, --help     Print help
1012
1013        Examples:
1014          Run with verbose output:
1015            $ testcli --verbose
1016          Run normally:
1017            $ testcli
1018        ");
1019
1020        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1021        Usage: testcli [--verbose]
1022
1023        Flags:
1024              --verbose  Enable verbose output
1025          -h, --help     Print help
1026
1027        Examples:
1028          Run with verbose output:
1029            $ testcli --verbose
1030          Run normally:
1031            Just runs the tool
1032            $ testcli
1033        ");
1034    }
1035
1036    #[test]
1037    fn test_render_help_with_version() {
1038        let spec = crate::spec! { r#"
1039bin "testcli"
1040name "TestCLI"
1041version "1.2.3"
1042flag "--verbose" help="Enable verbose output"
1043        "# }
1044        .unwrap();
1045
1046        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1047        TestCLI 1.2.3
1048        Usage: testcli [--verbose]
1049
1050        Flags:
1051              --verbose  Enable verbose output
1052          -h, --help     Print help
1053          -V, --version  Print version
1054        ");
1055    }
1056
1057    #[test]
1058    fn test_render_help_with_only_long_version() {
1059        let spec = crate::spec! { r#"
1060bin "testcli"
1061long_version "1.2.3\ncommit abc123"
1062flag "--verbose" help="Enable verbose output"
1063        "# }
1064        .unwrap();
1065
1066        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1067        Usage: testcli [--verbose]
1068
1069        Flags:
1070              --verbose  Enable verbose output
1071          -h, --help     Print help
1072          -V, --version  Print version
1073        ");
1074    }
1075
1076    #[test]
1077    fn test_render_help_omits_help_when_disabled() {
1078        // `disable_help` turns the parser's answer off, so the page must not offer it: the same
1079        // rule as a spelling the CLI claimed, with the spec doing the claiming. `--version`
1080        // stays, because nothing disabled that.
1081        let spec = crate::spec! { r#"
1082bin "testcli"
1083version "1.2.3"
1084disable_help #true
1085flag "--verbose" help="Enable verbose output"
1086        "# }
1087        .unwrap();
1088
1089        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1090        testcli 1.2.3
1091        Usage: testcli [--verbose]
1092
1093        Flags:
1094              --verbose  Enable verbose output
1095          -V, --version  Print version
1096        ");
1097    }
1098
1099    #[test]
1100    fn test_render_help_with_author_license() {
1101        let spec = crate::spec! { r#"
1102bin "testcli"
1103author "Test Author"
1104license "MIT"
1105flag "--verbose" help="Enable verbose output"
1106        "# }
1107        .unwrap();
1108
1109        // Short help should not show author/license
1110        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1111        Usage: testcli [--verbose]
1112
1113        Flags:
1114              --verbose  Enable verbose output
1115          -h, --help     Print help
1116        ");
1117
1118        // Long help should show author/license at the bottom
1119        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1120        Usage: testcli [--verbose]
1121
1122        Flags:
1123              --verbose  Enable verbose output
1124          -h, --help     Print help
1125
1126        Author: Test Author
1127        License: MIT
1128        ");
1129    }
1130
1131    #[test]
1132    fn test_render_help_with_deprecated_command() {
1133        let spec = crate::spec! { r#"
1134bin "testcli"
1135flag "--old" help="Old switch" deprecated="use --new" deprecated_warn_at="6.1" deprecated_remove_at="7.0"
1136cmd "old-cmd" help="Do something" deprecated="use new-cmd instead" deprecated_warn_at="6.2" deprecated_remove_at="7.0"
1137cmd "new-cmd" help="Do something better"
1138        "# }
1139        .unwrap();
1140
1141        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1142        Usage: testcli [--old] <SUBCOMMAND>
1143
1144        Commands:
1145          new-cmd  Do something better
1146          old-cmd [deprecated: use new-cmd instead; warns at 6.2; removed at 7.0]  Do something
1147          help  Print this message or the help of the given subcommand(s)
1148
1149        Flags:
1150              --old   Old switch [deprecated: use --new; warns at 6.1; removed at 7.0]
1151          -h, --help  Print help
1152        ");
1153    }
1154
1155    #[test]
1156    fn deprecation_milestones_do_not_need_a_message() {
1157        let spec = crate::spec! { r#"
1158bin "testcli"
1159flag "--old" help="Old switch" deprecated_remove_at="7.0"
1160cmd "old-cmd" help="Do something" deprecated_warn_at="6.2"
1161        "# }
1162        .unwrap();
1163
1164        let page = render_help(&spec, &spec.cmd, false);
1165        assert!(
1166            page.contains("old-cmd [deprecated: warns at 6.2]"),
1167            "{page}"
1168        );
1169        assert!(page.contains("[deprecated: removed at 7.0]"), "{page}");
1170        assert!(!page.contains("[deprecated:;"), "{page}");
1171    }
1172
1173    #[test]
1174    fn test_render_help_with_subcommand_presentation() {
1175        let spec = crate::spec! { r#"
1176bin "testcli"
1177subcommand_help_heading "Actions"
1178subcommand_value_name "ACTION"
1179cmd "run" help="Run it\n"
1180        "# }
1181        .unwrap();
1182
1183        let page = render_help(&spec, &spec.cmd, false);
1184        assert!(page.contains("Usage: testcli <ACTION>"), "{page}");
1185        assert!(page.contains("\nActions:\n"), "{page}");
1186    }
1187
1188    #[test]
1189    fn test_render_help_honors_explicit_display_order() {
1190        let spec = crate::spec! { r#"
1191bin "testcli"
1192flag "--unset" help="Unordered"
1193flag "--later" help="Later" display_order=20
1194flag "--first" help="First" display_order=10
1195cmd "zulu" help="Unordered"
1196cmd "later" help="Later" display_order=20
1197cmd "first" help="First" display_order=10
1198cmd "alpha" help="Unordered"
1199        "# }
1200        .unwrap();
1201
1202        let page = render_help(&spec, &spec.cmd, false);
1203        let commands = page.split_once("\nCommands:\n").unwrap().1;
1204        assert!(
1205            commands.find("first").unwrap() < commands.find("later").unwrap()
1206                && commands.find("later").unwrap() < commands.find("alpha").unwrap()
1207                && commands.find("alpha").unwrap() < commands.find("zulu").unwrap(),
1208            "{page}"
1209        );
1210        let flags = page.split_once("\nFlags:\n").unwrap().1;
1211        assert!(
1212            flags.find("--first").unwrap() < flags.find("--later").unwrap()
1213                && flags.find("--later").unwrap() < flags.find("--unset").unwrap(),
1214            "{page}"
1215        );
1216    }
1217
1218    #[test]
1219    fn test_render_help_groups_subcommands_by_heading() {
1220        let spec = crate::spec! { r#"
1221bin "testcli"
1222cmd "run" help="Run it" help_heading="Core commands"
1223cmd "clean" help="Remove old state" help_heading="Maintenance"
1224cmd "status" help="Show status" help_heading="Commands"
1225        "# }
1226        .unwrap();
1227
1228        for page in [
1229            render_help(&spec, &spec.cmd, false),
1230            render_help(&spec, &spec.cmd, true),
1231        ] {
1232            let commands = page.find("\nCommands:\n").expect("default command section");
1233            assert_eq!(page.matches("\nCommands:\n").count(), 1, "{page}");
1234            let core = page.find("\nCore commands:\n").expect("core section");
1235            let maintenance = page.find("\nMaintenance:\n").expect("maintenance section");
1236            assert!(commands < core && commands < maintenance, "{page}");
1237            let default_end = core.min(maintenance);
1238            assert!(page[commands..default_end].contains("status"), "{page}");
1239            assert!(page[commands..default_end].contains("help"), "{page}");
1240            assert!(page[core..].contains("run"), "{page}");
1241            assert!(page[maintenance..].contains("clean"), "{page}");
1242        }
1243    }
1244
1245    #[test]
1246    fn test_render_help_with_next_line_layout() {
1247        let spec = crate::spec! { r#"
1248bin "testcli"
1249next_line_help #true
1250arg "<input>" help="Input file" env="INPUT" default="fast" {
1251    choices {
1252        choice "fast"
1253        choice "slow"
1254    }
1255}
1256flag "--verbose" help="Enable verbose output"
1257cmd "run" help="Run it"
1258        "# }
1259        .unwrap();
1260
1261        let short = render_help(&spec, &spec.cmd, false);
1262        assert!(!short.contains("    Run it\n\n  help"), "{short}");
1263        for page in [short, render_help(&spec, &spec.cmd, true)] {
1264            assert!(page.contains("  [input]\n    Input file"), "{page}");
1265            assert!(
1266                page.contains("--verbose\n    Enable verbose output"),
1267                "{page}"
1268            );
1269            assert!(
1270                page.contains(
1271                    "    [possible values: fast, slow]\n    [env: INPUT]\n    (default: fast)"
1272                ),
1273                "{page}"
1274            );
1275            assert!(page.contains("  run\n    Run it"), "{page}");
1276        }
1277    }
1278
1279    #[test]
1280    fn flatten_help_expands_subcommands_instead_of_listing_them() {
1281        let spec = crate::spec! { r#"
1282bin "testcli"
1283flatten_help #true
1284next_line_help #true
1285cmd "run" help="Run it" {
1286    arg "<task>" help="Task name" env="TASK" default="build" {
1287        choices {
1288            choice "build"
1289            choice "test"
1290        }
1291    }
1292    flag "--dry-run" help="Only show changes"
1293    flatten_help #true
1294    cmd "nested" help="Nested operation" {
1295        flag "--deep" help="Deep option"
1296    }
1297}
1298        "# }
1299        .unwrap();
1300
1301        for page in [
1302            render_help(&spec, &spec.cmd, false),
1303            render_help(&spec, &spec.cmd, true),
1304        ] {
1305            assert!(
1306                page.contains("Usage: testcli\n       testcli run"),
1307                "{page}"
1308            );
1309            assert!(!page.contains("\nCommands:\n"), "{page}");
1310            assert!(page.contains("\nrun:\nRun it"), "{page}");
1311            assert!(page.contains("[task]"), "{page}");
1312            assert!(page.contains("--dry-run"), "{page}");
1313            assert!(page.contains("\nrun nested:\nNested operation"), "{page}");
1314            assert!(page.contains("--deep"), "{page}");
1315            assert!(
1316                page.contains(
1317                    "    [possible values: build, test]\n    [env: TASK]\n    (default: build)"
1318                ),
1319                "{page}"
1320            );
1321        }
1322    }
1323}