motorcortex-rust 0.5.0

Motorcortex Rust: a Rust client for the Motorcortex Core real-time control system (async + blocking).
Documentation
//! Free helpers for the Motorcortex URL convention.
//!
//! The server can be reached two ways:
//! 1. Two distinct ports on the same host — Req/Rep and Pub/Sub each
//!    get their own: `scheme://host:req_port:sub_port`.
//! 2. One host, no ports — typical behind an nginx reverse proxy that
//!    routes `/mcx_req` and `/mcx_sub` paths to the two backends:
//!    `scheme://host` is expanded to `scheme://host/mcx_req` and
//!    `scheme://host/mcx_sub`.
//!
//! `parse_url` returns `(req_url, sub_url)` for either form. Supports
//! hostnames, IPv4, and IPv6 (bracketed) hosts.

use crate::error::{MotorcortexError, Result};

/// Default path endpoints appended when no ports are present —
/// matches motorcortex-python's `parseUrl` fallback.
const DEFAULT_REQ_PATH: &str = "/mcx_req";
const DEFAULT_SUB_PATH: &str = "/mcx_sub";

/// Split a Motorcortex URL into `(req_url, sub_url)`.
///
/// Two input forms are accepted:
/// - `scheme://host:req_port:sub_port` → each port attached to its
///   own copy of the host.
/// - `scheme://host` → `/mcx_req` / `/mcx_sub` appended, matching the
///   nginx reverse-proxy convention used by motorcortex-python.
///
/// Examples:
///
/// ```
/// use motorcortex_rust::parse_url;
/// let (req, sub) = parse_url("wss://host:5568:5567").unwrap();
/// assert_eq!(req, "wss://host:5568");
/// assert_eq!(sub, "wss://host:5567");
///
/// let (req, sub) = parse_url("wss://host").unwrap();
/// assert_eq!(req, "wss://host/mcx_req");
/// assert_eq!(sub, "wss://host/mcx_sub");
/// ```
///
/// Errors with [`MotorcortexError::Connection`] if the scheme is
/// missing, an IPv6 bracket is unclosed, only one port is given, or
/// either port isn't parseable as a `u16`.
pub fn parse_url(s: &str) -> Result<(String, String)> {
    let scheme_end = s
        .find("://")
        .ok_or_else(|| invalid("missing `scheme://` prefix"))?;
    let scheme_prefix = &s[..scheme_end + 3]; // includes `://`
    let after = &s[scheme_end + 3..];

    // Split host from the port tail. Anything after the host (the
    // closing `]` for IPv6, or the first `:` otherwise) is treated as
    // the tail; an empty tail means "no ports — use default paths".
    let (host, port_tail) = if let Some(rest) = after.strip_prefix('[') {
        let close = rest
            .find(']')
            .ok_or_else(|| invalid("IPv6 host missing closing `]`"))?;
        let host = &after[..=close + 1]; // `[` + inner + `]`
        let tail = &rest[close + 1..]; // everything after `]`
        (host, tail)
    } else {
        match after.find(':') {
            Some(i) => (&after[..i], &after[i..]),
            None => (after, ""),
        }
    };

    if port_tail.is_empty() {
        return Ok((
            format!("{scheme_prefix}{host}{DEFAULT_REQ_PATH}"),
            format!("{scheme_prefix}{host}{DEFAULT_SUB_PATH}"),
        ));
    }

    let ports = port_tail
        .strip_prefix(':')
        .ok_or_else(|| invalid("expected `:` before ports"))?;
    let colon = ports
        .find(':')
        .ok_or_else(|| invalid("need two ports separated by `:`"))?;
    let req_port = &ports[..colon];
    let sub_port = &ports[colon + 1..];

    req_port
        .parse::<u16>()
        .map_err(|_| invalid(&format!("req_port {req_port:?} is not a valid u16")))?;
    sub_port
        .parse::<u16>()
        .map_err(|_| invalid(&format!("sub_port {sub_port:?} is not a valid u16")))?;

    Ok((
        format!("{scheme_prefix}{host}:{req_port}"),
        format!("{scheme_prefix}{host}:{sub_port}"),
    ))
}

fn invalid(msg: &str) -> MotorcortexError {
    MotorcortexError::Connection(format!("parse_url: {msg}"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn hostname_with_both_ports() {
        let (r, s) = parse_url("wss://host:5568:5567").unwrap();
        assert_eq!(r, "wss://host:5568");
        assert_eq!(s, "wss://host:5567");
    }

    #[test]
    fn ipv4_with_both_ports() {
        let (r, s) = parse_url("wss://192.168.1.10:5568:5567").unwrap();
        assert_eq!(r, "wss://192.168.1.10:5568");
        assert_eq!(s, "wss://192.168.1.10:5567");
    }

    #[test]
    fn ipv6_loopback_with_both_ports() {
        let (r, s) = parse_url("wss://[::1]:5568:5567").unwrap();
        assert_eq!(r, "wss://[::1]:5568");
        assert_eq!(s, "wss://[::1]:5567");
    }

    #[test]
    fn ipv6_full_address() {
        let (r, s) =
            parse_url("wss://[2001:db8::8a2e:370:7334]:5568:5567").unwrap();
        assert_eq!(r, "wss://[2001:db8::8a2e:370:7334]:5568");
        assert_eq!(s, "wss://[2001:db8::8a2e:370:7334]:5567");
    }

    #[test]
    fn tcp_scheme() {
        let (r, s) = parse_url("tcp://host:5568:5567").unwrap();
        assert_eq!(r, "tcp://host:5568");
        assert_eq!(s, "tcp://host:5567");
    }

    #[test]
    fn missing_scheme_errors() {
        let err = parse_url("host:5568:5567").unwrap_err();
        assert!(matches!(err, MotorcortexError::Connection(ref m) if m.contains("scheme")));
    }

    #[test]
    fn hostname_no_ports_uses_default_paths() {
        let (r, s) = parse_url("wss://host").unwrap();
        assert_eq!(r, "wss://host/mcx_req");
        assert_eq!(s, "wss://host/mcx_sub");
    }

    #[test]
    fn ipv4_no_ports_uses_default_paths() {
        let (r, s) = parse_url("wss://192.168.2.100").unwrap();
        assert_eq!(r, "wss://192.168.2.100/mcx_req");
        assert_eq!(s, "wss://192.168.2.100/mcx_sub");
    }

    #[test]
    fn ipv6_no_ports_uses_default_paths() {
        let (r, s) = parse_url("wss://[::1]").unwrap();
        assert_eq!(r, "wss://[::1]/mcx_req");
        assert_eq!(s, "wss://[::1]/mcx_sub");
    }

    #[test]
    fn ipv6_link_local_no_ports_uses_default_paths() {
        let (r, _) = parse_url("wss://[fe80::1]").unwrap();
        assert_eq!(r, "wss://[fe80::1]/mcx_req");
    }

    #[test]
    fn single_port_errors() {
        // Only one port — we need two.
        let err = parse_url("wss://host:5568").unwrap_err();
        assert!(matches!(err, MotorcortexError::Connection(ref m) if m.contains("two ports")));
    }

    #[test]
    fn non_numeric_port_errors() {
        let err = parse_url("wss://host:5568:abc").unwrap_err();
        assert!(matches!(err, MotorcortexError::Connection(ref m) if m.contains("sub_port")));
    }

    #[test]
    fn ipv6_unclosed_bracket_errors() {
        let err = parse_url("wss://[::1:5568:5567").unwrap_err();
        assert!(matches!(err, MotorcortexError::Connection(ref m) if m.contains("closing")));
    }

    #[test]
    fn ipv6_missing_colon_after_bracket_errors() {
        // `]` followed by something other than `:`.
        let err = parse_url("wss://[::1]5568:5567").unwrap_err();
        assert!(matches!(err, MotorcortexError::Connection(ref m) if m.contains(":")));
    }

    #[test]
    fn port_above_u16_max_errors() {
        let err = parse_url("wss://host:70000:5567").unwrap_err();
        assert!(matches!(err, MotorcortexError::Connection(ref m) if m.contains("req_port")));
    }
}