Match vmic's custom top-level --help renderer

Ports vmic's print_full_help (main.rs) verbatim in spirit: one line per
subcommand with its positional args inline, an indented per-flag block
underneath with globally-aligned columns, a separate Aliases section, and
NO_COLOR-aware coloring - so `porthole`/`porthole -h`/`porthole help` show
every subcommand's flags without drilling into each one's own --help,
matching vmic's actual rendered output exactly in structure.

Also shortened --via's clap value_name from the full grammar
([user@]host[:port][,...]) to HOPS - the long form blew out the column
alignment for every other flag's help text; the full grammar is still in
the flag's help string itself.

Dropped vmic's MULTI_CHAR_ALIASES workaround (for short flags like -sv
that clap can't express natively) since porthole has no multi-char short
flags today - add it back if one shows up.
This commit is contained in:
2026-08-13 16:13:02 +02:00
parent 0c9f0585fa
commit 4a8faf1131
3 changed files with 173 additions and 5 deletions

View File

@@ -66,9 +66,9 @@ pub struct MappingArgs {
#[arg(short, long, value_name = "SPEC")]
pub dynamic: Option<String>,
/// SSH hop chain, comma-separated; the last hop is the actual
/// connection target, any before it are -J jumps.
#[arg(long, value_name = "[user@]host[:port][,...]")]
/// SSH hop chain, comma-separated: [user@]host[:port][,...]. The last
/// hop is the actual connection target, any before it are -J jumps.
#[arg(long, value_name = "HOPS")]
pub via: Option<String>,
/// Default user for the target and any --via hop without its own.

View File

@@ -9,11 +9,24 @@ mod supervisor;
mod timefmt;
mod ui;
use clap::Parser;
use clap::{CommandFactory, Parser};
use cli::{Cli, Commands};
fn main() {
let cli = Cli::parse();
let args: Vec<String> = std::env::args().collect();
// Plain `porthole`, `-h`/`--help`, or `help` at the top level: show
// every subcommand's own flags inline instead of making the user drill
// into each one with its own `--help` - same convention as vmic.
if wants_top_level_help(&args) {
print_full_help();
std::process::exit(if args.len() <= 1 { 2 } else { 0 });
}
let cli = match Cli::try_parse_from(&args) {
Ok(cli) => cli,
Err(e) => e.exit(),
};
let result = match cli.command {
Commands::Add(args) => commands::add::run(args),
@@ -40,3 +53,154 @@ fn main() {
std::process::exit(1);
}
}
/// True for a bare `porthole` invocation, or top-level `-h`/`--help`/`help`
/// - i.e. anything that should show the expanded help rather than being
/// handled (or rejected) by a specific subcommand.
fn wants_top_level_help(args: &[String]) -> bool {
match args.get(1..) {
Some([]) => true,
Some([a]) => a == "-h" || a == "--help" || a == "help",
_ => false,
}
}
/// Prints one screen of help: one short summary line per subcommand (name,
/// positional args, about), followed by an indented line per flag with its
/// own help text - so nothing requires drilling into a subcommand's own
/// `--help` just to see what it takes. Ported from vmic's `print_full_help`
/// so the two tools present identically.
fn print_full_help() {
let mut cmd = Cli::command();
cmd.build(); // resolve default value names etc. before introspecting
let bin = cmd.get_name().to_string();
if let Some(about) = cmd.get_about() {
println!("{about}");
println!();
}
println!("{} {bin} {} {}", ui::blue("Usage:"), ui::cyan("<COMMAND>"), ui::yellow("[ARGS]"));
println!();
println!("{}", ui::blue("Commands:"));
let subcommands: Vec<&clap::Command> =
cmd.get_subcommands().filter(|s| s.get_name() != "help" && !s.is_hide_set()).collect();
let flag_rows_by_cmd: Vec<Vec<(String, String)>> = subcommands.iter().map(|s| flag_rows(s)).collect();
let name_w = subcommands.iter().map(|s| s.get_name().len()).max().unwrap_or(0);
let pos_w = subcommands.iter().map(|s| visual_width(&positional_args(s))).max().unwrap_or(0);
// One width across every subcommand's flags, not just its own, so the
// help-text column lines up no matter which command it's under.
let flag_w = flag_rows_by_cmd.iter().flatten().map(|(f, _)| visual_width(f)).max().unwrap_or(0);
for (i, s) in subcommands.iter().enumerate() {
let name = s.get_name();
let positionals = positional_args(s);
let about = s.get_about().map(|a| a.to_string()).unwrap_or_default();
println!(" {name:name_w$} {} {about}", pad_visual(&positionals, pos_w));
let rows = &flag_rows_by_cmd[i];
for (flag, help) in rows {
println!(" {} {help}", pad_visual(flag, flag_w));
}
// Flagless commands stay a tight single line; only the multi-line
// (flag-bearing) ones get a blank line to separate them visually.
if !rows.is_empty() && i + 1 < subcommands.len() {
println!();
}
}
println!();
let alias_rows: Vec<(String, String)> = subcommands
.iter()
.filter_map(|s| {
let aliases = s.get_visible_aliases().collect::<Vec<_>>().join(", ");
(!aliases.is_empty()).then(|| (s.get_name().to_string(), aliases))
})
.collect();
if !alias_rows.is_empty() {
println!("{}", ui::blue("Aliases:"));
let name_w = alias_rows.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
for (name, aliases) in &alias_rows {
println!(" {name:name_w$} {aliases}");
}
println!();
}
println!("{}", ui::blue("Options:"));
let opt_rows: Vec<(String, String)> = cmd
.get_arguments()
.filter(|a| !a.is_positional())
.filter_map(|a| Some((flag_names(a)?, a.get_help().map(|h| h.to_string()).unwrap_or_default())))
.collect();
let flag_w = opt_rows.iter().map(|(f, _)| f.len()).max().unwrap_or(0);
for (flag, help) in &opt_rows {
println!(" {flag:flag_w$} {help}");
}
}
/// `<name>` for each positional arg of `cmd`, space-joined and colored cyan.
fn positional_args(cmd: &clap::Command) -> String {
cmd.get_positionals()
.map(|a| ui::cyan(&format!("<{}>", a.get_id().as_str())))
.collect::<Vec<_>>()
.join(" ")
}
/// `(flag display, help text)` for each of `cmd`'s non-positional, non-help
/// args, e.g. `("-l/--local <SPEC>", "Local forward: your machine ->
/// remote")`. The flag display is colored yellow.
fn flag_rows(cmd: &clap::Command) -> Vec<(String, String)> {
cmd.get_arguments()
.filter(|a| !a.is_positional() && a.get_id().as_str() != "help")
.filter_map(|a| {
let flag = flag_names(a)?;
let display = if matches!(a.get_action(), clap::ArgAction::Set | clap::ArgAction::Append) {
let value = a
.get_value_names()
.and_then(|v| v.first())
.map(|v| v.to_string())
.unwrap_or_else(|| a.get_id().as_str().to_uppercase());
format!("{flag} <{value}>")
} else {
flag
};
let help = a.get_help().map(|h| h.to_string()).unwrap_or_default();
Some((ui::yellow(&display), help))
})
.collect()
}
/// `-x/--long` / `-x` / `--long` for a non-positional arg, or `None` for
/// one with no visible flag at all.
fn flag_names(arg: &clap::Arg) -> Option<String> {
match (arg.get_short(), arg.get_long()) {
(Some(s), Some(l)) => Some(format!("-{s}/--{l}")),
(Some(s), None) => Some(format!("-{s}")),
(None, Some(l)) => Some(format!("--{l}")),
(None, None) => None,
}
}
/// Number of visible columns in `s`, skipping any `\x1b[...m` ANSI SGR
/// escape sequences it contains.
fn visual_width(s: &str) -> usize {
let mut width = 0;
let mut in_escape = false;
for c in s.chars() {
if in_escape {
in_escape = c != 'm';
} else if c == '\x1b' {
in_escape = true;
} else {
width += 1;
}
}
width
}
/// Right-pads `s` with spaces to `width` visible columns, per `visual_width`.
fn pad_visual(s: &str, width: usize) -> String {
format!("{s}{}", " ".repeat(width.saturating_sub(visual_width(s))))
}

View File

@@ -38,6 +38,10 @@ pub fn blue(s: &str) -> String {
paint("34", s)
}
pub fn cyan(s: &str) -> String {
paint("36", s)
}
pub fn err(msg: &str) {
eprintln!("{}", red(&format!("Error: {msg}")));
}