prettyping-rs 1.0.2

Rust port of prettyping with a pure Rust ping engine
Documentation
use std::ffi::OsString;

use clap::error::ErrorKind;
use clap::{ArgAction, CommandFactory, Parser};

use crate::config::{self, Config, ConfigInput, DEFAULT_LAST};

const CLI_AFTER_HELP: &str = "Compatibility notes:\n  - Removed legacy flags: --awkbin, --pingbin (hard error).\n  - Unsupported legacy flags: -f, -R, -q, -a (hard error).\n  - Legacy -v is accepted and ignored for compatibility.";

const CLI_HELP_TEXT: &str = "Usage: prettyping [prettyping parameters] <host>\n\nThis is a Rust port of prettyping. It prints each ping response as a compact\ncharacter graph, with optional color and stats lines.\n\nprettyping parameters:\n  --[no]color        Enable/disable color output. (default: enabled)\n  --[no]multicolor   Enable/disable multi-color output. Has no effect if\n                       color is disabled. (default: enabled)\n  --[no]unicode      Enable/disable unicode characters. (default: enabled)\n  --[no]legend       Enable/disable the latency legend. (default: enabled)\n  --[no]globalstats  Enable/disable the global statistics line. (default: enabled)\n  --[no]recentstats  Enable/disable the last n statistics line. (default: enabled)\n  --[no]terminal     Force the output designed for a terminal. (default: auto)\n  --last <n>         Use the last n pings at the statistics line. (default: 60)\n  --columns <n>      Override auto-detection of terminal dimensions.\n  --lines <n>        Override auto-detection of terminal dimensions.\n  --rttmin <n>       Minimum RTT represented in the graph. (default: auto)\n  --rttmax <n>       Maximum RTT represented in the graph. (default: auto)\n\nping parameters handled by prettyping:\n  -4, --ipv4         Use IPv4 only.\n  -6, --ipv6         Use IPv6 only.\n  -c, --count <n>    Stop after sending n probes.\n  -i, --interval <s> Interval between probes in seconds.\n  -W, --timeout <s>  Per-probe timeout in seconds.\n  -s, --size <n>     Payload size in bytes.\n  -t, --ttl <n>      IP time-to-live (hop limit).\n\n";

#[derive(Debug, Parser)]
#[command(
    name = "prettyping",
    about = "Rust port of prettyping",
    after_help = CLI_AFTER_HELP,
    disable_version_flag = true
)]
struct RawCliArgs {
    /// Destination host or IP
    #[arg(value_name = "HOST")]
    host: String,

    // Kept prettyping flags
    #[arg(long = "color", action = ArgAction::SetTrue)]
    color: bool,
    #[arg(long = "nocolor", action = ArgAction::SetTrue)]
    nocolor: bool,
    #[arg(long = "multicolor", action = ArgAction::SetTrue)]
    multicolor: bool,
    #[arg(long = "nomulticolor", action = ArgAction::SetTrue)]
    nomulticolor: bool,
    #[arg(long = "unicode", action = ArgAction::SetTrue)]
    unicode: bool,
    #[arg(long = "nounicode", action = ArgAction::SetTrue)]
    nounicode: bool,
    #[arg(long = "legend", action = ArgAction::SetTrue)]
    legend: bool,
    #[arg(long = "nolegend", action = ArgAction::SetTrue)]
    nolegend: bool,
    #[arg(long = "globalstats", action = ArgAction::SetTrue)]
    globalstats: bool,
    #[arg(long = "noglobalstats", action = ArgAction::SetTrue)]
    noglobalstats: bool,
    #[arg(long = "recentstats", action = ArgAction::SetTrue)]
    recentstats: bool,
    #[arg(long = "norecentstats", action = ArgAction::SetTrue)]
    norecentstats: bool,
    #[arg(long = "terminal", action = ArgAction::SetTrue)]
    terminal: bool,
    #[arg(long = "noterminal", action = ArgAction::SetTrue)]
    noterminal: bool,
    #[arg(long = "last", default_value_t = DEFAULT_LAST)]
    last: u32,
    #[arg(long = "columns")]
    columns: Option<u16>,
    #[arg(long = "lines")]
    lines: Option<u16>,
    #[arg(long = "rttmin")]
    rttmin: Option<u32>,
    #[arg(long = "rttmax")]
    rttmax: Option<u32>,

    // Native ping flags (explicitly handled in Rust)
    #[arg(short = '4', long = "ipv4", action = ArgAction::SetTrue)]
    ipv4: bool,
    #[arg(short = '6', long = "ipv6", action = ArgAction::SetTrue)]
    ipv6: bool,
    #[arg(short = 'c', long = "count")]
    count: Option<u32>,
    #[arg(short = 'i', long = "interval")]
    interval_secs: Option<f64>,
    #[arg(short = 'W', long = "timeout")]
    timeout_secs: Option<f64>,
    #[arg(short = 's', long = "size")]
    packet_size: Option<u32>,
    #[arg(short = 't', long = "ttl")]
    ttl: Option<u16>,

    // Removed legacy passthrough flags - parsed only to emit explicit errors.
    #[arg(long = "awkbin", value_name = "EXEC", hide = true)]
    removed_awkbin: Option<String>,
    #[arg(long = "pingbin", value_name = "EXEC", hide = true)]
    removed_pingbin: Option<String>,

    // Unsupported legacy flags - parsed only to emit explicit errors.
    #[arg(short = 'a', action = ArgAction::SetTrue, hide = true)]
    legacy_audible: bool,
    #[arg(short = 'f', action = ArgAction::SetTrue, hide = true)]
    legacy_flood: bool,
    #[arg(short = 'R', action = ArgAction::SetTrue, hide = true)]
    legacy_record_route: bool,
    #[arg(short = 'q', action = ArgAction::SetTrue, hide = true)]
    legacy_quiet: bool,

    // Accepted legacy compatibility no-op.
    #[arg(short = 'v', action = ArgAction::SetTrue, hide = true)]
    legacy_verbose_ignored: bool,
}

pub fn parse_config_from_args<I, T>(args: I) -> Result<Config, clap::Error>
where
    I: IntoIterator<Item = T>,
    T: Into<OsString>,
{
    let normalized = normalize_legacy_long_spellings(args);
    if wants_help(&normalized) {
        return Err(help_error());
    }
    let color = parse_toggle_override(&normalized, "--color", "--nocolor", true);
    let multicolor = parse_toggle_override(&normalized, "--multicolor", "--nomulticolor", true);
    let unicode = parse_toggle_override(&normalized, "--unicode", "--nounicode", true);
    let legend = parse_toggle_override(&normalized, "--legend", "--nolegend", true);
    let globalstats = parse_toggle_override(&normalized, "--globalstats", "--noglobalstats", true);
    let recentstats = parse_toggle_override(&normalized, "--recentstats", "--norecentstats", true);
    let terminal_override = parse_terminal_override(&normalized);

    let raw = RawCliArgs::try_parse_from(normalized)?;

    if raw.removed_awkbin.is_some() {
        return Err(usage_error(
            "--awkbin was removed in prettyping (pure Rust engine has no awk subprocess)",
        ));
    }
    if raw.removed_pingbin.is_some() {
        return Err(usage_error(
            "--pingbin was removed in prettyping (pure Rust engine has no ping subprocess)",
        ));
    }

    if raw.legacy_audible {
        return Err(usage_error("unsupported legacy flag: -a"));
    }
    if raw.legacy_flood {
        return Err(usage_error("unsupported legacy flag: -f"));
    }
    if raw.legacy_record_route {
        return Err(usage_error("unsupported legacy flag: -R"));
    }
    if raw.legacy_quiet {
        return Err(usage_error("unsupported legacy flag: -q"));
    }

    let _ = raw.legacy_verbose_ignored;

    let input = ConfigInput {
        host: raw.host,
        color,
        multicolor,
        unicode,
        legend,
        globalstats,
        recentstats,
        terminal: terminal_override,
        last: raw.last,
        columns: raw.columns,
        lines: raw.lines,
        rttmin: raw.rttmin,
        rttmax: raw.rttmax,
        force_ipv4: raw.ipv4,
        force_ipv6: raw.ipv6,
        count: raw.count,
        interval_secs: raw.interval_secs,
        timeout_secs: raw.timeout_secs,
        packet_size: raw.packet_size,
        ttl: raw.ttl,
    };

    config::normalize(input).map_err(|err| usage_error(err.to_string()))
}

pub fn parse_config_from_env() -> Result<Config, clap::Error> {
    parse_config_from_args(std::env::args_os())
}

fn usage_error(message: impl Into<String>) -> clap::Error {
    let mut cmd = RawCliArgs::command();
    cmd.error(ErrorKind::ValueValidation, message.into())
}

fn wants_help(args: &[OsString]) -> bool {
    args.iter()
        .any(|arg| arg == "--help" || arg == "-h" || arg == "-help" || arg == "--h")
}

fn help_error() -> clap::Error {
    let mut out = String::new();
    out.push_str(CLI_HELP_TEXT);
    out.push_str(CLI_AFTER_HELP);
    clap::Error::raw(ErrorKind::DisplayHelp, out)
}

fn parse_terminal_override(args: &[OsString]) -> Option<bool> {
    parse_toggle_override_optional(args, "--terminal", "--noterminal")
}

fn parse_toggle_override(
    args: &[OsString],
    enabled_flag: &str,
    disabled_flag: &str,
    default: bool,
) -> bool {
    parse_toggle_override_optional(args, enabled_flag, disabled_flag).unwrap_or(default)
}

fn parse_toggle_override_optional(
    args: &[OsString],
    enabled_flag: &str,
    disabled_flag: &str,
) -> Option<bool> {
    args.iter().skip(1).fold(None, |current, arg| {
        if arg == enabled_flag {
            Some(true)
        } else if arg == disabled_flag {
            Some(false)
        } else {
            current
        }
    })
}

fn normalize_legacy_long_spellings<I, T>(args: I) -> Vec<OsString>
where
    I: IntoIterator<Item = T>,
    T: Into<OsString>,
{
    args.into_iter()
        .map(Into::into)
        .map(|arg| {
            arg.to_str()
                .and_then(rewrite_legacy_single_dash_flag)
                .map(OsString::from)
                .unwrap_or(arg)
        })
        .collect()
}

fn rewrite_legacy_single_dash_flag(flag: &str) -> Option<&'static str> {
    match flag {
        "-help" => Some("--help"),
        "-color" => Some("--color"),
        "-nocolor" => Some("--nocolor"),
        "-multicolor" => Some("--multicolor"),
        "-nomulticolor" => Some("--nomulticolor"),
        "-unicode" => Some("--unicode"),
        "-nounicode" => Some("--nounicode"),
        "-legend" => Some("--legend"),
        "-nolegend" => Some("--nolegend"),
        "-globalstats" => Some("--globalstats"),
        "-noglobalstats" => Some("--noglobalstats"),
        "-recentstats" => Some("--recentstats"),
        "-norecentstats" => Some("--norecentstats"),
        "-terminal" => Some("--terminal"),
        "-noterminal" => Some("--noterminal"),
        "-last" => Some("--last"),
        "-columns" => Some("--columns"),
        "-lines" => Some("--lines"),
        "-rttmin" => Some("--rttmin"),
        "-rttmax" => Some("--rttmax"),
        "-awkbin" => Some("--awkbin"),
        "-pingbin" => Some("--pingbin"),
        _ => None,
    }
}