zerodds_webdds_bridge/daemon/
cli.rs1use zerodds_config::{CommandLine, ConfigError};
14
15#[derive(Debug, Clone, Default)]
17pub struct CliArgs {
18 pub listen: Option<String>,
20 pub domain: Option<i32>,
22 pub types: Vec<String>,
24 pub tls_cert: Option<String>,
26 pub tls_key: Option<String>,
28 pub auth_token: Option<String>,
30 pub log_level: Option<String>,
32 pub metrics: Option<String>,
34 pub version: bool,
36 pub help: bool,
38}
39
40pub use zerodds_config::cli::CliError;
41use zerodds_config::cli::Flags;
42
43pub 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
76pub const VERSION_TEXT: &str = "zerodds-webdds-bridged 1.0";
78
79impl CliArgs {
80 #[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 #[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 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
146pub 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 #[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}