use anyhow::{Context, Result};
use clap::CommandFactory;
use termcolor::{Color, ColorSpec, WriteColor};
use crate::cli::Cli;
use crate::{SubcommandRow, row_from_subcommand, rows_display_width};
const LIST_HEADING: &str = "Installed Commands:";
const ROW_INDENT: &str = " ";
pub(crate) fn print_list<W: WriteColor>(out: &mut W) -> Result<()> {
let mut cmd = Cli::command();
cmd.build();
write_command_list(out, &cmd).context("failed to write command list")
}
fn write_command_list<W: WriteColor>(out: &mut W, cmd: &clap::Command) -> std::io::Result<()> {
let entries = collect_entries(cmd);
let width = rows_display_width(&entries);
let mut heading_spec = ColorSpec::new();
heading_spec
.set_fg(Some(Color::Green))
.set_intense(true)
.set_bold(true);
out.set_color(&heading_spec)?;
write!(out, "{LIST_HEADING}")?;
out.reset()?;
writeln!(out)?;
let mut name_spec = ColorSpec::new();
name_spec
.set_fg(Some(Color::Cyan))
.set_intense(true)
.set_bold(true);
for entry in &entries {
out.write_all(ROW_INDENT.as_bytes())?;
let plain_width: usize = entry.tokens.join(", ").len();
for (i, token) in entry.tokens.iter().enumerate() {
if i > 0 {
// The `, ` between name and alias stays plain
// text - same as cargo.
out.write_all(b", ")?;
}
out.set_color(&name_spec)?;
write!(out, "{token}")?;
out.reset()?;
}
if entry.about.is_empty() {
writeln!(out)?;
} else {
let padding = width.saturating_sub(plain_width);
for _ in 0..padding {
out.write_all(b" ")?;
}
writeln!(out, " {about}", about = entry.about)?;
}
}
Ok(())
}
/// Test-only convenience that drives [`write_command_list`]
/// against an in-memory uncolored buffer and returns the
/// rendered text. Wrapping the real renderer (instead of
/// duplicating its formatting code) keeps unit-test
/// expectations honest: any change to the production layout
/// shows up in both surfaces in one place.
#[cfg(test)]
fn format_command_list(cmd: &clap::Command) -> String {
use termcolor::NoColor;
let mut buf = NoColor::new(Vec::<u8>::new());
write_command_list(&mut buf, cmd).expect("Vec writer never fails");
String::from_utf8(buf.into_inner()).expect("rendered output is utf-8")
}
fn collect_entries(cmd: &clap::Command) -> Vec<SubcommandRow> {
let mut entries: Vec<SubcommandRow> = cmd.get_subcommands().map(row_from_subcommand).collect();
entries.sort_by(|a, b| a.tokens[0].cmp(&b.tokens[0]));
entries
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{Args, Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(name = "test")]
struct FixtureCli {
#[command(subcommand)]
cmd: FixtureCmd,
}
#[derive(Subcommand, Debug)]
enum FixtureCmd {
/// Build a thing.
#[command(visible_alias = "b")]
Build(EmptyArgs),
/// Clean output.
Clean(EmptyArgs),
/// Generate completions (advanced).
#[command(hide = true)]
Compgen(EmptyArgs),
}
#[derive(Args, Debug)]
struct EmptyArgs {}
fn fixture_cmd() -> clap::Command {
<FixtureCli as CommandFactory>::command()
}
#[test]
fn header_is_first_line() {
let out = format_command_list(&fixture_cmd());
let first = out.lines().next().expect("non-empty output");
assert_eq!(first, LIST_HEADING);
}
#[test]
fn output_ends_with_newline() {
let out = format_command_list(&fixture_cmd());
assert!(out.ends_with('\n'), "expected trailing newline: {out}");
}
#[test]
fn entries_are_sorted_alphabetically() {
let out = format_command_list(&fixture_cmd());
let names: Vec<&str> = out
.lines()
.skip(1)
.filter_map(|line| line.split_whitespace().next())
.collect();
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(names, sorted, "rows must be alphabetically sorted");
}
#[test]
fn hidden_commands_are_listed() {
let out = format_command_list(&fixture_cmd());
// `compgen` was annotated `#[command(hide = true)]`; the
// list view still surfaces it.
assert!(
out.contains("compgen"),
"hidden subcommands must still appear in `--list`: {out}"
);
}
#[test]
fn help_pseudo_subcommand_is_listed_when_built() {
// clap auto-injects a `help` pseudo-subcommand only
// after `Command::build`. Once built, the row is
// included in the listing - matching cargo's
// `cargo --list` which also surfaces `help`.
let mut cmd = fixture_cmd();
cmd.build();
let out = format_command_list(&cmd);
let names: Vec<&str> = out
.lines()
.skip(1)
.filter_map(|line| line.split_whitespace().next())
.collect();
assert!(
names.contains(&"help"),
"`help` should appear in --list once the command tree is built: {names:?}"
);
}
#[test]
fn name_about_separator_is_present() {
let out = format_command_list(&fixture_cmd());
// Each entry has the `<name> <about>` shape; spot-check
// one entry rather than over-coupling to the exact
// column width (which depends on the longest name).
// clap strips trailing punctuation from rustdoc-derived
// about lines, so we compare on the leading words only.
let build_line = out
.lines()
.find(|line| line.trim_start().starts_with("build"))
.expect("build row");
assert!(
build_line.contains("Build a thing"),
"build row should carry its about: {build_line}"
);
}
#[test]
fn entries_align_to_longest_name() {
let out = format_command_list(&fixture_cmd());
// The longest visible name in the fixture is `compgen`
// (7 chars). Build (5 chars) gets right-padded to 7
// before its about text, so the gap is 2 columns. This
// is a structural assertion: the formatter must compute
// the width once, not per-row.
let build_line = out
.lines()
.find(|line| line.trim_start().starts_with("build"))
.expect("build row");
let compgen_line = out
.lines()
.find(|line| line.trim_start().starts_with("compgen"))
.expect("compgen row");
// Both rows have an `about` and the about text starts
// at the same column.
let build_about_col = build_line.find("Build").unwrap();
let compgen_about_col = compgen_line.find("Generate").unwrap();
assert_eq!(
build_about_col, compgen_about_col,
"about columns must align across rows"
);
}
#[test]
fn visible_aliases_are_rendered_cargo_style() {
let out = format_command_list(&fixture_cmd());
// Cargo renders aliases comma-separated after the name
// (`build, b`), not in clap's `[aliases: b]` form. The
// fixture's build subcommand has a `b` alias.
assert!(
out.contains("build, b"),
"expected cargo-style `build, b` row: {out}"
);
assert!(
!out.contains("[aliases:"),
"must not use clap's default `[aliases: ...]` form: {out}"
);
}
#[test]
fn empty_about_does_not_emit_separator() {
// Synthesize a command tree where one subcommand has no
// about text; the formatter must still emit the row,
// and the row must not contain the column separator
// spaces that other rows have.
let cmd = clap::Command::new("test")
.subcommand(clap::Command::new("alpha").about("Alpha command."))
.subcommand(clap::Command::new("beta"));
let out = format_command_list(&cmd);
let beta_line = out
.lines()
.find(|line| line.trim_start().starts_with("beta"))
.expect("beta row");
assert_eq!(beta_line.trim(), "beta");
}
}