use-docker-port 0.0.1

Primitive Docker port mapping parsing for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::{fmt, str::FromStr};
use std::error::Error;

/// Error returned when a Docker port mapping is invalid.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PortMappingError {
    /// The mapping was empty after trimming.
    Empty,
    /// A port number was missing or outside `1..=65535`.
    InvalidPort,
    /// The protocol suffix was not recognized.
    InvalidProtocol,
    /// The mapping had too many fields or unsupported host syntax.
    InvalidMapping,
}

impl fmt::Display for PortMappingError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("Docker port mapping cannot be empty"),
            Self::InvalidPort => formatter.write_str("invalid Docker port number"),
            Self::InvalidProtocol => formatter.write_str("invalid Docker port protocol"),
            Self::InvalidMapping => formatter.write_str("invalid Docker port mapping"),
        }
    }
}

impl Error for PortMappingError {}

/// A Docker port protocol label.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum PortProtocol {
    /// TCP.
    Tcp,
    /// UDP.
    Udp,
    /// SCTP.
    Sctp,
}

impl PortProtocol {
    /// Returns the lowercase protocol label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Tcp => "tcp",
            Self::Udp => "udp",
            Self::Sctp => "sctp",
        }
    }
}

impl fmt::Display for PortProtocol {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for PortProtocol {
    type Err = PortMappingError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "tcp" => Ok(Self::Tcp),
            "udp" => Ok(Self::Udp),
            "sctp" => Ok(Self::Sctp),
            _ => Err(PortMappingError::InvalidProtocol),
        }
    }
}

/// A validated TCP/UDP/SCTP port number.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PortNumber(u16);

impl PortNumber {
    /// Creates a port number in `1..=65535`.
    pub const fn new(value: u16) -> Result<Self, PortMappingError> {
        if value == 0 {
            Err(PortMappingError::InvalidPort)
        } else {
            Ok(Self(value))
        }
    }

    /// Returns the numeric port.
    #[must_use]
    pub const fn get(self) -> u16 {
        self.0
    }
}

impl fmt::Display for PortNumber {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}", self.get())
    }
}

/// A Docker port mapping.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PortMapping {
    host_ip: Option<String>,
    host_port: Option<PortNumber>,
    container_port: PortNumber,
    protocol: PortProtocol,
}

impl PortMapping {
    /// Creates a mapping from validated parts.
    #[must_use]
    pub fn new(
        host_ip: Option<String>,
        host_port: Option<PortNumber>,
        container_port: PortNumber,
        protocol: PortProtocol,
    ) -> Self {
        Self {
            host_ip,
            host_port,
            container_port,
            protocol,
        }
    }

    /// Returns the optional host IP.
    #[must_use]
    pub fn host_ip(&self) -> Option<&str> {
        self.host_ip.as_deref()
    }

    /// Returns the optional host port.
    #[must_use]
    pub const fn host_port(&self) -> Option<PortNumber> {
        self.host_port
    }

    /// Returns the container port.
    #[must_use]
    pub const fn container_port(&self) -> PortNumber {
        self.container_port
    }

    /// Returns the protocol.
    #[must_use]
    pub const fn protocol(&self) -> PortProtocol {
        self.protocol
    }
}

impl fmt::Display for PortMapping {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        if let Some(host_ip) = &self.host_ip {
            write!(formatter, "{host_ip}:")?;
        }
        if let Some(host_port) = self.host_port {
            write!(formatter, "{host_port}:")?;
        }
        write!(formatter, "{}/{}", self.container_port, self.protocol)
    }
}

impl FromStr for PortMapping {
    type Err = PortMappingError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        parse_port_mapping(value)
    }
}

impl TryFrom<&str> for PortMapping {
    type Error = PortMappingError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        parse_port_mapping(value)
    }
}

fn parse_port_mapping(value: &str) -> Result<PortMapping, PortMappingError> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return Err(PortMappingError::Empty);
    }
    let (mapping, protocol) = match trimmed.rsplit_once('/') {
        Some((mapping, protocol)) => (mapping, protocol.parse()?),
        None => (trimmed, PortProtocol::Tcp),
    };
    let parts = mapping.split(':').collect::<Vec<_>>();
    match parts.as_slice() {
        [container] => Ok(PortMapping::new(
            None,
            None,
            parse_port(container)?,
            protocol,
        )),
        [host, container] => Ok(PortMapping::new(
            None,
            Some(parse_port(host)?),
            parse_port(container)?,
            protocol,
        )),
        [host_ip, host, container] => {
            validate_host_ip(host_ip)?;
            Ok(PortMapping::new(
                Some((*host_ip).to_string()),
                Some(parse_port(host)?),
                parse_port(container)?,
                protocol,
            ))
        },
        _ => Err(PortMappingError::InvalidMapping),
    }
}

fn parse_port(value: &str) -> Result<PortNumber, PortMappingError> {
    let parsed = value
        .parse::<u16>()
        .map_err(|_| PortMappingError::InvalidPort)?;
    PortNumber::new(parsed)
}

fn validate_host_ip(value: &str) -> Result<(), PortMappingError> {
    if value.is_empty() || value.chars().any(char::is_whitespace) || value.contains(':') {
        Err(PortMappingError::InvalidMapping)
    } else {
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::{PortMapping, PortProtocol};

    #[test]
    fn parses_port_mappings() -> Result<(), Box<dyn std::error::Error>> {
        let mapping: PortMapping = "127.0.0.1:8080:80/tcp".parse()?;
        let udp: PortMapping = "53/udp".parse()?;

        assert_eq!(mapping.host_ip(), Some("127.0.0.1"));
        assert_eq!(mapping.host_port().map(super::PortNumber::get), Some(8080));
        assert_eq!(mapping.container_port().get(), 80);
        assert_eq!(udp.protocol(), PortProtocol::Udp);
        assert_eq!("8080:80".parse::<PortMapping>()?.to_string(), "8080:80/tcp");
        Ok(())
    }
}