Skip to main content

zerodds_webdds_bridge/daemon/
cli.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! CLI argument parser for `zerodds-webdds-bridged`.
5//!
6//! Hand-written, matching every other `zerodds-*-bridged` daemon: no
7//! `clap` dependency. `--config`, `--set` and `--check-config` are
8//! taken out before this parser runs (`zerodds_config::CommandLine`);
9//! what remains are this daemon's convenience flags, which
10//! [`CliArgs::apply_to`] adds to that command line so they sit in the flag
11//! layer above every file.
12
13use zerodds_config::{CommandLine, ConfigError};
14
15/// Parsed CLI args.
16#[derive(Debug, Clone, Default)]
17pub struct CliArgs {
18    /// `--listen <ADDR>` — HTTP bind address override.
19    pub listen: Option<String>,
20    /// `--domain <ID>` — DDS domain id override.
21    pub domain: Option<i32>,
22    /// `--type <NAME=FILE>` — a type source override (repeatable).
23    pub types: Vec<String>,
24    /// `--tls-cert <FILE>` — TLS cert file (PEM); enables `https://`.
25    pub tls_cert: Option<String>,
26    /// `--tls-key <FILE>` — TLS key file (PEM).
27    pub tls_key: Option<String>,
28    /// `--auth-token <SECRET>` — bearer token auth.
29    pub auth_token: Option<String>,
30    /// `--log-level <LEVEL>`.
31    pub log_level: Option<String>,
32    /// `--metrics <ADDR>` — Prometheus listen address.
33    pub metrics: Option<String>,
34    /// `--version`.
35    pub version: bool,
36    /// `--help`.
37    pub help: bool,
38}
39
40pub use zerodds_config::cli::CliError;
41use zerodds_config::cli::Flags;
42
43/// Help text.
44pub const HELP_TEXT: &str = "\
45zerodds-webdds-bridged 1.0 — OMG DDS-WEB 1.0 gateway (REST + SOAP)
46
47USAGE:
48    zerodds-webdds-bridged [OPTIONS]
49
50OPTIONS:
51    --listen <ADDR>          HTTP bind address (default 127.0.0.1:8080)
52    --domain <ID>            DDS domain id (default 0)
53    --type <NAME=FILE>       Type source override (multiple), e.g. M::Sensor=/path/t.idl
54    --tls-cert <FILE>        TLS cert (PEM); enables https://
55    --tls-key <FILE>         TLS key (PEM)
56    --auth-token <SECRET>    Bearer token auth
57    --log-level <LEVEL>      trace/debug/info/warn/error (default info)
58    --metrics <ADDR>         Prometheus scrape endpoint
59    --version                Version info
60    --help                   Help
61
62Each option above is `--set` of its file key, so it wins over the
63files. The file is webdds-bridged.yaml with webdds-bridged.d/ in the
64system configuration directory unless --config names another.
65
66EXIT CODES:
67    0   normal shutdown (SIGTERM/SIGINT)
68    1   config error
69    2   bind error (port in use)
70    3   DDS discovery error
71    4   TLS error
72
73Spec: OMG Web-Enabled DDS 1.0, formal/2014-12-01.
74";
75
76/// Version string.
77pub const VERSION_TEXT: &str = "zerodds-webdds-bridged 1.0";
78
79impl CliArgs {
80    /// The convenience flags as `PATH=VALUE` assignments, in the order
81    /// the daemon applies them.
82    #[must_use]
83    pub fn sets(&self) -> Vec<String> {
84        let mut out = Vec::new();
85        if let Some(v) = &self.listen {
86            out.push(format!("listen={v}"));
87        }
88        if let Some(v) = self.domain {
89            out.push(format!("domain={v}"));
90        }
91        if let Some(v) = &self.log_level {
92            out.push(format!("log_level={v}"));
93        }
94        if let Some(v) = &self.auth_token {
95            out.push("auth.mode=bearer".to_string());
96            out.push(format!("auth.bearer_token={v}"));
97        }
98        if let Some(v) = &self.tls_cert {
99            out.push(format!("tls.cert_file={v}"));
100            out.push("tls.enabled=true".to_string());
101        }
102        if let Some(v) = &self.tls_key {
103            out.push(format!("tls.key_file={v}"));
104            out.push("tls.enabled=true".to_string());
105        }
106        if let Some(v) = &self.metrics {
107            out.push(format!("metrics.listen={v}"));
108            out.push("metrics.enabled=true".to_string());
109        }
110        out
111    }
112
113    /// Every `--type NAME=FILE` as `(name, file)`, in the order given; each
114    /// is one entry appended to `types`. [`parse`] has refused a value
115    /// without `=`.
116    #[must_use]
117    pub fn type_entries(&self) -> Vec<(&str, &str)> {
118        self.types
119            .iter()
120            .filter_map(|entry| entry.split_once('='))
121            .collect()
122    }
123
124    /// Adds the convenience flags to `command_line`: [`Self::sets`] as
125    /// `--set` after any explicit one, then each `--type` as one entry
126    /// appended to `types`.
127    ///
128    /// # Errors
129    /// Whatever [`CommandLine::push_set`] or [`CommandLine::push_entry`]
130    /// reports.
131    pub fn apply_to(&self, command_line: &mut CommandLine) -> Result<(), ConfigError> {
132        for assignment in self.sets() {
133            command_line.push_set(&assignment)?;
134        }
135        for (name, file) in self.type_entries() {
136            command_line.push_entry(
137                "+types",
138                &[("name", name), ("idl_file", file)],
139                &format!("--type {name}={file}"),
140            )?;
141        }
142        Ok(())
143    }
144}
145
146/// Parses the CLI args (typically from
147/// `std::env::args().skip(1).collect()`).
148///
149/// # Errors
150/// See [`CliError`].
151pub fn parse(args: &[String]) -> Result<CliArgs, CliError> {
152    let mut out = CliArgs::default();
153    let mut flags = Flags::new(args);
154    while let Some(flag) = flags.next_flag() {
155        match flag.name() {
156            "--help" | "-h" => out.help = true,
157            "--version" | "-V" => out.version = true,
158            "--listen" => out.listen = Some(flags.value(&flag)?),
159            "--domain" => out.domain = Some(flags.parsed(&flag)?),
160            "--type" => {
161                let v = flags.value(&flag)?;
162                match v.split_once('=') {
163                    Some((name, file)) if !name.trim().is_empty() && !file.trim().is_empty() => {
164                        out.types.push(v);
165                    }
166                    _ => {
167                        return Err(CliError::InvalidValue {
168                            flag: "--type".to_string(),
169                            value: v,
170                        });
171                    }
172                }
173            }
174            "--tls-cert" => out.tls_cert = Some(flags.value(&flag)?),
175            "--tls-key" => out.tls_key = Some(flags.value(&flag)?),
176            "--auth-token" => out.auth_token = Some(flags.value(&flag)?),
177            "--log-level" => out.log_level = Some(flags.value(&flag)?),
178            "--metrics" => out.metrics = Some(flags.value(&flag)?),
179            other => return Err(CliError::UnknownFlag(other.to_string())),
180        }
181    }
182    Ok(out)
183}
184
185#[cfg(test)]
186#[allow(clippy::unwrap_used, clippy::expect_used)]
187mod tests {
188    use super::*;
189
190    fn args(parts: &[&str]) -> Vec<String> {
191        parts.iter().map(|s| (*s).to_string()).collect()
192    }
193
194    #[test]
195    fn empty_args_yield_default() {
196        let p = parse(&[]).unwrap();
197        assert!(p.listen.is_none());
198        assert!(!p.help);
199    }
200
201    #[test]
202    fn help_and_version_no_arg() {
203        assert!(parse(&args(&["--help"])).unwrap().help);
204        assert!(parse(&args(&["--version"])).unwrap().version);
205    }
206
207    #[test]
208    fn space_and_equals_form_both_work() {
209        assert_eq!(
210            parse(&args(&["--listen", "0.0.0.0:1"]))
211                .unwrap()
212                .listen
213                .as_deref(),
214            Some("0.0.0.0:1")
215        );
216        assert_eq!(
217            parse(&args(&["--listen=0.0.0.0:2"]))
218                .unwrap()
219                .listen
220                .as_deref(),
221            Some("0.0.0.0:2")
222        );
223    }
224
225    #[test]
226    fn domain_rejects_non_int() {
227        let err = parse(&args(&["--domain", "abc"])).unwrap_err();
228        assert!(matches!(err, CliError::InvalidValue { .. }));
229    }
230
231    #[test]
232    fn unknown_flag_rejected() {
233        assert!(matches!(
234            parse(&args(&["--nope"])).unwrap_err(),
235            CliError::UnknownFlag(_)
236        ));
237    }
238
239    #[test]
240    fn missing_value_rejected() {
241        assert!(matches!(
242            parse(&args(&["--listen"])).unwrap_err(),
243            CliError::MissingValue(_)
244        ));
245    }
246
247    #[test]
248    fn auth_token_becomes_bearer_sets() {
249        let p = parse(&args(&["--auth-token", "t"])).unwrap();
250        assert_eq!(p.sets(), ["auth.mode=bearer", "auth.bearer_token=t"]);
251    }
252
253    #[test]
254    fn type_override_becomes_a_type_entry() {
255        let p = parse(&args(&["--type", "M::Sensor=/x.idl", "--type=B=/b.idl"])).unwrap();
256        assert!(p.sets().is_empty());
257        assert_eq!(p.type_entries(), [("M::Sensor", "/x.idl"), ("B", "/b.idl")]);
258    }
259
260    /// Measured on the Linux test host: the README quickstart
261    /// `--type 'Demo::Sensor=…/sensor.idl'` ended the daemon with "invalid
262    /// types[0]: expected a map of settings".
263    #[test]
264    fn type_flags_reach_the_daemon_config() {
265        let p = parse(&args(&[
266            "--domain",
267            "7",
268            "--type",
269            "M::Sensor=/x.idl",
270            "--type",
271            "M::Other=/y.idl",
272        ]))
273        .unwrap();
274        let mut command_line = CommandLine::default();
275        p.apply_to(&mut command_line).unwrap();
276        let mut config = zerodds_config::Config::default();
277        command_line.apply(&mut config).unwrap();
278        let cfg = crate::daemon::config::DaemonConfig::from_config(Some(&config)).unwrap();
279        assert_eq!(cfg.domain, 7);
280        let types: Vec<(&str, Option<&str>)> = cfg
281            .types
282            .iter()
283            .map(|t| (t.type_name.as_str(), t.idl_file.as_deref()))
284            .collect();
285        assert_eq!(
286            types,
287            [("M::Sensor", Some("/x.idl")), ("M::Other", Some("/y.idl"))]
288        );
289    }
290
291    #[test]
292    fn type_without_name_or_file_is_refused() {
293        for bad in ["M::Sensor", "=/x.idl", "M::Sensor="] {
294            assert!(
295                matches!(
296                    parse(&args(&["--type", bad])),
297                    Err(CliError::InvalidValue { .. })
298                ),
299                "{bad}"
300            );
301        }
302    }
303}