Skip to main content

irtt_cli/
summary.rs

1use std::fmt::Write as _;
2
3use irtt_stats::{
4    DurationStats, DurationStatsWithMedian, FiniteSummary, SignedDurationStatsWithMedian,
5};
6
7pub fn format_summary(summary: &FiniteSummary) -> String {
8    format_summary_with_options(summary, SummaryFormatOptions::default())
9}
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub struct SummaryFormatOptions {
13    pub verbose: bool,
14}
15
16pub fn format_summary_with_options(
17    summary: &FiniteSummary,
18    options: SummaryFormatOptions,
19) -> String {
20    let mut out = String::new();
21    let packets = summary.packets;
22    let loss = summary.loss;
23
24    writeln!(out).unwrap();
25    writeln!(out, "irtt-rs summary").unwrap();
26    writeln!(out).unwrap();
27    writeln!(
28        out,
29        "  {:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
30        "Metric", "Count", "Min", "Mean", "Median", "Max", "Stddev"
31    )
32    .unwrap();
33    writeln!(out, "  {}", "-".repeat(82)).unwrap();
34
35    write_signed_duration_row(&mut out, "RTT", &summary.rtt.primary);
36    if options.verbose {
37        write_duration_row(&mut out, "raw RTT", &summary.rtt.raw);
38        write_signed_duration_row(&mut out, "adjusted RTT", &summary.rtt.adjusted);
39    }
40    write_duration_row(&mut out, "IPDV/jitter", &summary.ipdv.round_trip);
41    write_duration_row(&mut out, "send IPDV", &summary.ipdv.send);
42    write_duration_row(&mut out, "receive IPDV", &summary.ipdv.receive);
43    write_duration_row(&mut out, "send delay", &summary.one_way_delay.send_delay);
44    write_duration_row(
45        &mut out,
46        "receive delay",
47        &summary.one_way_delay.receive_delay,
48    );
49    write_duration_row_no_median(
50        &mut out,
51        "server processing",
52        &summary.server_processing.processing,
53    );
54    write_duration_row_no_median(&mut out, "send call", &summary.send_call);
55    write_duration_row_no_median(&mut out, "timer error", &summary.timer_error);
56
57    writeln!(out).unwrap();
58    writeln!(
59        out,
60        "packets: sent={} received={} unique={} lost={} loss={}",
61        packets.packets_sent,
62        packets.packets_received,
63        packets.unique_replies,
64        loss.lost_packets,
65        format_percent(loss.packet_loss_percent)
66    )
67    .unwrap();
68    if packets.duplicates != 0 || packets.late_packets != 0 {
69        writeln!(
70            out,
71            "replies: duplicates={} ({}) late={} ({})",
72            packets.duplicates,
73            format_percent(loss.duplicate_percent),
74            packets.late_packets,
75            format_percent(loss.late_packets_percent)
76        )
77        .unwrap();
78    }
79    writeln!(
80        out,
81        "bytes: sent={} received={}",
82        packets.bytes_sent, packets.bytes_received
83    )
84    .unwrap();
85
86    if packets.server_packets_received.is_some() || packets.server_received_window.is_some() {
87        write!(out, "server:").unwrap();
88        if let Some(count) = packets.server_packets_received {
89            write!(out, " received={count}").unwrap();
90        }
91        if let Some(window) = packets.server_received_window {
92            write!(out, " window={window:#x}").unwrap();
93        }
94        writeln!(out).unwrap();
95    }
96
97    out
98}
99
100fn write_duration_row(out: &mut String, label: &str, value: &DurationStatsWithMedian) {
101    if value.stats.count == 0 {
102        return;
103    }
104    writeln!(
105        out,
106        "  {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
107        value.stats.count,
108        format_ns_u64(value.stats.min_ns),
109        format_ns_f64(value.stats.mean_ns),
110        format_ns_f64_opt(value.median_ns),
111        format_ns_u64(value.stats.max_ns),
112        format_ns_f64(value.stddev_ns())
113    )
114    .unwrap();
115}
116
117fn write_duration_row_no_median(out: &mut String, label: &str, value: &DurationStats) {
118    if value.count == 0 {
119        return;
120    }
121    writeln!(
122        out,
123        "  {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
124        value.count,
125        format_ns_u64(value.min_ns),
126        format_ns_f64(value.mean_ns),
127        "-",
128        format_ns_u64(value.max_ns),
129        format_ns_f64(value.stddev_ns())
130    )
131    .unwrap();
132}
133
134fn write_signed_duration_row(out: &mut String, label: &str, value: &SignedDurationStatsWithMedian) {
135    if value.stats.count == 0 {
136        return;
137    }
138    writeln!(
139        out,
140        "  {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
141        value.stats.count,
142        format_ns_i128(value.stats.min_ns),
143        format_ns_f64(value.stats.mean_ns),
144        format_ns_f64_opt(value.median_ns),
145        format_ns_i128(value.stats.max_ns),
146        format_ns_f64(value.stddev_ns())
147    )
148    .unwrap();
149}
150
151fn format_percent(value: f64) -> String {
152    format!("{value:.2}%")
153}
154
155fn format_ns_u64(value: Option<u64>) -> String {
156    value
157        .map(|value| format_ns_f64(value as f64))
158        .unwrap_or_else(|| "-".to_owned())
159}
160
161fn format_ns_i128(value: Option<i128>) -> String {
162    value
163        .map(|value| format_ns_f64(value as f64))
164        .unwrap_or_else(|| "-".to_owned())
165}
166
167fn format_ns_f64_opt(value: Option<f64>) -> String {
168    value.map(format_ns_f64).unwrap_or_else(|| "-".to_owned())
169}
170
171fn format_ns_f64(value: f64) -> String {
172    let sign = if value.is_sign_negative() { "-" } else { "" };
173    let value = value.abs();
174    if value < 1_000.0 {
175        format!("{sign}{value:.0}ns")
176    } else if value < 1_000_000.0 {
177        format!("{sign}{:.1}µs", value / 1_000.0)
178    } else if value < 1_000_000_000.0 {
179        format!("{sign}{:.1}ms", value / 1_000_000.0)
180    } else {
181        format!("{sign}{:.3}s", value / 1_000_000_000.0)
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use irtt_client::{
189        ClientEvent, ClientTimestamp, PacketMeta, RttSample, ServerTiming, SignedDuration,
190    };
191    use irtt_stats::{SignedDurationStats, StatsCollector, StatsConfig};
192    use std::{
193        net::{IpAddr, Ipv4Addr, SocketAddr},
194        time::{Duration, Instant, UNIX_EPOCH},
195    };
196
197    fn test_timestamp(offset: Duration) -> ClientTimestamp {
198        ClientTimestamp {
199            wall: UNIX_EPOCH + offset,
200            mono: Instant::now() + offset,
201        }
202    }
203
204    fn test_remote() -> SocketAddr {
205        SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
206    }
207
208    #[test]
209    fn empty_summary_omits_optional_metric_sections() {
210        let summary = StatsCollector::new(StatsConfig::finite()).summary();
211        let output = format_summary(&summary);
212
213        assert!(output.contains("Metric"));
214        assert!(output.contains("Min"));
215        assert!(output.contains("Mean"));
216        assert!(output.contains("Median"));
217        assert!(output.contains("Max"));
218        assert!(output.contains("Stddev"));
219        assert!(output.contains("packets: sent=0 received=0 unique=0 lost=0"));
220        assert!(!output.contains("RTT                  0"));
221        assert!(!output.contains("server processing"));
222    }
223
224    #[test]
225    fn summary_formats_counts_and_available_metrics() {
226        let mut collector = StatsCollector::new(StatsConfig::finite());
227        let sent_at = test_timestamp(Duration::from_secs(1));
228        let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
229
230        collector.process(&ClientEvent::EchoSent {
231            seq: 1,
232            logical_seq: 1,
233            remote: test_remote(),
234            scheduled_at: sent_at.mono,
235            sent_at,
236            bytes: 64,
237            send_call: Duration::from_micros(10),
238            timer_error: Duration::from_micros(2),
239        });
240        collector.process(&ClientEvent::EchoReply {
241            seq: 1,
242            logical_seq: 1,
243            remote: test_remote(),
244            sent_at,
245            received_at,
246            rtt: RttSample {
247                raw: Duration::from_micros(1500),
248                adjusted: Some(Duration::from_micros(1200)),
249                effective: Duration::from_micros(1200),
250                adjusted_signed: Some(SignedDuration { ns: 1_200_000 }),
251                effective_signed: SignedDuration { ns: 1_200_000 },
252            },
253            server_timing: Some(ServerTiming {
254                receive_wall_ns: None,
255                receive_mono_ns: None,
256                send_wall_ns: None,
257                send_mono_ns: None,
258                midpoint_wall_ns: None,
259                midpoint_mono_ns: None,
260                processing: Some(Duration::from_micros(300)),
261            }),
262            one_way: None,
263            received_stats: None,
264            bytes: 64,
265            packet_meta: PacketMeta::default(),
266        });
267
268        let output = format_summary(&collector.summary());
269
270        assert!(output.contains("packets: sent=1 received=1 unique=1 lost=0 loss=0.00%"));
271        assert!(output.contains("bytes: sent=64 received=64"));
272        assert!(output.contains("RTT"));
273        assert!(output.contains("1.2ms"));
274        assert!(!output.contains("raw RTT"));
275        assert!(!output.contains("adjusted RTT"));
276        assert!(output.contains("server processing"));
277        assert!(output.contains("300.0µs"));
278        assert!(output.contains("send call"));
279        assert!(output.contains("10.0µs"));
280        assert!(output.contains("timer error"));
281        assert!(output.contains("2.0µs"));
282    }
283
284    #[test]
285    fn verbose_summary_includes_raw_and_adjusted_rtt_rows() {
286        let mut collector = StatsCollector::new(StatsConfig::finite());
287        let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
288        collector.process(&ClientEvent::EchoReply {
289            seq: 1,
290            logical_seq: 1,
291            remote: test_remote(),
292            sent_at: test_timestamp(Duration::from_secs(1)),
293            received_at,
294            rtt: RttSample {
295                raw: Duration::from_micros(1500),
296                adjusted: Some(Duration::from_micros(1200)),
297                effective: Duration::from_micros(1200),
298                adjusted_signed: Some(SignedDuration { ns: 1_200_000 }),
299                effective_signed: SignedDuration { ns: 1_200_000 },
300            },
301            server_timing: None,
302            one_way: None,
303            received_stats: None,
304            bytes: 64,
305            packet_meta: PacketMeta::default(),
306        });
307
308        let output = format_summary_with_options(
309            &collector.summary(),
310            SummaryFormatOptions { verbose: true },
311        );
312
313        assert!(output.contains("raw RTT"));
314        assert!(output.contains("adjusted RTT"));
315    }
316
317    #[test]
318    fn signed_stats_can_format_negative_values() {
319        let stats = SignedDurationStatsWithMedian {
320            stats: SignedDurationStats {
321                count: 1,
322                total_ns: -500,
323                min_ns: Some(-500),
324                max_ns: Some(-500),
325                mean_ns: -500.0,
326                variance_ns2: 0.0,
327            },
328            median_ns: Some(-500.0),
329        };
330
331        let mut out = String::new();
332        write_signed_duration_row(&mut out, "RTT", &stats);
333        assert!(out.contains("-500ns"));
334    }
335}