arvo 1.0.0

Validated, immutable value objects for common domain types (email, money, identifiers, …)
Documentation
use crate::errors::ValidationError;
use crate::traits::{PrimitiveValue, ValueObject};

/// A string whose length (in Unicode characters) is constrained to `MIN..=MAX`.
///
/// Surrounding whitespace is stripped before the length check. The type encodes
/// the allowed range at compile time via const generics, making length
/// constraints self-documenting at the call site:
///
/// ```rust,ignore
/// type Username = BoundedString<3, 32>;
/// ```
///
/// # Example
///
/// ```rust,ignore
/// use arvo::primitives::BoundedString;
/// use arvo::traits::ValueObject;
///
/// let name: BoundedString<2, 50> = BoundedString::new("Alice".into()).unwrap();
/// assert_eq!(name.value(), "Alice");
///
/// assert!(BoundedString::<2, 50>::new("A".into()).is_err()); // too short
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
pub struct BoundedString<const MIN: usize, const MAX: usize>(String);

impl<const MIN: usize, const MAX: usize> ValueObject for BoundedString<MIN, MAX> {
    type Input = String;
    type Error = ValidationError;

    fn new(value: Self::Input) -> Result<Self, Self::Error> {
        if MIN > MAX {
            return Err(ValidationError::Custom {
                type_name: "BoundedString",
                message: format!("MIN ({MIN}) must be <= MAX ({MAX})"),
            });
        }
        let trimmed = value.trim().to_owned();
        let len = trimmed.chars().count();
        if len < MIN || len > MAX {
            return Err(ValidationError::OutOfRange {
                type_name: "BoundedString",
                min: MIN.to_string(),
                max: MAX.to_string(),
                actual: len.to_string(),
            });
        }
        Ok(Self(trimmed))
    }

    fn into_inner(self) -> Self::Input {
        self.0
    }
}

impl<const MIN: usize, const MAX: usize> PrimitiveValue for BoundedString<MIN, MAX> {
    type Primitive = String;
    fn value(&self) -> &String {
        &self.0
    }
}

impl<const MIN: usize, const MAX: usize> TryFrom<String> for BoundedString<MIN, MAX> {
    type Error = ValidationError;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}

#[cfg(feature = "serde")]
impl<const MIN: usize, const MAX: usize> From<BoundedString<MIN, MAX>> for String {
    fn from(v: BoundedString<MIN, MAX>) -> String {
        v.0
    }
}

impl<const MIN: usize, const MAX: usize> TryFrom<&str> for BoundedString<MIN, MAX> {
    type Error = ValidationError;

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

impl<const MIN: usize, const MAX: usize> std::fmt::Display for BoundedString<MIN, MAX> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

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

    #[test]
    fn accepts_string_within_bounds() {
        let s: BoundedString<2, 10> = BoundedString::new("hello".into()).unwrap();
        assert_eq!(s.value(), "hello");
    }

    #[test]
    fn trims_surrounding_whitespace() {
        let s: BoundedString<1, 10> = BoundedString::new("  hi  ".into()).unwrap();
        assert_eq!(s.value(), "hi");
    }

    #[test]
    fn rejects_too_short() {
        assert!(BoundedString::<3, 10>::new("ab".into()).is_err());
    }

    #[test]
    fn rejects_too_long() {
        assert!(BoundedString::<1, 3>::new("toolong".into()).is_err());
    }

    #[test]
    fn accepts_exact_min() {
        let s: BoundedString<3, 10> = BoundedString::new("abc".into()).unwrap();
        assert_eq!(s.value(), "abc");
    }

    #[test]
    fn accepts_exact_max() {
        let s: BoundedString<1, 5> = BoundedString::new("hello".into()).unwrap();
        assert_eq!(s.value(), "hello");
    }

    #[test]
    fn counts_unicode_chars_not_bytes() {
        // "café" is 4 chars but 5 bytes
        let s: BoundedString<1, 4> = BoundedString::new("café".into()).unwrap();
        assert_eq!(s.value(), "café");
    }

    #[test]
    fn try_from_str() {
        let s: BoundedString<1, 10> = "test".try_into().unwrap();
        assert_eq!(s.value(), "test");
    }
}