Skip to main content

aimdb_client/
endpoint.rs

1//! Endpoint resolution — map a `scheme://` URL (or a bare path) to a transport
2//! [`Dialer`], so an operator can pick the transport at runtime the same way
3//! records pick one for links.
4//!
5//! Two layers, deliberately split so the grammar is testable without any
6//! transport compiled in:
7//! - [`parse_endpoint`] — **pure, feature-independent**. Recognizes the scheme
8//!   grammar (`unix://` / `uds://` / `serial://` / `tcp://host:port`, plus a
9//!   bare path as `unix://` shorthand) into a [`ParsedEndpoint`]. An unknown
10//!   scheme is rejected here.
11//! - [`dial`] — builds the concrete [`Dialer`] for a parsed endpoint, under the
12//!   matching `transport-*` feature. A scheme whose transport isn't compiled into
13//!   this binary is rejected here (distinct from "unknown scheme").
14//!
15use aimdb_core::session::Dialer;
16
17use crate::error::{ClientError, ClientResult};
18
19/// Default serial baud when a `serial://` endpoint omits `?baud=`.
20pub const DEFAULT_SERIAL_BAUD: u32 = 115_200;
21
22/// Schemes the resolver's *grammar* understands, independent of which transports
23/// are compiled in. Used to phrase the "unknown scheme" error.
24const KNOWN_SCHEMES: &[&str] = &["unix", "uds", "serial", "tcp"];
25
26/// The transport family an endpoint names. Always compiled (it is grammar, not a
27/// capability) — whether a given variant can actually be dialed depends on the
28/// `transport-*` features, enforced in [`dial`].
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Scheme {
31    /// A Unix-domain socket (`unix://` / `uds://`, or a bare path).
32    Unix,
33    /// A serial/UART device (`serial://`).
34    Serial,
35    /// A TCP endpoint (`tcp://host:port`).
36    Tcp,
37}
38
39/// A parsed endpoint: the transport family plus its target and any transport
40/// options (currently just the serial baud).
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ParsedEndpoint {
43    /// Which transport family this endpoint names.
44    pub scheme: Scheme,
45    /// The transport target — a socket path, serial device path, or host:port.
46    pub target: String,
47    /// Serial baud from `?baud=N`, if given (`Serial` only; [`dial`] defaults it
48    /// to [`DEFAULT_SERIAL_BAUD`]).
49    pub baud: Option<u32>,
50}
51
52/// Parse an endpoint string into a [`ParsedEndpoint`] (pure; no transport needed).
53///
54/// - `unix://PATH` / `uds://PATH` → [`Scheme::Unix`].
55/// - a bare path (no `scheme://`) → [`Scheme::Unix`] (the shorthand).
56/// - `serial://PATH` (optionally `?baud=N`) → [`Scheme::Serial`].
57/// - `tcp://HOST:PORT` → [`Scheme::Tcp`].
58/// - anything else → [`ClientError::UnsupportedEndpoint`].
59pub 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    // No `scheme://` → a bare path is the `unix://` shorthand.
66    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            // Split off an optional `?baud=N[&…]` query; unknown query keys are
85            // ignored for forward-compat, but a malformed `baud` is an error.
86            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
113/// Resolve an endpoint string to a boxed [`Dialer`] for the linked-in transport.
114///
115/// Errors with [`ClientError::UnsupportedEndpoint`] if the string is malformed,
116/// names an unknown scheme, or names a scheme whose transport isn't compiled into
117/// this binary.
118pub 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                // The adapter owns the socket; the connector owns the
148                // host/port grammar.
149                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
164/// Reject an empty target (e.g. `unix://`), pointing at the original endpoint.
165fn 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
176/// Validate `tcp://host:port`.
177///
178/// The grammar itself is [`split_host_port_opt`] — one implementation, shared
179/// with the connector, reachable here even when `transport-tcp` is off. What
180/// this adds is the client's own policy: a URL must name its port, where a
181/// connector constructor may default one.
182fn 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    // Brackets are stripped from a well-formed literal, so any left over came
189    // from the middle of a host: `tcp://foo[bar:7001`.
190    if host.contains(['[', ']']) {
191        return Err(ClientError::unsupported_endpoint(
192            endpoint,
193            "malformed TCP host",
194        ));
195    }
196
197    if port.is_none() {
198        // An unbracketed IPv6 literal cannot carry a port, so "add one" is the
199        // wrong advice — say what actually has to change. Keyed on the original
200        // target, since a bracketed host arrives here already stripped.
201        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
212/// Pull `baud` out of a `serial://` query string (`baud=N[&k=v…]`).
213fn 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/// A recognized scheme whose transport feature isn't compiled in.
227#[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        // Bare paths (absolute + relative) are the `unix://` shorthand.
254        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        // No query → baud unset (dial defaults it to DEFAULT_SERIAL_BAUD).
269        let p = parse_endpoint("serial:///dev/ttyUSB0").expect("parse");
270        assert_eq!(p.baud, None);
271
272        // Unknown query keys are ignored; baud is still picked up.
273        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    /// Each rejection says what has to change. Asserting the reasons, not just
297    /// `is_err`, is what stops the grammar move from silently degrading them —
298    /// an unbracketed IPv6 literal needs brackets, not a port appended.
299    #[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        // Malformed TCP.
318        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        // Empty + empty target.
327        assert!(parse_endpoint("").is_err());
328        assert!(parse_endpoint("unix://").is_err());
329        // Non-numeric baud.
330        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}