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}
42
43impl Default for RecordArgs {
44 fn default() -> Self {
45 Self {
46 output: None,
47 domain: 0,
48 topics: Vec::new(),
49 duration: None,
50 max_sample_bytes: 1 << 20,
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct InfoArgs {
58 pub file: String,
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 "record" => parse_record(&args[1..]).map(Command::Record),
101 "info" => parse_info(&args[1..]).map(Command::Info),
102 "list" => parse_info(&args[1..]).map(Command::List),
103 other => Err(ParseError::Unknown(other.to_string())),
104 }
105}
106
107fn parse_record(rest: &[String]) -> Result<RecordArgs, ParseError> {
108 let mut out = RecordArgs::default();
109 let mut i = 0;
110 while i < rest.len() {
111 match rest[i].as_str() {
112 "--output" | "-o" => {
113 i += 1;
114 out.output = Some(rest.get(i).ok_or(ParseError::MissingArg("output"))?.clone());
115 }
116 "--domain" | "-d" => {
117 i += 1;
118 let v = rest.get(i).ok_or(ParseError::MissingArg("domain"))?;
119 out.domain = v.parse().map_err(|_| ParseError::BadValue {
120 flag: "domain",
121 got: v.clone(),
122 })?;
123 }
124 "--topic" | "-t" => {
125 i += 1;
126 out.topics
127 .push(rest.get(i).ok_or(ParseError::MissingArg("topic"))?.clone());
128 }
129 "--duration" => {
130 i += 1;
131 let v = rest.get(i).ok_or(ParseError::MissingArg("duration"))?;
132 out.duration = Some(parse_duration(v)?);
133 }
134 "--max-sample-bytes" => {
135 i += 1;
136 let v = rest
137 .get(i)
138 .ok_or(ParseError::MissingArg("max-sample-bytes"))?;
139 out.max_sample_bytes = v.parse().map_err(|_| ParseError::BadValue {
140 flag: "max-sample-bytes",
141 got: v.clone(),
142 })?;
143 }
144 other => return Err(ParseError::Unknown(other.to_string())),
145 }
146 i += 1;
147 }
148 Ok(out)
149}
150
151fn parse_info(rest: &[String]) -> Result<InfoArgs, ParseError> {
152 let file = rest.first().ok_or(ParseError::MissingArg("FILE"))?.clone();
153 Ok(InfoArgs { file })
154}
155
156fn parse_duration(s: &str) -> Result<Duration, ParseError> {
157 let bad = || ParseError::BadValue {
158 flag: "duration",
159 got: s.to_string(),
160 };
161 let (num, unit) = s
162 .find(|c: char| c.is_alphabetic())
163 .map_or((s, "s"), |idx| (&s[..idx], &s[idx..]));
164 let n: u64 = num.parse().map_err(|_| bad())?;
165 let secs = match unit {
166 "s" | "" => n,
167 "m" => n.checked_mul(60).ok_or_else(bad)?,
168 "h" => n.checked_mul(3600).ok_or_else(bad)?,
169 _ => return Err(bad()),
170 };
171 Ok(Duration::from_secs(secs))
172}
173
174pub fn read_header_summary(path: &str) -> std::io::Result<HeaderSummary> {
180 use zerodds_recorder::reader::RecordReader;
181 let bytes = std::fs::read(path)?;
182 let mut r = RecordReader::new(&bytes);
183 let h = r
184 .parse_header()
185 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{e:?}")))?;
186 Ok(HeaderSummary {
187 time_base_unix_ns: h.time_base_unix_ns,
188 participants: h.participants.len(),
189 topics: h.topics.iter().map(|t| t.name.clone()).collect(),
190 })
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct HeaderSummary {
196 pub time_base_unix_ns: i64,
198 pub participants: usize,
200 pub topics: Vec<String>,
202}
203
204pub fn count_frames_per_topic(path: &str) -> std::io::Result<Vec<(String, u64)>> {
210 use zerodds_recorder::reader::RecordReader;
211 let bytes = std::fs::read(path)?;
212 let mut r = RecordReader::new(&bytes);
213 let h = r
214 .parse_header()
215 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{e:?}")))?;
216 let topic_names: Vec<String> = h.topics.iter().map(|t| t.name.clone()).collect();
217 let mut counts = vec![0u64; topic_names.len()];
218
219 while let Some(frame) = r
220 .next_frame_view()
221 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{e:?}")))?
222 {
223 let idx = frame.topic_idx as usize;
224 if idx < counts.len() {
225 counts[idx] = counts[idx].saturating_add(1);
226 }
227 }
228
229 Ok(topic_names.into_iter().zip(counts).collect())
230}
231
232#[cfg(test)]
233#[allow(
234 clippy::unwrap_used,
235 clippy::expect_used,
236 clippy::panic,
237 clippy::missing_panics_doc
238)]
239mod tests {
240 use super::*;
241
242 fn s(args: &[&str]) -> Vec<String> {
243 args.iter().map(|s| (*s).to_string()).collect()
244 }
245
246 #[test]
247 fn parse_record_minimal() {
248 let cmd = parse_args(&s(&["record"])).unwrap();
249 assert_eq!(cmd, Command::Record(RecordArgs::default()));
250 }
251
252 #[test]
253 fn parse_record_full() {
254 let cmd = parse_args(&s(&[
255 "record",
256 "-o",
257 "out.zddsrec",
258 "--domain",
259 "7",
260 "-t",
261 "Sensor*",
262 "--duration",
263 "30s",
264 "--max-sample-bytes",
265 "65536",
266 ]))
267 .unwrap();
268 let Command::Record(r) = cmd else {
269 panic!("expected record");
270 };
271 assert_eq!(r.output.as_deref(), Some("out.zddsrec"));
272 assert_eq!(r.domain, 7);
273 assert_eq!(r.topics, vec!["Sensor*"]);
274 assert_eq!(r.duration, Some(Duration::from_secs(30)));
275 assert_eq!(r.max_sample_bytes, 65536);
276 }
277
278 #[test]
279 fn parse_duration_units() {
280 assert_eq!(parse_duration("5").unwrap(), Duration::from_secs(5));
281 assert_eq!(parse_duration("5s").unwrap(), Duration::from_secs(5));
282 assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
283 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
284 assert!(matches!(
285 parse_duration("3x"),
286 Err(ParseError::BadValue { .. })
287 ));
288 }
289
290 #[test]
291 fn parse_info_subcommand() {
292 let cmd = parse_args(&s(&["info", "capture.zddsrec"])).unwrap();
293 assert_eq!(
294 cmd,
295 Command::Info(InfoArgs {
296 file: "capture.zddsrec".into()
297 })
298 );
299 }
300
301 #[test]
302 fn parse_unknown_subcommand_rejected() {
303 let err = parse_args(&s(&["uhoh"])).unwrap_err();
304 assert!(matches!(err, ParseError::Unknown(_)));
305 }
306
307 #[test]
308 fn parse_no_args_rejected() {
309 let err = parse_args(&[]).unwrap_err();
310 assert!(matches!(err, ParseError::Missing));
311 }
312
313 #[test]
314 fn header_summary_reads_synthetic_file() {
315 use zerodds_recorder::format::{Header, ParticipantEntry, TopicEntry};
316
317 let mut buf = Vec::new();
318 let h = Header {
319 time_base_unix_ns: 1_700_000_000_000_000_000,
320 participants: vec![ParticipantEntry {
321 guid: [1u8; 16],
322 name: "test-participant".into(),
323 }],
324 topics: vec![TopicEntry {
325 name: "Foo".into(),
326 type_name: "TestType".into(),
327 }],
328 };
329 h.write(&mut buf);
330
331 let path =
332 std::env::temp_dir().join(format!("zds-rec-test-{}.zddsrec", std::process::id()));
333 std::fs::write(&path, &buf).unwrap();
334 let summary = read_header_summary(path.to_str().unwrap()).unwrap();
335 assert_eq!(summary.time_base_unix_ns, 1_700_000_000_000_000_000);
336 assert_eq!(summary.participants, 1);
337 assert_eq!(summary.topics, vec!["Foo"]);
338 let _ = std::fs::remove_file(path);
339 }
340}