foundation_core 0.0.3

Central crate for all foundation crates for the ewe-platform project
Documentation
//! URI path and query components.

use std::fmt;

use crate::url::errors::InvalidUri;

/// Path and query component of a URI.
///
/// WHY: Paths and queries are often handled together since they form
/// the resource identifier after the authority.
///
/// WHAT: Represents `path[?query]` with validation and normalization.
///
/// HOW: Stores path and optional query, ensuring path is valid according
/// to RFC 3986.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PathAndQuery {
    /// Path component (may be empty for authority-only URIs)
    pub path: String,
    /// Optional query component (without the '?')
    pub query: Option<String>,
}

impl PathAndQuery {
    /// Parses a path and query from a string.
    ///
    /// # Arguments
    ///
    /// * `s` - String containing path and optionally query (no scheme/authority)
    ///
    /// # Errors
    ///
    /// Returns `InvalidUri` if path contains invalid characters.
    ///
    /// # Panics
    ///
    /// This function does not panic. Invalid path characters return `Err(InvalidUri)`.
    pub fn parse(s: &str) -> Result<Self, InvalidUri> {
        // Empty string is valid (becomes "/" in absolute URIs)
        if s.is_empty() {
            return Ok(PathAndQuery {
                path: "/".to_string(),
                query: None,
            });
        }

        // Split on '?' for query
        let (path_str, query) = if let Some(q_pos) = s.find('?') {
            let query_str = &s[q_pos + 1..];
            let query = if query_str.is_empty() {
                None
            } else {
                Some(query_str.to_string())
            };
            (&s[..q_pos], query)
        } else {
            (s, None)
        };

        // Validate path
        Self::validate_path(path_str)?;

        // Ensure path starts with '/' for absolute URIs
        let path = if path_str.is_empty() || path_str.starts_with('/') {
            if path_str.is_empty() {
                "/".to_string()
            } else {
                path_str.to_string()
            }
        } else {
            // Relative path - prepend '/'
            format!("/{path_str}")
        };

        Ok(PathAndQuery { path, query })
    }

    /// Validates a path string according to RFC 3986.
    ///
    /// Allows: unreserved / pct-encoded / sub-delims / ":" / "@" / "/"
    fn validate_path(s: &str) -> Result<(), InvalidUri> {
        for c in s.chars() {
            if !(c.is_ascii_alphanumeric()
                || c == '-'
                || c == '.'
                || c == '_'
                || c == '~' // unreserved
                || c == '!'
                || c == '$'
                || c == '&'
                || c == '\''
                || c == '('
                || c == ')'
                || c == '*'
                || c == '+'
                || c == ','
                || c == ';'
                || c == '=' // sub-delims
                || c == ':'
                || c == '@' // pchar
                || c == '/' // path separator
                || c == '%')
            // pct-encoded
            {
                return Err(InvalidUri::new(format!("invalid path character: {c}")));
            }
        }
        Ok(())
    }

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

    /// Returns the query component if present.
    #[must_use]
    pub fn query(&self) -> Option<&str> {
        self.query.as_deref()
    }
}

impl fmt::Display for PathAndQuery {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.path)?;
        if let Some(query) = &self.query {
            write!(f, "?{query}")?;
        }
        Ok(())
    }
}