feat(cli): implement command-line argument parsing and event formatting

This commit is contained in:
Nils Andreas Svee committed 2026-05-10 20:56:43 +07:00
1 parent 4b90187d1d
commit 510f307acc
1 file changed
+717
+717
View File
@@ -0,0 +1,717 @@
#![forbid(unsafe_code)]
use std::{
fmt::Write as _,
net::{IpAddr, Ipv4Addr, SocketAddr},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use clap::{Parser, ValueEnum};
use irtt_client::{
ClientConfig, ClientEvent, ClientTimestamp, NegotiatedParams, OneWayDelaySample, PacketMeta,
ReceivedStatsSample, RttSample, ServerTiming, SocketConfig, WarningKind,
};
use irtt_proto::{Clock, ReceivedStats, StampAt};
const DEFAULT_RECV_TIMEOUT: Duration = Duration::from_millis(20);
const MAX_UDP_PAYLOAD_LENGTH: u32 = 65_507;
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-rs", about = "Minimal IRTT-compatible client")]
pub struct CliArgs {
pub server: String,
#[arg(long, default_value = "10s", value_parser = parse_duration)]
pub duration: Duration,
#[arg(long, default_value = "1s", value_parser = parse_duration)]
pub interval: Duration,
#[arg(long, default_value_t = 0, value_parser = parse_length)]
pub length: u32,
#[arg(long)]
pub hmac: Option<String>,
#[arg(long, value_enum, default_value_t = ClockArg::Both)]
pub clock: ClockArg,
#[arg(long, value_enum, default_value_t = TimestampArg::Both)]
pub timestamps: TimestampArg,
#[arg(long, value_enum, default_value_t = OutputMode::Simple)]
pub output: OutputMode,
}
impl CliArgs {
pub fn to_client_config(&self) -> ClientConfig {
ClientConfig {
server_addr: self.server.clone(),
duration: Some(self.duration),
interval: self.interval,
length: self.length,
received_stats: ReceivedStats::Both,
stamp_at: self.timestamps.into(),
clock: self.clock.into(),
hmac_key: self.hmac.as_ref().map(|key| key.as_bytes().to_vec()),
socket_config: SocketConfig {
recv_timeout: Some(DEFAULT_RECV_TIMEOUT),
..SocketConfig::default()
},
..ClientConfig::default()
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ClockArg {
Wall,
Monotonic,
Both,
}
impl From<ClockArg> for Clock {
fn from(value: ClockArg) -> Self {
match value {
ClockArg::Wall => Self::Wall,
ClockArg::Monotonic => Self::Monotonic,
ClockArg::Both => Self::Both,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum TimestampArg {
None,
Send,
Receive,
Both,
}
impl From<TimestampArg> for StampAt {
fn from(value: TimestampArg) -> Self {
match value {
TimestampArg::None => Self::None,
TimestampArg::Send => Self::Send,
TimestampArg::Receive => Self::Receive,
TimestampArg::Both => Self::Both,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputMode {
Machine,
Simple,
RttUs,
}
pub fn parse_duration(input: &str) -> Result<Duration, String> {
let (number, unit) = split_duration(input)?;
let value: u64 = number
.parse()
.map_err(|_| format!("invalid duration value {input:?}"))?;
if value == 0 {
return Err("duration must be greater than zero".to_owned());
}
match unit {
"ms" => Ok(Duration::from_millis(value)),
"s" => Ok(Duration::from_secs(value)),
"m" => value
.checked_mul(60)
.map(Duration::from_secs)
.ok_or_else(|| "duration is too large".to_owned()),
_ => Err(format!(
"unsupported duration unit {unit:?}; use ms, s, or m"
)),
}
}
fn split_duration(input: &str) -> Result<(&str, &str), String> {
let split = input
.find(|ch: char| !ch.is_ascii_digit())
.ok_or_else(|| "duration must include a unit: ms, s, or m".to_owned())?;
let (number, unit) = input.split_at(split);
if number.is_empty() || unit.is_empty() {
return Err("duration must include a positive value and unit".to_owned());
}
if number.starts_with('-') {
return Err("duration must be greater than zero".to_owned());
}
Ok((number, unit))
}
pub fn parse_length(input: &str) -> Result<u32, String> {
let length: u32 = input
.parse()
.map_err(|_| format!("invalid packet length {input:?}"))?;
if length > MAX_UDP_PAYLOAD_LENGTH {
return Err(format!("packet length must be <= {MAX_UDP_PAYLOAD_LENGTH}"));
}
Ok(length)
}
pub fn format_event(event: &ClientEvent, mode: OutputMode) -> Option<String> {
match mode {
OutputMode::RttUs => format_rtt_us(event),
OutputMode::Machine => Some(format_machine(event)),
OutputMode::Simple => Some(format_simple(event)),
}
}
fn format_rtt_us(event: &ClientEvent) -> Option<String> {
match event {
ClientEvent::EchoReply { rtt, .. } => Some(duration_us(rtt.effective).to_string()),
_ => None,
}
}
fn format_machine(event: &ClientEvent) -> String {
let mut out = String::new();
match event {
ClientEvent::SessionStarted {
remote,
token,
negotiated,
at,
} => {
write_common(&mut out, "session_started");
write_remote(&mut out, *remote);
write_token(&mut out, *token);
write_wall(&mut out, "at_ns", at.wall);
write_negotiated(&mut out, negotiated);
}
ClientEvent::NoTestCompleted {
remote,
negotiated,
at,
} => {
write_common(&mut out, "no_test_completed");
write_remote(&mut out, *remote);
write_wall(&mut out, "at_ns", at.wall);
write_negotiated(&mut out, negotiated);
}
ClientEvent::SessionClosed { remote, token, at } => {
write_common(&mut out, "session_closed");
write_remote(&mut out, *remote);
write_token(&mut out, *token);
write_wall(&mut out, "at_ns", at.wall);
}
ClientEvent::EchoReply {
seq,
logical_seq,
remote,
sent_at,
received_at,
rtt,
server_timing,
one_way,
received_stats,
packet_meta,
} => {
write_common(&mut out, "echo_reply");
write_seq(&mut out, *seq, Some(*logical_seq));
write_remote(&mut out, *remote);
write_wall(&mut out, "sent_ns", sent_at.wall);
write_wall(&mut out, "received_ns", received_at.wall);
write_rtt(&mut out, rtt);
write_server_timing(&mut out, *server_timing);
write_one_way(&mut out, *one_way);
write_received_stats(&mut out, *received_stats);
write_packet_meta(&mut out, *packet_meta);
}
ClientEvent::EchoLoss {
seq,
logical_seq,
sent_at,
..
} => {
write_common(&mut out, "loss");
write_seq(&mut out, *seq, Some(*logical_seq));
write_wall(&mut out, "sent_ns", sent_at.wall);
out.push_str(" warning=loss");
}
ClientEvent::DuplicateReply {
seq,
remote,
received_at,
} => {
write_common(&mut out, "duplicate");
write_seq(&mut out, *seq, None);
write_remote(&mut out, *remote);
write_wall(&mut out, "received_ns", received_at.wall);
out.push_str(" warning=duplicate");
}
ClientEvent::LateReply {
seq,
logical_seq,
highest_seen,
remote,
sent_at,
received_at,
rtt,
server_timing,
one_way,
received_stats,
packet_meta,
} => {
write_common(&mut out, "late");
write_seq(&mut out, *seq, *logical_seq);
write_remote(&mut out, *remote);
write!(out, " highest_seen={highest_seen}").unwrap();
if let Some(sent_at) = sent_at {
write_wall(&mut out, "sent_ns", sent_at.wall);
}
write_wall(&mut out, "received_ns", received_at.wall);
if let Some(rtt) = rtt {
write_rtt(&mut out, rtt);
}
write_server_timing(&mut out, *server_timing);
write_one_way(&mut out, *one_way);
write_received_stats(&mut out, *received_stats);
write_packet_meta(&mut out, *packet_meta);
out.push_str(" warning=late");
}
ClientEvent::Warning { kind, message } => {
write_common(&mut out, "warning");
write!(
out,
" warning_kind={} message={}",
warning_kind(*kind),
escape_value(message)
)
.unwrap();
}
}
out
}
fn format_simple(event: &ClientEvent) -> String {
match event {
ClientEvent::SessionStarted { remote, token, .. } => {
format!("session started remote={remote} token={token:#x}")
}
ClientEvent::NoTestCompleted { remote, .. } => {
format!("no-test completed remote={remote}")
}
ClientEvent::SessionClosed { remote, token, .. } => {
format!("session closed remote={remote} token={token:#x}")
}
ClientEvent::EchoReply {
seq,
logical_seq,
remote,
rtt,
server_timing,
..
} => {
let mut out = format!(
"reply seq={seq} logical_seq={logical_seq} remote={remote} rtt_us={}",
duration_us(rtt.effective)
);
if let Some(raw) = rtt.adjusted.map(|_| rtt.raw) {
write!(out, " raw_rtt_us={}", duration_us(raw)).unwrap();
}
if let Some(processing) = server_timing.and_then(|timing| timing.processing) {
write!(out, " server_processing_us={}", duration_us(processing)).unwrap();
}
out
}
ClientEvent::EchoLoss {
seq, logical_seq, ..
} => {
format!("loss seq={seq} logical_seq={logical_seq}")
}
ClientEvent::DuplicateReply { seq, remote, .. } => {
format!("duplicate seq={seq} remote={remote}")
}
ClientEvent::LateReply {
seq,
logical_seq,
highest_seen,
remote,
rtt,
..
} => {
let mut out = format!(
"late seq={seq} logical_seq={} highest_seen={highest_seen} remote={remote}",
optional_u64(*logical_seq)
);
if let Some(rtt) = rtt {
write!(out, " rtt_us={}", duration_us(rtt.effective)).unwrap();
}
out
}
ClientEvent::Warning { kind, message } => {
format!("warning kind={} message={message}", warning_kind(*kind))
}
}
}
fn write_common(out: &mut String, event: &str) {
write!(out, "event={event}").unwrap();
}
fn write_seq(out: &mut String, seq: u32, logical_seq: Option<u64>) {
write!(out, " seq={seq}").unwrap();
if let Some(logical_seq) = logical_seq {
write!(out, " logical_seq={logical_seq}").unwrap();
}
}
fn write_remote(out: &mut String, remote: SocketAddr) {
write!(out, " remote={remote}").unwrap();
}
fn write_token(out: &mut String, token: u64) {
write!(out, " token={token:#x}").unwrap();
}
fn write_wall(out: &mut String, key: &str, wall: SystemTime) {
if let Ok(duration) = wall.duration_since(UNIX_EPOCH) {
write!(out, " {key}={}", duration.as_nanos()).unwrap();
}
}
fn write_negotiated(out: &mut String, negotiated: &NegotiatedParams) {
write!(
out,
" duration_ns={} interval_ns={} length={}",
negotiated.params.duration_ns, negotiated.params.interval_ns, negotiated.params.length
)
.unwrap();
}
fn write_rtt(out: &mut String, rtt: &RttSample) {
write!(
out,
" raw_rtt_us={} effective_rtt_us={}",
duration_us(rtt.raw),
duration_us(rtt.effective)
)
.unwrap();
if let Some(adjusted) = rtt.adjusted {
write!(out, " adjusted_rtt_us={}", duration_us(adjusted)).unwrap();
}
}
fn write_server_timing(out: &mut String, timing: Option<ServerTiming>) {
if let Some(timing) = timing {
write_optional_i64(out, "server_receive_wall_ns", timing.receive_wall_ns);
write_optional_i64(out, "server_receive_mono_ns", timing.receive_mono_ns);
write_optional_i64(out, "server_send_wall_ns", timing.send_wall_ns);
write_optional_i64(out, "server_send_mono_ns", timing.send_mono_ns);
write_optional_i64(out, "server_midpoint_wall_ns", timing.midpoint_wall_ns);
write_optional_i64(out, "server_midpoint_mono_ns", timing.midpoint_mono_ns);
if let Some(processing) = timing.processing {
write!(out, " server_processing_us={}", duration_us(processing)).unwrap();
}
}
}
fn write_one_way(out: &mut String, one_way: Option<OneWayDelaySample>) {
if let Some(one_way) = one_way {
if let Some(value) = one_way.client_to_server {
write!(out, " client_to_server_us={}", duration_us(value)).unwrap();
}
if let Some(value) = one_way.server_to_client {
write!(out, " server_to_client_us={}", duration_us(value)).unwrap();
}
}
}
fn write_received_stats(out: &mut String, stats: Option<ReceivedStatsSample>) {
if let Some(stats) = stats {
if let Some(count) = stats.count {
write!(out, " server_received_count={count}").unwrap();
}
if let Some(window) = stats.window {
write!(out, " server_received_window={window:#x}").unwrap();
}
}
}
fn write_packet_meta(out: &mut String, meta: PacketMeta) {
if let Some(traffic_class) = meta.traffic_class {
write!(out, " traffic_class={traffic_class}").unwrap();
}
if let Some(dscp) = meta.dscp {
write!(out, " dscp={dscp}").unwrap();
}
if let Some(ecn) = meta.ecn {
write!(out, " ecn={ecn}").unwrap();
}
if let Some(timestamp) = meta.kernel_rx_timestamp {
write_wall(out, "kernel_rx_ns", timestamp);
}
}
fn write_optional_i64(out: &mut String, key: &str, value: Option<i64>) {
if let Some(value) = value {
write!(out, " {key}={value}").unwrap();
}
}
fn duration_us(duration: Duration) -> u128 {
duration.as_micros()
}
fn optional_u64(value: Option<u64>) -> String {
value
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_owned())
}
fn warning_kind(kind: WarningKind) -> &'static str {
match kind {
WarningKind::MalformedOrUnrelatedPacket => "malformed_or_unrelated_packet",
WarningKind::WrongToken => "wrong_token",
WarningKind::UntrackedReply => "untracked_reply",
}
}
fn escape_value(value: &str) -> String {
value.replace('\\', "\\\\").replace(' ', "\\s")
}
pub fn test_timestamp(offset: Duration) -> ClientTimestamp {
ClientTimestamp {
wall: UNIX_EPOCH + offset,
mono: Instant::now() + offset,
}
}
pub fn test_remote() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use irtt_client::{PacketMeta, RttSample};
use irtt_proto::{Params, PROTOCOL_VERSION};
fn parse(args: &[&str]) -> Result<CliArgs, clap::Error> {
let mut argv = vec!["irtt-rs"];
argv.extend_from_slice(args);
CliArgs::try_parse_from(argv)
}
fn reply_event() -> ClientEvent {
ClientEvent::EchoReply {
seq: 7,
logical_seq: 8,
remote: test_remote(),
sent_at: test_timestamp(Duration::from_secs(1)),
received_at: test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500)),
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: Some(Duration::from_micros(1200)),
effective: Duration::from_micros(1200),
},
server_timing: Some(ServerTiming {
receive_wall_ns: Some(1_000),
receive_mono_ns: Some(2_000),
send_wall_ns: Some(301_000),
send_mono_ns: Some(302_000),
midpoint_wall_ns: None,
midpoint_mono_ns: None,
processing: Some(Duration::from_micros(300)),
}),
one_way: Some(OneWayDelaySample {
client_to_server: Some(Duration::from_micros(400)),
server_to_client: Some(Duration::from_micros(500)),
}),
received_stats: Some(ReceivedStatsSample {
count: Some(9),
window: Some(0x7),
}),
packet_meta: PacketMeta::default(),
}
}
#[test]
fn parses_valid_defaults() {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.server, "127.0.0.1:2112");
assert_eq!(args.duration, Duration::from_secs(10));
assert_eq!(args.interval, Duration::from_secs(1));
assert_eq!(args.length, 0);
assert_eq!(args.hmac, None);
assert_eq!(args.clock, ClockArg::Both);
assert_eq!(args.timestamps, TimestampArg::Both);
assert_eq!(args.output, OutputMode::Simple);
let config = args.to_client_config();
assert_eq!(config.duration, Some(Duration::from_secs(10)));
assert_eq!(config.interval, Duration::from_secs(1));
assert_eq!(config.length, 0);
assert_eq!(config.stamp_at, StampAt::Both);
assert_eq!(config.clock, Clock::Both);
}
#[test]
fn parses_durations() {
assert_eq!(parse_duration("1ms").unwrap(), Duration::from_millis(1));
assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2));
assert_eq!(parse_duration("3m").unwrap(), Duration::from_secs(180));
}
#[test]
fn rejects_invalid_duration() {
assert!(parse(&["--duration", "0s", "127.0.0.1:2112"]).is_err());
assert!(parse(&["--duration", "-1s", "127.0.0.1:2112"]).is_err());
assert!(parse(&["--duration", "5", "127.0.0.1:2112"]).is_err());
assert!(parse(&["--duration", "1h", "127.0.0.1:2112"]).is_err());
}
#[test]
fn rejects_invalid_interval() {
assert!(parse(&["--interval", "0ms", "127.0.0.1:2112"]).is_err());
assert!(parse(&["--interval", "-1ms", "127.0.0.1:2112"]).is_err());
}
#[test]
fn parses_output_clock_and_timestamp_modes() {
let args = parse(&[
"--output",
"machine",
"--clock",
"wall",
"--timestamps",
"send",
"127.0.0.1:2112",
])
.unwrap();
assert_eq!(args.output, OutputMode::Machine);
assert_eq!(args.clock, ClockArg::Wall);
assert_eq!(args.timestamps, TimestampArg::Send);
let args = parse(&[
"--output",
"rtt-us",
"--clock",
"monotonic",
"--timestamps",
"receive",
"127.0.0.1:2112",
])
.unwrap();
assert_eq!(args.output, OutputMode::RttUs);
assert_eq!(args.clock, ClockArg::Monotonic);
assert_eq!(args.timestamps, TimestampArg::Receive);
}
#[test]
fn rejects_invalid_length() {
assert!(parse(&["--length", "-1", "127.0.0.1:2112"]).is_err());
assert!(parse(&["--length", "65508", "127.0.0.1:2112"]).is_err());
}
#[test]
fn rtt_us_prints_only_effective_reply_rtt() {
assert_eq!(
format_event(&reply_event(), OutputMode::RttUs),
Some("1200".to_owned())
);
assert_eq!(
format_event(
&ClientEvent::Warning {
kind: WarningKind::WrongToken,
message: "bad".to_owned(),
},
OutputMode::RttUs
),
None
);
}
#[test]
fn machine_prints_stable_key_value_fields() {
let line = format_event(&reply_event(), OutputMode::Machine).unwrap();
assert!(line.starts_with("event=echo_reply "));
assert!(line.contains("seq=7"));
assert!(line.contains("logical_seq=8"));
assert!(line.contains("remote=127.0.0.1:2112"));
assert!(line.contains("sent_ns=1000000000"));
assert!(line.contains("received_ns=1001500000"));
assert!(line.contains("raw_rtt_us=1500"));
assert!(line.contains("adjusted_rtt_us=1200"));
assert!(line.contains("effective_rtt_us=1200"));
assert!(line.contains("server_processing_us=300"));
assert!(line.contains("server_received_count=9"));
}
#[test]
fn simple_prints_readable_reply_line() {
let line = format_event(&reply_event(), OutputMode::Simple).unwrap();
assert_eq!(
line,
"reply seq=7 logical_seq=8 remote=127.0.0.1:2112 rtt_us=1200 raw_rtt_us=1500 server_processing_us=300"
);
}
#[test]
fn warning_and_loss_variants_format_without_panicking() {
let ts = test_timestamp(Duration::from_secs(1));
let events = [
ClientEvent::EchoLoss {
seq: 1,
logical_seq: 2,
sent_at: ts,
timeout_at: ts.mono + Duration::from_secs(1),
},
ClientEvent::DuplicateReply {
seq: 3,
remote: test_remote(),
received_at: ts,
},
ClientEvent::LateReply {
seq: 4,
logical_seq: None,
highest_seen: 9,
remote: test_remote(),
sent_at: None,
received_at: ts,
rtt: None,
server_timing: None,
one_way: None,
received_stats: None,
packet_meta: PacketMeta::default(),
},
ClientEvent::Warning {
kind: WarningKind::UntrackedReply,
message: "untracked reply".to_owned(),
},
];
for event in events {
let machine = format_event(&event, OutputMode::Machine).unwrap();
let simple = format_event(&event, OutputMode::Simple).unwrap();
assert!(!machine.is_empty());
assert!(!simple.is_empty());
}
}
#[test]
fn session_events_do_not_print_summary() {
let event = ClientEvent::SessionStarted {
remote: test_remote(),
token: 0x1234,
negotiated: NegotiatedParams {
params: Params {
protocol_version: PROTOCOL_VERSION,
duration_ns: 10_000_000_000,
interval_ns: 1_000_000_000,
..Params::default()
},
},
at: test_timestamp(Duration::from_secs(1)),
};
let line = format_event(&event, OutputMode::Simple).unwrap();
assert!(!line.contains("summary"));
assert!(!line.contains("packets_sent"));
assert!(!line.contains("packet_loss"));
}
}