55//! Two layers, deliberately split so the grammar is testable without any
66//! transport compiled in:
77//! - [`parse_endpoint`] — **pure, feature-independent**. Recognizes the scheme
8- //! grammar (`unix://` / `uds://` / `serial://`, plus a bare path as `unix://`
9- //! shorthand) into a [`ParsedEndpoint`]. An unknown scheme is rejected here.
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.
1011//! - [`dial`] — builds the concrete [`Dialer`] for a parsed endpoint, under the
1112//! matching `transport-*` feature. A scheme whose transport isn't compiled into
1213//! this binary is rejected here (distinct from "unknown scheme").
1314//!
14- //! TCP is intentionally absent for now (tracked separately); adding it is a new
15- //! [`Scheme`] arm plus a `dial` branch.
16-
1715use aimdb_core:: session:: Dialer ;
1816
1917use crate :: error:: { ClientError , ClientResult } ;
@@ -23,7 +21,7 @@ pub const DEFAULT_SERIAL_BAUD: u32 = 115_200;
2321
2422/// Schemes the resolver's *grammar* understands, independent of which transports
2523/// are compiled in. Used to phrase the "unknown scheme" error.
26- const KNOWN_SCHEMES : & [ & str ] = & [ "unix" , "uds" , "serial" ] ;
24+ const KNOWN_SCHEMES : & [ & str ] = & [ "unix" , "uds" , "serial" , "tcp" ] ;
2725
2826/// The transport family an endpoint names. Always compiled (it is grammar, not a
2927/// capability) — whether a given variant can actually be dialed depends on the
@@ -34,6 +32,8 @@ pub enum Scheme {
3432 Unix ,
3533 /// A serial/UART device (`serial://`).
3634 Serial ,
35+ /// A TCP endpoint (`tcp://host:port`).
36+ Tcp ,
3737}
3838
3939/// A parsed endpoint: the transport family plus its target and any transport
@@ -42,7 +42,7 @@ pub enum Scheme {
4242pub struct ParsedEndpoint {
4343 /// Which transport family this endpoint names.
4444 pub scheme : Scheme ,
45- /// The transport target — a socket path (`Unix`) or device path (`Serial`) .
45+ /// The transport target — a socket path, serial device path, or host:port .
4646 pub target : String ,
4747 /// Serial baud from `?baud=N`, if given (`Serial` only; [`dial`] defaults it
4848 /// to [`DEFAULT_SERIAL_BAUD`]).
@@ -54,7 +54,8 @@ pub struct ParsedEndpoint {
5454/// - `unix://PATH` / `uds://PATH` → [`Scheme::Unix`].
5555/// - a bare path (no `scheme://`) → [`Scheme::Unix`] (the shorthand).
5656/// - `serial://PATH` (optionally `?baud=N`) → [`Scheme::Serial`].
57- /// - anything else (e.g. `tcp://…`) → [`ClientError::UnsupportedEndpoint`].
57+ /// - `tcp://HOST:PORT` → [`Scheme::Tcp`].
58+ /// - anything else → [`ClientError::UnsupportedEndpoint`].
5859pub fn parse_endpoint ( endpoint : & str ) -> ClientResult < ParsedEndpoint > {
5960 let endpoint = endpoint. trim ( ) ;
6061 if endpoint. is_empty ( ) {
@@ -91,6 +92,14 @@ pub fn parse_endpoint(endpoint: &str) -> ClientResult<ParsedEndpoint> {
9192 baud,
9293 } )
9394 }
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+ }
94103 other => Err ( ClientError :: unsupported_endpoint (
95104 endpoint,
96105 format ! (
@@ -132,6 +141,16 @@ pub fn dial(endpoint: &str) -> ClientResult<Box<dyn Dialer>> {
132141 Err ( not_built_in ( endpoint, "serial" , "transport-serial" ) )
133142 }
134143 }
144+ Scheme :: Tcp => {
145+ #[ cfg( feature = "transport-tcp" ) ]
146+ {
147+ Ok ( Box :: new ( aimdb_tcp_connector:: TcpDialer :: new ( parsed. target ) ) )
148+ }
149+ #[ cfg( not( feature = "transport-tcp" ) ) ]
150+ {
151+ Err ( not_built_in ( endpoint, "tcp" , "transport-tcp" ) )
152+ }
153+ }
135154 }
136155}
137156
@@ -147,6 +166,34 @@ fn require_nonempty(endpoint: &str, target: &str) -> ClientResult<()> {
147166 }
148167}
149168
169+ /// Validate `tcp://host:port`.
170+ fn require_tcp_target ( endpoint : & str , target : & str ) -> ClientResult < ( ) > {
171+ require_nonempty ( endpoint, target) ?;
172+
173+ let Some ( ( host, port) ) = target. rsplit_once ( ':' ) else {
174+ return Err ( ClientError :: unsupported_endpoint (
175+ endpoint,
176+ "missing TCP port" ,
177+ ) ) ;
178+ } ;
179+ if host. is_empty ( ) {
180+ return Err ( ClientError :: unsupported_endpoint (
181+ endpoint,
182+ "missing TCP host" ,
183+ ) ) ;
184+ }
185+ if port. is_empty ( ) {
186+ return Err ( ClientError :: unsupported_endpoint (
187+ endpoint,
188+ "missing TCP port" ,
189+ ) ) ;
190+ }
191+ port. parse :: < u16 > ( ) . map_err ( |_| {
192+ ClientError :: unsupported_endpoint ( endpoint, format ! ( "invalid TCP port {port:?}" ) )
193+ } ) ?;
194+ Ok ( ( ) )
195+ }
196+
150197/// Pull `baud` out of a `serial://` query string (`baud=N[&k=v…]`).
151198fn parse_baud ( endpoint : & str , query : & str ) -> ClientResult < Option < u32 > > {
152199 for pair in query. split ( '&' ) . filter ( |p| !p. is_empty ( ) ) {
@@ -162,7 +209,11 @@ fn parse_baud(endpoint: &str, query: &str) -> ClientResult<Option<u32>> {
162209}
163210
164211/// A recognized scheme whose transport feature isn't compiled in.
165- #[ cfg( any( not( feature = "transport-uds" ) , not( feature = "transport-serial" ) ) ) ]
212+ #[ cfg( any(
213+ not( feature = "transport-uds" ) ,
214+ not( feature = "transport-serial" ) ,
215+ not( feature = "transport-tcp" )
216+ ) ) ]
166217fn not_built_in ( endpoint : & str , scheme : & str , feature : & str ) -> ClientError {
167218 ClientError :: unsupported_endpoint (
168219 endpoint,
@@ -208,13 +259,24 @@ mod tests {
208259 assert_eq ! ( p. baud, Some ( 230400 ) ) ;
209260 }
210261
262+ #[ test]
263+ fn tcp_scheme_parses_host_and_port ( ) {
264+ let p = parse_endpoint ( "tcp://127.0.0.1:7001" ) . expect ( "parse" ) ;
265+ assert_eq ! ( p. scheme, Scheme :: Tcp ) ;
266+ assert_eq ! ( p. target, "127.0.0.1:7001" ) ;
267+
268+ let p = parse_endpoint ( "tcp://localhost:7001" ) . expect ( "parse" ) ;
269+ assert_eq ! ( p. scheme, Scheme :: Tcp ) ;
270+ assert_eq ! ( p. target, "localhost:7001" ) ;
271+ }
272+
211273 #[ test]
212274 fn malformed_endpoints_are_rejected ( ) {
213- // Unknown scheme .
214- assert ! ( matches! (
215- parse_endpoint( "tcp://host:1234" ) ,
216- Err ( ClientError :: UnsupportedEndpoint { .. } )
217- ) ) ;
275+ // Malformed TCP .
276+ assert ! ( parse_endpoint ( "tcp://" ) . is_err ( ) ) ;
277+ assert ! ( parse_endpoint( "tcp://host" ) . is_err ( ) ) ;
278+ assert ! ( parse_endpoint ( "tcp://:1234" ) . is_err ( ) ) ;
279+ assert ! ( parse_endpoint ( "tcp://host:fast" ) . is_err ( ) ) ;
218280 // Empty + empty target.
219281 assert ! ( parse_endpoint( "" ) . is_err( ) ) ;
220282 assert ! ( parse_endpoint( "unix://" ) . is_err( ) ) ;
@@ -225,7 +287,7 @@ mod tests {
225287 #[ test]
226288 fn dial_rejects_unknown_scheme ( ) {
227289 assert ! ( matches!(
228- dial( "tcp ://host:1234" ) ,
290+ dial( "http ://host:1234" ) ,
229291 Err ( ClientError :: UnsupportedEndpoint { .. } )
230292 ) ) ;
231293 }
@@ -251,4 +313,19 @@ mod tests {
251313 fn dial_builds_a_serial_dialer ( ) {
252314 assert ! ( dial( "serial:///dev/ttyACM0?baud=115200" ) . is_ok( ) ) ;
253315 }
316+
317+ #[ cfg( not( feature = "transport-tcp" ) ) ]
318+ #[ test]
319+ fn dial_rejects_tcp_when_not_built_in ( ) {
320+ assert ! ( matches!(
321+ dial( "tcp://127.0.0.1:7001" ) ,
322+ Err ( ClientError :: UnsupportedEndpoint { .. } )
323+ ) ) ;
324+ }
325+
326+ #[ cfg( feature = "transport-tcp" ) ]
327+ #[ test]
328+ fn dial_builds_a_tcp_dialer ( ) {
329+ assert ! ( dial( "tcp://127.0.0.1:7001" ) . is_ok( ) ) ;
330+ }
254331}
0 commit comments