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 flag_aliases_do_not_leak_into_interactive_help() {
508        let spec = crate::spec! { r#"
509bin "ex"
510flag "-t -f --tail --follow" help="Follow output"
511        "# }
512        .unwrap();
513
514        for long in [false, true] {
515            let page = super::render_help(&spec, &spec.cmd, long);
516            assert!(page.contains("-t, --tail"), "long={long}:\n{page}");
517            assert!(!page.contains("aliases:"), "long={long}:\n{page}");
518        }
519    }
520
521    #[test]
522    fn a_hidden_ancestor_claim_keeps_help_off_the_page() {
523        // `--help` is supplied by the parser, and a hidden global that declares it still binds
524        // first — `hide` keeps a flag off the page, not out of the parse. Deciding the supplied
525        // entries from the *visible* inherited list lost exactly that, and the page offered a
526        // `--help` that does something else.
527        let spec = crate::spec! { r#"
528bin "ex"
529flag "--help" global=#true hide=#true help="the CLI's own, and invisible"
530cmd inner help="a command" {
531    flag "--plain" help="its own"
532}
533        "# }
534        .unwrap();
535
536        let inner = spec.cmd.subcommands.get("inner").expect("inner");
537        for long in [false, true] {
538            let page = super::render_help(&spec, inner, long);
539            assert!(
540                !page.contains("--help"),
541                "long={long}: a hidden ancestor binds this:\n{page}"
542            );
543            // The short form is untouched, since nothing claimed it.
544            assert!(page.contains("-h"), "long={long}:\n{page}");
545        }
546    }
547
548    #[test]
549    fn a_long_beats_a_negation_however_far_away_it_is() {
550        // A negation is stored *with* its dashes here and without them in usage-argv, so the
551        // spelling was being looked up as `----no-cache` and matched nothing — negations were
552        // counted in name only. And which one binds is not about distance: a word is resolved
553        // against every long in scope before any negation is considered, so the root's plain
554        // `--no-cache` wins over the subcommand's negation and belongs on its page.
555        let spec = crate::spec! { r#"
556bin "ex"
557flag "--no-cache" global=#true help="the root's plain long"
558flag "--colour" negate="--no-colour" global=#true help="the root's, with a negation"
559cmd narrow help="a command" {
560    flag "--cache" negate="--no-cache" help="its own, with a negation"
561    flag "--tint" negate="--no-colour" help="claims the root's negation"
562}
563        "# }
564        .unwrap();
565
566        let narrow = spec.cmd.subcommands.get("narrow").expect("narrow");
567        for long in [false, true] {
568            let page = super::render_help(&spec, narrow, long);
569            assert!(
570                page.contains("--no-cache"),
571                "long={long}: a long beats a negation, so this still binds here:\n{page}"
572            );
573            // And a negation *is* claimed by a nearer negation — which is what the dashes
574            // matter for. `--colour` stays; the negation it used to carry does not.
575            assert!(page.contains("--colour"), "long={long}:\n{page}");
576            let global = page
577                .split_once("Global flags:")
578                .expect("a global section")
579                .1;
580            assert!(
581                !global.contains("--colour / --no-colour"),
582                "long={long}: the nearer negation owns that spelling:\n{page}"
583            );
584        }
585    }
586
587    #[test]
588    fn a_description_of_only_spaces_is_no_description() {
589        // `usage-argv` filters a blank description wherever it reads one, and this template
590        // asked only whether the string was there — so `help="   "` bought a column of padding
591        // and a line of trailing spaces here and nothing there. Two renderings of one spec.
592        //
593        // Asserted on the trailing whitespace rather than by comparing the two renderers, so
594        // the test says what is wrong with the line rather than only that they disagree.
595        let spec = crate::spec! { r#"
596bin "ex"
597flag "--blank" help="   "
598flag "--plain" help="plain"
599        "# }
600        .unwrap();
601
602        for long in [false, true] {
603            let page = super::render_help(&spec, &spec.cmd, long);
604            // In the flags section, not the usage line — `Usage: ex [--blank] [--plain]`
605            // also contains the name and has no padding to get wrong.
606            let listing = page.split_once("\nFlags:").expect("a flags section").1;
607            let line = listing
608                .lines()
609                .find(|l| l.contains("--blank"))
610                .unwrap_or_else(|| panic!("long={long}: {page}"));
611            assert_eq!(
612                line,
613                line.trim_end(),
614                "long={long}: trailing space on {line:?}"
615            );
616        }
617    }
618
619    #[test]
620    fn test_render_help_omits_hidden_entries() {
621        let spec = crate::spec! { r#"
622bin "ex"
623flag "--visible" help="shown"
624flag "--secret" hide=#true help="hidden"
625flag "--filtered" hide=#true help="hidden" help_heading="Filtering"
626arg "[SHOWN]" help="an arg"
627arg "[HIDDEN]" hide=#true help="a hidden arg"
628cmd open help="a command"
629cmd sneaky hide=#true help="a hidden command"
630        "# }
631        .unwrap();
632
633        // `hide` keeps something out of help. The usage line filtered already — through
634        // `SpecCommand::usage` — so before this, `ex --help` listed a `--secret` the line
635        // above it did not mention. A heading whose every entry is hidden produces no
636        // section, which is the rule markdown rendering already followed.
637        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
638        Usage: ex [--visible] [SHOWN] <SUBCOMMAND>
639
640        Commands:
641          open  a command
642          help  Print this message or the help of the given subcommand(s)
643
644        Arguments:
645          [SHOWN]  an arg
646
647        Flags:
648              --visible  shown
649          -h, --help     Print help
650        ");
651    }
652
653    #[test]
654    fn test_render_help_groups_by_heading() {
655        let spec = crate::spec! { r#"
656bin "testcli"
657flag "--verbose" help="Verbose output"
658flag "--filter <pattern>" help="Only matching" help_heading="Filtering"
659flag "--exclude <pattern>" help="Skip matching" help_heading="Filtering"
660flag "--jobs <n>" help="How many at once" help_heading="Performance"
661arg "<file>" help="The file"
662arg "<mode>" help="How to run" help_heading="Behaviour"
663        "# }
664        .unwrap();
665
666        // Unheaded entries keep the default title and come first; each heading
667        // then gets its own section, in the order the headings first appear.
668        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
669        Usage: testcli [FLAGS] <file> <mode>
670
671        Arguments:
672          <file>  The file
673
674        Behaviour:
675          <mode>  How to run
676
677        Flags:
678              --verbose            Verbose output
679          -h, --help               Print help
680
681        Filtering:
682              --filter <pattern>   Only matching
683              --exclude <pattern>  Skip matching
684
685        Performance:
686              --jobs <n>           How many at once
687        ");
688    }
689
690    #[test]
691    fn test_render_help_with_only_headed_flags() {
692        // No default section when nothing lands in it: a CLI that gives every
693        // flag a heading should not get an empty "Flags:".
694        let spec = crate::spec! { r#"
695bin "testcli"
696flag "--filter <pattern>" help="Only matching" help_heading="Filtering"
697        "# }
698        .unwrap();
699
700        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
701        Usage: testcli [--filter <pattern>]
702
703        Flags:
704          -h, --help              Print help
705
706        Filtering:
707              --filter <pattern>  Only matching
708        ");
709    }
710
711    #[test]
712    fn test_render_help_with_env() {
713        let spec = crate::spec! { r#"
714bin "testcli"
715flag "--color" env="MYCLI_COLOR" help="Enable color output"
716flag "--verbose" env="MYCLI_VERBOSE" help="Verbose output"
717flag "--debug" help="Debug mode"
718        "# }
719        .unwrap();
720
721        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
722        Usage: testcli [FLAGS]
723
724        Flags:
725              --color    Enable color output [env: MYCLI_COLOR]
726              --verbose  Verbose output [env: MYCLI_VERBOSE]
727              --debug    Debug mode
728          -h, --help     Print help
729        ");
730
731        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
732        Usage: testcli [FLAGS]
733
734        Flags:
735              --color    Enable color output
736            [env: MYCLI_COLOR]
737              --verbose  Verbose output
738            [env: MYCLI_VERBOSE]
739              --debug    Debug mode
740          -h, --help     Print help
741        ");
742    }
743
744    #[test]
745    fn test_render_help_with_arg_env() {
746        let spec = crate::spec! { r#"
747bin "testcli"
748arg "<input>" env="MY_INPUT" help="Input file"
749arg "<output>" env="MY_OUTPUT" help="Output file"
750arg "<extra>" help="Extra arg without env"
751arg "[default]" help="Arg with default value" default="default value"
752        "# }
753        .unwrap();
754
755        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
756        Usage: testcli <ARGS>…
757
758        Arguments:
759          <input>    Input file [env: MY_INPUT]
760          <output>   Output file [env: MY_OUTPUT]
761          <extra>    Extra arg without env
762          [default]  Arg with default value (default: default value)
763
764        Flags:
765          -h, --help  Print help
766        ");
767
768        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
769        Usage: testcli <ARGS>…
770
771        Arguments:
772          <input>    Input file
773            [env: MY_INPUT]
774          <output>   Output file
775            [env: MY_OUTPUT]
776          <extra>    Extra arg without env
777          [default]  Arg with default value
778            (default: default value)
779
780        Flags:
781          -h, --help  Print help
782        ");
783    }
784
785    #[test]
786    fn test_render_help_with_negated_flag() {
787        let spec = crate::spec! { r#"
788bin "testcli"
789flag "--compress" negate="--no-compress" default=#true help="Compress output"
790flag "--verbose" help="Verbose output"
791        "# }
792        .unwrap();
793
794        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
795        Usage: testcli [--compress] [--verbose]
796
797        Flags:
798              --compress / --no-compress  Compress output (default: true)
799              --verbose                   Verbose output
800          -h, --help                      Print help
801        ");
802
803        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
804        Usage: testcli [--compress] [--verbose]
805
806        Flags:
807              --compress / --no-compress  Compress output
808            (default: true)
809              --verbose                   Verbose output
810          -h, --help                      Print help
811        ");
812    }
813
814    #[test]
815    fn granular_help_hides_preserve_behavior_but_remove_presentation() {
816        let spec = crate::spec! { r#"
817bin "testcli"
818flag "--mode <mode>" help="Select mode" env="MODE" default="fast" hide_default_value=#true hide_env=#true hide_possible_values=#true {
819  choices {
820    choice "fast"
821    choice "slow"
822  }
823}
824flag "--short-only" help="short" hide_long_help=#true
825flag "--long-only" help="long" hide_short_help=#true
826arg "[input]" help="Input" env="INPUT" default="file" hide_default_value=#true hide_env=#true
827        "# }
828        .unwrap();
829
830        let short = render_help(&spec, &spec.cmd, false);
831        assert!(short.contains("--mode <mode>"), "{short}");
832        assert!(short.contains("--short-only"), "{short}");
833        assert!(!short.contains("--long-only"), "{short}");
834        assert!(
835            !short.contains("MODE") && !short.contains("fast, slow"),
836            "{short}"
837        );
838        assert!(
839            !short.contains("default: fast") && !short.contains("default: file"),
840            "{short}"
841        );
842
843        let long = render_help(&spec, &spec.cmd, true);
844        assert!(long.contains("--long-only"), "{long}");
845        assert!(!long.contains("--short-only"), "{long}");
846        assert!(
847            !long.contains("MODE") && !long.contains("possible values"),
848            "{long}"
849        );
850
851        let rendered = spec.to_string();
852        let reparsed: crate::Spec = rendered.parse().unwrap();
853        assert!(reparsed.cmd.flags[0].hide_default_value);
854        assert!(reparsed.cmd.flags[0].hide_env);
855        assert!(reparsed.cmd.flags[0].hide_possible_values);
856    }
857
858    #[test]
859    fn a_help_template_reorders_omits_and_wraps_the_sections() {
860        // The whole of what a template can do: `{{flags}}` before `{{args}}`, no
861        // `{{commands}}` at all, and text of the author's own around them. Nothing else is
862        // substituted, so the layout is the spec's and the sections' contents are not.
863        let spec = crate::spec! { r#"
864bin "ex"
865about "An example"
866help_template "{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}\n\n-- ask a person --"
867flag "--force" help="Do it anyway"
868arg "<file>" help="Which file"
869cmd "run" help="Run it"
870        "# }
871        .unwrap();
872
873        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
874        An example
875
876        Usage: ex [--force] <file> <SUBCOMMAND>
877
878        Flags:
879              --force  Do it anyway
880          -h, --help   Print help
881
882        Arguments:
883          <file>  Which file
884
885        -- ask a person --
886        ");
887    }
888
889    #[test]
890    fn a_template_places_the_sections_a_page_actually_has() {
891        // A template names every section, and this command has no arguments — the gap
892        // `{{args}}` would leave closes up rather than pushing the commands down the page.
893        // What lets one template serve a whole CLI, since most commands are missing most
894        // sections. Here the version banner and description are last, and `after_help`
895        // carries them nothing.
896        let spec = crate::spec! { r#"
897bin "ex"
898version "1.2.3"
899about "An example"
900after_help "Read the docs."
901help_template "{{usage}}\n\n{{flags}}\n\n{{args}}\n\n{{commands}}\n\n{{after_help}}\n\n{{about}}"
902flag "--force" help="Do it anyway"
903cmd "run" help="Run it"
904        "# }
905        .unwrap();
906
907        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
908        Usage: ex [--force] <SUBCOMMAND>
909
910        Flags:
911              --force    Do it anyway
912          -h, --help     Print help
913          -V, --version  Print version
914
915        Commands:
916          run
917            Run it
918
919          help
920            Print this message or the help of the given subcommand(s)
921
922        Read the docs.
923
924        ex 1.2.3
925        An example
926        ");
927    }
928
929    #[test]
930    fn a_flattened_page_puts_its_bodies_where_the_commands_would_go() {
931        // `flatten_help` replaces a command list with the subcommands' own bodies, so a
932        // template that places `{{commands}}` places whichever of the two this command has.
933        let spec = crate::spec! { r#"
934bin "ex"
935flatten_help #true
936help_template "{{usage}}\n\n{{commands}}\n\n{{flags}}"
937cmd "run" help="Run it" {
938    flag "--dry-run" help="Only show changes"
939}
940        "# }
941        .unwrap();
942
943        let page = render_help(&spec, &spec.cmd, false);
944        assert!(
945            page.find("run:").unwrap() < page.find("Flags:").unwrap(),
946            "{page}"
947        );
948        assert!(page.contains("--dry-run"), "{page}");
949    }
950
951    #[test]
952    fn test_render_help_with_before_after_help() {
953        let spec = crate::spec! { r#"
954bin "testcli"
955before_help "This text appears before the help"
956after_help "This text appears after the help"
957flag "--verbose" help="Enable verbose output"
958        "# }
959        .unwrap();
960
961        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
962        This text appears before the help
963
964        Usage: testcli [--verbose]
965
966        Flags:
967              --verbose  Enable verbose output
968          -h, --help     Print help
969
970        This text appears after the help
971        ");
972    }
973
974    #[test]
975    fn test_render_help_with_before_after_help_long() {
976        let spec = crate::spec! { r#"
977bin "testcli"
978before_help "short before"
979before_help_long "This is the long version of before help"
980after_help "short after"
981after_help_long "This is the long version of after help"
982flag "--verbose" help="Enable verbose output"
983        "# }
984        .unwrap();
985
986        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
987        short before
988
989        Usage: testcli [--verbose]
990
991        Flags:
992              --verbose  Enable verbose output
993          -h, --help     Print help
994
995        short after
996        ");
997
998        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
999        This is the long version of before help
1000
1001        Usage: testcli [--verbose]
1002
1003        Flags:
1004              --verbose  Enable verbose output
1005          -h, --help     Print help
1006
1007        This is the long version of after help
1008        ");
1009    }
1010
1011    #[test]
1012    fn test_render_help_with_examples() {
1013        let spec = crate::spec! { r#"
1014bin "testcli"
1015flag "--verbose" help="Enable verbose output"
1016example "testcli --verbose" header="Run with verbose output"
1017example "testcli" header="Run normally" help="Just runs the tool"
1018        "# }
1019        .unwrap();
1020
1021        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1022        Usage: testcli [--verbose]
1023
1024        Flags:
1025              --verbose  Enable verbose output
1026          -h, --help     Print help
1027
1028        Examples:
1029          Run with verbose output:
1030            $ testcli --verbose
1031          Run normally:
1032            $ testcli
1033        ");
1034
1035        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1036        Usage: testcli [--verbose]
1037
1038        Flags:
1039              --verbose  Enable verbose output
1040          -h, --help     Print help
1041
1042        Examples:
1043          Run with verbose output:
1044            $ testcli --verbose
1045          Run normally:
1046            Just runs the tool
1047            $ testcli
1048        ");
1049    }
1050
1051    #[test]
1052    fn test_render_help_with_version() {
1053        let spec = crate::spec! { r#"
1054bin "testcli"
1055name "TestCLI"
1056version "1.2.3"
1057flag "--verbose" help="Enable verbose output"
1058        "# }
1059        .unwrap();
1060
1061        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1062        TestCLI 1.2.3
1063        Usage: testcli [--verbose]
1064
1065        Flags:
1066              --verbose  Enable verbose output
1067          -h, --help     Print help
1068          -V, --version  Print version
1069        ");
1070    }
1071
1072    #[test]
1073    fn test_render_help_with_only_long_version() {
1074        let spec = crate::spec! { r#"
1075bin "testcli"
1076long_version "1.2.3\ncommit abc123"
1077flag "--verbose" help="Enable verbose output"
1078        "# }
1079        .unwrap();
1080
1081        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1082        Usage: testcli [--verbose]
1083
1084        Flags:
1085              --verbose  Enable verbose output
1086          -h, --help     Print help
1087          -V, --version  Print version
1088        ");
1089    }
1090
1091    #[test]
1092    fn test_render_help_omits_help_when_disabled() {
1093        // `disable_help` turns the parser's answer off, so the page must not offer it: the same
1094        // rule as a spelling the CLI claimed, with the spec doing the claiming. `--version`
1095        // stays, because nothing disabled that.
1096        let spec = crate::spec! { r#"
1097bin "testcli"
1098version "1.2.3"
1099disable_help #true
1100flag "--verbose" help="Enable verbose output"
1101        "# }
1102        .unwrap();
1103
1104        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1105        testcli 1.2.3
1106        Usage: testcli [--verbose]
1107
1108        Flags:
1109              --verbose  Enable verbose output
1110          -V, --version  Print version
1111        ");
1112    }
1113
1114    #[test]
1115    fn test_render_help_with_author_license() {
1116        let spec = crate::spec! { r#"
1117bin "testcli"
1118author "Test Author"
1119license "MIT"
1120flag "--verbose" help="Enable verbose output"
1121        "# }
1122        .unwrap();
1123
1124        // Short help should not show author/license
1125        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1126        Usage: testcli [--verbose]
1127
1128        Flags:
1129              --verbose  Enable verbose output
1130          -h, --help     Print help
1131        ");
1132
1133        // Long help should show author/license at the bottom
1134        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1135        Usage: testcli [--verbose]
1136
1137        Flags:
1138              --verbose  Enable verbose output
1139          -h, --help     Print help
1140
1141        Author: Test Author
1142        License: MIT
1143        ");
1144    }
1145
1146    #[test]
1147    fn test_render_help_with_deprecated_command() {
1148        let spec = crate::spec! { r#"
1149bin "testcli"
1150flag "--old" help="Old switch" deprecated="use --new" deprecated_warn_at="6.1" deprecated_remove_at="7.0"
1151cmd "old-cmd" help="Do something" deprecated="use new-cmd instead" deprecated_warn_at="6.2" deprecated_remove_at="7.0"
1152cmd "new-cmd" help="Do something better"
1153        "# }
1154        .unwrap();
1155
1156        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1157        Usage: testcli [--old] <SUBCOMMAND>
1158
1159        Commands:
1160          new-cmd  Do something better
1161          old-cmd [deprecated: use new-cmd instead; warns at 6.2; removed at 7.0]  Do something
1162          help  Print this message or the help of the given subcommand(s)
1163
1164        Flags:
1165              --old   Old switch [deprecated: use --new; warns at 6.1; removed at 7.0]
1166          -h, --help  Print help
1167        ");
1168    }
1169
1170    #[test]
1171    fn deprecation_milestones_do_not_need_a_message() {
1172        let spec = crate::spec! { r#"
1173bin "testcli"
1174flag "--old" help="Old switch" deprecated_remove_at="7.0"
1175cmd "old-cmd" help="Do something" deprecated_warn_at="6.2"
1176        "# }
1177        .unwrap();
1178
1179        let page = render_help(&spec, &spec.cmd, false);
1180        assert!(
1181            page.contains("old-cmd [deprecated: warns at 6.2]"),
1182            "{page}"
1183        );
1184        assert!(page.contains("[deprecated: removed at 7.0]"), "{page}");
1185        assert!(!page.contains("[deprecated:;"), "{page}");
1186    }
1187
1188    #[test]
1189    fn test_render_help_with_subcommand_presentation() {
1190        let spec = crate::spec! { r#"
1191bin "testcli"
1192subcommand_help_heading "Actions"
1193subcommand_value_name "ACTION"
1194cmd "run" help="Run it\n"
1195        "# }
1196        .unwrap();
1197
1198        let page = render_help(&spec, &spec.cmd, false);
1199        assert!(page.contains("Usage: testcli <ACTION>"), "{page}");
1200        assert!(page.contains("\nActions:\n"), "{page}");
1201    }
1202
1203    #[test]
1204    fn test_render_help_honors_explicit_display_order() {
1205        let spec = crate::spec! { r#"
1206bin "testcli"
1207flag "--unset" help="Unordered"
1208flag "--later" help="Later" display_order=20
1209flag "--first" help="First" display_order=10
1210cmd "zulu" help="Unordered"
1211cmd "later" help="Later" display_order=20
1212cmd "first" help="First" display_order=10
1213cmd "alpha" help="Unordered"
1214        "# }
1215        .unwrap();
1216
1217        let page = render_help(&spec, &spec.cmd, false);
1218        let commands = page.split_once("\nCommands:\n").unwrap().1;
1219        assert!(
1220            commands.find("first").unwrap() < commands.find("later").unwrap()
1221                && commands.find("later").unwrap() < commands.find("alpha").unwrap()
1222                && commands.find("alpha").unwrap() < commands.find("zulu").unwrap(),
1223            "{page}"
1224        );
1225        let flags = page.split_once("\nFlags:\n").unwrap().1;
1226        assert!(
1227            flags.find("--first").unwrap() < flags.find("--later").unwrap()
1228                && flags.find("--later").unwrap() < flags.find("--unset").unwrap(),
1229            "{page}"
1230        );
1231    }
1232
1233    #[test]
1234    fn test_render_help_groups_subcommands_by_heading() {
1235        let spec = crate::spec! { r#"
1236bin "testcli"
1237cmd "run" help="Run it" help_heading="Core commands"
1238cmd "clean" help="Remove old state" help_heading="Maintenance"
1239cmd "status" help="Show status" help_heading="Commands"
1240        "# }
1241        .unwrap();
1242
1243        for page in [
1244            render_help(&spec, &spec.cmd, false),
1245            render_help(&spec, &spec.cmd, true),
1246        ] {
1247            let commands = page.find("\nCommands:\n").expect("default command section");
1248            assert_eq!(page.matches("\nCommands:\n").count(), 1, "{page}");
1249            let core = page.find("\nCore commands:\n").expect("core section");
1250            let maintenance = page.find("\nMaintenance:\n").expect("maintenance section");
1251            assert!(commands < core && commands < maintenance, "{page}");
1252            let default_end = core.min(maintenance);
1253            assert!(page[commands..default_end].contains("status"), "{page}");
1254            assert!(page[commands..default_end].contains("help"), "{page}");
1255            assert!(page[core..].contains("run"), "{page}");
1256            assert!(page[maintenance..].contains("clean"), "{page}");
1257        }
1258    }
1259
1260    #[test]
1261    fn test_render_help_with_next_line_layout() {
1262        let spec = crate::spec! { r#"
1263bin "testcli"
1264next_line_help #true
1265arg "<input>" help="Input file" env="INPUT" default="fast" {
1266    choices {
1267        choice "fast"
1268        choice "slow"
1269    }
1270}
1271flag "--verbose" help="Enable verbose output"
1272cmd "run" help="Run it"
1273        "# }
1274        .unwrap();
1275
1276        let short = render_help(&spec, &spec.cmd, false);
1277        assert!(!short.contains("    Run it\n\n  help"), "{short}");
1278        for page in [short, render_help(&spec, &spec.cmd, true)] {
1279            assert!(page.contains("  [input]\n    Input file"), "{page}");
1280            assert!(
1281                page.contains("--verbose\n    Enable verbose output"),
1282                "{page}"
1283            );
1284            assert!(
1285                page.contains(
1286                    "    [possible values: fast, slow]\n    [env: INPUT]\n    (default: fast)"
1287                ),
1288                "{page}"
1289            );
1290            assert!(page.contains("  run\n    Run it"), "{page}");
1291        }
1292    }
1293
1294    #[test]
1295    fn flatten_help_expands_subcommands_instead_of_listing_them() {
1296        let spec = crate::spec! { r#"
1297bin "testcli"
1298flatten_help #true
1299next_line_help #true
1300cmd "run" help="Run it" {
1301    arg "<task>" help="Task name" env="TASK" default="build" {
1302        choices {
1303            choice "build"
1304            choice "test"
1305        }
1306    }
1307    flag "--dry-run" help="Only show changes"
1308    flatten_help #true
1309    cmd "nested" help="Nested operation" {
1310        flag "--deep" help="Deep option"
1311    }
1312}
1313        "# }
1314        .unwrap();
1315
1316        for page in [
1317            render_help(&spec, &spec.cmd, false),
1318            render_help(&spec, &spec.cmd, true),
1319        ] {
1320            assert!(
1321                page.contains("Usage: testcli\n       testcli run"),
1322                "{page}"
1323            );
1324            assert!(!page.contains("\nCommands:\n"), "{page}");
1325            assert!(page.contains("\nrun:\nRun it"), "{page}");
1326            assert!(page.contains("[task]"), "{page}");
1327            assert!(page.contains("--dry-run"), "{page}");
1328            assert!(page.contains("\nrun nested:\nNested operation"), "{page}");
1329            assert!(page.contains("--deep"), "{page}");
1330            assert!(
1331                page.contains(
1332                    "    [possible values: build, test]\n    [env: TASK]\n    (default: build)"
1333                ),
1334                "{page}"
1335            );
1336        }
1337    }
1338}