Skip to main content

zerodds_websocket_bridge/daemon/
cli.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! CLI-Argument-Parser fuer `zerodds-ws-bridged`.
5//!
6//! Spec: `zerodds-ws-bridge-1.0.md` §2.
7//!
8//! Bewusst handgeschrieben — keine `clap`-Dep im Workspace, keine
9//! `getopts`-Heritage. Akzeptierte Forms:
10//!
11//! * `--flag value`
12//! * `--flag=value`
13//! * `--bool-flag`            (no-arg, fuer `--help` / `--version`)
14//! * Wiederholungen fuer `--topic` (Multi-Value)
15
16use std::string::String;
17use std::vec::Vec;
18
19/// Geparste CLI-Args.
20#[derive(Debug, Clone, Default)]
21pub struct CliArgs {
22    /// `--config <FILE>` — Path zur YAML-Config.
23    pub config: Option<String>,
24    /// `--listen <ADDR>` — Override fuer Bind-Address.
25    pub listen: Option<String>,
26    /// `--domain <ID>` — DDS-Domain-ID-Override.
27    pub domain: Option<i32>,
28    /// `--topic <NAME[:KEY]>` — Single-Topic-Overrides (mehrfach).
29    pub topics: Vec<String>,
30    /// `--tls-cert <FILE>` — TLS-Cert-File. L5-Stub.
31    pub tls_cert: Option<String>,
32    /// `--tls-key <FILE>` — TLS-Key-File. L5-Stub.
33    pub tls_key: Option<String>,
34    /// `--auth-token <SECRET>` — Bearer-Token-Auth. L5-Stub.
35    pub auth_token: Option<String>,
36    /// `--log-level <LEVEL>` — `trace|debug|info|warn|error`.
37    pub log_level: Option<String>,
38    /// `--metrics <ADDR>` — Prometheus-Listen-Address. L5-Stub.
39    pub metrics: Option<String>,
40    /// `--version` — Versions-Info ausgeben + exit.
41    pub version: bool,
42    /// `--help` — Help-Text + exit.
43    pub help: bool,
44}
45
46/// Fehler beim CLI-Parse.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum CliError {
49    /// Unbekannter Flag.
50    UnknownFlag(String),
51    /// Flag braucht ein Value, hat aber keinen.
52    MissingValue(String),
53    /// Wert nicht parsbar (z.B. `--domain abc`).
54    InvalidValue {
55        /// Flag-Name.
56        flag: String,
57        /// Roher Wert.
58        value: String,
59    },
60}
61
62impl core::fmt::Display for CliError {
63    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
64        match self {
65            Self::UnknownFlag(name) => write!(f, "unknown flag: {name}"),
66            Self::MissingValue(name) => write!(f, "flag {name} requires a value"),
67            Self::InvalidValue { flag, value } => {
68                write!(f, "flag {flag} got invalid value: {value}")
69            }
70        }
71    }
72}
73
74impl std::error::Error for CliError {}
75
76/// Help-Text gemaess Spec §2.
77pub const HELP_TEXT: &str = "\
78zerodds-ws-bridged 1.0 — DDS↔WebSocket-Bridge-Daemon
79
80USAGE:
81    zerodds-ws-bridged [OPTIONS]
82
83OPTIONS:
84    --config <FILE>         Path zur Config-File (YAML)
85    --listen <ADDR>         Bind-Address (Default 0.0.0.0:8080)
86    --domain <ID>           DDS-Domain-ID (Default 0)
87    --topic <NAME>          Single-Topic-Override (mehrfach)
88    --tls-cert <FILE>       TLS-Cert (PEM); aktiviert wss:// — L5-stub
89    --tls-key <FILE>        TLS-Key (PEM) — L5-stub
90    --auth-token <SECRET>   Bearer-Token-Auth — L5-stub
91    --log-level <LEVEL>     trace/debug/info/warn/error (Default info)
92    --metrics <ADDR>        Prometheus-Scrape-Endpoint — L5-stub
93    --version               Versions-Info
94    --help                  Hilfe
95
96EXIT-CODES:
97    0   normaler Shutdown (SIGTERM/SIGINT)
98    1   Config-Fehler
99    2   Bind-Fehler (Port belegt)
100    3   DDS-Discovery-Fehler
101    4   TLS-Fehler
102
103Spec: docs/specs/zerodds-ws-bridge-1.0.md
104";
105
106/// Versions-String.
107pub const VERSION_TEXT: &str = "zerodds-ws-bridged 1.0";
108
109/// Parst die CLI-Args (typisch aus `std::env::args().skip(1).collect()`).
110///
111/// # Errors
112/// Siehe [`CliError`].
113pub fn parse(args: &[String]) -> Result<CliArgs, CliError> {
114    let mut out = CliArgs::default();
115    let mut i = 0;
116    while i < args.len() {
117        let raw = &args[i];
118        // Spalte `--flag=value`.
119        let (flag, inline) = match raw.split_once('=') {
120            Some((k, v)) => (k.to_string(), Some(v.to_string())),
121            None => (raw.clone(), None),
122        };
123        let take_value = |i: &mut usize, flag: &str| -> Result<String, CliError> {
124            if let Some(v) = inline.clone() {
125                Ok(v)
126            } else {
127                *i += 1;
128                args.get(*i)
129                    .cloned()
130                    .ok_or_else(|| CliError::MissingValue(flag.to_string()))
131            }
132        };
133        match flag.as_str() {
134            "--help" | "-h" => out.help = true,
135            "--version" | "-V" => out.version = true,
136            "--config" => out.config = Some(take_value(&mut i, "--config")?),
137            "--listen" => out.listen = Some(take_value(&mut i, "--listen")?),
138            "--domain" => {
139                let v = take_value(&mut i, "--domain")?;
140                out.domain = Some(v.parse().map_err(|_| CliError::InvalidValue {
141                    flag: "--domain".to_string(),
142                    value: v,
143                })?);
144            }
145            "--topic" => {
146                out.topics.push(take_value(&mut i, "--topic")?);
147            }
148            "--tls-cert" => out.tls_cert = Some(take_value(&mut i, "--tls-cert")?),
149            "--tls-key" => out.tls_key = Some(take_value(&mut i, "--tls-key")?),
150            "--auth-token" => out.auth_token = Some(take_value(&mut i, "--auth-token")?),
151            "--log-level" => out.log_level = Some(take_value(&mut i, "--log-level")?),
152            "--metrics" => out.metrics = Some(take_value(&mut i, "--metrics")?),
153            other => return Err(CliError::UnknownFlag(other.to_string())),
154        }
155        i += 1;
156    }
157    Ok(out)
158}
159
160#[cfg(test)]
161#[allow(clippy::expect_used, clippy::unwrap_used)]
162mod tests {
163    use super::*;
164
165    fn args(parts: &[&str]) -> Vec<String> {
166        parts.iter().map(|s| (*s).to_string()).collect()
167    }
168
169    #[test]
170    fn empty_args_yield_default() {
171        let p = parse(&[]).unwrap();
172        assert!(p.config.is_none());
173        assert!(!p.help);
174    }
175
176    #[test]
177    fn help_and_version_no_arg() {
178        assert!(parse(&args(&["--help"])).unwrap().help);
179        assert!(parse(&args(&["--version"])).unwrap().version);
180    }
181
182    #[test]
183    fn space_separated_value() {
184        let p = parse(&args(&["--config", "/tmp/c.yaml"])).unwrap();
185        assert_eq!(p.config.as_deref(), Some("/tmp/c.yaml"));
186    }
187
188    #[test]
189    fn equals_form_value() {
190        let p = parse(&args(&["--listen=0.0.0.0:9000"])).unwrap();
191        assert_eq!(p.listen.as_deref(), Some("0.0.0.0:9000"));
192    }
193
194    #[test]
195    fn domain_parses_int() {
196        assert_eq!(parse(&args(&["--domain", "7"])).unwrap().domain, Some(7));
197    }
198
199    #[test]
200    fn domain_rejects_non_int() {
201        let err = parse(&args(&["--domain", "abc"])).unwrap_err();
202        assert!(matches!(err, CliError::InvalidValue { .. }));
203    }
204
205    #[test]
206    fn topic_repeats_collected() {
207        let p = parse(&args(&["--topic", "A", "--topic", "B"])).unwrap();
208        assert_eq!(p.topics, vec!["A".to_string(), "B".to_string()]);
209    }
210
211    #[test]
212    fn unknown_flag_rejected() {
213        let err = parse(&args(&["--nope"])).unwrap_err();
214        assert!(matches!(err, CliError::UnknownFlag(_)));
215    }
216
217    #[test]
218    fn missing_value_rejected() {
219        let err = parse(&args(&["--config"])).unwrap_err();
220        assert!(matches!(err, CliError::MissingValue(_)));
221    }
222}