From 181b796e8d6c30f78f69c9d6a1f6ec39e56fc456 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Fri, 1 May 2026 22:10:57 +0700 Subject: [PATCH] feat(cli): implement summary formatting for statistics --- crates/irtt-cli/src/summary.rs | 268 +++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 crates/irtt-cli/src/summary.rs diff --git a/crates/irtt-cli/src/summary.rs b/crates/irtt-cli/src/summary.rs new file mode 100644 index 0000000..de02c6f --- /dev/null +++ b/crates/irtt-cli/src/summary.rs @@ -0,0 +1,268 @@ +use std::fmt::Write as _; + +use irtt_stats::{ + DurationStats, DurationStatsWithMedian, FiniteSummary, SignedDurationStatsWithMedian, +}; + +pub fn format_summary(summary: &FiniteSummary) -> String { + let mut out = String::new(); + let packets = summary.packets; + let loss = summary.loss; + + writeln!(out).unwrap(); + writeln!(out, "--- irtt-rs statistics ---").unwrap(); + writeln!( + out, + "packets: sent={} received={} lost={} loss={}", + packets.packets_sent, + packets.packets_received, + loss.lost_packets, + format_percent(loss.packet_loss_percent) + ) + .unwrap(); + writeln!( + out, + "replies: unique={} duplicates={} ({}) late={} ({})", + packets.unique_replies, + packets.duplicates, + format_percent(loss.duplicate_percent), + packets.late_packets, + format_percent(loss.late_packets_percent) + ) + .unwrap(); + writeln!( + out, + "bytes: sent={} received={}", + packets.bytes_sent, packets.bytes_received + ) + .unwrap(); + + if let Some(count) = packets.server_packets_received { + writeln!(out, "server_received_count: {count}").unwrap(); + } + if let Some(window) = packets.server_received_window { + writeln!(out, "server_received_window: {window:#x}").unwrap(); + } + + if let Some(line) = format_signed_duration_stats("rtt", &summary.rtt.primary) { + writeln!(out, "{line}").unwrap(); + } + if let Some(line) = format_duration_stats("raw_rtt", &summary.rtt.raw) { + writeln!(out, "{line}").unwrap(); + } + if let Some(line) = format_signed_duration_stats("adjusted_rtt", &summary.rtt.adjusted) { + writeln!(out, "{line}").unwrap(); + } + if let Some(line) = format_duration_stats("ipdv", &summary.ipdv.round_trip) { + writeln!(out, "{line}").unwrap(); + } + if let Some(line) = format_duration_stats("send_ipdv", &summary.ipdv.send) { + writeln!(out, "{line}").unwrap(); + } + if let Some(line) = format_duration_stats("receive_ipdv", &summary.ipdv.receive) { + writeln!(out, "{line}").unwrap(); + } + if let Some(line) = format_duration_stats("send_delay", &summary.one_way_delay.send_delay) { + writeln!(out, "{line}").unwrap(); + } + if let Some(line) = format_duration_stats("receive_delay", &summary.one_way_delay.receive_delay) + { + writeln!(out, "{line}").unwrap(); + } + if let Some(line) = + format_duration_stats_no_median("server_processing", &summary.server_processing.processing) + { + writeln!(out, "{line}").unwrap(); + } + if let Some(line) = format_duration_stats_no_median("send_call", &summary.send_call) { + writeln!(out, "{line}").unwrap(); + } + if let Some(line) = format_duration_stats_no_median("timer_error", &summary.timer_error) { + writeln!(out, "{line}").unwrap(); + } + + out +} + +fn format_duration_stats(label: &str, value: &DurationStatsWithMedian) -> Option { + if value.stats.count == 0 { + return None; + } + Some(format!( + "{}: n={} min={} mean={} median={} max={} stddev={}", + label, + value.stats.count, + format_ns_u64(value.stats.min_ns), + format_ns_f64(value.stats.mean_ns), + format_ns_f64_opt(value.median_ns), + format_ns_u64(value.stats.max_ns), + format_ns_f64(value.stddev_ns()) + )) +} + +fn format_duration_stats_no_median(label: &str, value: &DurationStats) -> Option { + if value.count == 0 { + return None; + } + Some(format!( + "{}: n={} min={} mean={} max={} stddev={}", + label, + value.count, + format_ns_u64(value.min_ns), + format_ns_f64(value.mean_ns), + format_ns_u64(value.max_ns), + format_ns_f64(value.stddev_ns()) + )) +} + +fn format_signed_duration_stats( + label: &str, + value: &SignedDurationStatsWithMedian, +) -> Option { + if value.stats.count == 0 { + return None; + } + Some(format!( + "{}: n={} min={} mean={} median={} max={} stddev={}", + label, + value.stats.count, + format_ns_i128(value.stats.min_ns), + format_ns_f64(value.stats.mean_ns), + format_ns_f64_opt(value.median_ns), + format_ns_i128(value.stats.max_ns), + format_ns_f64(value.stddev_ns()) + )) +} + +fn format_percent(value: f64) -> String { + format!("{value:.2}%") +} + +fn format_ns_u64(value: Option) -> String { + value + .map(|value| format_ns_f64(value as f64)) + .unwrap_or_else(|| "-".to_owned()) +} + +fn format_ns_i128(value: Option) -> String { + value + .map(|value| format_ns_f64(value as f64)) + .unwrap_or_else(|| "-".to_owned()) +} + +fn format_ns_f64_opt(value: Option) -> String { + value.map(format_ns_f64).unwrap_or_else(|| "-".to_owned()) +} + +fn format_ns_f64(value: f64) -> String { + let us = value / 1_000.0; + format!("{us:.3} us") +} + +#[cfg(test)] +mod tests { + use super::*; + use irtt_client::{ + ClientEvent, ClientTimestamp, PacketMeta, RttSample, ServerTiming, SignedDuration, + }; + use irtt_stats::{SignedDurationStats, StatsCollector, StatsConfig}; + use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr}, + time::{Duration, Instant, UNIX_EPOCH}, + }; + + fn test_timestamp(offset: Duration) -> ClientTimestamp { + ClientTimestamp { + wall: UNIX_EPOCH + offset, + mono: Instant::now() + offset, + } + } + + fn test_remote() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112) + } + + #[test] + fn empty_summary_omits_optional_metric_sections() { + let summary = StatsCollector::new(StatsConfig::finite()).summary(); + let output = format_summary(&summary); + + assert!(output.contains("packets: sent=0 received=0 lost=0")); + assert!(!output.contains("rtt: n=")); + assert!(!output.contains("server_processing: n=")); + } + + #[test] + fn summary_formats_counts_and_available_metrics() { + let mut collector = StatsCollector::new(StatsConfig::finite()); + let sent_at = test_timestamp(Duration::from_secs(1)); + let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500)); + + collector.process(&ClientEvent::EchoSent { + seq: 1, + logical_seq: 1, + remote: test_remote(), + scheduled_at: sent_at.mono, + sent_at, + bytes: 64, + send_call: Duration::from_micros(10), + timer_error: Duration::from_micros(2), + }); + collector.process(&ClientEvent::EchoReply { + seq: 1, + logical_seq: 1, + remote: test_remote(), + sent_at, + received_at, + rtt: RttSample { + raw: Duration::from_micros(1500), + adjusted: Some(Duration::from_micros(1200)), + effective: Duration::from_micros(1200), + adjusted_signed: Some(SignedDuration { ns: 1_200_000 }), + effective_signed: SignedDuration { ns: 1_200_000 }, + }, + server_timing: Some(ServerTiming { + receive_wall_ns: None, + receive_mono_ns: None, + send_wall_ns: None, + send_mono_ns: None, + midpoint_wall_ns: None, + midpoint_mono_ns: None, + processing: Some(Duration::from_micros(300)), + }), + one_way: None, + received_stats: None, + bytes: 64, + packet_meta: PacketMeta::default(), + }); + + let output = format_summary(&collector.summary()); + + assert!(output.contains("packets: sent=1 received=1 lost=0 loss=0.00%")); + assert!(output.contains("bytes: sent=64 received=64")); + assert!(output.contains("rtt: n=1 min=1200.000 us")); + assert!(output.contains("server_processing: n=1 min=300.000 us")); + assert!(output.contains("send_call: n=1 min=10.000 us")); + assert!(output.contains("timer_error: n=1 min=2.000 us")); + } + + #[test] + fn signed_stats_can_format_negative_values() { + let stats = SignedDurationStatsWithMedian { + stats: SignedDurationStats { + count: 1, + total_ns: -500, + min_ns: Some(-500), + max_ns: Some(-500), + mean_ns: -500.0, + variance_ns2: 0.0, + }, + median_ns: Some(-500.0), + }; + + let line = format_signed_duration_stats("rtt", &stats).unwrap(); + assert!(line.contains("min=-0.500 us")); + assert!(line.contains("mean=-0.500 us")); + assert!(line.contains("median=-0.500 us")); + } +}