1#![allow(clippy::module_name_repetitions)]
15
16use std::time::Duration;
17
18pub const DEFAULT_DOMAIN: u32 = 0;
20pub const DEFAULT_TOPIC: &str = "zerodds/bench/loopback";
22pub const DEFAULT_PAYLOAD: usize = 64;
24pub const DEFAULT_DURATION_SECS: u64 = 5;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum Command {
30 Latency(BenchArgs),
32 Throughput(BenchArgs),
34 Info,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct BenchArgs {
41 pub domain: u32,
43 pub topic: String,
45 pub payload: usize,
47 pub duration: Duration,
49}
50
51impl Default for BenchArgs {
52 fn default() -> Self {
53 Self {
54 domain: DEFAULT_DOMAIN,
55 topic: DEFAULT_TOPIC.to_string(),
56 payload: DEFAULT_PAYLOAD,
57 duration: Duration::from_secs(DEFAULT_DURATION_SECS),
58 }
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum ParseError {
65 Missing,
67 Unknown(String),
69 MissingArg(&'static str),
71 BadValue {
73 flag: &'static str,
75 got: String,
77 },
78}
79
80impl std::fmt::Display for ParseError {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 match self {
83 Self::Missing => write!(f, "no sub-command given"),
84 Self::Unknown(s) => write!(f, "unknown sub-command: {s}"),
85 Self::MissingArg(a) => write!(f, "missing required arg: {a}"),
86 Self::BadValue { flag, got } => write!(f, "bad value for --{flag}: {got}"),
87 }
88 }
89}
90
91impl std::error::Error for ParseError {}
92
93pub fn parse_args(args: &[String]) -> Result<Command, ParseError> {
98 let sub = args.first().ok_or(ParseError::Missing)?;
99 match sub.as_str() {
100 "latency" => parse_bench(&args[1..]).map(Command::Latency),
101 "throughput" => parse_bench(&args[1..]).map(Command::Throughput),
102 "info" => {
103 if args.len() > 1 {
104 return Err(ParseError::Unknown(args[1].clone()));
105 }
106 Ok(Command::Info)
107 }
108 other => Err(ParseError::Unknown(other.to_string())),
109 }
110}
111
112fn parse_bench(rest: &[String]) -> Result<BenchArgs, ParseError> {
113 let mut out = BenchArgs::default();
114 let mut i = 0;
115 while i < rest.len() {
116 match rest[i].as_str() {
117 "--domain" | "-d" => {
118 i += 1;
119 let v = rest.get(i).ok_or(ParseError::MissingArg("domain"))?;
120 out.domain = v.parse().map_err(|_| ParseError::BadValue {
121 flag: "domain",
122 got: v.clone(),
123 })?;
124 }
125 "--topic" | "-t" => {
126 i += 1;
127 out.topic = rest.get(i).ok_or(ParseError::MissingArg("topic"))?.clone();
128 }
129 "--payload" | "-p" => {
130 i += 1;
131 let v = rest.get(i).ok_or(ParseError::MissingArg("payload"))?;
132 out.payload = v.parse().map_err(|_| ParseError::BadValue {
133 flag: "payload",
134 got: v.clone(),
135 })?;
136 }
137 "--duration" => {
138 i += 1;
139 let v = rest.get(i).ok_or(ParseError::MissingArg("duration"))?;
140 out.duration =
141 zerodds_cli_common::parse_duration(v).map_err(|_| ParseError::BadValue {
142 flag: "duration",
143 got: v.clone(),
144 })?;
145 }
146 other => return Err(ParseError::Unknown(other.to_string())),
147 }
148 i += 1;
149 }
150 Ok(out)
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct LatencyStats {
156 pub samples: usize,
158 pub min_ns: u64,
160 pub p50_ns: u64,
162 pub p99_ns: u64,
164 pub max_ns: u64,
166}
167
168#[must_use]
172pub fn compute_stats(rtts_ns: &mut [u64]) -> Option<LatencyStats> {
173 if rtts_ns.is_empty() {
174 return None;
175 }
176 rtts_ns.sort_unstable();
177 let n = rtts_ns.len();
178 let p50_idx = n / 2;
179 let p99_idx = n.saturating_mul(99) / 100;
180 Some(LatencyStats {
181 samples: n,
182 min_ns: rtts_ns[0],
183 p50_ns: rtts_ns[p50_idx.min(n - 1)],
184 p99_ns: rtts_ns[p99_idx.min(n - 1)],
185 max_ns: rtts_ns[n - 1],
186 })
187}
188
189#[cfg(test)]
190#[allow(
191 clippy::unwrap_used,
192 clippy::expect_used,
193 clippy::panic,
194 clippy::missing_panics_doc
195)]
196mod tests {
197 use super::*;
198
199 fn s(args: &[&str]) -> Vec<String> {
200 args.iter().map(|s| (*s).to_string()).collect()
201 }
202
203 #[test]
204 fn parse_latency_minimal() {
205 let cmd = parse_args(&s(&["latency"])).unwrap();
206 assert_eq!(cmd, Command::Latency(BenchArgs::default()));
207 }
208
209 #[test]
210 fn parse_latency_full() {
211 let cmd = parse_args(&s(&[
212 "latency",
213 "-d",
214 "5",
215 "-t",
216 "Foo",
217 "--payload",
218 "1024",
219 "--duration",
220 "30s",
221 ]))
222 .unwrap();
223 let Command::Latency(b) = cmd else {
224 panic!("expected latency");
225 };
226 assert_eq!(b.domain, 5);
227 assert_eq!(b.topic, "Foo");
228 assert_eq!(b.payload, 1024);
229 assert_eq!(b.duration, Duration::from_secs(30));
230 }
231
232 #[test]
233 fn parse_throughput_smoke() {
234 let cmd = parse_args(&s(&["throughput", "-p", "65536"])).unwrap();
235 let Command::Throughput(b) = cmd else {
236 panic!("expected throughput");
237 };
238 assert_eq!(b.payload, 65536);
239 }
240
241 #[test]
242 fn parse_info() {
243 let cmd = parse_args(&s(&["info"])).unwrap();
244 assert_eq!(cmd, Command::Info);
245 }
246
247 #[test]
248 fn parse_unknown_subcommand_rejected() {
249 let err = parse_args(&s(&["unknown"])).unwrap_err();
250 assert!(matches!(err, ParseError::Unknown(_)));
251 }
252
253 #[test]
254 fn parse_no_args_rejected() {
255 let err = parse_args(&[]).unwrap_err();
256 assert!(matches!(err, ParseError::Missing));
257 }
258
259 #[test]
260 fn parse_bad_payload_rejected() {
261 let err = parse_args(&s(&["latency", "--payload", "abc"])).unwrap_err();
262 assert!(matches!(err, ParseError::BadValue { .. }));
263 }
264
265 #[test]
266 fn compute_stats_basic() {
267 let mut rtts = vec![100u64, 200, 300, 400, 500];
268 let stats = compute_stats(&mut rtts).unwrap();
269 assert_eq!(stats.samples, 5);
270 assert_eq!(stats.min_ns, 100);
271 assert_eq!(stats.max_ns, 500);
272 assert_eq!(stats.p50_ns, 300);
273 }
274
275 #[test]
276 fn compute_stats_empty() {
277 let mut empty: Vec<u64> = Vec::new();
278 assert!(compute_stats(&mut empty).is_none());
279 }
280
281 #[test]
282 fn compute_stats_single() {
283 let mut single = vec![42u64];
284 let stats = compute_stats(&mut single).unwrap();
285 assert_eq!(stats.min_ns, 42);
286 assert_eq!(stats.max_ns, 42);
287 assert_eq!(stats.p50_ns, 42);
288 assert_eq!(stats.p99_ns, 42);
289 }
290}