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}