Skip to main content

irtt_cli/
lib.rs

1#![forbid(unsafe_code)]
2
3#[cfg(feature = "stats")]
4pub mod summary;
5
6use std::{
7    fmt::Write as _,
8    net::SocketAddr,
9    time::{Duration, SystemTime, UNIX_EPOCH},
10};
11
12use clap::{Parser, ValueEnum};
13use irtt_client::{
14    ClientConfig, ClientEvent, NegotiatedParams, NegotiationPolicy, OneWayDelaySample, PacketMeta,
15    ReceivedStatsSample, RttSample, ServerTiming, SocketConfig, WarningKind, MAX_DSCP_CODEPOINT,
16    MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH,
17};
18use irtt_proto::{Clock, ReceivedStats, StampAt};
19
20const DEFAULT_RECV_TIMEOUT: Duration = Duration::from_millis(20);
21#[derive(Debug, Clone, Parser)]
22#[command(name = "irtt-rs", about = "Minimal IRTT-compatible client")]
23pub struct CliArgs {
24    /// Server address or host, with optional port.
25    pub server: String,
26
27    /// Test duration; use 0 for continuous mode.
28    #[arg(long, default_value = "10s", value_parser = parse_test_duration)]
29    pub duration: Duration,
30
31    /// Probe interval.
32    #[arg(long, default_value = "1s", value_parser = parse_duration)]
33    pub interval: Duration,
34
35    /// UDP payload length.
36    #[arg(long, default_value_t = 0, value_parser = parse_length)]
37    pub length: u32,
38
39    /// HMAC key.
40    #[arg(long)]
41    pub hmac: Option<String>,
42
43    /// Clock mode to request.
44    #[arg(long, value_enum, default_value_t = ClockArg::Both)]
45    pub clock: ClockArg,
46
47    /// Timestamp mode to request.
48    #[arg(
49        long = "tstamp",
50        visible_alias = "timestamps",
51        value_enum,
52        value_name = "MODE",
53        default_value_t = TimestampArg::Both
54    )]
55    pub tstamp: TimestampArg,
56
57    /// Received-stats mode to request.
58    #[arg(
59        long = "stats",
60        value_enum,
61        default_value_t = ReceivedStatsArg::Both,
62        help = "Server received-stats negotiation mode"
63    )]
64    pub stats: ReceivedStatsArg,
65
66    /// Server payload fill string to request, up to 32 bytes.
67    #[arg(
68        long = "sfill",
69        visible_alias = "server-fill",
70        value_name = "STRING",
71        value_parser = parse_server_fill
72    )]
73    pub server_fill: Option<String>,
74
75    /// DSCP codepoint to request; this is not a raw TOS or Traffic Class byte.
76    #[arg(long, default_value_t = 0, value_name = "0..=63", value_parser = parse_dscp)]
77    pub dscp: u8,
78
79    /// Local outgoing IPv4 TTL or IPv6 unicast hop limit; not negotiated.
80    #[arg(long, value_name = "1..=255", value_parser = parse_ttl)]
81    pub ttl: Option<u32>,
82
83    /// Accept safe server restrictions during negotiation.
84    #[arg(long)]
85    pub loose: bool,
86
87    /// Output format: human, simple, machine, or rtt-us.
88    #[arg(long, value_enum, default_value_t = OutputMode::Human)]
89    pub output: OutputMode,
90
91    /// Include extra fields in human output.
92    #[arg(long)]
93    pub verbose: bool,
94}
95
96impl CliArgs {
97    pub fn to_client_config(&self) -> ClientConfig {
98        ClientConfig {
99            server_addr: self.server.clone(),
100            duration: (!self.is_continuous()).then_some(self.duration),
101            interval: self.interval,
102            length: self.length,
103            received_stats: self.stats.into(),
104            stamp_at: self.timestamp_mode().into(),
105            clock: self.clock.into(),
106            dscp: self.dscp,
107            hmac_key: self.hmac.as_ref().map(|key| key.as_bytes().to_vec()),
108            server_fill: self.server_fill.clone(),
109            negotiation_policy: if self.loose {
110                NegotiationPolicy::Loose
111            } else {
112                NegotiationPolicy::Strict
113            },
114            socket_config: SocketConfig {
115                ttl: self.ttl,
116                recv_timeout: Some(DEFAULT_RECV_TIMEOUT),
117                ..SocketConfig::default()
118            },
119            ..ClientConfig::default()
120        }
121    }
122
123    pub fn is_continuous(&self) -> bool {
124        self.duration == Duration::ZERO
125    }
126
127    pub fn timestamp_mode(&self) -> TimestampArg {
128        self.tstamp
129    }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
133pub enum ClockArg {
134    Wall,
135    Monotonic,
136    Both,
137}
138
139impl From<ClockArg> for Clock {
140    fn from(value: ClockArg) -> Self {
141        match value {
142            ClockArg::Wall => Self::Wall,
143            ClockArg::Monotonic => Self::Monotonic,
144            ClockArg::Both => Self::Both,
145        }
146    }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
150pub enum TimestampArg {
151    None,
152    Send,
153    Receive,
154    Both,
155    Midpoint,
156}
157
158impl From<TimestampArg> for StampAt {
159    fn from(value: TimestampArg) -> Self {
160        match value {
161            TimestampArg::None => Self::None,
162            TimestampArg::Send => Self::Send,
163            TimestampArg::Receive => Self::Receive,
164            TimestampArg::Both => Self::Both,
165            TimestampArg::Midpoint => Self::Midpoint,
166        }
167    }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
171pub enum ReceivedStatsArg {
172    None,
173    Count,
174    Window,
175    Both,
176}
177
178impl From<ReceivedStatsArg> for ReceivedStats {
179    fn from(value: ReceivedStatsArg) -> Self {
180        match value {
181            ReceivedStatsArg::None => Self::None,
182            ReceivedStatsArg::Count => Self::Count,
183            ReceivedStatsArg::Window => Self::Window,
184            ReceivedStatsArg::Both => Self::Both,
185        }
186    }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
190pub enum OutputMode {
191    /// Readable terminal output with a final summary.
192    Human,
193    /// Parseable full event fields.
194    Machine,
195    /// Simple key=value-ish event stream.
196    Simple,
197    /// RTT microseconds only.
198    RttUs,
199}
200
201impl OutputMode {
202    pub fn prints_summary(self) -> bool {
203        matches!(self, Self::Human)
204    }
205}
206
207pub fn parse_duration(input: &str) -> Result<Duration, String> {
208    let (number, unit) = split_duration(input)?;
209    let value: u64 = number
210        .parse()
211        .map_err(|_| format!("invalid duration value {input:?}"))?;
212    if value == 0 {
213        return Err("duration must be greater than zero".to_owned());
214    }
215    match unit {
216        "ms" => Ok(Duration::from_millis(value)),
217        "s" => Ok(Duration::from_secs(value)),
218        "m" => value
219            .checked_mul(60)
220            .map(Duration::from_secs)
221            .ok_or_else(|| "duration is too large".to_owned()),
222        _ => Err(format!(
223            "unsupported duration unit {unit:?}; use ms, s, or m"
224        )),
225    }
226}
227
228pub fn parse_test_duration(input: &str) -> Result<Duration, String> {
229    if input == "0" {
230        return Ok(Duration::ZERO);
231    }
232    let (number, unit) = split_duration(input)?;
233    let value: u64 = number
234        .parse()
235        .map_err(|_| format!("invalid duration value {input:?}"))?;
236    if value == 0 {
237        return Ok(Duration::ZERO);
238    }
239    match unit {
240        "ms" => Ok(Duration::from_millis(value)),
241        "s" => Ok(Duration::from_secs(value)),
242        "m" => value
243            .checked_mul(60)
244            .map(Duration::from_secs)
245            .ok_or_else(|| "duration is too large".to_owned()),
246        _ => Err(format!(
247            "unsupported duration unit {unit:?}; use ms, s, or m"
248        )),
249    }
250}
251
252fn split_duration(input: &str) -> Result<(&str, &str), String> {
253    let split = input
254        .find(|ch: char| !ch.is_ascii_digit())
255        .ok_or_else(|| "duration must include a unit: ms, s, or m".to_owned())?;
256    let (number, unit) = input.split_at(split);
257    if number.is_empty() || unit.is_empty() {
258        return Err("duration must include a positive value and unit".to_owned());
259    }
260    if number.starts_with('-') {
261        return Err("duration must be greater than zero".to_owned());
262    }
263    Ok((number, unit))
264}
265
266pub fn parse_length(input: &str) -> Result<u32, String> {
267    let length: u32 = input
268        .parse()
269        .map_err(|_| format!("invalid packet length {input:?}"))?;
270    if length > MAX_UDP_PAYLOAD_LENGTH {
271        return Err(format!("packet length must be <= {MAX_UDP_PAYLOAD_LENGTH}"));
272    }
273    Ok(length)
274}
275
276pub fn parse_server_fill(input: &str) -> Result<String, String> {
277    if input.is_empty() {
278        return Err("server fill must not be empty".to_owned());
279    }
280    let len = input.len();
281    if len > MAX_SERVER_FILL_BYTES {
282        return Err(format!(
283            "server fill must be <= {MAX_SERVER_FILL_BYTES} bytes, got {len}"
284        ));
285    }
286    Ok(input.to_owned())
287}
288
289pub fn parse_dscp(input: &str) -> Result<u8, String> {
290    let value: u8 = input
291        .parse()
292        .map_err(|_| format!("invalid DSCP codepoint {input:?}"))?;
293    if value > MAX_DSCP_CODEPOINT {
294        return Err(format!("DSCP codepoint must be <= {MAX_DSCP_CODEPOINT}"));
295    }
296    Ok(value)
297}
298
299pub fn parse_ttl(input: &str) -> Result<u32, String> {
300    let value: u32 = input
301        .parse()
302        .map_err(|_| format!("invalid TTL/hop limit {input:?}"))?;
303    if value == 0 || value > MAX_TTL {
304        return Err(format!("TTL/hop limit must be in range 1..={MAX_TTL}"));
305    }
306    Ok(value)
307}
308
309pub fn format_event(event: &ClientEvent, mode: OutputMode) -> Option<String> {
310    if matches!(event, ClientEvent::EchoSent { .. }) {
311        return None;
312    }
313    match mode {
314        OutputMode::RttUs => format_rtt_us(event),
315        OutputMode::Human => Some(format_human_event(event, None)),
316        OutputMode::Machine => Some(format_machine(event)),
317        OutputMode::Simple => Some(format_simple(event)),
318    }
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Default)]
322pub struct HumanEventStats {
323    pub contributed_sample: bool,
324    pub ipdv_pairs: Vec<HumanIpdvPair>,
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub struct HumanIpdvPair {
329    pub previous_logical_seq: u64,
330    pub current_logical_seq: u64,
331    pub rtt_ipdv: Duration,
332    pub send_ipdv: Option<Duration>,
333    pub receive_ipdv: Option<Duration>,
334}
335
336#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
337pub struct HumanOutputOptions {
338    pub verbose: bool,
339}
340
341#[cfg(feature = "stats")]
342impl From<irtt_stats::EventStatsUpdate> for HumanEventStats {
343    fn from(value: irtt_stats::EventStatsUpdate) -> Self {
344        Self {
345            contributed_sample: value.contributed_sample,
346            ipdv_pairs: value.ipdv_pairs.into_iter().map(Into::into).collect(),
347        }
348    }
349}
350
351#[cfg(feature = "stats")]
352impl From<irtt_stats::IpdvPairUpdate> for HumanIpdvPair {
353    fn from(value: irtt_stats::IpdvPairUpdate) -> Self {
354        Self {
355            previous_logical_seq: value.previous_logical_seq,
356            current_logical_seq: value.current_logical_seq,
357            rtt_ipdv: value.rtt_ipdv,
358            send_ipdv: value.send_ipdv,
359            receive_ipdv: value.receive_ipdv,
360        }
361    }
362}
363
364pub fn format_human_event(event: &ClientEvent, stats: Option<HumanEventStats>) -> String {
365    format_human_event_with_options(event, stats, HumanOutputOptions::default())
366}
367
368pub fn format_human_event_with_options(
369    event: &ClientEvent,
370    stats: Option<HumanEventStats>,
371    options: HumanOutputOptions,
372) -> String {
373    let stats = stats.as_ref();
374
375    match event {
376        ClientEvent::SessionStarted { remote, token, .. } => {
377            format!("session started  remote={remote}  token={token:#x}")
378        }
379        ClientEvent::NoTestCompleted { remote, .. } => {
380            format!("no-test completed  remote={remote}")
381        }
382        ClientEvent::SessionClosed { remote, token, .. } => {
383            format!("session closed  remote={remote}  token={token:#x}")
384        }
385        ClientEvent::EchoSent { .. } => String::new(),
386        ClientEvent::EchoReply {
387            seq,
388            logical_seq,
389            rtt,
390            server_timing,
391            one_way,
392            received_stats,
393            ..
394        } => {
395            let mut out = format!("seq={seq}");
396            if options.verbose {
397                write!(out, "  logical_seq={logical_seq}").unwrap();
398            }
399            write!(out, "  rtt={}", format_duration(rtt.effective)).unwrap();
400            write_human_one_way(&mut out, *one_way);
401            write!(
402                out,
403                "  ipdv={}",
404                format_human_ipdv(stats, Some(*logical_seq))
405            )
406            .unwrap();
407            if let Some(processing) = server_timing.and_then(|timing| timing.processing) {
408                write!(out, "  proc={}", format_duration(processing)).unwrap();
409            }
410            if options.verbose {
411                write_human_received_stats(&mut out, *received_stats);
412            }
413            out
414        }
415        ClientEvent::EchoLoss {
416            seq, logical_seq, ..
417        } => {
418            format!("loss  seq={seq}  logical_seq={logical_seq}")
419        }
420        ClientEvent::DuplicateReply { seq, remote, .. } => {
421            format!("duplicate  seq={seq}  remote={remote}")
422        }
423        ClientEvent::LateReply {
424            seq,
425            logical_seq,
426            highest_seen,
427            remote,
428            rtt,
429            one_way,
430            received_stats,
431            ..
432        } => {
433            let mut out = format!(
434                "late  seq={seq}  logical_seq={}  highest_seen={highest_seen}  remote={remote}",
435                optional_u64(*logical_seq)
436            );
437            if let Some(rtt) = rtt {
438                write!(out, "  rtt={}", format_duration(rtt.effective)).unwrap();
439                write_human_one_way(&mut out, *one_way);
440                write!(out, "  ipdv={}", format_human_ipdv(stats, *logical_seq)).unwrap();
441            }
442            write_human_received_stats(&mut out, *received_stats);
443            out
444        }
445        ClientEvent::Warning { kind, message } => {
446            format!("warning  kind={}  message={message}", warning_kind(*kind))
447        }
448    }
449}
450
451fn format_rtt_us(event: &ClientEvent) -> Option<String> {
452    match event {
453        ClientEvent::EchoReply { rtt, .. } => Some(duration_us(rtt.effective).to_string()),
454        _ => None,
455    }
456}
457
458fn format_machine(event: &ClientEvent) -> String {
459    let mut out = String::new();
460    match event {
461        ClientEvent::SessionStarted {
462            remote,
463            token,
464            negotiated,
465            at,
466        } => {
467            write_common(&mut out, "session_started");
468            write_remote(&mut out, *remote);
469            write_token(&mut out, *token);
470            write_wall(&mut out, "event_wall_ns", at.wall);
471            write_negotiated(&mut out, negotiated);
472        }
473        ClientEvent::NoTestCompleted {
474            remote,
475            negotiated,
476            at,
477        } => {
478            write_common(&mut out, "no_test_completed");
479            write_remote(&mut out, *remote);
480            write_wall(&mut out, "event_wall_ns", at.wall);
481            write_negotiated(&mut out, negotiated);
482        }
483        ClientEvent::SessionClosed { remote, token, at } => {
484            write_common(&mut out, "session_closed");
485            write_remote(&mut out, *remote);
486            write_token(&mut out, *token);
487            write_wall(&mut out, "event_wall_ns", at.wall);
488        }
489        ClientEvent::EchoSent { .. } => {}
490        ClientEvent::EchoReply {
491            seq,
492            logical_seq,
493            remote,
494            sent_at,
495            received_at,
496            rtt,
497            server_timing,
498            one_way,
499            received_stats,
500            bytes: _,
501            packet_meta,
502        } => {
503            write_common(&mut out, "echo_reply");
504            write_seq(&mut out, *seq, Some(*logical_seq));
505            write_remote(&mut out, *remote);
506            write_wall(&mut out, "client_send_wall_ns", sent_at.wall);
507            write_wall(&mut out, "client_receive_wall_ns", received_at.wall);
508            write_rtt(&mut out, rtt);
509            write_server_timing(&mut out, *server_timing);
510            write_one_way(&mut out, *one_way);
511            write_received_stats(&mut out, *received_stats);
512            write_packet_meta(&mut out, *packet_meta);
513        }
514        ClientEvent::EchoLoss {
515            seq,
516            logical_seq,
517            sent_at,
518            ..
519        } => {
520            write_common(&mut out, "loss");
521            write_seq(&mut out, *seq, Some(*logical_seq));
522            write_wall(&mut out, "client_send_wall_ns", sent_at.wall);
523            out.push_str(" warning=loss");
524        }
525        ClientEvent::DuplicateReply {
526            seq,
527            remote,
528            received_at,
529            bytes: _,
530        } => {
531            write_common(&mut out, "duplicate");
532            write_seq(&mut out, *seq, None);
533            write_remote(&mut out, *remote);
534            write_wall(&mut out, "client_receive_wall_ns", received_at.wall);
535            out.push_str(" warning=duplicate");
536        }
537        ClientEvent::LateReply {
538            seq,
539            logical_seq,
540            highest_seen,
541            remote,
542            sent_at,
543            received_at,
544            rtt,
545            server_timing,
546            one_way,
547            received_stats,
548            bytes: _,
549            packet_meta,
550        } => {
551            write_common(&mut out, "late");
552            write_seq(&mut out, *seq, *logical_seq);
553            write_remote(&mut out, *remote);
554            write!(out, " highest_seen={highest_seen}").unwrap();
555            if let Some(sent_at) = sent_at {
556                write_wall(&mut out, "client_send_wall_ns", sent_at.wall);
557            }
558            write_wall(&mut out, "client_receive_wall_ns", received_at.wall);
559            if let Some(rtt) = rtt {
560                write_rtt(&mut out, rtt);
561            }
562            write_server_timing(&mut out, *server_timing);
563            write_one_way(&mut out, *one_way);
564            write_received_stats(&mut out, *received_stats);
565            write_packet_meta(&mut out, *packet_meta);
566            out.push_str(" warning=late");
567        }
568        ClientEvent::Warning { kind, message } => {
569            write_common(&mut out, "warning");
570            write!(
571                out,
572                " warning_kind={} message={}",
573                warning_kind(*kind),
574                escape_value(message)
575            )
576            .unwrap();
577        }
578    }
579    out
580}
581
582fn format_simple(event: &ClientEvent) -> String {
583    match event {
584        ClientEvent::SessionStarted { remote, token, .. } => {
585            format!("session started remote={remote} token={token:#x}")
586        }
587        ClientEvent::NoTestCompleted { remote, .. } => {
588            format!("no-test completed remote={remote}")
589        }
590        ClientEvent::SessionClosed { remote, token, .. } => {
591            format!("session closed remote={remote} token={token:#x}")
592        }
593        ClientEvent::EchoSent { .. } => String::new(),
594        ClientEvent::EchoReply {
595            seq,
596            logical_seq,
597            remote,
598            rtt,
599            server_timing,
600            bytes: _,
601            ..
602        } => {
603            let mut out = format!(
604                "reply seq={seq} logical_seq={logical_seq} remote={remote} rtt_us={}",
605                duration_us(rtt.effective)
606            );
607            if let Some(raw) = rtt.adjusted.map(|_| rtt.raw) {
608                write!(out, " raw_rtt_us={}", duration_us(raw)).unwrap();
609            }
610            if let Some(processing) = server_timing.and_then(|timing| timing.processing) {
611                write!(out, " server_processing_us={}", duration_us(processing)).unwrap();
612            }
613            out
614        }
615        ClientEvent::EchoLoss {
616            seq, logical_seq, ..
617        } => {
618            format!("loss seq={seq} logical_seq={logical_seq}")
619        }
620        ClientEvent::DuplicateReply { seq, remote, .. } => {
621            format!("duplicate seq={seq} remote={remote}")
622        }
623        ClientEvent::LateReply {
624            seq,
625            logical_seq,
626            highest_seen,
627            remote,
628            rtt,
629            ..
630        } => {
631            let mut out = format!(
632                "late seq={seq} logical_seq={} highest_seen={highest_seen} remote={remote}",
633                optional_u64(*logical_seq)
634            );
635            if let Some(rtt) = rtt {
636                write!(out, " rtt_us={}", duration_us(rtt.effective)).unwrap();
637            }
638            out
639        }
640        ClientEvent::Warning { kind, message } => {
641            format!("warning kind={} message={message}", warning_kind(*kind))
642        }
643    }
644}
645
646fn write_common(out: &mut String, event: &str) {
647    write!(out, "event={event}").unwrap();
648}
649
650fn write_seq(out: &mut String, seq: u32, logical_seq: Option<u64>) {
651    write!(out, " seq={seq}").unwrap();
652    if let Some(logical_seq) = logical_seq {
653        write!(out, " logical_seq={logical_seq}").unwrap();
654    }
655}
656
657fn write_remote(out: &mut String, remote: SocketAddr) {
658    write!(out, " remote={remote}").unwrap();
659}
660
661fn write_token(out: &mut String, token: u64) {
662    write!(out, " token={token:#x}").unwrap();
663}
664
665fn write_wall(out: &mut String, key: &str, wall: SystemTime) {
666    if let Ok(duration) = wall.duration_since(UNIX_EPOCH) {
667        write!(out, " {key}={}", duration.as_nanos()).unwrap();
668    }
669}
670
671fn write_negotiated(out: &mut String, negotiated: &NegotiatedParams) {
672    write!(
673        out,
674        " duration_ns={} interval_ns={} payload_length={}",
675        negotiated.params.duration_ns, negotiated.params.interval_ns, negotiated.params.length
676    )
677    .unwrap();
678}
679
680fn write_rtt(out: &mut String, rtt: &RttSample) {
681    write!(
682        out,
683        " raw_rtt_us={} effective_rtt_us={}",
684        duration_us(rtt.raw),
685        duration_us(rtt.effective)
686    )
687    .unwrap();
688    if let Some(adjusted) = rtt.adjusted {
689        write!(out, " adjusted_rtt_us={}", duration_us(adjusted)).unwrap();
690    }
691}
692
693fn write_server_timing(out: &mut String, timing: Option<ServerTiming>) {
694    if let Some(timing) = timing {
695        write_optional_i64(out, "server_receive_wall_ns", timing.receive_wall_ns);
696        write_optional_i64(out, "server_receive_mono_ns", timing.receive_mono_ns);
697        write_optional_i64(out, "server_send_wall_ns", timing.send_wall_ns);
698        write_optional_i64(out, "server_send_mono_ns", timing.send_mono_ns);
699        write_optional_i64(out, "server_midpoint_wall_ns", timing.midpoint_wall_ns);
700        write_optional_i64(out, "server_midpoint_mono_ns", timing.midpoint_mono_ns);
701        if let Some(processing) = timing.processing {
702            write!(out, " server_processing_us={}", duration_us(processing)).unwrap();
703        }
704    }
705}
706
707fn write_one_way(out: &mut String, one_way: Option<OneWayDelaySample>) {
708    if let Some(one_way) = one_way {
709        if let Some(value) = one_way.client_to_server {
710            write!(out, " client_to_server_us={}", duration_us(value)).unwrap();
711        }
712        if let Some(value) = one_way.server_to_client {
713            write!(out, " server_to_client_us={}", duration_us(value)).unwrap();
714        }
715    }
716}
717
718fn write_received_stats(out: &mut String, stats: Option<ReceivedStatsSample>) {
719    if let Some(stats) = stats {
720        if let Some(count) = stats.count {
721            write!(out, " server_received_count={count}").unwrap();
722        }
723        if let Some(window) = stats.window {
724            write!(out, " server_received_window={window:#x}").unwrap();
725        }
726    }
727}
728
729fn write_packet_meta(out: &mut String, meta: PacketMeta) {
730    write_optional_u8(out, "traffic_class", meta.traffic_class);
731    write_optional_u8(out, "dscp", meta.dscp);
732    write_optional_u8(out, "ecn", meta.ecn);
733    match meta.kernel_rx_timestamp {
734        Some(timestamp) => write_wall(out, "kernel_rx_ns", timestamp),
735        None => write!(out, " kernel_rx_ns=none").unwrap(),
736    }
737}
738
739fn write_human_one_way(out: &mut String, one_way: Option<OneWayDelaySample>) {
740    match one_way {
741        Some(one_way) => {
742            write!(
743                out,
744                "  rd={}  sd={}",
745                format_optional_duration(one_way.server_to_client),
746                format_optional_duration(one_way.client_to_server)
747            )
748            .unwrap();
749        }
750        None => out.push_str("  rd=n/a  sd=n/a"),
751    }
752}
753
754fn write_human_received_stats(out: &mut String, stats: Option<ReceivedStatsSample>) {
755    if let Some(stats) = stats {
756        if let Some(count) = stats.count {
757            write!(out, "  server_received={count}").unwrap();
758        }
759        if let Some(window) = stats.window {
760            write!(out, "  server_window={window:#x}").unwrap();
761        }
762    }
763}
764
765fn format_human_ipdv(stats: Option<&HumanEventStats>, logical_seq: Option<u64>) -> String {
766    let Some(stats) = stats else {
767        return "n/a".to_owned();
768    };
769
770    let pair = logical_seq
771        .and_then(|seq| {
772            stats
773                .ipdv_pairs
774                .iter()
775                .find(|pair| pair.current_logical_seq == seq)
776        })
777        .or_else(|| stats.ipdv_pairs.last());
778
779    pair.map(|pair| format_duration(pair.rtt_ipdv))
780        .unwrap_or_else(|| "n/a".to_owned())
781}
782
783fn write_optional_u8(out: &mut String, key: &str, value: Option<u8>) {
784    match value {
785        Some(value) => write!(out, " {key}={value}").unwrap(),
786        None => write!(out, " {key}=none").unwrap(),
787    }
788}
789
790fn write_optional_i64(out: &mut String, key: &str, value: Option<i64>) {
791    if let Some(value) = value {
792        write!(out, " {key}={value}").unwrap();
793    }
794}
795
796fn duration_us(duration: Duration) -> u128 {
797    duration.as_micros()
798}
799
800fn format_optional_duration(duration: Option<Duration>) -> String {
801    duration
802        .map(format_duration)
803        .unwrap_or_else(|| "n/a".to_owned())
804}
805
806fn format_duration(duration: Duration) -> String {
807    format_ns(duration.as_nanos() as f64)
808}
809
810fn format_ns(ns: f64) -> String {
811    if ns < 1_000.0 {
812        format!("{ns:.0}ns")
813    } else if ns < 1_000_000.0 {
814        format!("{:.1}µs", ns / 1_000.0)
815    } else if ns < 1_000_000_000.0 {
816        format!("{:.1}ms", ns / 1_000_000.0)
817    } else {
818        format!("{:.3}s", ns / 1_000_000_000.0)
819    }
820}
821
822fn optional_u64(value: Option<u64>) -> String {
823    value
824        .map(|value| value.to_string())
825        .unwrap_or_else(|| "none".to_owned())
826}
827
828fn warning_kind(kind: WarningKind) -> &'static str {
829    match kind {
830        WarningKind::MalformedOrUnrelatedPacket => "malformed_or_unrelated_packet",
831        WarningKind::WrongToken => "wrong_token",
832        WarningKind::UntrackedReply => "untracked_reply",
833    }
834}
835
836fn escape_value(value: &str) -> String {
837    value
838        .replace('\\', "\\\\")
839        .replace(' ', "\\s")
840        .replace('\n', "\\n")
841        .replace('\r', "\\r")
842        .replace('\t', "\\t")
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848    use clap::{CommandFactory, Parser};
849    use irtt_client::{ClientTimestamp, PacketMeta, RttSample, SignedDuration};
850    use irtt_proto::{Params, PROTOCOL_VERSION};
851    use std::{
852        net::{IpAddr, Ipv4Addr, SocketAddr},
853        time::Instant,
854    };
855
856    fn test_timestamp(offset: Duration) -> ClientTimestamp {
857        ClientTimestamp {
858            wall: UNIX_EPOCH + offset,
859            mono: Instant::now() + offset,
860        }
861    }
862
863    fn test_remote() -> SocketAddr {
864        SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
865    }
866
867    fn parse(args: &[&str]) -> Result<CliArgs, clap::Error> {
868        let mut argv = vec!["irtt-rs"];
869        argv.extend_from_slice(args);
870        CliArgs::try_parse_from(argv)
871    }
872
873    fn reply_event() -> ClientEvent {
874        reply_event_with_meta(PacketMeta::default())
875    }
876
877    fn reply_event_with_meta(packet_meta: PacketMeta) -> ClientEvent {
878        ClientEvent::EchoReply {
879            seq: 7,
880            logical_seq: 8,
881            remote: test_remote(),
882            sent_at: test_timestamp(Duration::from_secs(1)),
883            received_at: test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500)),
884            rtt: RttSample {
885                raw: Duration::from_micros(1500),
886                adjusted: Some(Duration::from_micros(1200)),
887                effective: Duration::from_micros(1200),
888                adjusted_signed: Some(SignedDuration { ns: 1_200_000 }),
889                effective_signed: SignedDuration { ns: 1_200_000 },
890            },
891            server_timing: Some(ServerTiming {
892                receive_wall_ns: Some(1_000),
893                receive_mono_ns: Some(2_000),
894                send_wall_ns: Some(301_000),
895                send_mono_ns: Some(302_000),
896                midpoint_wall_ns: None,
897                midpoint_mono_ns: None,
898                processing: Some(Duration::from_micros(300)),
899            }),
900            one_way: Some(OneWayDelaySample {
901                client_to_server: Some(Duration::from_micros(400)),
902                server_to_client: Some(Duration::from_micros(500)),
903            }),
904            received_stats: Some(ReceivedStatsSample {
905                count: Some(9),
906                window: Some(0x7),
907            }),
908            bytes: 64,
909            packet_meta,
910        }
911    }
912
913    fn late_event_with_meta(packet_meta: PacketMeta) -> ClientEvent {
914        ClientEvent::LateReply {
915            seq: 4,
916            logical_seq: None,
917            highest_seen: 9,
918            remote: test_remote(),
919            sent_at: None,
920            received_at: test_timestamp(Duration::from_secs(1)),
921            rtt: None,
922            server_timing: None,
923            one_way: None,
924            received_stats: None,
925            bytes: 64,
926            packet_meta,
927        }
928    }
929
930    fn packet_meta(traffic_class: u8, dscp: u8, ecn: u8) -> PacketMeta {
931        PacketMeta {
932            traffic_class: Some(traffic_class),
933            dscp: Some(dscp),
934            ecn: Some(ecn),
935            kernel_rx_timestamp: None,
936        }
937    }
938
939    fn packet_meta_with_timestamp(timestamp: Option<SystemTime>) -> PacketMeta {
940        PacketMeta {
941            kernel_rx_timestamp: timestamp,
942            ..PacketMeta::default()
943        }
944    }
945
946    fn assert_machine_packet_meta(
947        line: &str,
948        traffic_class: &str,
949        dscp: &str,
950        ecn: &str,
951        kernel_rx_ns: &str,
952    ) {
953        assert!(line.contains(&format!("traffic_class={traffic_class}")));
954        assert!(line.contains(&format!("dscp={dscp}")));
955        assert!(line.contains(&format!("ecn={ecn}")));
956        assert!(line.contains(&format!("kernel_rx_ns={kernel_rx_ns}")));
957    }
958
959    #[test]
960    fn parses_valid_defaults() {
961        let args = parse(&["127.0.0.1:2112"]).unwrap();
962        assert_eq!(args.server, "127.0.0.1:2112");
963        assert_eq!(args.duration, Duration::from_secs(10));
964        assert_eq!(args.interval, Duration::from_secs(1));
965        assert_eq!(args.length, 0);
966        assert_eq!(args.hmac, None);
967        assert_eq!(args.clock, ClockArg::Both);
968        assert_eq!(args.tstamp, TimestampArg::Both);
969        assert_eq!(args.timestamp_mode(), TimestampArg::Both);
970        assert_eq!(args.stats, ReceivedStatsArg::Both);
971        assert_eq!(args.server_fill, None);
972        assert_eq!(args.dscp, 0);
973        assert_eq!(args.ttl, None);
974        assert!(!args.loose);
975        assert_eq!(args.output, OutputMode::Human);
976
977        let config = args.to_client_config();
978        assert_eq!(config.duration, Some(Duration::from_secs(10)));
979        assert_eq!(config.interval, Duration::from_secs(1));
980        assert_eq!(config.length, 0);
981        assert_eq!(config.received_stats, ReceivedStats::Both);
982        assert_eq!(config.stamp_at, StampAt::Both);
983        assert_eq!(config.clock, Clock::Both);
984        assert_eq!(config.dscp, 0);
985        assert_eq!(config.socket_config.ttl, None);
986        assert_eq!(config.server_fill, None);
987        assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
988        assert!(!args.is_continuous());
989    }
990
991    #[test]
992    fn parses_durations() {
993        assert_eq!(parse_duration("1ms").unwrap(), Duration::from_millis(1));
994        assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2));
995        assert_eq!(parse_duration("3m").unwrap(), Duration::from_secs(180));
996        assert_eq!(parse_test_duration("0").unwrap(), Duration::ZERO);
997        assert_eq!(parse_test_duration("0s").unwrap(), Duration::ZERO);
998        assert_eq!(
999            parse_test_duration("1ms").unwrap(),
1000            Duration::from_millis(1)
1001        );
1002    }
1003
1004    #[test]
1005    fn duration_zero_selects_continuous_config() {
1006        let args = parse(&["--duration", "0", "127.0.0.1:2112"]).unwrap();
1007        assert!(args.is_continuous());
1008        assert_eq!(args.duration, Duration::ZERO);
1009        assert_eq!(args.to_client_config().duration, None);
1010    }
1011
1012    #[test]
1013    fn parses_output_clock_and_timestamp_modes() {
1014        let args = parse(&[
1015            "--output",
1016            "machine",
1017            "--clock",
1018            "wall",
1019            "--tstamp",
1020            "send",
1021            "127.0.0.1:2112",
1022        ])
1023        .unwrap();
1024        assert_eq!(args.output, OutputMode::Machine);
1025        assert_eq!(args.clock, ClockArg::Wall);
1026        assert_eq!(args.timestamp_mode(), TimestampArg::Send);
1027        assert_eq!(args.to_client_config().stamp_at, StampAt::Send);
1028
1029        let args = parse(&[
1030            "--output",
1031            "rtt-us",
1032            "--clock",
1033            "monotonic",
1034            "--timestamps",
1035            "receive",
1036            "127.0.0.1:2112",
1037        ])
1038        .unwrap();
1039        assert_eq!(args.output, OutputMode::RttUs);
1040        assert_eq!(args.clock, ClockArg::Monotonic);
1041        assert_eq!(args.timestamp_mode(), TimestampArg::Receive);
1042        assert_eq!(args.to_client_config().stamp_at, StampAt::Receive);
1043
1044        let args = parse(&["--output", "human", "127.0.0.1:2112"]).unwrap();
1045        assert_eq!(args.output, OutputMode::Human);
1046    }
1047
1048    #[test]
1049    fn maps_tstamp_modes() {
1050        for (value, expected) in [
1051            ("none", StampAt::None),
1052            ("send", StampAt::Send),
1053            ("receive", StampAt::Receive),
1054            ("both", StampAt::Both),
1055            ("midpoint", StampAt::Midpoint),
1056        ] {
1057            let args = parse(&["--tstamp", value, "127.0.0.1:2112"]).unwrap();
1058            assert_eq!(args.to_client_config().stamp_at, expected);
1059        }
1060    }
1061
1062    #[test]
1063    fn timestamps_alias_accepts_midpoint() {
1064        let args = parse(&["--timestamps", "midpoint", "127.0.0.1:2112"]).unwrap();
1065        assert_eq!(args.timestamp_mode(), TimestampArg::Midpoint);
1066        assert_eq!(args.to_client_config().stamp_at, StampAt::Midpoint);
1067    }
1068
1069    #[test]
1070    fn rejects_duplicate_timestamp_options() {
1071        assert!(parse(&[
1072            "--tstamp",
1073            "send",
1074            "--timestamps",
1075            "receive",
1076            "127.0.0.1:2112"
1077        ])
1078        .is_err());
1079    }
1080
1081    #[test]
1082    fn maps_received_stats_modes() {
1083        for (value, expected) in [
1084            ("none", ReceivedStats::None),
1085            ("count", ReceivedStats::Count),
1086            ("window", ReceivedStats::Window),
1087            ("both", ReceivedStats::Both),
1088        ] {
1089            let args = parse(&["--stats", value, "127.0.0.1:2112"]).unwrap();
1090            assert_eq!(args.to_client_config().received_stats, expected);
1091        }
1092
1093        let args = parse(&["127.0.0.1:2112"]).unwrap();
1094        assert_eq!(args.to_client_config().received_stats, ReceivedStats::Both);
1095    }
1096
1097    #[test]
1098    fn maps_sfill_server_fill_options() {
1099        let args = parse(&["--sfill", "abc", "127.0.0.1:2112"]).unwrap();
1100        assert_eq!(args.to_client_config().server_fill.as_deref(), Some("abc"));
1101
1102        let args = parse(&["--server-fill", "abc", "127.0.0.1:2112"]).unwrap();
1103        assert_eq!(args.to_client_config().server_fill.as_deref(), Some("abc"));
1104
1105        let max = "0123456789abcdef0123456789abcdef";
1106        let args = parse(&["--sfill", max, "127.0.0.1:2112"]).unwrap();
1107        assert_eq!(args.to_client_config().server_fill.as_deref(), Some(max));
1108    }
1109
1110    #[test]
1111    fn maps_dscp_codepoints() {
1112        for value in ["0", "46", "63"] {
1113            let args = parse(&["--dscp", value, "127.0.0.1:2112"]).unwrap();
1114            assert_eq!(args.to_client_config().dscp, value.parse::<u8>().unwrap());
1115        }
1116    }
1117
1118    #[test]
1119    fn maps_ttl_values() {
1120        for value in ["1", "64", "255"] {
1121            let args = parse(&["--ttl", value, "127.0.0.1:2112"]).unwrap();
1122            assert_eq!(
1123                args.to_client_config().socket_config.ttl,
1124                Some(value.parse::<u32>().unwrap())
1125            );
1126        }
1127    }
1128
1129    #[test]
1130    fn maps_length_option() {
1131        let args = parse(&["--length", "1472", "127.0.0.1:2112"]).unwrap();
1132        assert_eq!(args.length, 1472);
1133        assert_eq!(args.to_client_config().length, 1472);
1134
1135        let args = parse(&["127.0.0.1:2112"]).unwrap();
1136        assert_eq!(args.length, 0);
1137        assert_eq!(args.to_client_config().length, 0);
1138    }
1139
1140    #[test]
1141    fn maps_loose_negotiation() {
1142        let args = parse(&["127.0.0.1:2112"]).unwrap();
1143        assert_eq!(
1144            args.to_client_config().negotiation_policy,
1145            NegotiationPolicy::Strict
1146        );
1147
1148        let args = parse(&["--loose", "127.0.0.1:2112"]).unwrap();
1149        assert_eq!(
1150            args.to_client_config().negotiation_policy,
1151            NegotiationPolicy::Loose
1152        );
1153    }
1154
1155    #[test]
1156    fn help_lists_advanced_protocol_options() {
1157        let help = CliArgs::command().render_help().to_string();
1158        assert!(help.contains("--tstamp <MODE>"));
1159        assert!(help.contains("--stats <STATS>"));
1160        assert!(help.contains("--sfill <STRING>"));
1161        assert!(help.contains("--dscp <0..=63>"));
1162        assert!(help.contains("--ttl <1..=255>"));
1163        assert!(help.contains("--loose"));
1164        assert!(help.contains("DSCP codepoint"));
1165        assert!(help.contains("up to 32 bytes"));
1166        assert!(help.contains("not negotiated"));
1167    }
1168
1169    #[test]
1170    fn rejects_invalid_argument_values() {
1171        for args in [
1172            &["--duration", "-1s", "127.0.0.1:2112"][..],
1173            &["--duration", "5", "127.0.0.1:2112"],
1174            &["--duration", "1h", "127.0.0.1:2112"],
1175            &["--interval", "0ms", "127.0.0.1:2112"],
1176            &["--interval", "-1ms", "127.0.0.1:2112"],
1177            &["--sfill", "", "127.0.0.1:2112"],
1178            &[
1179                "--sfill",
1180                "0123456789abcdef0123456789abcdefx",
1181                "127.0.0.1:2112",
1182            ],
1183            &["--dscp", "64", "127.0.0.1:2112"],
1184            &["--dscp", "-1", "127.0.0.1:2112"],
1185            &["--dscp", "abc", "127.0.0.1:2112"],
1186            &["--ttl", "0", "127.0.0.1:2112"],
1187            &["--ttl", "256", "127.0.0.1:2112"],
1188            &["--ttl", "-1", "127.0.0.1:2112"],
1189            &["--ttl", "abc", "127.0.0.1:2112"],
1190            &["--length", "-1", "127.0.0.1:2112"],
1191            &["--length", "65508", "127.0.0.1:2112"],
1192        ] {
1193            assert!(parse(args).is_err(), "expected parse failure for {args:?}");
1194        }
1195    }
1196
1197    #[test]
1198    fn rtt_us_prints_only_effective_reply_rtt() {
1199        assert_eq!(
1200            format_event(&reply_event(), OutputMode::RttUs),
1201            Some("1200".to_owned())
1202        );
1203        assert_eq!(
1204            format_event(
1205                &ClientEvent::Warning {
1206                    kind: WarningKind::WrongToken,
1207                    message: "bad".to_owned(),
1208                },
1209                OutputMode::RttUs
1210            ),
1211            None
1212        );
1213    }
1214
1215    #[test]
1216    fn machine_prints_stable_key_value_fields() {
1217        let line = format_event(&reply_event(), OutputMode::Machine).unwrap();
1218        assert!(line.starts_with("event=echo_reply "));
1219        assert!(line.contains("seq=7"));
1220        assert!(line.contains("logical_seq=8"));
1221        assert!(line.contains("remote=127.0.0.1:2112"));
1222        assert!(line.contains("client_send_wall_ns=1000000000"));
1223        assert!(line.contains("client_receive_wall_ns=1001500000"));
1224        assert!(!line.contains(" sent_ns="));
1225        assert!(!line.contains(" received_ns="));
1226        assert!(line.contains("raw_rtt_us=1500"));
1227        assert!(line.contains("adjusted_rtt_us=1200"));
1228        assert!(line.contains("effective_rtt_us=1200"));
1229        assert!(line.contains("server_processing_us=300"));
1230        assert!(line.contains("server_received_count=9"));
1231    }
1232
1233    #[test]
1234    fn machine_echo_reply_metadata_unavailable_prints_none() {
1235        let line = format_event(&reply_event(), OutputMode::Machine).unwrap();
1236
1237        assert_machine_packet_meta(&line, "none", "none", "none", "none");
1238    }
1239
1240    #[test]
1241    fn machine_echo_reply_metadata_observed_zero_prints_zero() {
1242        let line = format_event(
1243            &reply_event_with_meta(packet_meta(0, 0, 0)),
1244            OutputMode::Machine,
1245        )
1246        .unwrap();
1247
1248        assert_machine_packet_meta(&line, "0", "0", "0", "none");
1249    }
1250
1251    #[test]
1252    fn machine_echo_reply_metadata_dscp_46_ecn_0_prints_values() {
1253        let line = format_event(
1254            &reply_event_with_meta(packet_meta(184, 46, 0)),
1255            OutputMode::Machine,
1256        )
1257        .unwrap();
1258
1259        assert_machine_packet_meta(&line, "184", "46", "0", "none");
1260    }
1261
1262    #[test]
1263    fn machine_echo_reply_metadata_dscp_46_ecn_2_prints_values() {
1264        let line = format_event(
1265            &reply_event_with_meta(packet_meta(186, 46, 2)),
1266            OutputMode::Machine,
1267        )
1268        .unwrap();
1269
1270        assert_machine_packet_meta(&line, "186", "46", "2", "none");
1271    }
1272
1273    #[test]
1274    fn machine_echo_reply_metadata_kernel_rx_timestamp_prints_ns() {
1275        let line = format_event(
1276            &reply_event_with_meta(packet_meta_with_timestamp(Some(
1277                UNIX_EPOCH + Duration::new(1, 234),
1278            ))),
1279            OutputMode::Machine,
1280        )
1281        .unwrap();
1282
1283        assert_machine_packet_meta(&line, "none", "none", "none", "1000000234");
1284    }
1285
1286    #[test]
1287    fn machine_late_reply_metadata_unavailable_prints_none() {
1288        let line = format_event(
1289            &late_event_with_meta(PacketMeta::default()),
1290            OutputMode::Machine,
1291        )
1292        .unwrap();
1293
1294        assert!(line.starts_with("event=late "));
1295        assert_machine_packet_meta(&line, "none", "none", "none", "none");
1296    }
1297
1298    #[test]
1299    fn machine_late_reply_metadata_observed_values_prints_values() {
1300        let line = format_event(
1301            &late_event_with_meta(packet_meta(186, 46, 2)),
1302            OutputMode::Machine,
1303        )
1304        .unwrap();
1305
1306        assert!(line.starts_with("event=late "));
1307        assert_machine_packet_meta(&line, "186", "46", "2", "none");
1308    }
1309
1310    #[test]
1311    fn machine_late_reply_metadata_kernel_rx_timestamp_prints_ns() {
1312        let line = format_event(
1313            &late_event_with_meta(packet_meta_with_timestamp(Some(
1314                UNIX_EPOCH + Duration::new(2, 345),
1315            ))),
1316            OutputMode::Machine,
1317        )
1318        .unwrap();
1319
1320        assert!(line.starts_with("event=late "));
1321        assert_machine_packet_meta(&line, "none", "none", "none", "2000000345");
1322    }
1323
1324    #[test]
1325    fn simple_prints_readable_reply_line() {
1326        let line = format_event(&reply_event(), OutputMode::Simple).unwrap();
1327        assert_eq!(
1328            line,
1329            "reply seq=7 logical_seq=8 remote=127.0.0.1:2112 rtt_us=1200 raw_rtt_us=1500 server_processing_us=300"
1330        );
1331    }
1332
1333    #[test]
1334    fn human_uses_readable_per_event_lines() {
1335        let line = format_event(&reply_event(), OutputMode::Human).unwrap();
1336        assert!(line.starts_with("seq=7  rtt=1.2ms"));
1337        assert!(line.contains("rd=500.0µs"));
1338        assert!(line.contains("sd=400.0µs"));
1339        assert!(line.contains("ipdv=n/a"));
1340        assert!(line.contains("proc=300.0µs"));
1341        assert!(!line.contains("logical_seq="));
1342        assert!(!line.contains("server_received="));
1343        assert!(!line.contains("server_window="));
1344        assert!(!line.contains("rtt_us="));
1345        assert_ne!(
1346            line,
1347            format_event(&reply_event(), OutputMode::Simple).unwrap()
1348        );
1349        assert!(OutputMode::Human.prints_summary());
1350        assert!(!OutputMode::Simple.prints_summary());
1351        assert!(!OutputMode::Machine.prints_summary());
1352        assert!(!OutputMode::RttUs.prints_summary());
1353    }
1354
1355    #[test]
1356    fn verbose_human_reply_includes_extra_fields() {
1357        let line = format_human_event_with_options(
1358            &reply_event(),
1359            None,
1360            HumanOutputOptions { verbose: true },
1361        );
1362
1363        assert!(line.contains("logical_seq=8"));
1364        assert!(line.contains("server_received=9"));
1365        assert!(line.contains("server_window=0x7"));
1366    }
1367
1368    #[test]
1369    fn human_reply_uses_supplied_ipdv_update() {
1370        let line = format_human_event(
1371            &reply_event(),
1372            Some(HumanEventStats {
1373                contributed_sample: true,
1374                ipdv_pairs: vec![HumanIpdvPair {
1375                    previous_logical_seq: 7,
1376                    current_logical_seq: 8,
1377                    rtt_ipdv: Duration::from_micros(47),
1378                    send_ipdv: None,
1379                    receive_ipdv: None,
1380                }],
1381            }),
1382        );
1383
1384        assert!(line.contains("ipdv=47.0µs"));
1385    }
1386
1387    #[test]
1388    fn human_reply_marks_missing_one_way_delay_unavailable() {
1389        let ClientEvent::EchoReply {
1390            seq,
1391            logical_seq,
1392            remote,
1393            sent_at,
1394            received_at,
1395            rtt,
1396            server_timing,
1397            received_stats,
1398            bytes,
1399            packet_meta,
1400            ..
1401        } = reply_event()
1402        else {
1403            unreachable!();
1404        };
1405        let event = ClientEvent::EchoReply {
1406            seq,
1407            logical_seq,
1408            remote,
1409            sent_at,
1410            received_at,
1411            rtt,
1412            server_timing,
1413            one_way: None,
1414            received_stats,
1415            bytes,
1416            packet_meta,
1417        };
1418
1419        let line = format_human_event(&event, None);
1420
1421        assert!(line.contains("rd=n/a"));
1422        assert!(line.contains("sd=n/a"));
1423    }
1424
1425    #[test]
1426    fn echo_sent_is_not_formatted_for_stream_outputs() {
1427        let ts = test_timestamp(Duration::from_secs(1));
1428        let event = ClientEvent::EchoSent {
1429            seq: 1,
1430            logical_seq: 2,
1431            remote: test_remote(),
1432            scheduled_at: ts.mono,
1433            sent_at: ts,
1434            bytes: 64,
1435            send_call: Duration::from_micros(10),
1436            timer_error: Duration::ZERO,
1437        };
1438
1439        assert!(format_event(&event, OutputMode::RttUs).is_none());
1440        assert!(format_event(&event, OutputMode::Human).is_none());
1441        assert!(format_event(&event, OutputMode::Machine).is_none());
1442        assert!(format_event(&event, OutputMode::Simple).is_none());
1443    }
1444
1445    #[test]
1446    fn warning_and_loss_variants_format_without_panicking() {
1447        let ts = test_timestamp(Duration::from_secs(1));
1448        let events = [
1449            ClientEvent::EchoLoss {
1450                seq: 1,
1451                logical_seq: 2,
1452                sent_at: ts,
1453                timeout_at: ts.mono + Duration::from_secs(1),
1454            },
1455            ClientEvent::DuplicateReply {
1456                seq: 3,
1457                remote: test_remote(),
1458                received_at: ts,
1459                bytes: 64,
1460            },
1461            ClientEvent::LateReply {
1462                seq: 4,
1463                logical_seq: None,
1464                highest_seen: 9,
1465                remote: test_remote(),
1466                sent_at: None,
1467                received_at: ts,
1468                rtt: None,
1469                server_timing: None,
1470                one_way: None,
1471                received_stats: None,
1472                bytes: 64,
1473                packet_meta: PacketMeta::default(),
1474            },
1475            ClientEvent::Warning {
1476                kind: WarningKind::UntrackedReply,
1477                message: "untracked reply".to_owned(),
1478            },
1479        ];
1480
1481        for event in events {
1482            let machine = format_event(&event, OutputMode::Machine).unwrap();
1483            let simple = format_event(&event, OutputMode::Simple).unwrap();
1484            assert!(!machine.is_empty());
1485            assert!(!simple.is_empty());
1486        }
1487    }
1488
1489    #[test]
1490    fn session_events_do_not_print_summary() {
1491        let event = ClientEvent::SessionStarted {
1492            remote: test_remote(),
1493            token: 0x1234,
1494            negotiated: NegotiatedParams {
1495                params: Params {
1496                    protocol_version: PROTOCOL_VERSION,
1497                    duration_ns: 10_000_000_000,
1498                    interval_ns: 1_000_000_000,
1499                    ..Params::default()
1500                },
1501            },
1502            at: test_timestamp(Duration::from_secs(1)),
1503        };
1504        let line = format_event(&event, OutputMode::Simple).unwrap();
1505        assert!(!line.contains("summary"));
1506        assert!(!line.contains("packets_sent"));
1507        assert!(!line.contains("packet_loss"));
1508
1509        let line = format_event(&event, OutputMode::Machine).unwrap();
1510        assert!(line.contains("event_wall_ns=1000000000"));
1511        assert!(line.contains("payload_length=0"));
1512        assert!(!line.contains(" at_ns="));
1513        assert!(!line.contains(" length=0"));
1514    }
1515}