1#![allow(clippy::module_name_repetitions)]
14
15use std::time::Duration;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum Command {
20 Record(RecordArgs),
22 Info(InfoArgs),
24 List(InfoArgs),
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct RecordArgs {
31 pub output: Option<String>,
33 pub domain: u32,
35 pub topics: Vec<String>,
37 pub duration: Option<Duration>,
39 pub max_sample_bytes: usize,
41 pub decode: bool,
43 pub type_file: Option<String>,
45 pub map: Vec<String>,
47 pub out_json: Option<String>,
49 pub out_sqlite: Option<String>,
51}
52
53impl Default for RecordArgs {
54 fn default() -> Self {
55 Self {
56 output: None,
57 domain: 0,
58 topics: Vec::new(),
59 duration: None,
60 max_sample_bytes: 1 << 20,
61 decode: false,
62 type_file: None,
63 map: Vec::new(),
64 out_json: None,
65 out_sqlite: None,
66 }
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct InfoArgs {
73 pub file: String,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum ParseError {
80 Missing,
82 Unknown(String),
84 MissingArg(&'static str),
86 BadValue {
88 flag: &'static str,
90 got: String,
92 },
93}
94
95impl std::fmt::Display for ParseError {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 match self {
98 Self::Missing => write!(f, "no sub-command given"),
99 Self::Unknown(s) => write!(f, "unknown sub-command: {s}"),
100 Self::MissingArg(a) => write!(f, "missing required arg: {a}"),
101 Self::BadValue { flag, got } => write!(f, "bad value for --{flag}: {got}"),
102 }
103 }
104}
105
106impl std::error::Error for ParseError {}
107
108pub fn parse_args(args: &[String]) -> Result<Command, ParseError> {
113 let sub = args.first().ok_or(ParseError::Missing)?;
114 match sub.as_str() {
115 "record" => parse_record(&args[1..]).map(Command::Record),
116 "info" => parse_info(&args[1..]).map(Command::Info),
117 "list" => parse_info(&args[1..]).map(Command::List),
118 other => Err(ParseError::Unknown(other.to_string())),
119 }
120}
121
122fn parse_record(rest: &[String]) -> Result<RecordArgs, ParseError> {
123 let mut out = RecordArgs::default();
124 let mut i = 0;
125 while i < rest.len() {
126 match rest[i].as_str() {
127 "--output" | "-o" => {
128 i += 1;
129 out.output = Some(rest.get(i).ok_or(ParseError::MissingArg("output"))?.clone());
130 }
131 "--domain" | "-d" => {
132 i += 1;
133 let v = rest.get(i).ok_or(ParseError::MissingArg("domain"))?;
134 out.domain = v.parse().map_err(|_| ParseError::BadValue {
135 flag: "domain",
136 got: v.clone(),
137 })?;
138 }
139 "--topic" | "-t" => {
140 i += 1;
141 out.topics
142 .push(rest.get(i).ok_or(ParseError::MissingArg("topic"))?.clone());
143 }
144 "--duration" => {
145 i += 1;
146 let v = rest.get(i).ok_or(ParseError::MissingArg("duration"))?;
147 out.duration = Some(parse_duration(v)?);
148 }
149 "--max-sample-bytes" => {
150 i += 1;
151 let v = rest
152 .get(i)
153 .ok_or(ParseError::MissingArg("max-sample-bytes"))?;
154 out.max_sample_bytes = v.parse().map_err(|_| ParseError::BadValue {
155 flag: "max-sample-bytes",
156 got: v.clone(),
157 })?;
158 }
159 "--decode" => {
160 out.decode = true;
161 }
162 "--type-file" => {
163 i += 1;
164 out.type_file = Some(
165 rest.get(i)
166 .ok_or(ParseError::MissingArg("type-file"))?
167 .clone(),
168 );
169 }
170 "--map" => {
171 i += 1;
172 out.map
173 .push(rest.get(i).ok_or(ParseError::MissingArg("map"))?.clone());
174 }
175 "--out-json" => {
176 i += 1;
177 out.out_json = Some(
178 rest.get(i)
179 .ok_or(ParseError::MissingArg("out-json"))?
180 .clone(),
181 );
182 }
183 "--out-sqlite" => {
184 i += 1;
185 out.out_sqlite = Some(
186 rest.get(i)
187 .ok_or(ParseError::MissingArg("out-sqlite"))?
188 .clone(),
189 );
190 }
191 other => return Err(ParseError::Unknown(other.to_string())),
192 }
193 i += 1;
194 }
195 Ok(out)
196}
197
198fn parse_info(rest: &[String]) -> Result<InfoArgs, ParseError> {
199 let file = rest.first().ok_or(ParseError::MissingArg("FILE"))?.clone();
200 Ok(InfoArgs { file })
201}
202
203fn parse_duration(s: &str) -> Result<Duration, ParseError> {
204 let bad = || ParseError::BadValue {
205 flag: "duration",
206 got: s.to_string(),
207 };
208 let (num, unit) = s
209 .find(|c: char| c.is_alphabetic())
210 .map_or((s, "s"), |idx| (&s[..idx], &s[idx..]));
211 let n: u64 = num.parse().map_err(|_| bad())?;
212 let secs = match unit {
213 "s" | "" => n,
214 "m" => n.checked_mul(60).ok_or_else(bad)?,
215 "h" => n.checked_mul(3600).ok_or_else(bad)?,
216 _ => return Err(bad()),
217 };
218 Ok(Duration::from_secs(secs))
219}
220
221pub fn read_header_summary(path: &str) -> std::io::Result<HeaderSummary> {
227 use zerodds_recorder::reader::RecordReader;
228 let bytes = std::fs::read(path)?;
229 let mut r = RecordReader::new(&bytes);
230 let h = r
231 .parse_header()
232 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{e:?}")))?;
233 Ok(HeaderSummary {
234 time_base_unix_ns: h.time_base_unix_ns,
235 participants: h.participants.len(),
236 topics: h.topics.iter().map(|t| t.name.clone()).collect(),
237 })
238}
239
240#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct HeaderSummary {
243 pub time_base_unix_ns: i64,
245 pub participants: usize,
247 pub topics: Vec<String>,
249}
250
251pub fn count_frames_per_topic(path: &str) -> std::io::Result<Vec<(String, u64)>> {
257 use zerodds_recorder::reader::RecordReader;
258 let bytes = std::fs::read(path)?;
259 let mut r = RecordReader::new(&bytes);
260 let h = r
261 .parse_header()
262 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{e:?}")))?;
263 let topic_names: Vec<String> = h.topics.iter().map(|t| t.name.clone()).collect();
264 let mut counts = vec![0u64; topic_names.len()];
265
266 while let Some(frame) = r
267 .next_frame_view()
268 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{e:?}")))?
269 {
270 let idx = frame.topic_idx as usize;
271 if idx < counts.len() {
272 counts[idx] = counts[idx].saturating_add(1);
273 }
274 }
275
276 Ok(topic_names.into_iter().zip(counts).collect())
277}
278
279#[cfg(test)]
280#[allow(
281 clippy::unwrap_used,
282 clippy::expect_used,
283 clippy::panic,
284 clippy::missing_panics_doc
285)]
286mod tests {
287 use super::*;
288
289 fn s(args: &[&str]) -> Vec<String> {
290 args.iter().map(|s| (*s).to_string()).collect()
291 }
292
293 #[test]
294 fn parse_record_minimal() {
295 let cmd = parse_args(&s(&["record"])).unwrap();
296 assert_eq!(cmd, Command::Record(RecordArgs::default()));
297 }
298
299 #[test]
300 fn parse_record_full() {
301 let cmd = parse_args(&s(&[
302 "record",
303 "-o",
304 "out.zddsrec",
305 "--domain",
306 "7",
307 "-t",
308 "Sensor*",
309 "--duration",
310 "30s",
311 "--max-sample-bytes",
312 "65536",
313 ]))
314 .unwrap();
315 let Command::Record(r) = cmd else {
316 panic!("expected record");
317 };
318 assert_eq!(r.output.as_deref(), Some("out.zddsrec"));
319 assert_eq!(r.domain, 7);
320 assert_eq!(r.topics, vec!["Sensor*"]);
321 assert_eq!(r.duration, Some(Duration::from_secs(30)));
322 assert_eq!(r.max_sample_bytes, 65536);
323 }
324
325 #[test]
326 fn parse_duration_units() {
327 assert_eq!(parse_duration("5").unwrap(), Duration::from_secs(5));
328 assert_eq!(parse_duration("5s").unwrap(), Duration::from_secs(5));
329 assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
330 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
331 assert!(matches!(
332 parse_duration("3x"),
333 Err(ParseError::BadValue { .. })
334 ));
335 }
336
337 #[test]
338 fn parse_info_subcommand() {
339 let cmd = parse_args(&s(&["info", "capture.zddsrec"])).unwrap();
340 assert_eq!(
341 cmd,
342 Command::Info(InfoArgs {
343 file: "capture.zddsrec".into()
344 })
345 );
346 }
347
348 #[test]
349 fn parse_unknown_subcommand_rejected() {
350 let err = parse_args(&s(&["uhoh"])).unwrap_err();
351 assert!(matches!(err, ParseError::Unknown(_)));
352 }
353
354 #[test]
355 fn parse_no_args_rejected() {
356 let err = parse_args(&[]).unwrap_err();
357 assert!(matches!(err, ParseError::Missing));
358 }
359
360 #[test]
361 fn header_summary_reads_synthetic_file() {
362 use zerodds_recorder::format::{Header, ParticipantEntry, TopicEntry};
363
364 let mut buf = Vec::new();
365 let h = Header {
366 time_base_unix_ns: 1_700_000_000_000_000_000,
367 participants: vec![ParticipantEntry {
368 guid: [1u8; 16],
369 name: "test-participant".into(),
370 }],
371 topics: vec![TopicEntry {
372 name: "Foo".into(),
373 type_name: "TestType".into(),
374 }],
375 };
376 h.write(&mut buf);
377
378 let path =
379 std::env::temp_dir().join(format!("zds-rec-test-{}.zddsrec", std::process::id()));
380 std::fs::write(&path, &buf).unwrap();
381 let summary = read_header_summary(path.to_str().unwrap()).unwrap();
382 assert_eq!(summary.time_base_unix_ns, 1_700_000_000_000_000_000);
383 assert_eq!(summary.participants, 1);
384 assert_eq!(summary.topics, vec!["Foo"]);
385 let _ = std::fs::remove_file(path);
386 }
387}