cruxi 0.2.0

Minimal, transport-agnostic hexagonal architecture framework
Documentation
//! Unified Resource Name (URN) type for stable resource references.
//!
//! URNs provide stable, version-aware resource identifiers that work
//! across API versions and services.

use std::fmt;
use thiserror::Error;

/// Errors that can occur when parsing or constructing URNs.
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum UrnError {
    /// The URN does not have exactly 5 colon-separated parts.
    #[error("invalid URN format: expected 5 parts separated by colons")]
    InvalidFormat,

    /// The URN does not start with "urn".
    #[error("invalid URN prefix: must start with 'urn'")]
    InvalidPrefix,

    /// The version part is invalid.
    #[error("invalid URN version: must start with 'v' followed by digits (e.g., v1, v2)")]
    InvalidVersion,

    /// One of the required parts is empty.
    #[error("invalid URN: {0} cannot be empty")]
    EmptyPart(&'static str),
}

/// A Unified Resource Name for stable resource references.
///
/// URN format: `urn:<capability>:<version>:<resource>:<id>`
///
/// # Components
///
/// - **capability**: The service or domain (e.g., "users", "orders")
/// - **version**: API version starting with 'v' (e.g., "v1", "v2")
/// - **resource**: The resource type (e.g., "user", "order")
/// - **id**: The unique identifier
///
/// # Examples
///
/// ```
/// use cruxi::Urn;
///
/// // Parse an existing URN
/// let parsed = Urn::parse("urn:users:v1:user:12345");
/// assert!(parsed.is_ok());
/// let Ok(urn) = parsed else {
///     return;
/// };
/// assert_eq!(urn.capability(), "users");
/// assert_eq!(urn.version(), "v1");
/// assert_eq!(urn.resource(), "user");
/// assert_eq!(urn.id(), "12345");
///
/// // Construct a new URN
/// let created = Urn::new("orders", "v2", "order", "abc-123");
/// assert!(created.is_ok());
/// let Ok(urn) = created else {
///     return;
/// };
/// assert_eq!(urn.to_string(), "urn:orders:v2:order:abc-123");
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Urn {
    capability: String,
    version: String,
    resource: String,
    id: String,
}

impl Urn {
    /// Creates a new URN from its components.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - Any component is empty
    /// - Version doesn't start with 'v' followed by digits
    ///
    /// # Example
    ///
    /// ```
    /// use cruxi::Urn;
    ///
    /// let created = Urn::new("users", "v1", "user", "12345");
    /// assert!(created.is_ok());
    /// let Ok(urn) = created else {
    ///     return;
    /// };
    /// assert_eq!(urn.to_string(), "urn:users:v1:user:12345");
    /// ```
    pub fn new(
        capability: impl Into<String>,
        version: impl Into<String>,
        resource: impl Into<String>,
        id: impl Into<String>,
    ) -> Result<Self, UrnError> {
        let capability = capability.into();
        let version = version.into();
        let resource = resource.into();
        let id = id.into();

        if capability.is_empty() {
            return Err(UrnError::EmptyPart("capability"));
        }
        if version.is_empty() {
            return Err(UrnError::EmptyPart("version"));
        }
        if resource.is_empty() {
            return Err(UrnError::EmptyPart("resource"));
        }
        if id.is_empty() {
            return Err(UrnError::EmptyPart("id"));
        }

        Self::validate_version(&version)?;

        Ok(Self {
            capability,
            version,
            resource,
            id,
        })
    }

    /// Parses a URN from a string.
    ///
    /// # Format
    ///
    /// `urn:<capability>:<version>:<resource>:<id>`
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - The string doesn't have exactly 5 colon-separated parts
    /// - The first part is not "urn"
    /// - Any part is empty
    /// - Version doesn't start with 'v' followed by digits
    ///
    /// # Example
    ///
    /// ```
    /// use cruxi::Urn;
    ///
    /// let parsed = Urn::parse("urn:orders:v2:order:abc-123");
    /// assert!(parsed.is_ok());
    /// let Ok(urn) = parsed else {
    ///     return;
    /// };
    /// assert_eq!(urn.capability(), "orders");
    /// ```
    pub fn parse(value: &str) -> Result<Self, UrnError> {
        let parts: Vec<&str> = value.split(':').collect();

        match parts.as_slice() {
            [prefix, capability, version, resource, id] => {
                if *prefix != "urn" {
                    return Err(UrnError::InvalidPrefix);
                }

                Self::new(*capability, *version, *resource, *id)
            }
            [] | [_] | [_, _] | [_, _, _] | [_, _, _, _] | [_, _, _, _, _, ..] => {
                Err(UrnError::InvalidFormat)
            }
        }
    }

    /// Validates that a string is a valid URN without constructing it.
    ///
    /// # Example
    ///
    /// ```
    /// use cruxi::Urn;
    ///
    /// assert!(Urn::validate("urn:users:v1:user:123").is_ok());
    /// assert!(Urn::validate("invalid").is_err());
    /// ```
    ///
    /// # Errors
    ///
    /// Returns an error when `value` is not a valid URN.
    pub fn validate(value: &str) -> Result<(), UrnError> {
        Self::parse(value).map(|_| ())
    }

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

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

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

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

    /// Validates that a version string is valid.
    fn validate_version(version: &str) -> Result<(), UrnError> {
        match version.strip_prefix('v') {
            Some(digits) if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) => {
                Ok(())
            }
            Some(_) | None => Err(UrnError::InvalidVersion),
        }
    }
}

impl fmt::Display for Urn {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "urn:{}:{}:{}:{}",
            self.capability, self.version, self.resource, self.id
        )
    }
}

impl std::str::FromStr for Urn {
    type Err = UrnError;

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

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

    #[test]
    fn parse_valid_urn() {
        let urn = Urn::parse("urn:users:v1:user:12345");
        assert!(urn.is_ok());

        let urn = urn.ok();
        assert_eq!(urn.as_ref().map(Urn::capability), Some("users"));
        assert_eq!(urn.as_ref().map(Urn::version), Some("v1"));
        assert_eq!(urn.as_ref().map(Urn::resource), Some("user"));
        assert_eq!(urn.as_ref().map(Urn::id), Some("12345"));
    }

    #[test]
    fn parse_invalid_format() {
        let cases = [
            ("", UrnError::InvalidFormat),
            ("urn", UrnError::InvalidFormat),
            ("urn:a:b:c", UrnError::InvalidFormat),
            ("urn:a:b:c:d:e", UrnError::InvalidFormat),
        ];

        for (input, expected) in cases {
            let result = Urn::parse(input);
            assert_eq!(result.err(), Some(expected.clone()), "input: {input}");
        }
    }

    #[test]
    fn parse_invalid_prefix() {
        let result = Urn::parse("URN:users:v1:user:123");
        assert_eq!(result.err(), Some(UrnError::InvalidPrefix));
    }

    #[test]
    fn parse_invalid_version() {
        let cases = [
            "urn:users:1:user:123",        // missing 'v'
            "urn:users:version1:user:123", // not just 'v' prefix
            "urn:users:v:user:123",        // no digits
            "urn:users:va:user:123",       // non-digit after 'v'
        ];

        for input in cases {
            let result = Urn::parse(input);
            assert_eq!(
                result.err(),
                Some(UrnError::InvalidVersion),
                "input: {input}"
            );
        }
    }

    #[test]
    fn parse_empty_parts() {
        let cases = [
            ("urn::v1:user:123", UrnError::EmptyPart("capability")),
            ("urn:users::user:123", UrnError::EmptyPart("version")),
            ("urn:users:v1::123", UrnError::EmptyPart("resource")),
            ("urn:users:v1:user:", UrnError::EmptyPart("id")),
        ];

        for (input, expected) in cases {
            let result = Urn::parse(input);
            assert_eq!(result.err(), Some(expected), "input: {input}");
        }
    }

    #[test]
    fn new_valid_urn() {
        let urn = Urn::new("users", "v1", "user", "12345");
        assert!(urn.is_ok());
        assert_eq!(
            urn.ok().map(|u| u.to_string()),
            Some("urn:users:v1:user:12345".to_string())
        );
    }

    #[test]
    fn new_invalid_parts() {
        let cases = [
            (("", "v1", "user", "123"), UrnError::EmptyPart("capability")),
            (("users", "", "user", "123"), UrnError::EmptyPart("version")),
            (("users", "v1", "", "123"), UrnError::EmptyPart("resource")),
            (("users", "v1", "user", ""), UrnError::EmptyPart("id")),
        ];

        for ((cap, ver, res, id), expected) in cases {
            let result = Urn::new(cap, ver, res, id);
            assert_eq!(result.err(), Some(expected));
        }
    }

    #[test]
    fn display() {
        let urn = Urn::new("orders", "v2", "order", "abc-123").ok();
        assert_eq!(
            urn.map(|u| u.to_string()),
            Some("urn:orders:v2:order:abc-123".to_string())
        );
    }

    #[test]
    fn from_str() {
        let urn: Result<Urn, _> = "urn:users:v1:user:123".parse();
        assert!(urn.is_ok());
    }

    #[test]
    fn validate() {
        assert!(Urn::validate("urn:users:v1:user:123").is_ok());
        assert!(Urn::validate("invalid").is_err());
    }

    #[test]
    fn multi_digit_version() {
        let urn = Urn::parse("urn:api:v123:resource:id");
        assert!(urn.is_ok());
        assert_eq!(
            urn.ok().map(|u| u.version().to_string()),
            Some("v123".to_string())
        );
    }

    #[test]
    fn id_with_special_chars() {
        let urn = Urn::parse("urn:users:v1:user:abc-123_def.456");
        assert!(urn.is_ok());
        assert_eq!(
            urn.ok().map(|u| u.id().to_string()),
            Some("abc-123_def.456".to_string())
        );
    }
}