Skip to main content

zerodds_mq/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! `zerodds-mq` library — argument parsing.
5//!
6//! Crate `zerodds-mq`. Safety classification: **COMFORT**.
7//! User-facing cross-domain Bridge; nur CLI-Frontend.
8
9#![allow(clippy::module_name_repetitions)]
10
11use std::time::Duration;
12
13/// Default Source-Domain.
14pub const DEFAULT_SRC_DOMAIN: u32 = 0;
15/// Default Destination-Domain.
16pub const DEFAULT_DST_DOMAIN: u32 = 1;
17
18/// Sub-command des MQ-CLIs.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum Command {
21    /// `bridge` — leitet ein Topic von Source-Domain in Destination-Domain.
22    Bridge(BridgeArgs),
23}
24
25/// Argumente für `bridge`.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct BridgeArgs {
28    /// Source-Domain.
29    pub src_domain: u32,
30    /// Destination-Domain.
31    pub dst_domain: u32,
32    /// Topic auf Source-Seite.
33    pub src_topic: String,
34    /// Topic auf Destination-Seite (default == src_topic).
35    pub dst_topic: String,
36    /// Maximale Lebensdauer; `None` = bis SIGINT.
37    pub duration: Option<Duration>,
38    /// Bidirektional (sub<->pub auf beiden Seiten).
39    pub bidirectional: bool,
40}
41
42impl Default for BridgeArgs {
43    fn default() -> Self {
44        Self {
45            src_domain: DEFAULT_SRC_DOMAIN,
46            dst_domain: DEFAULT_DST_DOMAIN,
47            src_topic: String::new(),
48            dst_topic: String::new(),
49            duration: None,
50            bidirectional: false,
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`]. `bridge` 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        ("bridge", args)
95    } else {
96        (first.as_str(), &args[1..])
97    };
98    match sub {
99        "bridge" => parse_bridge(rest).map(Command::Bridge),
100        other => Err(ParseError::Unknown(other.to_string())),
101    }
102}
103
104fn parse_bridge(rest: &[String]) -> Result<BridgeArgs, ParseError> {
105    let mut out = BridgeArgs::default();
106    let mut i = 0;
107    while i < rest.len() {
108        match rest[i].as_str() {
109            "--src-domain" => {
110                i += 1;
111                let v = rest.get(i).ok_or(ParseError::MissingArg("src-domain"))?;
112                out.src_domain = v.parse().map_err(|_| ParseError::BadValue {
113                    flag: "src-domain",
114                    got: v.clone(),
115                })?;
116            }
117            "--dst-domain" => {
118                i += 1;
119                let v = rest.get(i).ok_or(ParseError::MissingArg("dst-domain"))?;
120                out.dst_domain = v.parse().map_err(|_| ParseError::BadValue {
121                    flag: "dst-domain",
122                    got: v.clone(),
123                })?;
124            }
125            "--topic" | "-t" => {
126                i += 1;
127                let topic = rest.get(i).ok_or(ParseError::MissingArg("topic"))?.clone();
128                out.src_topic = topic.clone();
129                if out.dst_topic.is_empty() {
130                    out.dst_topic = topic;
131                }
132            }
133            "--src-topic" => {
134                i += 1;
135                out.src_topic = rest
136                    .get(i)
137                    .ok_or(ParseError::MissingArg("src-topic"))?
138                    .clone();
139            }
140            "--dst-topic" => {
141                i += 1;
142                out.dst_topic = rest
143                    .get(i)
144                    .ok_or(ParseError::MissingArg("dst-topic"))?
145                    .clone();
146            }
147            "--duration" => {
148                i += 1;
149                let v = rest.get(i).ok_or(ParseError::MissingArg("duration"))?;
150                out.duration = Some(zerodds_cli_common::parse_duration(v).map_err(|_| {
151                    ParseError::BadValue {
152                        flag: "duration",
153                        got: v.clone(),
154                    }
155                })?);
156            }
157            "--bidirectional" => {
158                out.bidirectional = true;
159            }
160            other => return Err(ParseError::Unknown(other.to_string())),
161        }
162        i += 1;
163    }
164    Ok(out)
165}
166
167#[cfg(test)]
168#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
169mod tests {
170    use super::*;
171
172    fn s(args: &[&str]) -> Vec<String> {
173        args.iter().map(|s| (*s).to_string()).collect()
174    }
175
176    #[test]
177    fn parse_bridge_minimal() {
178        let cmd = parse_args(&s(&["bridge", "-t", "Foo"])).unwrap();
179        let Command::Bridge(b) = cmd;
180        assert_eq!(b.src_topic, "Foo");
181        assert_eq!(b.dst_topic, "Foo");
182        assert_eq!(b.src_domain, 0);
183        assert_eq!(b.dst_domain, 1);
184    }
185
186    #[test]
187    fn parse_bridge_implicit() {
188        let cmd = parse_args(&s(&["-t", "Bar"])).unwrap();
189        let Command::Bridge(b) = cmd;
190        assert_eq!(b.src_topic, "Bar");
191    }
192
193    #[test]
194    fn parse_bridge_full() {
195        let cmd = parse_args(&s(&[
196            "bridge",
197            "--src-domain",
198            "0",
199            "--dst-domain",
200            "5",
201            "--src-topic",
202            "X",
203            "--dst-topic",
204            "Y",
205            "--duration",
206            "10s",
207            "--bidirectional",
208        ]))
209        .unwrap();
210        let Command::Bridge(b) = cmd;
211        assert_eq!(b.src_domain, 0);
212        assert_eq!(b.dst_domain, 5);
213        assert_eq!(b.src_topic, "X");
214        assert_eq!(b.dst_topic, "Y");
215        assert_eq!(b.duration, Some(Duration::from_secs(10)));
216        assert!(b.bidirectional);
217    }
218
219    #[test]
220    fn parse_bridge_topic_propagates_to_dst() {
221        let cmd = parse_args(&s(&["bridge", "-t", "T1"])).unwrap();
222        let Command::Bridge(b) = cmd;
223        assert_eq!(b.src_topic, b.dst_topic);
224    }
225
226    #[test]
227    fn parse_no_args_rejected() {
228        assert!(matches!(parse_args(&[]), Err(ParseError::Missing)));
229    }
230
231    #[test]
232    fn parse_unknown_subcommand_rejected() {
233        let err = parse_args(&s(&["wat"])).unwrap_err();
234        assert!(matches!(err, ParseError::Unknown(_)));
235    }
236
237    #[test]
238    fn parse_bad_domain_rejected() {
239        assert!(matches!(
240            parse_args(&s(&["bridge", "--src-domain", "abc"])),
241            Err(ParseError::BadValue { .. })
242        ));
243    }
244}