use aimdb_core::session::Dialer;
use crate::error::{ClientError, ClientResult};
pub const DEFAULT_SERIAL_BAUD: u32 = 115_200;
const KNOWN_SCHEMES: &[&str] = &["unix", "uds", "serial", "tcp"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scheme {
Unix,
Serial,
Tcp,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedEndpoint {
pub scheme: Scheme,
pub target: String,
pub baud: Option<u32>,
}
pub fn parse_endpoint(endpoint: &str) -> ClientResult<ParsedEndpoint> {
let endpoint = endpoint.trim();
if endpoint.is_empty() {
return Err(ClientError::unsupported_endpoint("", "empty endpoint"));
}
let Some((scheme, rest)) = endpoint.split_once("://") else {
return Ok(ParsedEndpoint {
scheme: Scheme::Unix,
target: endpoint.to_string(),
baud: None,
});
};
match scheme.to_ascii_lowercase().as_str() {
"unix" | "uds" => {
require_nonempty(endpoint, rest)?;
Ok(ParsedEndpoint {
scheme: Scheme::Unix,
target: rest.to_string(),
baud: None,
})
}
"serial" => {
let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
require_nonempty(endpoint, path)?;
let baud = parse_baud(endpoint, query)?;
Ok(ParsedEndpoint {
scheme: Scheme::Serial,
target: path.to_string(),
baud,
})
}
"tcp" => {
require_tcp_target(endpoint, rest)?;
Ok(ParsedEndpoint {
scheme: Scheme::Tcp,
target: rest.to_string(),
baud: None,
})
}
other => Err(ClientError::unsupported_endpoint(
endpoint,
format!(
"unknown scheme {other:?}; built-in schemes: {}",
KNOWN_SCHEMES.join(", ")
),
)),
}
}
pub fn dial(endpoint: &str) -> ClientResult<Box<dyn Dialer>> {
let parsed = parse_endpoint(endpoint)?;
match parsed.scheme {
Scheme::Unix => {
#[cfg(feature = "transport-uds")]
{
Ok(Box::new(aimdb_uds_connector::UdsDialer::new(parsed.target)))
}
#[cfg(not(feature = "transport-uds"))]
{
Err(not_built_in(endpoint, "unix", "transport-uds"))
}
}
Scheme::Serial => {
#[cfg(feature = "transport-serial")]
{
Ok(Box::new(aimdb_serial_connector::SerialPortDialer::new(
parsed.target,
parsed.baud.unwrap_or(DEFAULT_SERIAL_BAUD),
)))
}
#[cfg(not(feature = "transport-serial"))]
{
Err(not_built_in(endpoint, "serial", "transport-serial"))
}
}
Scheme::Tcp => {
#[cfg(feature = "transport-tcp")]
{
let dialer = aimdb_tcp_connector::framed_dialer_at(
aimdb_tokio_adapter::net::TokioNet::tcp(),
&parsed.target,
)
.map_err(|e| ClientError::unsupported_endpoint(endpoint, e.to_string()))?;
Ok(Box::new(dialer))
}
#[cfg(not(feature = "transport-tcp"))]
{
Err(not_built_in(endpoint, "tcp", "transport-tcp"))
}
}
}
}
fn require_nonempty(endpoint: &str, target: &str) -> ClientResult<()> {
if target.is_empty() {
Err(ClientError::unsupported_endpoint(
endpoint,
"missing path after scheme",
))
} else {
Ok(())
}
}
fn require_tcp_target(endpoint: &str, target: &str) -> ClientResult<()> {
require_nonempty(endpoint, target)?;
let (host, port) = aimdb_core::session::split_host_port_opt(target)
.map_err(|e| ClientError::unsupported_endpoint(endpoint, e.to_string()))?;
if host.contains(['[', ']']) {
return Err(ClientError::unsupported_endpoint(
endpoint,
"malformed TCP host",
));
}
if port.is_none() {
let reason = if !target.starts_with('[') && host.contains(':') {
"IPv6 TCP hosts must be bracketed, e.g. tcp://[::1]:7001"
} else {
"missing TCP port"
};
return Err(ClientError::unsupported_endpoint(endpoint, reason));
}
Ok(())
}
fn parse_baud(endpoint: &str, query: &str) -> ClientResult<Option<u32>> {
for pair in query.split('&').filter(|p| !p.is_empty()) {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
if key.eq_ignore_ascii_case("baud") {
let baud = value.parse::<u32>().map_err(|_| {
ClientError::unsupported_endpoint(endpoint, format!("invalid baud {value:?}"))
})?;
return Ok(Some(baud));
}
}
Ok(None)
}
#[cfg(any(
not(feature = "transport-uds"),
not(feature = "transport-serial"),
not(feature = "transport-tcp")
))]
fn not_built_in(endpoint: &str, scheme: &str, feature: &str) -> ClientError {
ClientError::unsupported_endpoint(
endpoint,
format!(
"scheme {scheme:?} is not built into this binary (rebuild with --features {feature})"
),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unix_scheme_and_bare_path_both_resolve_to_unix() {
for ep in ["unix:///tmp/aimdb.sock", "uds:///tmp/aimdb.sock"] {
let p = parse_endpoint(ep).expect("parse");
assert_eq!(p.scheme, Scheme::Unix);
assert_eq!(p.target, "/tmp/aimdb.sock");
assert_eq!(p.baud, None);
}
for bare in ["/tmp/aimdb.sock", "./rel.sock"] {
let p = parse_endpoint(bare).expect("parse");
assert_eq!(p.scheme, Scheme::Unix);
assert_eq!(p.target, bare);
}
}
#[test]
fn serial_scheme_parses_path_and_optional_baud() {
let p = parse_endpoint("serial:///dev/ttyACM0?baud=9600").expect("parse");
assert_eq!(p.scheme, Scheme::Serial);
assert_eq!(p.target, "/dev/ttyACM0");
assert_eq!(p.baud, Some(9600));
let p = parse_endpoint("serial:///dev/ttyUSB0").expect("parse");
assert_eq!(p.baud, None);
let p = parse_endpoint("serial:///dev/ttyUSB0?foo=bar&baud=230400").expect("parse");
assert_eq!(p.baud, Some(230400));
}
#[test]
fn tcp_scheme_parses_host_and_port() {
let p = parse_endpoint("tcp://127.0.0.1:7001").expect("parse");
assert_eq!(p.scheme, Scheme::Tcp);
assert_eq!(p.target, "127.0.0.1:7001");
let p = parse_endpoint("tcp://localhost:7001").expect("parse");
assert_eq!(p.scheme, Scheme::Tcp);
assert_eq!(p.target, "localhost:7001");
let p = parse_endpoint("tcp://[::1]:7001").expect("parse");
assert_eq!(p.scheme, Scheme::Tcp);
assert_eq!(p.target, "[::1]:7001");
let p = parse_endpoint("tcp://[fe80::1]:7001").expect("parse");
assert_eq!(p.scheme, Scheme::Tcp);
assert_eq!(p.target, "[fe80::1]:7001");
}
#[test]
fn a_rejected_tcp_endpoint_says_what_is_wrong() {
let reason = |ep: &str| match parse_endpoint(ep) {
Err(ClientError::UnsupportedEndpoint { reason, .. }) => reason,
other => panic!("{ep} should be rejected as an endpoint, got {other:?}"),
};
assert!(reason("tcp://host").contains("missing TCP port"));
assert!(reason("tcp://[fe80::1]").contains("missing TCP port"));
assert!(reason("tcp://fe80::1").contains("must be bracketed"));
assert!(reason("tcp://:1234").contains("no host"));
assert!(reason("tcp://[]:7001").contains("no host"));
assert!(reason("tcp://host:fast").contains("not a number"));
assert!(reason("tcp://[fe80::1:7001").contains("closing bracket"));
}
#[test]
fn malformed_endpoints_are_rejected() {
assert!(parse_endpoint("tcp://").is_err());
assert!(parse_endpoint("tcp://host").is_err());
assert!(parse_endpoint("tcp://:1234").is_err());
assert!(parse_endpoint("tcp://host:fast").is_err());
assert!(parse_endpoint("tcp://fe80::1").is_err());
assert!(parse_endpoint("tcp://[fe80::1]").is_err());
assert!(parse_endpoint("tcp://[]:7001").is_err());
assert!(parse_endpoint("tcp://[fe80::1:7001").is_err());
assert!(parse_endpoint("").is_err());
assert!(parse_endpoint("unix://").is_err());
assert!(parse_endpoint("serial:///dev/x?baud=fast").is_err());
}
#[test]
fn dial_rejects_unknown_scheme() {
assert!(matches!(
dial("http://host:1234"),
Err(ClientError::UnsupportedEndpoint { .. })
));
}
#[cfg(feature = "transport-uds")]
#[test]
fn dial_builds_a_unix_dialer() {
assert!(dial("unix:///tmp/aimdb.sock").is_ok());
assert!(dial("/tmp/aimdb.sock").is_ok());
}
#[cfg(not(feature = "transport-serial"))]
#[test]
fn dial_rejects_serial_when_not_built_in() {
assert!(matches!(
dial("serial:///dev/ttyACM0"),
Err(ClientError::UnsupportedEndpoint { .. })
));
}
#[cfg(feature = "transport-serial")]
#[test]
fn dial_builds_a_serial_dialer() {
assert!(dial("serial:///dev/ttyACM0?baud=115200").is_ok());
}
#[cfg(not(feature = "transport-tcp"))]
#[test]
fn dial_rejects_tcp_when_not_built_in() {
assert!(matches!(
dial("tcp://127.0.0.1:7001"),
Err(ClientError::UnsupportedEndpoint { .. })
));
}
#[cfg(feature = "transport-tcp")]
#[test]
fn dial_builds_a_tcp_dialer() {
assert!(dial("tcp://127.0.0.1:7001").is_ok());
}
}