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, header: HeaderMode, default_table_rows: bool, verbose: bool, } impl OutputConfig { pub(super) fn new( format: OutputFormat, columns: Option<&str>, header: HeaderMode, verbose: bool, ) -> Result { 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 { 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::>() .join(","), ), OutputFormat::Tsv => Some( self.columns .iter() .map(|column| escape_tsv(column.name())) .collect::>() .join("\t"), ), OutputFormat::Jsonl => None, } } pub(super) fn render_event( &self, event: &ClientEvent, target: Option<&str>, stats: Option<&EventRenderStats>, ) -> Option { 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, } #[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, pub receive_ipdv: Option, } impl From 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 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, event_wall: SystemTime, duration_ns: i128, interval_ns: i128, payload_length: i128, } impl LifecycleRow { fn new( remote: SocketAddr, token: Option, 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, client_receive_wall: SystemTime, rtt: Option, server_timing: Option, one_way: Option, received_stats: Option, 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 { 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) -> 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, 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 { 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::>() .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::>() .join(" ") } fn format_table_cell(column: &Column, value: Option) -> 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: 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::>() .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 { 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 { 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 { 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 { match row { OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => row.token, OutputRow::SessionClosed { token, .. } => Some(*token), _ => None, } } fn row_bytes(row: &OutputRow) -> Option { 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 { 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 { 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 { 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 { 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, ) -> Option { 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 { 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()); } }