diff --git a/src/cli.rs b/src/cli.rs index 683c883..21caa94 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -66,9 +66,9 @@ pub struct MappingArgs { #[arg(short, long, value_name = "SPEC")] pub dynamic: Option, - /// 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, /// Default user for the target and any --via hop without its own. diff --git a/src/main.rs b/src/main.rs index 0480dde..da4a000 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 = 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(""), 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> = 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::>().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}"); + } +} + +/// `` 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::>() + .join(" ") +} + +/// `(flag display, help text)` for each of `cmd`'s non-positional, non-help +/// args, e.g. `("-l/--local ", "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 { + 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)))) +} diff --git a/src/ui.rs b/src/ui.rs index b5bc515..12cc1f1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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}"))); }