Skip to main content

zerodds_snitch/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! `zerodds-snitch` library — argument parsing.
5//!
6//! Crate `zerodds-snitch`. Safety classification: **COMFORT**.
7//! User-facing Discovery-Probe; nur CLI-Frontend.
8
9#![allow(clippy::module_name_repetitions)]
10
11use std::time::Duration;
12
13/// Default Domain.
14pub const DEFAULT_DOMAIN: u32 = 0;
15/// Default Probe-Duration.
16pub const DEFAULT_DURATION_SECS: u64 = 5;
17
18/// Sub-command des Snitch-CLIs.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum Command {
21    /// `probe` — startet kurzen Discovery-Run und druckt Snapshot.
22    Probe(ProbeArgs),
23}
24
25/// Argumente für `probe`.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ProbeArgs {
28    /// DDS Domain.
29    pub domain: u32,
30    /// Probe-Duration.
31    pub duration: Duration,
32    /// Output-Format.
33    pub format: ProbeFormat,
34}
35
36/// Output-Format für `probe`.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ProbeFormat {
39    /// Mensch-lesbarer Text-Tree.
40    Text,
41    /// JSON-Lines (eine Zeile pro Participant).
42    Json,
43}
44
45impl Default for ProbeArgs {
46    fn default() -> Self {
47        Self {
48            domain: DEFAULT_DOMAIN,
49            duration: Duration::from_secs(DEFAULT_DURATION_SECS),
50            format: ProbeFormat::Text,
51        }
52    }
53}
54
55/// Parse-Fehler.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum ParseError {
58    /// Kein subcommand.
59    Missing,
60    /// Unbekanntes subcommand.
61    Unknown(String),
62    /// Required-arg fehlt.
63    MissingArg(&'static str),
64    /// Wert nicht parse-bar.
65    BadValue {
66        /// Welche flag.
67        flag: &'static str,
68        /// Was eingegeben war.
69        got: String,
70    },
71}
72
73impl std::fmt::Display for ParseError {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Self::Missing => write!(f, "no sub-command given"),
77            Self::Unknown(s) => write!(f, "unknown sub-command: {s}"),
78            Self::MissingArg(a) => write!(f, "missing required arg: {a}"),
79            Self::BadValue { flag, got } => write!(f, "bad value for --{flag}: {got}"),
80        }
81    }
82}
83
84impl std::error::Error for ParseError {}
85
86/// Parst `args` zu einem [`Command`]. `probe` ist implizit wenn das
87/// erste Token mit `-` anfängt.
88///
89/// # Errors
90/// Siehe [`ParseError`].
91pub fn parse_args(args: &[String]) -> Result<Command, ParseError> {
92    let first = args.first().ok_or(ParseError::Missing)?;
93    let (sub, rest) = if first.starts_with('-') {
94        ("probe", args)
95    } else {
96        (first.as_str(), &args[1..])
97    };
98    match sub {
99        "probe" => parse_probe(rest).map(Command::Probe),
100        other => Err(ParseError::Unknown(other.to_string())),
101    }
102}
103
104fn parse_probe(rest: &[String]) -> Result<ProbeArgs, ParseError> {
105    let mut out = ProbeArgs::default();
106    let mut i = 0;
107    while i < rest.len() {
108        match rest[i].as_str() {
109            "--domain" | "-d" => {
110                i += 1;
111                let v = rest.get(i).ok_or(ParseError::MissingArg("domain"))?;
112                out.domain = v.parse().map_err(|_| ParseError::BadValue {
113                    flag: "domain",
114                    got: v.clone(),
115                })?;
116            }
117            "--duration" => {
118                i += 1;
119                let v = rest.get(i).ok_or(ParseError::MissingArg("duration"))?;
120                out.duration =
121                    zerodds_cli_common::parse_duration(v).map_err(|_| ParseError::BadValue {
122                        flag: "duration",
123                        got: v.clone(),
124                    })?;
125            }
126            "--format" | "-f" => {
127                i += 1;
128                let v = rest.get(i).ok_or(ParseError::MissingArg("format"))?;
129                out.format = match v.as_str() {
130                    "text" => ProbeFormat::Text,
131                    "json" => ProbeFormat::Json,
132                    other => {
133                        return Err(ParseError::BadValue {
134                            flag: "format",
135                            got: other.to_string(),
136                        });
137                    }
138                };
139            }
140            other => return Err(ParseError::Unknown(other.to_string())),
141        }
142        i += 1;
143    }
144    Ok(out)
145}
146
147#[cfg(test)]
148#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
149mod tests {
150    use super::*;
151
152    fn s(args: &[&str]) -> Vec<String> {
153        args.iter().map(|s| (*s).to_string()).collect()
154    }
155
156    #[test]
157    fn parse_probe_default() {
158        let cmd = parse_args(&s(&["probe"])).unwrap();
159        assert_eq!(cmd, Command::Probe(ProbeArgs::default()));
160    }
161
162    #[test]
163    fn parse_probe_implicit() {
164        let cmd = parse_args(&s(&["-d", "5"])).unwrap();
165        let Command::Probe(p) = cmd;
166        assert_eq!(p.domain, 5);
167    }
168
169    #[test]
170    fn parse_probe_full() {
171        let cmd = parse_args(&s(&[
172            "probe",
173            "-d",
174            "5",
175            "--duration",
176            "10s",
177            "--format",
178            "json",
179        ]))
180        .unwrap();
181        let Command::Probe(p) = cmd;
182        assert_eq!(p.domain, 5);
183        assert_eq!(p.duration, Duration::from_secs(10));
184        assert_eq!(p.format, ProbeFormat::Json);
185    }
186
187    #[test]
188    fn parse_no_args_rejected() {
189        assert!(matches!(parse_args(&[]), Err(ParseError::Missing)));
190    }
191
192    #[test]
193    fn parse_unknown_subcommand_rejected() {
194        let err = parse_args(&s(&["wat"])).unwrap_err();
195        assert!(matches!(err, ParseError::Unknown(_)));
196    }
197
198    #[test]
199    fn parse_bad_format_rejected() {
200        assert!(matches!(
201            parse_args(&s(&["probe", "--format", "xml"])),
202            Err(ParseError::BadValue { .. })
203        ));
204    }
205}