Skip to main content

zerodds_spy/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! `zerodds-spy` library — argument parsing.
5//!
6//! Crate `zerodds-spy`. Safety classification: **COMFORT**.
7//! User-facing Topic-Spy-Tool; nur CLI-Frontend ohne Runtime-Pfade.
8
9#![allow(clippy::module_name_repetitions)]
10
11use std::time::Duration;
12
13/// Default Domain.
14pub const DEFAULT_DOMAIN: u32 = 0;
15/// Default Hex-Snippet-Bytes pro Sample.
16pub const DEFAULT_HEX_BYTES: usize = 32;
17
18/// Sub-command des Spy-CLIs.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum Command {
21    /// `subscribe` — abonniere TOPIC und dump samples.
22    Subscribe(SubscribeArgs),
23}
24
25/// Argumente für `subscribe`.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct SubscribeArgs {
28    /// DDS Domain.
29    pub domain: u32,
30    /// Topic-Name.
31    pub topic: String,
32    /// Maximale Anzahl Samples bevor Auto-Stop; `None` = unlimitiert.
33    pub max_samples: Option<u64>,
34    /// Maximale Lebensdauer; `None` = bis SIGINT.
35    pub duration: Option<Duration>,
36    /// Wieviele Bytes pro Sample als Hex drucken (0 = nur metadata).
37    pub hex_bytes: usize,
38}
39
40impl Default for SubscribeArgs {
41    fn default() -> Self {
42        Self {
43            domain: DEFAULT_DOMAIN,
44            topic: String::new(),
45            max_samples: None,
46            duration: None,
47            hex_bytes: DEFAULT_HEX_BYTES,
48        }
49    }
50}
51
52/// Parse-Fehler.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum ParseError {
55    /// Kein subcommand.
56    Missing,
57    /// Unbekanntes subcommand.
58    Unknown(String),
59    /// Required-arg fehlt.
60    MissingArg(&'static str),
61    /// Wert nicht parse-bar.
62    BadValue {
63        /// Welche flag.
64        flag: &'static str,
65        /// Was eingegeben war.
66        got: String,
67    },
68}
69
70impl std::fmt::Display for ParseError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Self::Missing => write!(f, "no sub-command given"),
74            Self::Unknown(s) => write!(f, "unknown sub-command: {s}"),
75            Self::MissingArg(a) => write!(f, "missing required arg: {a}"),
76            Self::BadValue { flag, got } => write!(f, "bad value for --{flag}: {got}"),
77        }
78    }
79}
80
81impl std::error::Error for ParseError {}
82
83/// Parst `args` (typisch `env::args().skip(1)`) zu einem [`Command`].
84///
85/// # Errors
86/// Siehe [`ParseError`].
87pub fn parse_args(args: &[String]) -> Result<Command, ParseError> {
88    // Default-subcommand: `subscribe` wenn der erste Token mit `-`
89    // anfaengt oder ein Topic-Name ist. Erlaubt sowohl
90    // `zerodds-spy subscribe -t Foo` als auch direkt
91    // `zerodds-spy -t Foo`.
92    let first = args.first().ok_or(ParseError::Missing)?;
93    let (sub, rest) = if first.starts_with('-') || (!first.is_empty() && !is_subcommand_name(first))
94    {
95        ("subscribe", args)
96    } else {
97        (first.as_str(), &args[1..])
98    };
99    match sub {
100        "subscribe" => parse_subscribe(rest).map(Command::Subscribe),
101        other => Err(ParseError::Unknown(other.to_string())),
102    }
103}
104
105const fn is_subcommand_name(s: &str) -> bool {
106    matches!(s.as_bytes(), b"subscribe")
107}
108
109fn parse_subscribe(rest: &[String]) -> Result<SubscribeArgs, ParseError> {
110    let mut out = SubscribeArgs::default();
111    let mut i = 0;
112    while i < rest.len() {
113        match rest[i].as_str() {
114            "--domain" | "-d" => {
115                i += 1;
116                let v = rest.get(i).ok_or(ParseError::MissingArg("domain"))?;
117                out.domain = v.parse().map_err(|_| ParseError::BadValue {
118                    flag: "domain",
119                    got: v.clone(),
120                })?;
121            }
122            "--topic" | "-t" => {
123                i += 1;
124                out.topic = rest.get(i).ok_or(ParseError::MissingArg("topic"))?.clone();
125            }
126            "--count" | "-n" => {
127                i += 1;
128                let v = rest.get(i).ok_or(ParseError::MissingArg("count"))?;
129                out.max_samples = Some(v.parse().map_err(|_| ParseError::BadValue {
130                    flag: "count",
131                    got: v.clone(),
132                })?);
133            }
134            "--duration" => {
135                i += 1;
136                let v = rest.get(i).ok_or(ParseError::MissingArg("duration"))?;
137                out.duration = Some(zerodds_cli_common::parse_duration(v).map_err(|_| {
138                    ParseError::BadValue {
139                        flag: "duration",
140                        got: v.clone(),
141                    }
142                })?);
143            }
144            "--hex" | "-x" => {
145                i += 1;
146                let v = rest.get(i).ok_or(ParseError::MissingArg("hex"))?;
147                out.hex_bytes = v.parse().map_err(|_| ParseError::BadValue {
148                    flag: "hex",
149                    got: v.clone(),
150                })?;
151            }
152            other => return Err(ParseError::Unknown(other.to_string())),
153        }
154        i += 1;
155    }
156    Ok(out)
157}
158
159/// Formatiert ein Byte-Slice als Hex, max `limit` Bytes mit `…`-Suffix
160/// wenn länger.
161#[must_use]
162pub fn format_hex_snippet(bytes: &[u8], limit: usize) -> String {
163    if limit == 0 {
164        return String::new();
165    }
166    let take = bytes.len().min(limit);
167    let mut out = String::with_capacity(take * 3);
168    for (i, b) in bytes[..take].iter().enumerate() {
169        if i > 0 && i % 4 == 0 {
170            out.push(' ');
171        }
172        out.push_str(&format!("{b:02x}"));
173    }
174    if bytes.len() > limit {
175        out.push_str(&format!(" …(+{}B)", bytes.len() - limit));
176    }
177    out
178}
179
180#[cfg(test)]
181#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
182mod tests {
183    use super::*;
184
185    fn s(args: &[&str]) -> Vec<String> {
186        args.iter().map(|s| (*s).to_string()).collect()
187    }
188
189    #[test]
190    fn parse_subscribe_explicit() {
191        let cmd = parse_args(&s(&["subscribe", "-t", "Foo"])).unwrap();
192        let Command::Subscribe(a) = cmd;
193        assert_eq!(a.topic, "Foo");
194    }
195
196    #[test]
197    fn parse_subscribe_implicit() {
198        let cmd = parse_args(&s(&["-t", "Bar"])).unwrap();
199        let Command::Subscribe(a) = cmd;
200        assert_eq!(a.topic, "Bar");
201    }
202
203    #[test]
204    fn parse_subscribe_full() {
205        let cmd = parse_args(&s(&[
206            "subscribe",
207            "-d",
208            "5",
209            "-t",
210            "X",
211            "-n",
212            "10",
213            "--duration",
214            "30s",
215            "-x",
216            "16",
217        ]))
218        .unwrap();
219        let Command::Subscribe(a) = cmd;
220        assert_eq!(a.domain, 5);
221        assert_eq!(a.topic, "X");
222        assert_eq!(a.max_samples, Some(10));
223        assert_eq!(a.duration, Some(Duration::from_secs(30)));
224        assert_eq!(a.hex_bytes, 16);
225    }
226
227    #[test]
228    fn parse_no_args_rejected() {
229        assert!(matches!(parse_args(&[]), Err(ParseError::Missing)));
230    }
231
232    #[test]
233    fn parse_unknown_subcommand_rejected() {
234        // "publish" ist kein Subcommand und kein Flag → Unknown.
235        let err = parse_args(&s(&["publish", "-t", "Foo"])).unwrap_err();
236        assert!(matches!(err, ParseError::Unknown(_)));
237    }
238
239    #[test]
240    fn parse_bad_count_rejected() {
241        assert!(matches!(
242            parse_args(&s(&["subscribe", "-n", "abc"])),
243            Err(ParseError::BadValue { .. })
244        ));
245    }
246
247    #[test]
248    fn format_hex_snippet_basic() {
249        let s = format_hex_snippet(&[0xde, 0xad, 0xbe, 0xef, 0x01, 0x02], 4);
250        assert!(s.starts_with("deadbeef"));
251        assert!(s.contains("…(+2B)"));
252    }
253
254    #[test]
255    fn format_hex_snippet_zero_limit() {
256        assert_eq!(format_hex_snippet(&[1, 2, 3], 0), "");
257    }
258
259    #[test]
260    fn format_hex_snippet_no_truncation() {
261        let s = format_hex_snippet(&[0x12, 0x34], 4);
262        assert_eq!(s, "1234");
263    }
264}