Skip to main content

zerodds_bench/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! `zerodds-bench` library — CLI parsing + statistics primitives.
5//!
6//! Crate `zerodds-bench`. Safety classification: **COMFORT**.
7//! User-facing Bench-Tool; nutzt die DCPS-Pipeline ueber UserWriter/Reader.
8//!
9//! Das eigentliche Bench-Backend (DcpsRuntime + Loopback-Topic) lebt
10//! in `main.rs`. Diese Crate exportiert die argument-Strukturen und
11//! die Quantil-Berechnung damit beide unit-test-bar sind ohne ein
12//! tatsächliches DCPS-Setup zu starten.
13
14#![allow(clippy::module_name_repetitions)]
15
16use std::time::Duration;
17
18/// Default-Domain (DDS DOMAIN_ID 0).
19pub const DEFAULT_DOMAIN: u32 = 0;
20/// Default Bench-Topic.
21pub const DEFAULT_TOPIC: &str = "zerodds/bench/loopback";
22/// Default Payload-Größe in Bytes.
23pub const DEFAULT_PAYLOAD: usize = 64;
24/// Default Bench-Duration.
25pub const DEFAULT_DURATION_SECS: u64 = 5;
26
27/// Sub-command des Bench-CLIs.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum Command {
30    /// `latency` — RTT-style ping/echo Loopback-Latency.
31    Latency(BenchArgs),
32    /// `throughput` — Burst-Sender, msgs/s + MB/s.
33    Throughput(BenchArgs),
34    /// `info` — druckt Backend-Capabilities und exit.
35    Info,
36}
37
38/// Gemeinsame Argumente für Latency- und Throughput-Bench.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct BenchArgs {
41    /// DDS-Domain.
42    pub domain: u32,
43    /// Topic-Name.
44    pub topic: String,
45    /// Payload-Größe pro Sample.
46    pub payload: usize,
47    /// Run-Duration.
48    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/// Parse-Fehler beim CLI-args.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum ParseError {
65    /// Kein subcommand angegeben.
66    Missing,
67    /// Unbekanntes subcommand.
68    Unknown(String),
69    /// Required-arg fehlt.
70    MissingArg(&'static str),
71    /// Wert ist nicht parse-bar.
72    BadValue {
73        /// Welche flag.
74        flag: &'static str,
75        /// Was eingegeben war.
76        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
93/// Parst `args` (typisch `env::args().skip(1)`) zu einem [`Command`].
94///
95/// # Errors
96/// Siehe [`ParseError`].
97pub 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/// Statistik-Snapshot für Latency-Bench.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct LatencyStats {
156    /// Anzahl gesammelte Samples.
157    pub samples: usize,
158    /// Minimum (ns).
159    pub min_ns: u64,
160    /// Median (p50, ns).
161    pub p50_ns: u64,
162    /// 99th-Percentile (ns).
163    pub p99_ns: u64,
164    /// Maximum (ns).
165    pub max_ns: u64,
166}
167
168/// Berechnet [`LatencyStats`] aus einem Vector von Round-Trip-Times in ns.
169///
170/// Sortiert in-place. Liefert `None` falls leer.
171#[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}