Rename the application Cargo package from irtt-cli to irtt-rs (crates/irtt-cli -> crates/irtt-app), and hard-rename its scriptable client binary from irtt-cli to irtt-client, now that the application also ships a first-class server and the old name reads as an umbrella CLI for both roles. Give each dedicated binary (irtt-client, irtt-tui, irtt-server) its own entry point instead of funneling through the multicall dispatcher's argv0/subcommand logic, so a dedicated binary only links the applet code for the role it implements and always runs that role regardless of the name it's invoked or copied under. irtt-rs remains the feature-selectable multicall dispatcher and is now the only binary that inspects argv0 or subcommands. Move future application releases to the irtt-rs/vX.Y.Z tag namespace (regenerated via `dist generate`), and bump the application package to 0.6.0 as a breaking distribution change. Library crate versions are unaffected.
1478 lines
49 KiB
Rust
1478 lines
49 KiB
Rust
use std::{
|
|
fmt::Write as _,
|
|
net::SocketAddr,
|
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use irtt_client::{
|
|
ClientEvent, NegotiatedParams, OneWayDelaySample, PacketMeta, ReceivedStatsSample, RttSample,
|
|
ServerTiming, SignedDuration, WarningKind,
|
|
};
|
|
|
|
use super::args::{HeaderMode, OutputFormat};
|
|
use crate::cmd::format::{format_duration, format_signed_duration, ABSENT};
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(super) struct OutputConfig {
|
|
format: OutputFormat,
|
|
columns: Vec<Column>,
|
|
header: HeaderMode,
|
|
default_table_rows: bool,
|
|
verbose: bool,
|
|
}
|
|
|
|
impl OutputConfig {
|
|
pub(super) fn new(
|
|
format: OutputFormat,
|
|
columns: Option<&str>,
|
|
header: HeaderMode,
|
|
verbose: bool,
|
|
) -> Result<Self, String> {
|
|
let uses_default_columns = columns
|
|
.map(|columns| columns.trim() == "default")
|
|
.unwrap_or(true);
|
|
let default_table_rows = format == OutputFormat::Table && uses_default_columns;
|
|
let columns = match columns {
|
|
Some(columns) => parse_columns(columns, format, verbose)?,
|
|
None => default_columns(format, verbose),
|
|
};
|
|
|
|
Ok(Self {
|
|
format,
|
|
columns,
|
|
header,
|
|
default_table_rows,
|
|
verbose,
|
|
})
|
|
}
|
|
|
|
pub(super) fn prints_summary(&self) -> bool {
|
|
self.format.prints_summary()
|
|
}
|
|
|
|
pub(super) fn summary_verbose(&self) -> bool {
|
|
self.verbose
|
|
}
|
|
|
|
pub(super) fn should_print_header(&self) -> bool {
|
|
match self.header {
|
|
HeaderMode::Always => self.format != OutputFormat::Jsonl,
|
|
HeaderMode::Never => false,
|
|
HeaderMode::Auto => matches!(
|
|
self.format,
|
|
OutputFormat::Table | OutputFormat::Csv | OutputFormat::Tsv
|
|
),
|
|
}
|
|
}
|
|
|
|
pub(super) fn render_header(&self) -> Option<String> {
|
|
if !self.should_print_header() {
|
|
return None;
|
|
}
|
|
|
|
match self.format {
|
|
OutputFormat::Table => Some(render_table_header(&self.columns)),
|
|
OutputFormat::Csv => Some(
|
|
self.columns
|
|
.iter()
|
|
.map(|column| escape_csv(column.name()))
|
|
.collect::<Vec<_>>()
|
|
.join(","),
|
|
),
|
|
OutputFormat::Tsv => Some(
|
|
self.columns
|
|
.iter()
|
|
.map(|column| escape_tsv(column.name()))
|
|
.collect::<Vec<_>>()
|
|
.join("\t"),
|
|
),
|
|
OutputFormat::Jsonl => None,
|
|
}
|
|
}
|
|
|
|
pub(super) fn render_event(
|
|
&self,
|
|
event: &ClientEvent,
|
|
target: Option<&str>,
|
|
stats: Option<&EventRenderStats>,
|
|
) -> Option<String> {
|
|
let row = OutputRow::from_event(event);
|
|
if self.default_table_rows && row.is_default_table_hidden() {
|
|
return None;
|
|
}
|
|
|
|
let context = RenderContext {
|
|
stats,
|
|
target,
|
|
verbose: self.verbose,
|
|
};
|
|
|
|
match self.format {
|
|
OutputFormat::Table => Some(render_table_row(&row, &self.columns, context)),
|
|
OutputFormat::Csv => Some(render_delimited_row(
|
|
&row,
|
|
&self.columns,
|
|
context,
|
|
DelimitedFormat::Csv,
|
|
)),
|
|
OutputFormat::Tsv => Some(render_delimited_row(
|
|
&row,
|
|
&self.columns,
|
|
context,
|
|
DelimitedFormat::Tsv,
|
|
)),
|
|
OutputFormat::Jsonl => Some(render_jsonl_row(&row, &self.columns, context)),
|
|
}
|
|
}
|
|
|
|
pub(super) fn list_columns() -> String {
|
|
let mut out = String::new();
|
|
writeln!(out, "Available event columns:").unwrap();
|
|
for column in ALL_COLUMNS {
|
|
writeln!(out, " {:<24} {}", column.name(), column.description()).unwrap();
|
|
}
|
|
writeln!(out).unwrap();
|
|
writeln!(out, "Aliases:").unwrap();
|
|
writeln!(out, " receive_delay, receive_delay_us").unwrap();
|
|
writeln!(out, " send_delay, send_delay_us").unwrap();
|
|
writeln!(out, " server_processing").unwrap();
|
|
writeln!(out, " server_received_count, server_received_window").unwrap();
|
|
writeln!(out).unwrap();
|
|
writeln!(out, "Special column sets:").unwrap();
|
|
writeln!(
|
|
out,
|
|
" default format default; compact for table, all columns for csv/tsv/jsonl"
|
|
)
|
|
.unwrap();
|
|
writeln!(out, " all every column").unwrap();
|
|
out
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub struct EventRenderStats {
|
|
pub contributed_sample: bool,
|
|
pub ipdv_pairs: Vec<IpdvPair>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct IpdvPair {
|
|
pub previous_seq: u32,
|
|
pub current_seq: u32,
|
|
pub rtt_ipdv: Duration,
|
|
pub send_ipdv: Option<Duration>,
|
|
pub receive_ipdv: Option<Duration>,
|
|
}
|
|
|
|
impl From<irtt_stats::EventStatsUpdate> for EventRenderStats {
|
|
fn from(value: irtt_stats::EventStatsUpdate) -> Self {
|
|
Self {
|
|
contributed_sample: value.contributed_sample,
|
|
ipdv_pairs: value.ipdv_pairs.into_iter().map(Into::into).collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<irtt_stats::IpdvPairUpdate> for IpdvPair {
|
|
fn from(value: irtt_stats::IpdvPairUpdate) -> Self {
|
|
Self {
|
|
previous_seq: value.previous_seq,
|
|
current_seq: value.current_seq,
|
|
rtt_ipdv: value.rtt_ipdv,
|
|
send_ipdv: value.send_ipdv,
|
|
receive_ipdv: value.receive_ipdv,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct RenderContext<'a> {
|
|
stats: Option<&'a EventRenderStats>,
|
|
target: Option<&'a str>,
|
|
verbose: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum OutputRow {
|
|
SessionStarted(LifecycleRow),
|
|
NoTestCompleted(LifecycleRow),
|
|
SessionClosed {
|
|
remote: SocketAddr,
|
|
token: u64,
|
|
event_wall: SystemTime,
|
|
},
|
|
EchoSent {
|
|
seq: u32,
|
|
remote: SocketAddr,
|
|
client_send_wall: SystemTime,
|
|
bytes: usize,
|
|
send_call: Duration,
|
|
timer_error: Duration,
|
|
},
|
|
EchoReply(ReplyRow),
|
|
Loss {
|
|
seq: u32,
|
|
client_send_wall: SystemTime,
|
|
},
|
|
Duplicate {
|
|
seq: u32,
|
|
remote: SocketAddr,
|
|
client_receive_wall: SystemTime,
|
|
bytes: usize,
|
|
},
|
|
Late {
|
|
reply: ReplyRow,
|
|
highest_seen: u32,
|
|
},
|
|
Warning {
|
|
kind: WarningKind,
|
|
message: String,
|
|
event_wall: SystemTime,
|
|
},
|
|
}
|
|
|
|
impl OutputRow {
|
|
fn from_event(event: &ClientEvent) -> Self {
|
|
match event {
|
|
ClientEvent::SessionStarted {
|
|
remote,
|
|
token,
|
|
negotiated,
|
|
at,
|
|
} => Self::SessionStarted(LifecycleRow::new(
|
|
*remote,
|
|
Some(*token),
|
|
negotiated,
|
|
at.wall,
|
|
)),
|
|
ClientEvent::NoTestCompleted {
|
|
remote,
|
|
negotiated,
|
|
at,
|
|
} => Self::NoTestCompleted(LifecycleRow::new(*remote, None, negotiated, at.wall)),
|
|
ClientEvent::SessionClosed { remote, token, at } => Self::SessionClosed {
|
|
remote: *remote,
|
|
token: *token,
|
|
event_wall: at.wall,
|
|
},
|
|
ClientEvent::EchoSent {
|
|
seq,
|
|
remote,
|
|
sent_at,
|
|
bytes,
|
|
send_call,
|
|
timer_error,
|
|
..
|
|
} => Self::EchoSent {
|
|
seq: *seq,
|
|
remote: *remote,
|
|
client_send_wall: sent_at.wall,
|
|
bytes: *bytes,
|
|
send_call: *send_call,
|
|
timer_error: *timer_error,
|
|
},
|
|
ClientEvent::EchoReply {
|
|
seq,
|
|
remote,
|
|
sent_at,
|
|
received_at,
|
|
rtt,
|
|
server_timing,
|
|
one_way,
|
|
received_stats,
|
|
bytes,
|
|
packet_meta,
|
|
} => Self::EchoReply(ReplyRow {
|
|
seq: *seq,
|
|
remote: *remote,
|
|
client_send_wall: Some(sent_at.wall),
|
|
client_receive_wall: received_at.wall,
|
|
rtt: Some(*rtt),
|
|
server_timing: *server_timing,
|
|
one_way: *one_way,
|
|
received_stats: *received_stats,
|
|
bytes: *bytes,
|
|
packet_meta: *packet_meta,
|
|
}),
|
|
ClientEvent::EchoLoss { seq, sent_at, .. } => Self::Loss {
|
|
seq: *seq,
|
|
client_send_wall: sent_at.wall,
|
|
},
|
|
ClientEvent::DuplicateReply {
|
|
seq,
|
|
remote,
|
|
received_at,
|
|
bytes,
|
|
} => Self::Duplicate {
|
|
seq: *seq,
|
|
remote: *remote,
|
|
client_receive_wall: received_at.wall,
|
|
bytes: *bytes,
|
|
},
|
|
ClientEvent::LateReply {
|
|
seq,
|
|
highest_seen,
|
|
remote,
|
|
sent_at,
|
|
received_at,
|
|
rtt,
|
|
server_timing,
|
|
one_way,
|
|
received_stats,
|
|
bytes,
|
|
packet_meta,
|
|
} => Self::Late {
|
|
reply: ReplyRow {
|
|
seq: *seq,
|
|
remote: *remote,
|
|
client_send_wall: sent_at.map(|sent_at| sent_at.wall),
|
|
client_receive_wall: received_at.wall,
|
|
rtt: *rtt,
|
|
server_timing: *server_timing,
|
|
one_way: *one_way,
|
|
received_stats: *received_stats,
|
|
bytes: *bytes,
|
|
packet_meta: *packet_meta,
|
|
},
|
|
highest_seen: *highest_seen,
|
|
},
|
|
ClientEvent::Warning { kind, message, at } => Self::Warning {
|
|
kind: *kind,
|
|
message: message.clone(),
|
|
event_wall: at.wall,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn event_name(&self) -> &'static str {
|
|
match self {
|
|
Self::SessionStarted(_) => "session_started",
|
|
Self::NoTestCompleted(_) => "no_test_completed",
|
|
Self::SessionClosed { .. } => "session_closed",
|
|
Self::EchoSent { .. } => "echo_sent",
|
|
Self::EchoReply(_) => "echo_reply",
|
|
Self::Loss { .. } => "loss",
|
|
Self::Duplicate { .. } => "duplicate",
|
|
Self::Late { .. } => "late",
|
|
Self::Warning { .. } => "warning",
|
|
}
|
|
}
|
|
|
|
fn is_default_table_hidden(&self) -> bool {
|
|
matches!(self, Self::EchoSent { .. })
|
|
}
|
|
|
|
fn reply(&self) -> Option<&ReplyRow> {
|
|
match self {
|
|
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct LifecycleRow {
|
|
remote: SocketAddr,
|
|
token: Option<u64>,
|
|
event_wall: SystemTime,
|
|
duration_ns: i128,
|
|
interval_ns: i128,
|
|
payload_length: i128,
|
|
}
|
|
|
|
impl LifecycleRow {
|
|
fn new(
|
|
remote: SocketAddr,
|
|
token: Option<u64>,
|
|
negotiated: &NegotiatedParams,
|
|
event_wall: SystemTime,
|
|
) -> Self {
|
|
Self {
|
|
remote,
|
|
token,
|
|
event_wall,
|
|
duration_ns: i128::from(negotiated.params.duration_ns),
|
|
interval_ns: i128::from(negotiated.params.interval_ns),
|
|
payload_length: i128::from(negotiated.params.length),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct ReplyRow {
|
|
seq: u32,
|
|
remote: SocketAddr,
|
|
client_send_wall: Option<SystemTime>,
|
|
client_receive_wall: SystemTime,
|
|
rtt: Option<RttSample>,
|
|
server_timing: Option<ServerTiming>,
|
|
one_way: Option<OneWayDelaySample>,
|
|
received_stats: Option<ReceivedStatsSample>,
|
|
bytes: usize,
|
|
packet_meta: PacketMeta,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum Column {
|
|
Target,
|
|
Event,
|
|
Seq,
|
|
Remote,
|
|
Token,
|
|
Rtt,
|
|
RttUs,
|
|
RawRttUs,
|
|
EffectiveRttUs,
|
|
AdjustedRttUs,
|
|
ReceiveDelay,
|
|
ReceiveDelayUs,
|
|
SendDelay,
|
|
SendDelayUs,
|
|
Ipdv,
|
|
IpdvUs,
|
|
ServerProcessing,
|
|
ServerProcessingUs,
|
|
Bytes,
|
|
SendCallUs,
|
|
TimerErrorUs,
|
|
HighestSeen,
|
|
ServerReceivedCount,
|
|
ServerReceivedWindow,
|
|
Dscp,
|
|
Ecn,
|
|
TrafficClass,
|
|
KernelRxNs,
|
|
WarningKind,
|
|
Message,
|
|
EventWallNs,
|
|
ClientSendWallNs,
|
|
ClientReceiveWallNs,
|
|
DurationNs,
|
|
IntervalNs,
|
|
PayloadLength,
|
|
ServerReceiveWallNs,
|
|
ServerReceiveMonoNs,
|
|
ServerSendWallNs,
|
|
ServerSendMonoNs,
|
|
ServerMidpointWallNs,
|
|
ServerMidpointMonoNs,
|
|
}
|
|
|
|
const ALL_COLUMNS: &[Column] = &[
|
|
Column::Target,
|
|
Column::Event,
|
|
Column::Seq,
|
|
Column::Remote,
|
|
Column::Token,
|
|
Column::Rtt,
|
|
Column::RttUs,
|
|
Column::RawRttUs,
|
|
Column::EffectiveRttUs,
|
|
Column::AdjustedRttUs,
|
|
Column::ReceiveDelay,
|
|
Column::ReceiveDelayUs,
|
|
Column::SendDelay,
|
|
Column::SendDelayUs,
|
|
Column::Ipdv,
|
|
Column::IpdvUs,
|
|
Column::ServerProcessing,
|
|
Column::ServerProcessingUs,
|
|
Column::Bytes,
|
|
Column::SendCallUs,
|
|
Column::TimerErrorUs,
|
|
Column::HighestSeen,
|
|
Column::ServerReceivedCount,
|
|
Column::ServerReceivedWindow,
|
|
Column::Dscp,
|
|
Column::Ecn,
|
|
Column::TrafficClass,
|
|
Column::KernelRxNs,
|
|
Column::WarningKind,
|
|
Column::Message,
|
|
Column::EventWallNs,
|
|
Column::ClientSendWallNs,
|
|
Column::ClientReceiveWallNs,
|
|
Column::DurationNs,
|
|
Column::IntervalNs,
|
|
Column::PayloadLength,
|
|
Column::ServerReceiveWallNs,
|
|
Column::ServerReceiveMonoNs,
|
|
Column::ServerSendWallNs,
|
|
Column::ServerSendMonoNs,
|
|
Column::ServerMidpointWallNs,
|
|
Column::ServerMidpointMonoNs,
|
|
];
|
|
|
|
impl Column {
|
|
fn name(self) -> &'static str {
|
|
match self {
|
|
Self::Target => "target",
|
|
Self::Event => "event",
|
|
Self::Seq => "seq",
|
|
Self::Remote => "remote",
|
|
Self::Token => "token",
|
|
Self::Rtt => "rtt",
|
|
Self::RttUs => "rtt_us",
|
|
Self::RawRttUs => "raw_rtt_us",
|
|
Self::EffectiveRttUs => "effective_rtt_us",
|
|
Self::AdjustedRttUs => "adjusted_rtt_us",
|
|
Self::ReceiveDelay => "rd",
|
|
Self::ReceiveDelayUs => "rd_us",
|
|
Self::SendDelay => "sd",
|
|
Self::SendDelayUs => "sd_us",
|
|
Self::Ipdv => "ipdv",
|
|
Self::IpdvUs => "ipdv_us",
|
|
Self::ServerProcessing => "proc",
|
|
Self::ServerProcessingUs => "server_processing_us",
|
|
Self::Bytes => "bytes",
|
|
Self::SendCallUs => "send_call_us",
|
|
Self::TimerErrorUs => "timer_error_us",
|
|
Self::HighestSeen => "highest_seen",
|
|
Self::ServerReceivedCount => "server_received",
|
|
Self::ServerReceivedWindow => "server_window",
|
|
Self::Dscp => "dscp",
|
|
Self::Ecn => "ecn",
|
|
Self::TrafficClass => "traffic_class",
|
|
Self::KernelRxNs => "kernel_rx_ns",
|
|
Self::WarningKind => "warning_kind",
|
|
Self::Message => "message",
|
|
Self::EventWallNs => "event_wall_ns",
|
|
Self::ClientSendWallNs => "client_send_wall_ns",
|
|
Self::ClientReceiveWallNs => "client_receive_wall_ns",
|
|
Self::DurationNs => "duration_ns",
|
|
Self::IntervalNs => "interval_ns",
|
|
Self::PayloadLength => "payload_length",
|
|
Self::ServerReceiveWallNs => "server_receive_wall_ns",
|
|
Self::ServerReceiveMonoNs => "server_receive_mono_ns",
|
|
Self::ServerSendWallNs => "server_send_wall_ns",
|
|
Self::ServerSendMonoNs => "server_send_mono_ns",
|
|
Self::ServerMidpointWallNs => "server_midpoint_wall_ns",
|
|
Self::ServerMidpointMonoNs => "server_midpoint_mono_ns",
|
|
}
|
|
}
|
|
|
|
fn parse(input: &str) -> Option<Self> {
|
|
Some(match input {
|
|
"target" => Self::Target,
|
|
"event" => Self::Event,
|
|
"seq" => Self::Seq,
|
|
"remote" => Self::Remote,
|
|
"token" => Self::Token,
|
|
"rtt" => Self::Rtt,
|
|
"rtt_us" => Self::RttUs,
|
|
"raw_rtt_us" => Self::RawRttUs,
|
|
"effective_rtt_us" => Self::EffectiveRttUs,
|
|
"adjusted_rtt_us" => Self::AdjustedRttUs,
|
|
"rd" | "receive_delay" => Self::ReceiveDelay,
|
|
"rd_us" | "receive_delay_us" => Self::ReceiveDelayUs,
|
|
"sd" | "send_delay" => Self::SendDelay,
|
|
"sd_us" | "send_delay_us" => Self::SendDelayUs,
|
|
"ipdv" => Self::Ipdv,
|
|
"ipdv_us" => Self::IpdvUs,
|
|
"proc" | "server_processing" => Self::ServerProcessing,
|
|
"server_processing_us" => Self::ServerProcessingUs,
|
|
"bytes" => Self::Bytes,
|
|
"send_call_us" => Self::SendCallUs,
|
|
"timer_error_us" => Self::TimerErrorUs,
|
|
"highest_seen" => Self::HighestSeen,
|
|
"server_received" | "server_received_count" => Self::ServerReceivedCount,
|
|
"server_window" | "server_received_window" => Self::ServerReceivedWindow,
|
|
"dscp" => Self::Dscp,
|
|
"ecn" => Self::Ecn,
|
|
"traffic_class" => Self::TrafficClass,
|
|
"kernel_rx_ns" => Self::KernelRxNs,
|
|
"warning_kind" => Self::WarningKind,
|
|
"message" => Self::Message,
|
|
"event_wall_ns" => Self::EventWallNs,
|
|
"client_send_wall_ns" => Self::ClientSendWallNs,
|
|
"client_receive_wall_ns" => Self::ClientReceiveWallNs,
|
|
"duration_ns" => Self::DurationNs,
|
|
"interval_ns" => Self::IntervalNs,
|
|
"payload_length" => Self::PayloadLength,
|
|
"server_receive_wall_ns" => Self::ServerReceiveWallNs,
|
|
"server_receive_mono_ns" => Self::ServerReceiveMonoNs,
|
|
"server_send_wall_ns" => Self::ServerSendWallNs,
|
|
"server_send_mono_ns" => Self::ServerSendMonoNs,
|
|
"server_midpoint_wall_ns" => Self::ServerMidpointWallNs,
|
|
"server_midpoint_mono_ns" => Self::ServerMidpointMonoNs,
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
fn description(self) -> &'static str {
|
|
match self {
|
|
Self::Target => "logical CLI target label",
|
|
Self::Event => "event kind",
|
|
Self::Seq => "probe sequence number",
|
|
Self::Remote => "remote socket address",
|
|
Self::Token => "session token as hexadecimal",
|
|
Self::Rtt => "human-readable effective RTT",
|
|
Self::RttUs => "effective RTT in signed microseconds",
|
|
Self::RawRttUs => "raw client send-to-receive RTT in microseconds",
|
|
Self::EffectiveRttUs => "effective RTT in signed microseconds",
|
|
Self::AdjustedRttUs => "adjusted RTT in signed microseconds",
|
|
Self::ReceiveDelay => "human-readable server-to-client delay",
|
|
Self::ReceiveDelayUs => "server-to-client delay in signed microseconds",
|
|
Self::SendDelay => "human-readable client-to-server delay",
|
|
Self::SendDelayUs => "client-to-server delay in signed microseconds",
|
|
Self::Ipdv => "human-readable round-trip IPDV for adjacent samples",
|
|
Self::IpdvUs => "round-trip IPDV in microseconds",
|
|
Self::ServerProcessing => "human-readable server processing time",
|
|
Self::ServerProcessingUs => "server processing time in microseconds",
|
|
Self::Bytes => "packet bytes for packet events",
|
|
Self::SendCallUs => "send system call duration in microseconds",
|
|
Self::TimerErrorUs => "scheduled-vs-actual send timer error in microseconds",
|
|
Self::HighestSeen => "highest sequence seen when a late reply arrived",
|
|
Self::ServerReceivedCount => "server-reported received packet count",
|
|
Self::ServerReceivedWindow => "server-reported received window as hexadecimal",
|
|
Self::Dscp => "received packet DSCP codepoint",
|
|
Self::Ecn => "received packet ECN bits",
|
|
Self::TrafficClass => "received packet traffic class byte",
|
|
Self::KernelRxNs => "kernel receive timestamp as Unix nanoseconds",
|
|
Self::WarningKind => "warning classifier",
|
|
Self::Message => "warning or lifecycle message",
|
|
Self::EventWallNs => "event wall timestamp as Unix nanoseconds",
|
|
Self::ClientSendWallNs => "client send wall timestamp as Unix nanoseconds",
|
|
Self::ClientReceiveWallNs => "client receive wall timestamp as Unix nanoseconds",
|
|
Self::DurationNs => "negotiated test duration in nanoseconds",
|
|
Self::IntervalNs => "negotiated probe interval in nanoseconds",
|
|
Self::PayloadLength => "negotiated payload length",
|
|
Self::ServerReceiveWallNs => "server receive wall timestamp in nanoseconds",
|
|
Self::ServerReceiveMonoNs => "server receive monotonic timestamp in nanoseconds",
|
|
Self::ServerSendWallNs => "server send wall timestamp in nanoseconds",
|
|
Self::ServerSendMonoNs => "server send monotonic timestamp in nanoseconds",
|
|
Self::ServerMidpointWallNs => "server midpoint wall timestamp in nanoseconds",
|
|
Self::ServerMidpointMonoNs => "server midpoint monotonic timestamp in nanoseconds",
|
|
}
|
|
}
|
|
|
|
fn table_width(self) -> usize {
|
|
match self {
|
|
Self::Target => 18,
|
|
Self::Event => 17,
|
|
Self::Seq => 6,
|
|
Self::Remote => 21,
|
|
Self::Token => 18,
|
|
Self::Rtt
|
|
| Self::ReceiveDelay
|
|
| Self::SendDelay
|
|
| Self::Ipdv
|
|
| Self::ServerProcessing => 9,
|
|
Self::Message => 24,
|
|
Self::WarningKind => 28,
|
|
Self::ServerReceivedWindow => 13,
|
|
Self::ServerReceivedCount | Self::HighestSeen => 15,
|
|
Self::Dscp | Self::Ecn => 4,
|
|
Self::TrafficClass => 13,
|
|
_ => self.name().len().max(10),
|
|
}
|
|
}
|
|
|
|
fn align_right(self) -> bool {
|
|
!matches!(
|
|
self,
|
|
Self::Target
|
|
| Self::Event
|
|
| Self::Remote
|
|
| Self::Token
|
|
| Self::Message
|
|
| Self::WarningKind
|
|
| Self::ServerReceivedWindow
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum CellValue {
|
|
Text(String),
|
|
Integer(i128),
|
|
Unsigned(u128),
|
|
Hex(u128),
|
|
}
|
|
|
|
impl CellValue {
|
|
fn text(value: impl Into<String>) -> Self {
|
|
Self::Text(value.into())
|
|
}
|
|
|
|
fn display(&self) -> String {
|
|
match self {
|
|
Self::Text(value) => value.clone(),
|
|
Self::Integer(value) => value.to_string(),
|
|
Self::Unsigned(value) => value.to_string(),
|
|
Self::Hex(value) => format!("{value:#x}"),
|
|
}
|
|
}
|
|
|
|
fn write_json(&self, out: &mut String) {
|
|
match self {
|
|
Self::Text(value) => {
|
|
out.push('"');
|
|
write_json_string_content(out, value);
|
|
out.push('"');
|
|
}
|
|
Self::Integer(value) => write!(out, "{value}").unwrap(),
|
|
Self::Unsigned(value) => write!(out, "{value}").unwrap(),
|
|
Self::Hex(value) => {
|
|
out.push('"');
|
|
write!(out, "{value:#x}").unwrap();
|
|
out.push('"');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_columns(input: &str, format: OutputFormat, verbose: bool) -> Result<Vec<Column>, String> {
|
|
let trimmed = input.trim();
|
|
if trimmed.is_empty() {
|
|
return Err("--columns requires at least one column".to_owned());
|
|
}
|
|
if trimmed == "all" {
|
|
return Ok(ALL_COLUMNS.to_vec());
|
|
}
|
|
if trimmed == "default" {
|
|
return Ok(default_columns(format, verbose));
|
|
}
|
|
|
|
let mut columns = Vec::new();
|
|
for raw in trimmed.split(',') {
|
|
let name = raw.trim();
|
|
if name.is_empty() {
|
|
return Err("empty column in --columns list".to_owned());
|
|
}
|
|
let column = Column::parse(name).ok_or_else(|| {
|
|
format!("unknown output column {name:?}; run --list-columns to see valid names")
|
|
})?;
|
|
columns.push(column);
|
|
}
|
|
Ok(columns)
|
|
}
|
|
|
|
fn default_columns(format: OutputFormat, verbose: bool) -> Vec<Column> {
|
|
match format {
|
|
OutputFormat::Table => {
|
|
let mut columns = vec![
|
|
Column::Target,
|
|
Column::Event,
|
|
Column::Seq,
|
|
Column::Rtt,
|
|
Column::ReceiveDelay,
|
|
Column::SendDelay,
|
|
Column::Ipdv,
|
|
Column::ServerProcessing,
|
|
Column::Message,
|
|
];
|
|
if verbose {
|
|
columns.extend([
|
|
Column::Remote,
|
|
Column::ServerReceivedCount,
|
|
Column::ServerReceivedWindow,
|
|
Column::RawRttUs,
|
|
Column::AdjustedRttUs,
|
|
Column::Bytes,
|
|
Column::Dscp,
|
|
Column::Ecn,
|
|
]);
|
|
}
|
|
columns
|
|
}
|
|
OutputFormat::Csv | OutputFormat::Tsv | OutputFormat::Jsonl => ALL_COLUMNS.to_vec(),
|
|
}
|
|
}
|
|
|
|
fn render_table_header(columns: &[Column]) -> String {
|
|
columns
|
|
.iter()
|
|
.map(|column| format_table_cell(column, Some(column.name().to_owned())))
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
fn render_table_row(row: &OutputRow, columns: &[Column], context: RenderContext<'_>) -> String {
|
|
columns
|
|
.iter()
|
|
.map(|column| {
|
|
let value = cell_for(row, *column, context).map(|value| value.display());
|
|
format_table_cell(column, value)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
fn format_table_cell(column: &Column, value: Option<String>) -> String {
|
|
let value = value.unwrap_or_else(|| ABSENT.to_owned());
|
|
let width = column.table_width();
|
|
let value = if matches!(column, Column::Target) {
|
|
truncate_for_table(&value, width)
|
|
} else {
|
|
value
|
|
};
|
|
if column.align_right() {
|
|
format!("{value:>width$}")
|
|
} else {
|
|
format!("{value:<width$}")
|
|
}
|
|
}
|
|
|
|
/// Shortens `value` to fit within `max_width` display characters for TABLE
|
|
/// presentation only. Values within the limit are returned unchanged. Longer
|
|
/// values keep a prefix and end with `"..."` so the result is exactly
|
|
/// `max_width` characters. Operates on `char`s (never raw byte indices) so a
|
|
/// multibyte UTF-8 value cannot be split mid-code-point.
|
|
///
|
|
/// This is a presentation-only concern: callers must keep the untruncated
|
|
/// value for CSV/TSV/JSONL and any non-display use.
|
|
fn truncate_for_table(value: &str, max_width: usize) -> String {
|
|
if value.chars().count() <= max_width {
|
|
return value.to_owned();
|
|
}
|
|
let suffix = "...";
|
|
let prefix_len = max_width.saturating_sub(suffix.len());
|
|
let mut truncated: String = value.chars().take(prefix_len).collect();
|
|
truncated.push_str(suffix);
|
|
truncated
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum DelimitedFormat {
|
|
Csv,
|
|
Tsv,
|
|
}
|
|
|
|
fn render_delimited_row(
|
|
row: &OutputRow,
|
|
columns: &[Column],
|
|
context: RenderContext<'_>,
|
|
format: DelimitedFormat,
|
|
) -> String {
|
|
let separator = match format {
|
|
DelimitedFormat::Csv => ",",
|
|
DelimitedFormat::Tsv => "\t",
|
|
};
|
|
columns
|
|
.iter()
|
|
.map(|column| {
|
|
let value = cell_for(row, *column, context)
|
|
.map(|value| value.display())
|
|
.unwrap_or_default();
|
|
match format {
|
|
DelimitedFormat::Csv => escape_csv(&value),
|
|
DelimitedFormat::Tsv => escape_tsv(&value),
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(separator)
|
|
}
|
|
|
|
fn render_jsonl_row(row: &OutputRow, columns: &[Column], context: RenderContext<'_>) -> String {
|
|
let mut out = String::from("{");
|
|
for (index, column) in columns.iter().enumerate() {
|
|
if index > 0 {
|
|
out.push(',');
|
|
}
|
|
out.push('"');
|
|
write_json_string_content(&mut out, column.name());
|
|
out.push_str("\":");
|
|
if let Some(value) = cell_for(row, *column, context) {
|
|
value.write_json(&mut out);
|
|
} else {
|
|
out.push_str("null");
|
|
}
|
|
}
|
|
out.push('}');
|
|
out
|
|
}
|
|
|
|
fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Option<CellValue> {
|
|
match column {
|
|
Column::Target => context.target.map(CellValue::text),
|
|
Column::Event => Some(CellValue::text(row.event_name())),
|
|
Column::Seq => row_seq(row).map(|seq| CellValue::Unsigned(u128::from(seq))),
|
|
Column::Remote => row_remote(row).map(|remote| CellValue::text(remote.to_string())),
|
|
Column::Token => row_token(row).map(|token| CellValue::Hex(u128::from(token))),
|
|
Column::Rtt => row.reply().and_then(|reply| {
|
|
reply
|
|
.rtt
|
|
.map(|rtt| CellValue::text(format_signed_duration(rtt.effective)))
|
|
}),
|
|
Column::RttUs | Column::EffectiveRttUs => row.reply().and_then(|reply| {
|
|
reply
|
|
.rtt
|
|
.map(|rtt| CellValue::Integer(signed_duration_us(rtt.effective)))
|
|
}),
|
|
Column::RawRttUs => row.reply().and_then(|reply| {
|
|
reply
|
|
.rtt
|
|
.map(|rtt| CellValue::Unsigned(duration_us(rtt.raw)))
|
|
}),
|
|
Column::AdjustedRttUs => row.reply().and_then(|reply| {
|
|
reply
|
|
.rtt
|
|
.and_then(|rtt| rtt.adjusted)
|
|
.map(|adjusted| CellValue::Integer(signed_duration_us(adjusted)))
|
|
}),
|
|
Column::ReceiveDelay => row
|
|
.reply()
|
|
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.server_to_client))
|
|
.map(|value| CellValue::text(format_signed_duration(value))),
|
|
Column::ReceiveDelayUs => row
|
|
.reply()
|
|
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.server_to_client))
|
|
.map(|value| CellValue::Integer(signed_duration_us(value))),
|
|
Column::SendDelay => row
|
|
.reply()
|
|
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.client_to_server))
|
|
.map(|value| CellValue::text(format_signed_duration(value))),
|
|
Column::SendDelayUs => row
|
|
.reply()
|
|
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.client_to_server))
|
|
.map(|value| CellValue::Integer(signed_duration_us(value))),
|
|
Column::Ipdv => row_seq(row)
|
|
.and_then(|seq| ipdv_pair(context.stats, seq))
|
|
.map(|pair| CellValue::text(format_duration(pair.rtt_ipdv))),
|
|
Column::IpdvUs => row_seq(row)
|
|
.and_then(|seq| ipdv_pair(context.stats, seq))
|
|
.map(|pair| CellValue::Unsigned(duration_us(pair.rtt_ipdv))),
|
|
Column::ServerProcessing => row
|
|
.reply()
|
|
.and_then(|reply| reply.server_timing.and_then(|timing| timing.processing))
|
|
.map(|value| CellValue::text(format_duration(value))),
|
|
Column::ServerProcessingUs => row
|
|
.reply()
|
|
.and_then(|reply| reply.server_timing.and_then(|timing| timing.processing))
|
|
.map(|value| CellValue::Unsigned(duration_us(value))),
|
|
Column::Bytes => row_bytes(row).map(|bytes| CellValue::Unsigned(bytes as u128)),
|
|
Column::SendCallUs => match row {
|
|
OutputRow::EchoSent { send_call, .. } => {
|
|
Some(CellValue::Unsigned(duration_us(*send_call)))
|
|
}
|
|
_ => None,
|
|
},
|
|
Column::TimerErrorUs => match row {
|
|
OutputRow::EchoSent { timer_error, .. } => {
|
|
Some(CellValue::Unsigned(duration_us(*timer_error)))
|
|
}
|
|
_ => None,
|
|
},
|
|
Column::HighestSeen => match row {
|
|
OutputRow::Late { highest_seen, .. } => {
|
|
Some(CellValue::Unsigned(u128::from(*highest_seen)))
|
|
}
|
|
_ => None,
|
|
},
|
|
Column::ServerReceivedCount => row
|
|
.reply()
|
|
.and_then(|reply| reply.received_stats.and_then(|stats| stats.count))
|
|
.map(|value| CellValue::Unsigned(u128::from(value))),
|
|
Column::ServerReceivedWindow => row
|
|
.reply()
|
|
.and_then(|reply| reply.received_stats.and_then(|stats| stats.window))
|
|
.map(|value| CellValue::Hex(u128::from(value))),
|
|
Column::Dscp => row
|
|
.reply()
|
|
.and_then(|reply| reply.packet_meta.dscp)
|
|
.map(|value| CellValue::Unsigned(u128::from(value))),
|
|
Column::Ecn => row
|
|
.reply()
|
|
.and_then(|reply| reply.packet_meta.ecn)
|
|
.map(|value| CellValue::Unsigned(u128::from(value))),
|
|
Column::TrafficClass => row
|
|
.reply()
|
|
.and_then(|reply| reply.packet_meta.traffic_class)
|
|
.map(|value| CellValue::Unsigned(u128::from(value))),
|
|
Column::KernelRxNs => row
|
|
.reply()
|
|
.and_then(|reply| reply.packet_meta.kernel_rx_timestamp)
|
|
.and_then(wall_time_ns)
|
|
.map(CellValue::Unsigned),
|
|
Column::WarningKind => match row {
|
|
OutputRow::Warning { kind, .. } => Some(CellValue::text(warning_kind(*kind))),
|
|
_ => None,
|
|
},
|
|
Column::Message => row_message(row, context).map(CellValue::text),
|
|
Column::EventWallNs => row_event_wall(row)
|
|
.and_then(wall_time_ns)
|
|
.map(CellValue::Unsigned),
|
|
Column::ClientSendWallNs => row_client_send_wall(row)
|
|
.and_then(wall_time_ns)
|
|
.map(CellValue::Unsigned),
|
|
Column::ClientReceiveWallNs => row_client_receive_wall(row)
|
|
.and_then(wall_time_ns)
|
|
.map(CellValue::Unsigned),
|
|
Column::DurationNs => match row {
|
|
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => {
|
|
Some(CellValue::Integer(row.duration_ns))
|
|
}
|
|
_ => None,
|
|
},
|
|
Column::IntervalNs => match row {
|
|
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => {
|
|
Some(CellValue::Integer(row.interval_ns))
|
|
}
|
|
_ => None,
|
|
},
|
|
Column::PayloadLength => match row {
|
|
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => {
|
|
Some(CellValue::Integer(row.payload_length))
|
|
}
|
|
_ => None,
|
|
},
|
|
Column::ServerReceiveWallNs => server_timing_i64(row, |timing| timing.receive_wall_ns),
|
|
Column::ServerReceiveMonoNs => server_timing_i64(row, |timing| timing.receive_mono_ns),
|
|
Column::ServerSendWallNs => server_timing_i64(row, |timing| timing.send_wall_ns),
|
|
Column::ServerSendMonoNs => server_timing_i64(row, |timing| timing.send_mono_ns),
|
|
Column::ServerMidpointWallNs => server_timing_i64(row, |timing| timing.midpoint_wall_ns),
|
|
Column::ServerMidpointMonoNs => server_timing_i64(row, |timing| timing.midpoint_mono_ns),
|
|
}
|
|
}
|
|
|
|
fn row_seq(row: &OutputRow) -> Option<u32> {
|
|
match row {
|
|
OutputRow::EchoSent { seq, .. }
|
|
| OutputRow::Loss { seq, .. }
|
|
| OutputRow::Duplicate { seq, .. } => Some(*seq),
|
|
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => Some(reply.seq),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn row_remote(row: &OutputRow) -> Option<SocketAddr> {
|
|
match row {
|
|
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => Some(row.remote),
|
|
OutputRow::SessionClosed { remote, .. }
|
|
| OutputRow::EchoSent { remote, .. }
|
|
| OutputRow::Duplicate { remote, .. } => Some(*remote),
|
|
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => Some(reply.remote),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn row_token(row: &OutputRow) -> Option<u64> {
|
|
match row {
|
|
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => row.token,
|
|
OutputRow::SessionClosed { token, .. } => Some(*token),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn row_bytes(row: &OutputRow) -> Option<usize> {
|
|
match row {
|
|
OutputRow::EchoSent { bytes, .. } | OutputRow::Duplicate { bytes, .. } => Some(*bytes),
|
|
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => Some(reply.bytes),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn row_message(row: &OutputRow, context: RenderContext<'_>) -> Option<String> {
|
|
match row {
|
|
OutputRow::SessionStarted(row) => Some(format!(
|
|
"token={:#x} duration_ns={} interval_ns={} length={}",
|
|
row.token?, row.duration_ns, row.interval_ns, row.payload_length
|
|
)),
|
|
OutputRow::NoTestCompleted(row) => Some(format!(
|
|
"duration_ns={} interval_ns={} length={}",
|
|
row.duration_ns, row.interval_ns, row.payload_length
|
|
)),
|
|
OutputRow::SessionClosed { token, .. } => Some(format!("token={token:#x}")),
|
|
OutputRow::Late { highest_seen, .. } => Some(format!("highest_seen={highest_seen}")),
|
|
OutputRow::Warning { message, .. } => Some(message.clone()),
|
|
OutputRow::Loss { .. } => Some("timeout".to_owned()),
|
|
OutputRow::Duplicate { .. } if context.verbose => Some("duplicate reply".to_owned()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn row_event_wall(row: &OutputRow) -> Option<SystemTime> {
|
|
match row {
|
|
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => Some(row.event_wall),
|
|
OutputRow::SessionClosed { event_wall, .. } | OutputRow::Warning { event_wall, .. } => {
|
|
Some(*event_wall)
|
|
}
|
|
OutputRow::EchoSent {
|
|
client_send_wall, ..
|
|
}
|
|
| OutputRow::Loss {
|
|
client_send_wall, ..
|
|
} => Some(*client_send_wall),
|
|
OutputRow::Duplicate {
|
|
client_receive_wall,
|
|
..
|
|
} => Some(*client_receive_wall),
|
|
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => {
|
|
Some(reply.client_receive_wall)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn row_client_send_wall(row: &OutputRow) -> Option<SystemTime> {
|
|
match row {
|
|
OutputRow::EchoSent {
|
|
client_send_wall, ..
|
|
}
|
|
| OutputRow::Loss {
|
|
client_send_wall, ..
|
|
} => Some(*client_send_wall),
|
|
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => reply.client_send_wall,
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn row_client_receive_wall(row: &OutputRow) -> Option<SystemTime> {
|
|
match row {
|
|
OutputRow::Duplicate {
|
|
client_receive_wall,
|
|
..
|
|
} => Some(*client_receive_wall),
|
|
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => {
|
|
Some(reply.client_receive_wall)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn server_timing_i64(
|
|
row: &OutputRow,
|
|
select: impl FnOnce(ServerTiming) -> Option<i64>,
|
|
) -> Option<CellValue> {
|
|
row.reply()
|
|
.and_then(|reply| reply.server_timing)
|
|
.and_then(select)
|
|
.map(|value| CellValue::Integer(i128::from(value)))
|
|
}
|
|
|
|
fn ipdv_pair(stats: Option<&EventRenderStats>, seq: u32) -> Option<&IpdvPair> {
|
|
let stats = stats?;
|
|
stats
|
|
.ipdv_pairs
|
|
.iter()
|
|
.find(|pair| pair.current_seq == seq)
|
|
.or_else(|| {
|
|
stats
|
|
.ipdv_pairs
|
|
.iter()
|
|
.find(|pair| pair.previous_seq == seq)
|
|
})
|
|
}
|
|
|
|
fn duration_us(duration: Duration) -> u128 {
|
|
duration.as_micros()
|
|
}
|
|
|
|
fn signed_duration_us(duration: SignedDuration) -> i128 {
|
|
duration.as_micros()
|
|
}
|
|
|
|
fn wall_time_ns(wall: SystemTime) -> Option<u128> {
|
|
wall.duration_since(UNIX_EPOCH)
|
|
.ok()
|
|
.map(|duration| duration.as_nanos())
|
|
}
|
|
|
|
fn warning_kind(kind: WarningKind) -> &'static str {
|
|
match kind {
|
|
WarningKind::MalformedOrUnrelatedPacket => "malformed_or_unrelated_packet",
|
|
WarningKind::WrongToken => "wrong_token",
|
|
WarningKind::UntrackedReply => "untracked_reply",
|
|
_ => "unknown",
|
|
}
|
|
}
|
|
|
|
fn escape_csv(value: &str) -> String {
|
|
if value.contains([',', '"', '\n', '\r']) {
|
|
let escaped = value.replace('"', "\"\"");
|
|
format!("\"{escaped}\"")
|
|
} else {
|
|
value.to_owned()
|
|
}
|
|
}
|
|
|
|
fn escape_tsv(value: &str) -> String {
|
|
value.replace(['\t', '\n', '\r'], " ")
|
|
}
|
|
|
|
fn write_json_string_content(out: &mut String, value: &str) {
|
|
for c in value.chars() {
|
|
match c {
|
|
'"' => out.push_str("\\\""),
|
|
'\\' => out.push_str("\\\\"),
|
|
'\n' => out.push_str("\\n"),
|
|
'\r' => out.push_str("\\r"),
|
|
'\t' => out.push_str("\\t"),
|
|
'\u{08}' => out.push_str("\\b"),
|
|
'\u{0c}' => out.push_str("\\f"),
|
|
c if c < '\u{20}' => write!(out, "\\u{:04x}", c as u32).unwrap(),
|
|
c => out.push(c),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use irtt_client::ClientTimestamp;
|
|
use std::{
|
|
net::{IpAddr, Ipv4Addr, SocketAddr},
|
|
time::Instant,
|
|
};
|
|
|
|
fn output_config(format: OutputFormat, columns: Option<&str>) -> OutputConfig {
|
|
OutputConfig::new(format, columns, HeaderMode::Auto, false).unwrap()
|
|
}
|
|
|
|
fn remote() -> SocketAddr {
|
|
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
|
|
}
|
|
|
|
fn reply() -> ClientEvent {
|
|
let sent_at = ClientTimestamp {
|
|
wall: UNIX_EPOCH + Duration::from_secs(1),
|
|
mono: Instant::now(),
|
|
};
|
|
let received_at = ClientTimestamp {
|
|
wall: sent_at.wall + Duration::from_micros(1200),
|
|
mono: sent_at.mono + Duration::from_micros(1200),
|
|
};
|
|
ClientEvent::EchoReply {
|
|
seq: 7,
|
|
remote: remote(),
|
|
sent_at,
|
|
received_at,
|
|
rtt: RttSample {
|
|
raw: Duration::from_micros(1200),
|
|
adjusted: None,
|
|
effective: SignedDuration::from_nanos(1_200_000),
|
|
},
|
|
server_timing: None,
|
|
one_way: None,
|
|
received_stats: None,
|
|
bytes: 64,
|
|
packet_meta: PacketMeta::default(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn list_columns_includes_target() {
|
|
assert!(OutputConfig::list_columns().contains("target"));
|
|
}
|
|
|
|
#[test]
|
|
fn default_table_columns_always_include_target_first() {
|
|
let header = output_config(OutputFormat::Table, None)
|
|
.render_header()
|
|
.unwrap();
|
|
|
|
assert_eq!(header.split_whitespace().next(), Some("target"));
|
|
}
|
|
|
|
#[test]
|
|
fn default_structured_columns_always_include_target_first() {
|
|
for format in [OutputFormat::Csv, OutputFormat::Tsv, OutputFormat::Jsonl] {
|
|
let columns = default_columns(format, false);
|
|
assert_eq!(columns.first(), Some(&Column::Target));
|
|
assert_eq!(columns, ALL_COLUMNS);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn columns_default_keyword_matches_omitted_columns() {
|
|
for format in [
|
|
OutputFormat::Table,
|
|
OutputFormat::Csv,
|
|
OutputFormat::Tsv,
|
|
OutputFormat::Jsonl,
|
|
] {
|
|
let omitted = output_config(format, None).render_header();
|
|
let explicit = output_config(format, Some("default")).render_header();
|
|
assert_eq!(omitted, explicit);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn custom_columns_do_not_insert_target() {
|
|
let config = output_config(OutputFormat::Csv, Some("seq,effective_rtt_us"));
|
|
let header = config.render_header().unwrap();
|
|
|
|
assert!(!header.split(',').any(|column| column == "target"));
|
|
}
|
|
|
|
#[test]
|
|
fn custom_target_column_renders_label() {
|
|
let config = output_config(OutputFormat::Csv, Some("target,seq,effective_rtt_us"));
|
|
let line = config
|
|
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
config.render_header().as_deref(),
|
|
Some("target,seq,effective_rtt_us")
|
|
);
|
|
assert_eq!(line, "ams,7,1200");
|
|
}
|
|
|
|
#[test]
|
|
fn default_csv_renders_target_label_in_row() {
|
|
let config = output_config(OutputFormat::Csv, None);
|
|
let line = config
|
|
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
|
|
.unwrap();
|
|
|
|
assert_eq!(line.split(',').next(), Some("ams"));
|
|
}
|
|
|
|
#[test]
|
|
fn default_jsonl_renders_target_key_first() {
|
|
let config = output_config(OutputFormat::Jsonl, None);
|
|
let line = config
|
|
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
|
|
.unwrap();
|
|
|
|
assert!(line.starts_with("{\"target\":\"ams\""));
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_for_table_leaves_short_value_unchanged() {
|
|
assert_eq!(truncate_for_table("ams", 18), "ams");
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_for_table_leaves_exact_width_value_unchanged() {
|
|
let value = "a".repeat(18);
|
|
assert_eq!(truncate_for_table(&value, 18), value);
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_for_table_shortens_long_value_to_width() {
|
|
let value = "a".repeat(40);
|
|
let truncated = truncate_for_table(&value, 18);
|
|
|
|
assert_eq!(truncated.chars().count(), 18);
|
|
assert!(truncated.len() <= 18); // ASCII: byte len matches char len
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_for_table_keeps_prefix_and_appends_ellipsis_deterministically() {
|
|
let value = "abcdefghijklmnopqrstuvwxyz";
|
|
let truncated = truncate_for_table(value, 18);
|
|
|
|
assert_eq!(truncated, "abcdefghijklmno...");
|
|
assert_eq!(truncated.chars().count(), 18);
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_for_table_does_not_split_multibyte_code_points() {
|
|
// Each character below is multibyte in UTF-8; a byte-index slice
|
|
// would panic or corrupt the string on a non-boundary cut.
|
|
let value = "\u{1F600}".repeat(40); // 😀 repeated, well past width 18
|
|
let truncated = truncate_for_table(&value, 18);
|
|
|
|
// Must not panic (validated by test completing) and must remain
|
|
// valid UTF-8 with exactly 18 chars (15 prefix chars + "...").
|
|
assert_eq!(truncated.chars().count(), 18);
|
|
assert!(truncated.ends_with("..."));
|
|
assert_eq!(truncated.chars().filter(|c| *c == '\u{1F600}').count(), 15);
|
|
}
|
|
|
|
#[test]
|
|
fn table_row_with_long_target_keeps_next_column_aligned() {
|
|
let config = output_config(OutputFormat::Table, None);
|
|
let long_target = "a".repeat(40);
|
|
let header = config.render_header().unwrap();
|
|
let line = config
|
|
.render_event(
|
|
&reply(),
|
|
Some(&long_target),
|
|
Some(&EventRenderStats::default()),
|
|
)
|
|
.unwrap();
|
|
|
|
// Target column is truncated to its table width (18), so the
|
|
// "event" column starts at the same offset for both a normal and an
|
|
// overlong target label.
|
|
let short_config = output_config(OutputFormat::Table, None);
|
|
let short_line = short_config
|
|
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
|
|
.unwrap();
|
|
|
|
let target_width = Column::Target.table_width();
|
|
let expected_event_offset = target_width + " ".len();
|
|
assert_eq!(&header[expected_event_offset..][..5], "event");
|
|
// The event column ("echo_reply") starts at the same offset
|
|
// regardless of whether the target label was short or overlong.
|
|
assert_eq!(
|
|
&line[expected_event_offset..][.."echo_reply".len()],
|
|
"echo_reply"
|
|
);
|
|
assert_eq!(
|
|
&short_line[expected_event_offset..][.."echo_reply".len()],
|
|
"echo_reply"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn csv_preserves_full_long_target_label() {
|
|
let config = output_config(OutputFormat::Csv, Some("target,seq"));
|
|
let long_target = "a".repeat(40);
|
|
let line = config
|
|
.render_event(
|
|
&reply(),
|
|
Some(&long_target),
|
|
Some(&EventRenderStats::default()),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(line, format!("{long_target},7"));
|
|
}
|
|
|
|
#[test]
|
|
fn tsv_preserves_full_long_target_label() {
|
|
let config = output_config(OutputFormat::Tsv, Some("target,seq"));
|
|
let long_target = "b".repeat(40);
|
|
let line = config
|
|
.render_event(
|
|
&reply(),
|
|
Some(&long_target),
|
|
Some(&EventRenderStats::default()),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(line, format!("{long_target}\t7"));
|
|
}
|
|
|
|
#[test]
|
|
fn jsonl_preserves_full_long_target_label() {
|
|
let config = output_config(OutputFormat::Jsonl, Some("target,seq"));
|
|
let long_target = "c".repeat(40);
|
|
let line = config
|
|
.render_event(
|
|
&reply(),
|
|
Some(&long_target),
|
|
Some(&EventRenderStats::default()),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(line, format!("{{\"target\":\"{long_target}\",\"seq\":7}}"));
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_table_columns_with_target_truncate_same_as_default() {
|
|
let config = output_config(OutputFormat::Table, Some("target,seq"));
|
|
let long_target = "d".repeat(40);
|
|
let line = config
|
|
.render_event(
|
|
&reply(),
|
|
Some(&long_target),
|
|
Some(&EventRenderStats::default()),
|
|
)
|
|
.unwrap();
|
|
|
|
let target_width = Column::Target.table_width();
|
|
let expected_prefix: String = long_target
|
|
.chars()
|
|
.take(target_width - 3)
|
|
.chain("...".chars())
|
|
.collect();
|
|
assert!(line.starts_with(&expected_prefix));
|
|
assert_eq!(&line[..target_width], expected_prefix.as_str());
|
|
}
|
|
}
|