Skip to main content

zerodds_record/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! `zerodds-record` library — CLI parsing + dispatch logic.
5//!
6//! Crate `zerodds-record`. Safety classification: **COMFORT**.
7//! User-facing CLI-Tool; das Recording-Backend ist `zerodds-recorder`.
8//!
9//! Das eigentliche Recording-Backend liegt in `zerodds-recorder`
10//! (`crates/recorder/`). Diese Crate liefert nur das CLI-Frontend
11//! plus Inspect-Helpers für `.zddsrec`-Files.
12
13#![allow(clippy::module_name_repetitions)]
14
15use std::time::Duration;
16
17/// Sub-command des Record-CLIs.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum Command {
20    /// `record` — startet eine Capture-Session.
21    Record(RecordArgs),
22    /// `info <FILE>` — liest Header eines `.zddsrec` und druckt Metadata.
23    Info(InfoArgs),
24    /// `list <FILE>` — zählt Frames pro Topic und druckt Sample-Count.
25    List(InfoArgs),
26}
27
28/// Argumente für `zerodds-record record`.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct RecordArgs {
31    /// Output-Pfad für `.zddsrec`. Default: `capture-<timestamp>.zddsrec`.
32    pub output: Option<String>,
33    /// DDS-Domain-ID. Default: 0.
34    pub domain: u32,
35    /// Topic-Filter (Prefix oder Glob); leer = alle Topics.
36    pub topics: Vec<String>,
37    /// Recording-Duration; `None` = bis SIGINT.
38    pub duration: Option<Duration>,
39    /// Maximale Sample-Größe (DoS-Cap). Default 1 MiB.
40    pub max_sample_bytes: usize,
41    /// Decode opaque CDR samples to typed values (needs `type_file`).
42    pub decode: bool,
43    /// Out-of-band IDL file providing the types to decode against.
44    pub type_file: Option<String>,
45    /// `topic=TypeName` mappings; resolves which IDL type decodes a topic.
46    pub map: Vec<String>,
47    /// Write decoded samples as NDJSON to this path.
48    pub out_json: Option<String>,
49    /// Write decoded samples to a per-topic relational SQLite DB at this path.
50    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/// Argumente für `info` und `list`.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct InfoArgs {
73    /// Pfad zur `.zddsrec`-Datei.
74    pub file: String,
75}
76
77/// Parse-Fehler beim CLI-args.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum ParseError {
80    /// Kein subcommand angegeben.
81    Missing,
82    /// Unbekanntes subcommand.
83    Unknown(String),
84    /// Required-arg fehlt.
85    MissingArg(&'static str),
86    /// Wert ist nicht parse-bar.
87    BadValue {
88        /// Welche flag.
89        flag: &'static str,
90        /// Was eingegeben war.
91        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
108/// Parse `args` (typisch `env::args().skip(1)`) zu einem Command.
109///
110/// # Errors
111/// Siehe [`ParseError`].
112pub 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
221/// Versucht den Header eines `.zddsrec` zu lesen und liefert
222/// menschen-lesbare Zusammenfassung.
223///
224/// # Errors
225/// I/O- oder Format-Fehler.
226pub 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/// Komprimierte Header-Info zum Drucken.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct HeaderSummary {
243    /// Zeit-Anker in Unix-ns.
244    pub time_base_unix_ns: i64,
245    /// Anzahl Participants im Header.
246    pub participants: usize,
247    /// Topic-Namen.
248    pub topics: Vec<String>,
249}
250
251/// Zählt Frames pro Topic in einem `.zddsrec` und liefert
252/// Topic-Name → Sample-Count Map.
253///
254/// # Errors
255/// I/O- oder Format-Fehler.
256pub 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}