1use aimdb_core::session::Dialer;
16
17use crate::error::{ClientError, ClientResult};
18
19pub const DEFAULT_SERIAL_BAUD: u32 = 115_200;
21
22const KNOWN_SCHEMES: &[&str] = &["unix", "uds", "serial", "tcp"];
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Scheme {
31 Unix,
33 Serial,
35 Tcp,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ParsedEndpoint {
43 pub scheme: Scheme,
45 pub target: String,
47 pub baud: Option<u32>,
50}
51
52pub fn parse_endpoint(endpoint: &str) -> ClientResult<ParsedEndpoint> {
60 let endpoint = endpoint.trim();
61 if endpoint.is_empty() {
62 return Err(ClientError::unsupported_endpoint("", "empty endpoint"));
63 }
64
65 let Some((scheme, rest)) = endpoint.split_once("://") else {
67 return Ok(ParsedEndpoint {
68 scheme: Scheme::Unix,
69 target: endpoint.to_string(),
70 baud: None,
71 });
72 };
73
74 match scheme.to_ascii_lowercase().as_str() {
75 "unix" | "uds" => {
76 require_nonempty(endpoint, rest)?;
77 Ok(ParsedEndpoint {
78 scheme: Scheme::Unix,
79 target: rest.to_string(),
80 baud: None,
81 })
82 }
83 "serial" => {
84 let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
87 require_nonempty(endpoint, path)?;
88 let baud = parse_baud(endpoint, query)?;
89 Ok(ParsedEndpoint {
90 scheme: Scheme::Serial,
91 target: path.to_string(),
92 baud,
93 })
94 }
95 "tcp" => {
96 require_tcp_target(endpoint, rest)?;
97 Ok(ParsedEndpoint {
98 scheme: Scheme::Tcp,
99 target: rest.to_string(),
100 baud: None,
101 })
102 }
103 other => Err(ClientError::unsupported_endpoint(
104 endpoint,
105 format!(
106 "unknown scheme {other:?}; built-in schemes: {}",
107 KNOWN_SCHEMES.join(", ")
108 ),
109 )),
110 }
111}
112
113pub fn dial(endpoint: &str) -> ClientResult<Box<dyn Dialer>> {
119 let parsed = parse_endpoint(endpoint)?;
120 match parsed.scheme {
121 Scheme::Unix => {
122 #[cfg(feature = "transport-uds")]
123 {
124 Ok(Box::new(aimdb_uds_connector::UdsDialer::new(parsed.target)))
125 }
126 #[cfg(not(feature = "transport-uds"))]
127 {
128 Err(not_built_in(endpoint, "unix", "transport-uds"))
129 }
130 }
131 Scheme::Serial => {
132 #[cfg(feature = "transport-serial")]
133 {
134 Ok(Box::new(aimdb_serial_connector::SerialPortDialer::new(
135 parsed.target,
136 parsed.baud.unwrap_or(DEFAULT_SERIAL_BAUD),
137 )))
138 }
139 #[cfg(not(feature = "transport-serial"))]
140 {
141 Err(not_built_in(endpoint, "serial", "transport-serial"))
142 }
143 }
144 Scheme::Tcp => {
145 #[cfg(feature = "transport-tcp")]
146 {
147 let dialer = aimdb_tcp_connector::framed_dialer_at(
150 aimdb_tokio_adapter::net::TokioNet::tcp(),
151 &parsed.target,
152 )
153 .map_err(|e| ClientError::unsupported_endpoint(endpoint, e.to_string()))?;
154 Ok(Box::new(dialer))
155 }
156 #[cfg(not(feature = "transport-tcp"))]
157 {
158 Err(not_built_in(endpoint, "tcp", "transport-tcp"))
159 }
160 }
161 }
162}
163
164fn require_nonempty(endpoint: &str, target: &str) -> ClientResult<()> {
166 if target.is_empty() {
167 Err(ClientError::unsupported_endpoint(
168 endpoint,
169 "missing path after scheme",
170 ))
171 } else {
172 Ok(())
173 }
174}
175
176fn require_tcp_target(endpoint: &str, target: &str) -> ClientResult<()> {
183 require_nonempty(endpoint, target)?;
184
185 let (host, port) = aimdb_core::session::split_host_port_opt(target)
186 .map_err(|e| ClientError::unsupported_endpoint(endpoint, e.to_string()))?;
187
188 if host.contains(['[', ']']) {
191 return Err(ClientError::unsupported_endpoint(
192 endpoint,
193 "malformed TCP host",
194 ));
195 }
196
197 if port.is_none() {
198 let reason = if !target.starts_with('[') && host.contains(':') {
202 "IPv6 TCP hosts must be bracketed, e.g. tcp://[::1]:7001"
203 } else {
204 "missing TCP port"
205 };
206 return Err(ClientError::unsupported_endpoint(endpoint, reason));
207 }
208
209 Ok(())
210}
211
212fn parse_baud(endpoint: &str, query: &str) -> ClientResult<Option<u32>> {
214 for pair in query.split('&').filter(|p| !p.is_empty()) {
215 let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
216 if key.eq_ignore_ascii_case("baud") {
217 let baud = value.parse::<u32>().map_err(|_| {
218 ClientError::unsupported_endpoint(endpoint, format!("invalid baud {value:?}"))
219 })?;
220 return Ok(Some(baud));
221 }
222 }
223 Ok(None)
224}
225
226#[cfg(any(
228 not(feature = "transport-uds"),
229 not(feature = "transport-serial"),
230 not(feature = "transport-tcp")
231))]
232fn not_built_in(endpoint: &str, scheme: &str, feature: &str) -> ClientError {
233 ClientError::unsupported_endpoint(
234 endpoint,
235 format!(
236 "scheme {scheme:?} is not built into this binary (rebuild with --features {feature})"
237 ),
238 )
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn unix_scheme_and_bare_path_both_resolve_to_unix() {
247 for ep in ["unix:///tmp/aimdb.sock", "uds:///tmp/aimdb.sock"] {
248 let p = parse_endpoint(ep).expect("parse");
249 assert_eq!(p.scheme, Scheme::Unix);
250 assert_eq!(p.target, "/tmp/aimdb.sock");
251 assert_eq!(p.baud, None);
252 }
253 for bare in ["/tmp/aimdb.sock", "./rel.sock"] {
255 let p = parse_endpoint(bare).expect("parse");
256 assert_eq!(p.scheme, Scheme::Unix);
257 assert_eq!(p.target, bare);
258 }
259 }
260
261 #[test]
262 fn serial_scheme_parses_path_and_optional_baud() {
263 let p = parse_endpoint("serial:///dev/ttyACM0?baud=9600").expect("parse");
264 assert_eq!(p.scheme, Scheme::Serial);
265 assert_eq!(p.target, "/dev/ttyACM0");
266 assert_eq!(p.baud, Some(9600));
267
268 let p = parse_endpoint("serial:///dev/ttyUSB0").expect("parse");
270 assert_eq!(p.baud, None);
271
272 let p = parse_endpoint("serial:///dev/ttyUSB0?foo=bar&baud=230400").expect("parse");
274 assert_eq!(p.baud, Some(230400));
275 }
276
277 #[test]
278 fn tcp_scheme_parses_host_and_port() {
279 let p = parse_endpoint("tcp://127.0.0.1:7001").expect("parse");
280 assert_eq!(p.scheme, Scheme::Tcp);
281 assert_eq!(p.target, "127.0.0.1:7001");
282
283 let p = parse_endpoint("tcp://localhost:7001").expect("parse");
284 assert_eq!(p.scheme, Scheme::Tcp);
285 assert_eq!(p.target, "localhost:7001");
286
287 let p = parse_endpoint("tcp://[::1]:7001").expect("parse");
288 assert_eq!(p.scheme, Scheme::Tcp);
289 assert_eq!(p.target, "[::1]:7001");
290
291 let p = parse_endpoint("tcp://[fe80::1]:7001").expect("parse");
292 assert_eq!(p.scheme, Scheme::Tcp);
293 assert_eq!(p.target, "[fe80::1]:7001");
294 }
295
296 #[test]
300 fn a_rejected_tcp_endpoint_says_what_is_wrong() {
301 let reason = |ep: &str| match parse_endpoint(ep) {
302 Err(ClientError::UnsupportedEndpoint { reason, .. }) => reason,
303 other => panic!("{ep} should be rejected as an endpoint, got {other:?}"),
304 };
305
306 assert!(reason("tcp://host").contains("missing TCP port"));
307 assert!(reason("tcp://[fe80::1]").contains("missing TCP port"));
308 assert!(reason("tcp://fe80::1").contains("must be bracketed"));
309 assert!(reason("tcp://:1234").contains("no host"));
310 assert!(reason("tcp://[]:7001").contains("no host"));
311 assert!(reason("tcp://host:fast").contains("not a number"));
312 assert!(reason("tcp://[fe80::1:7001").contains("closing bracket"));
313 }
314
315 #[test]
316 fn malformed_endpoints_are_rejected() {
317 assert!(parse_endpoint("tcp://").is_err());
319 assert!(parse_endpoint("tcp://host").is_err());
320 assert!(parse_endpoint("tcp://:1234").is_err());
321 assert!(parse_endpoint("tcp://host:fast").is_err());
322 assert!(parse_endpoint("tcp://fe80::1").is_err());
323 assert!(parse_endpoint("tcp://[fe80::1]").is_err());
324 assert!(parse_endpoint("tcp://[]:7001").is_err());
325 assert!(parse_endpoint("tcp://[fe80::1:7001").is_err());
326 assert!(parse_endpoint("").is_err());
328 assert!(parse_endpoint("unix://").is_err());
329 assert!(parse_endpoint("serial:///dev/x?baud=fast").is_err());
331 }
332
333 #[test]
334 fn dial_rejects_unknown_scheme() {
335 assert!(matches!(
336 dial("http://host:1234"),
337 Err(ClientError::UnsupportedEndpoint { .. })
338 ));
339 }
340
341 #[cfg(feature = "transport-uds")]
342 #[test]
343 fn dial_builds_a_unix_dialer() {
344 assert!(dial("unix:///tmp/aimdb.sock").is_ok());
345 assert!(dial("/tmp/aimdb.sock").is_ok());
346 }
347
348 #[cfg(not(feature = "transport-serial"))]
349 #[test]
350 fn dial_rejects_serial_when_not_built_in() {
351 assert!(matches!(
352 dial("serial:///dev/ttyACM0"),
353 Err(ClientError::UnsupportedEndpoint { .. })
354 ));
355 }
356
357 #[cfg(feature = "transport-serial")]
358 #[test]
359 fn dial_builds_a_serial_dialer() {
360 assert!(dial("serial:///dev/ttyACM0?baud=115200").is_ok());
361 }
362
363 #[cfg(not(feature = "transport-tcp"))]
364 #[test]
365 fn dial_rejects_tcp_when_not_built_in() {
366 assert!(matches!(
367 dial("tcp://127.0.0.1:7001"),
368 Err(ClientError::UnsupportedEndpoint { .. })
369 ));
370 }
371
372 #[cfg(feature = "transport-tcp")]
373 #[test]
374 fn dial_builds_a_tcp_dialer() {
375 assert!(dial("tcp://127.0.0.1:7001").is_ok());
376 }
377}