1#![allow(clippy::module_name_repetitions)]
10
11use std::time::Duration;
12
13pub const DEFAULT_DOMAIN: u32 = 0;
15pub const DEFAULT_HEX_BYTES: usize = 32;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum Command {
21 Subscribe(SubscribeArgs),
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct SubscribeArgs {
28 pub domain: u32,
30 pub topic: String,
32 pub max_samples: Option<u64>,
34 pub duration: Option<Duration>,
36 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#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum ParseError {
55 Missing,
57 Unknown(String),
59 MissingArg(&'static str),
61 BadValue {
63 flag: &'static str,
65 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
83pub fn parse_args(args: &[String]) -> Result<Command, ParseError> {
88 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#[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 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}