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}
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/// Argumente für `info` und `list`.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct InfoArgs {
58    /// Pfad zur `.zddsrec`-Datei.
59    pub file: String,
60}
61
62/// Parse-Fehler beim CLI-args.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum ParseError {
65    /// Kein subcommand angegeben.
66    Missing,
67    /// Unbekanntes subcommand.
68    Unknown(String),
69    /// Required-arg fehlt.
70    MissingArg(&'static str),
71    /// Wert ist nicht parse-bar.
72    BadValue {
73        /// Welche flag.
74        flag: &'static str,
75        /// Was eingegeben war.
76        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
93/// Parse `args` (typisch `env::args().skip(1)`) zu einem Command.
94///
95/// # Errors
96/// Siehe [`ParseError`].
97pub 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
174/// Versucht den Header eines `.zddsrec` zu lesen und liefert
175/// menschen-lesbare Zusammenfassung.
176///
177/// # Errors
178/// I/O- oder Format-Fehler.
179pub 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/// Komprimierte Header-Info zum Drucken.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct HeaderSummary {
196    /// Zeit-Anker in Unix-ns.
197    pub time_base_unix_ns: i64,
198    /// Anzahl Participants im Header.
199    pub participants: usize,
200    /// Topic-Namen.
201    pub topics: Vec<String>,
202}
203
204/// Zählt Frames pro Topic in einem `.zddsrec` und liefert
205/// Topic-Name → Sample-Count Map.
206///
207/// # Errors
208/// I/O- oder Format-Fehler.
209pub 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}