//! The grouped command list `atgc --help` prints. //! //! clap renders top-level subcommands as one flat `Commands:` column and has //! no way to split it: `help_heading` is an argument setting, and //! `subcommand_help_heading` renames the single section rather than adding a //! second one. A dozen verbs in one alphabetical run read as a dozen //! unrelated things, when a reader arrives with a task and wants the two or //! three lines it could be in. //! //! So the section is rendered here instead: the same two-space, one-column //! shape clap would have printed, split under headings, handed to the root //! command as its `before_help`, and put back where `Commands:` was by a help //! template that drops clap's own list. Only the grouping is written down — //! every name, its column and its line of help still come out of the command //! tree, so a command whose `about` changes says the new thing here without //! anyone touching this file, and the one thing that can go stale (which //! group a command is in) is what this module's tests hold. use clap::builder::{StyledStr, Styles}; use clap::{Command, CommandFactory, FromArgMatches}; use std::fmt::Write as _; /// The top-level commands, in the groups and the order `--help` shows them. /// /// Every visible subcommand belongs to exactly one group, `help` included — /// it is clap's own and is listed rather than left to trail the list /// unexplained. The order inside a group is not alphabetical: it is the order /// somebody meets the commands in, so `repo` comes before the pull requests /// that are opened against it. /// /// The cut is by subject, because a reader arrives with a task rather than /// with a taxonomy: what atgc is, who you are, what you are doing to a repo, /// and what to do when it does not work. That is also why `report` is in the /// troubleshooting group despite being the only command there that writes /// anything — it is where a `doctor` row and a `logs` line end up, and /// somebody looking for it is already in that group. /// /// `Other` holds the two commands no subject fits, and they resist for /// opposite reasons. `completion` is run once, at install, and never again; /// `api` is authenticated access to the whole lexicon and is the most /// powerful thing here. What they share is only that no heading above is /// true of them, which is what the word means. Forcing either into a group /// that nearly fits would cost a reader more than the honest label does. const GROUPS: &[(&str, &[&str])] = &[ ("Meta", &["about", "agent", "help"]), ("Account management", &["auth", "key"]), ( "Repository operations", &["repo", "browse", "search", "issue", "pr", "stack"], ), ( "Checking and troubleshooting", &["doctor", "logs", "report"], ), ("Other", &["api", "completion"]), ]; /// Parse argv, with the grouped list standing in for clap's `Commands:`. /// /// This is `Cli::parse()` with two settings applied to the command first, /// and it has to be spelled out rather than derived because both of them /// are computed from the very command they are set on. pub fn parse() -> crate::Cli { let mut cmd = crate::Cli::command(); // `help` is added while building, so build before reading the tree: a // list assembled from an unbuilt command is one command short, and the // missing one is the command a lost user reaches for. Parsing builds // again, which clap makes a no-op. cmd.build(); let list = command_list(&cmd); let template = template(cmd.get_styles()); let matches = cmd.before_help(list).help_template(template).get_matches(); crate::Cli::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()) } /// clap's default help, with `{before-help}` moved to where the command /// list belongs and the list itself left out. /// /// `{options}` writes the options without their heading — the default /// template gets that heading from `{all-args}`, which would also write the /// flat `Commands:` section this exists to replace. So the heading is /// written here, in the command's own header style, to keep it looking like /// every other heading clap prints. fn template(styles: &Styles) -> String { let header = styles.get_header(); format!( "{{about-with-newline}}\n\ {{usage-heading}} {{usage}}\n\ \n\ {{before-help}}{header}Options:{header:#}\n\ {{options}}{{after-help}}" ) } /// Render the visible subcommands as headed groups. /// /// The filter is mechanical, not a policy: nothing in this tree is hidden. /// It used to be — a top-level `login` alias was kept out of the listing so /// that shell history would not break — and that alias is gone, along with /// the `auth log` one that `main`'s tests pin as a parse failure. atgc does /// not keep hidden aliases; a spelling that changes changes, and the error /// says where the command went. fn command_list(cmd: &Command) -> StyledStr { let styles = cmd.get_styles(); let header = styles.get_header(); let literal = styles.get_literal(); let listed: Vec<&Command> = cmd.get_subcommands().filter(|s| !s.is_hide_set()).collect(); // One column across the whole list rather than one per group: the // groups are read as a single list that happens to have headings in it, // and three different left margins would say otherwise. let width = listed .iter() .map(|s| s.get_name().len()) .max() .unwrap_or_default(); let mut out = String::new(); for (heading, names) in GROUPS { if !out.is_empty() { out.push('\n'); } let _ = writeln!(out, "{header}{heading}:{header:#}"); for sub in names .iter() .filter_map(|name| listed.iter().find(|s| s.get_name() == *name)) { let name = sub.get_name(); let pad = " ".repeat(width - name.len()); let about = sub.get_about().map(|a| a.ansi().to_string()); let _ = writeln!( out, " {literal}{name}{literal:#}{pad} {}", about.unwrap_or_default() ); } } // clap follows `{before-help}` with a blank line of its own, so the // block ends on its last command rather than on a newline. out.pop(); out.into() } #[cfg(test)] mod tests { use super::*; /// The grouping covers the command tree exactly, in both directions. /// /// This is the whole cost of writing the groups down by hand: a command /// added to `Command` and not to a group would simply stop being listed, /// and nothing else in the build would notice — `--help` would print a /// list that quietly omits a verb, which is worse than the flat list /// this replaced. The other direction catches the rename that leaves a /// group naming a command that no longer exists. #[test] fn every_visible_command_is_in_exactly_one_group() { let mut cmd = crate::Cli::command(); cmd.build(); let visible: Vec<&str> = cmd .get_subcommands() .filter(|s| !s.is_hide_set()) .map(|s| s.get_name()) .collect(); let grouped: Vec<&str> = GROUPS .iter() .flat_map(|(_, names)| *names) .copied() .collect(); for name in &visible { assert_eq!( grouped.iter().filter(|g| *g == name).count(), 1, "`{name}` is in {} groups, not 1", grouped.iter().filter(|g| *g == name).count() ); } for name in &grouped { assert!( visible.contains(name), "the groups name `{name}`, which is not a visible command" ); } } /// The rendered block is the list, headed and in group order. #[test] fn the_list_is_grouped_and_complete() { let mut cmd = crate::Cli::command(); cmd.build(); let rendered = command_list(&cmd).ansi().to_string(); // Styling is on in this rendering; the assertions read the text // around the escapes rather than trying to reproduce them. // // Every heading, in the order the const declares them: a group that // renders in the wrong place reads as a different taxonomy, and the // headings are the whole of what this module adds. let mut at = 0; for (heading, _) in GROUPS { let found = rendered .find(&format!("{heading}:")) .unwrap_or_else(|| panic!("no `{heading}:` heading in:\n{rendered}")); assert!(found >= at, "`{heading}` is out of order:\n{rendered}"); at = found; } for name in GROUPS.iter().flat_map(|(_, names)| *names) { assert!(rendered.contains(name), "`{name}` is not in the list"); } // Logging in is `atgc auth login`, and there is no top-level alias // for it. Subcommands are not lifted to the top level here: the // grouping above is what makes a long list readable, and a command // that appears in two places undoes that for the one reader who // needs it most. assert!( !rendered.contains("alias for `auth login`"), "a top-level `login` is back in the list:\n{rendered}" ); assert!(rendered.contains("Work with pull requests")); } /// The help renders with the list in it and clap's flat section gone. #[test] fn help_shows_the_groups_instead_of_a_flat_list() { let mut cmd = crate::Cli::command(); cmd.build(); let list = command_list(&cmd); let template = template(cmd.get_styles()); let help = cmd .before_help(list) .help_template(template) .render_help() .ansi() .to_string(); assert!(!help.contains("Commands:"), "the flat list is still there"); assert!(help.contains("Repository operations:")); // The parts of the default help the template has to carry itself. assert!(help.contains("Usage:")); assert!(help.contains("Options:")); assert!(help.contains("--account")); // `{after-help}` is in the template, and the root command puts the // project's page there. A template that dropped it would take the // one address in `--help` with it, silently. assert!( help.trim_end().ends_with(env!("CARGO_PKG_HOMEPAGE")), "the help does not end on the site:\n{help}" ); assert!(help.contains("https://atgc.codes"), "no site in:\n{help}"); } }