use-db-url 0.1.0

Primitive database URL and DSN metadata for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

//! Database URL and DSN primitives for `RustUse`.

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

macro_rules! database_url_text_type {
    ($type_name:ident, $empty_error:expr) => {
        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
        pub struct $type_name(String);

        impl $type_name {
            /// Creates a database URL text wrapper.
            ///
            /// # Errors
            ///
            /// Returns [`DatabaseUrlError`] when text is empty or contains control characters.
            pub fn new(input: impl AsRef<str>) -> Result<Self, DatabaseUrlError> {
                validate_text(input.as_ref(), $empty_error).map(|value| Self(value.to_owned()))
            }

            /// Returns the stored text.
            #[must_use]
            pub fn as_str(&self) -> &str {
                &self.0
            }

            /// Consumes the wrapper and returns the stored string.
            #[must_use]
            pub fn into_string(self) -> String {
                self.0
            }
        }

        impl AsRef<str> for $type_name {
            fn as_ref(&self) -> &str {
                self.as_str()
            }
        }

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

        impl FromStr for $type_name {
            type Err = DatabaseUrlError;

            fn from_str(input: &str) -> Result<Self, Self::Err> {
                Self::new(input)
            }
        }
    };
}

database_url_text_type!(DatabaseUrl, DatabaseUrlError::EmptyUrl);
database_url_text_type!(DatabaseHost, DatabaseUrlError::EmptyHost);
database_url_text_type!(DatabaseDsn, DatabaseUrlError::EmptyDsn);

/// A database URL scheme such as `postgresql` or `sqlite`.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DatabaseScheme(String);

impl DatabaseScheme {
    /// Creates a scheme from a conservative URI scheme label.
    ///
    /// # Errors
    ///
    /// Returns [`DatabaseUrlError`] when the scheme is empty or malformed.
    pub fn new(input: impl AsRef<str>) -> Result<Self, DatabaseUrlError> {
        let trimmed = validate_text(input.as_ref(), DatabaseUrlError::EmptyScheme)?;
        let mut characters = trimmed.chars();
        let Some(first) = characters.next() else {
            return Err(DatabaseUrlError::EmptyScheme);
        };
        if !first.is_ascii_alphabetic()
            || !characters.all(|character| {
                character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.')
            })
        {
            return Err(DatabaseUrlError::InvalidScheme);
        }
        Ok(Self(trimmed.to_ascii_lowercase()))
    }

    /// Returns the stored scheme.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

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

/// A database network port.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DatabasePort(u16);

impl DatabasePort {
    /// Creates a nonzero database port.
    ///
    /// # Errors
    ///
    /// Returns [`DatabaseUrlError::InvalidPort`] when `port` is zero.
    pub const fn new(port: u16) -> Result<Self, DatabaseUrlError> {
        if port == 0 {
            Err(DatabaseUrlError::InvalidPort)
        } else {
            Ok(Self(port))
        }
    }

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

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

/// A database URL path component.
#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DatabasePath(String);

impl DatabasePath {
    /// Creates a path wrapper. Empty paths are allowed and represent no path metadata.
    ///
    /// # Errors
    ///
    /// Returns [`DatabaseUrlError::ControlCharacter`] when text contains control characters.
    pub fn new(input: impl AsRef<str>) -> Result<Self, DatabaseUrlError> {
        let input = input.as_ref();
        if input.chars().any(char::is_control) {
            return Err(DatabaseUrlError::ControlCharacter);
        }
        let text = input.trim();
        Ok(Self(text.to_owned()))
    }

    /// Returns the stored path.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

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

/// Lightweight parts extracted from a database URL.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DatabaseUrlParts {
    /// URL scheme.
    pub scheme: DatabaseScheme,
    /// Optional host.
    pub host: Option<DatabaseHost>,
    /// Optional port.
    pub port: Option<DatabasePort>,
    /// URL path.
    pub path: DatabasePath,
}

/// Error returned by database URL primitives.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DatabaseUrlError {
    /// URL text was empty.
    EmptyUrl,
    /// URL scheme was empty.
    EmptyScheme,
    /// Host text was empty.
    EmptyHost,
    /// DSN text was empty.
    EmptyDsn,
    /// Text contained a control character.
    ControlCharacter,
    /// Scheme text was malformed.
    InvalidScheme,
    /// Port was zero or malformed.
    InvalidPort,
    /// URL text did not contain a scheme.
    MissingScheme,
}

impl fmt::Display for DatabaseUrlError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::EmptyUrl => formatter.write_str("database URL cannot be empty"),
            Self::EmptyScheme => formatter.write_str("database URL scheme cannot be empty"),
            Self::EmptyHost => formatter.write_str("database URL host cannot be empty"),
            Self::EmptyDsn => formatter.write_str("database DSN cannot be empty"),
            Self::ControlCharacter => {
                formatter.write_str("database URL text cannot contain control characters")
            },
            Self::InvalidScheme => formatter.write_str("database URL scheme is invalid"),
            Self::InvalidPort => formatter.write_str("database URL port is invalid"),
            Self::MissingScheme => formatter.write_str("database URL is missing a scheme"),
        }
    }
}

impl Error for DatabaseUrlError {}

impl DatabaseUrl {
    /// Parses lightweight URL parts with simple string splitting.
    ///
    /// # Errors
    ///
    /// Returns [`DatabaseUrlError`] when the URL has no valid scheme or contains invalid parts.
    pub fn parse_basic(&self) -> Result<DatabaseUrlParts, DatabaseUrlError> {
        parse_database_url_basic(self.as_str())
    }

    /// Returns the URL scheme when parsing succeeds.
    #[must_use]
    pub fn scheme(&self) -> Option<DatabaseScheme> {
        self.parse_basic().ok().map(|parts| parts.scheme)
    }
}

/// Parses lightweight database URL parts with dependency-free string splitting.
///
/// # Errors
///
/// Returns [`DatabaseUrlError`] when the URL has no valid scheme or contains invalid parts.
pub fn parse_database_url_basic(input: &str) -> Result<DatabaseUrlParts, DatabaseUrlError> {
    let trimmed = validate_text(input, DatabaseUrlError::EmptyUrl)?;
    let scheme_end = trimmed.find(':').ok_or(DatabaseUrlError::MissingScheme)?;
    let scheme = DatabaseScheme::new(&trimmed[..scheme_end])?;
    let mut remainder = &trimmed[scheme_end + 1..];
    let mut host = None;
    let mut port = None;

    if let Some(after_slashes) = remainder.strip_prefix("//") {
        let authority_end = after_slashes
            .find(['/', '?', '#'])
            .unwrap_or(after_slashes.len());
        let authority = &after_slashes[..authority_end];
        remainder = &after_slashes[authority_end..];

        if !authority.is_empty() {
            let host_port = authority
                .rsplit_once('@')
                .map_or(authority, |(_, tail)| tail);
            if let Some((host_text, port_text)) = host_port.rsplit_once(':') {
                if !host_text.is_empty()
                    && port_text
                        .chars()
                        .all(|character| character.is_ascii_digit())
                {
                    host = Some(DatabaseHost::new(host_text)?);
                    port = Some(DatabasePort::new(
                        port_text
                            .parse()
                            .map_err(|_| DatabaseUrlError::InvalidPort)?,
                    )?);
                } else {
                    host = Some(DatabaseHost::new(host_port)?);
                }
            } else {
                host = Some(DatabaseHost::new(host_port)?);
            }
        }
    }

    let suffix_end = remainder.find(['?', '#']).unwrap_or(remainder.len());
    let path = DatabasePath::new(&remainder[..suffix_end])?;

    Ok(DatabaseUrlParts {
        scheme,
        host,
        port,
        path,
    })
}

/// Returns whether input can be parsed as a lightweight database URL.
#[must_use]
pub fn looks_like_database_url(input: &str) -> bool {
    parse_database_url_basic(input).is_ok()
}

fn validate_text(input: &str, empty_error: DatabaseUrlError) -> Result<&str, DatabaseUrlError> {
    if input.chars().any(char::is_control) {
        return Err(DatabaseUrlError::ControlCharacter);
    }
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return Err(empty_error);
    }
    Ok(trimmed)
}

#[cfg(test)]
mod tests {
    use super::{
        DatabasePort, DatabaseScheme, DatabaseUrl, DatabaseUrlError, looks_like_database_url,
    };

    #[test]
    fn parses_database_urls() -> Result<(), DatabaseUrlError> {
        let url = DatabaseUrl::new("postgresql://localhost:5432/app")?;
        let parts = url.parse_basic()?;

        assert_eq!(parts.scheme.as_str(), "postgresql");
        assert_eq!(parts.host.expect("host").as_str(), "localhost");
        assert_eq!(parts.port.expect("port").value(), 5432);
        assert_eq!(parts.path.as_str(), "/app");
        assert!(looks_like_database_url("sqlite:///tmp/app.db"));
        Ok(())
    }

    #[test]
    fn validates_scheme_and_port() {
        assert_eq!(
            DatabaseScheme::new("1bad"),
            Err(DatabaseUrlError::InvalidScheme)
        );
        assert_eq!(DatabasePort::new(0), Err(DatabaseUrlError::InvalidPort));
        assert_eq!(DatabaseUrl::new(""), Err(DatabaseUrlError::EmptyUrl));
    }
}