Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
.DS_Store

# Repo management scripts (keep locally, not in repo)
setup-github-repo.sh
scripts/

# Coverage output
Expand Down
118 changes: 118 additions & 0 deletions src/contact/country_code.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
use crate::errors::ValidationError;
use crate::prelude::ValueObject;

/// A validated ISO 3166-1 alpha-2 country code.
///
/// On construction the value is trimmed and uppercased, so `"cz"` and `"CZ"`
/// produce equal instances. Only ASCII letters are accepted — digits and
/// special characters are rejected.
///
/// # Example
///
/// ```rust,ignore
/// use arvo::contact::CountryCode;
/// use arvo::traits::ValueObject;
///
/// let code = CountryCode::new("cz".into()).unwrap();
/// assert_eq!(code.value(), "CZ");
///
/// assert!(CountryCode::new("USA".into()).is_err()); // 3 letters — invalid
/// assert!(CountryCode::new("C1".into()).is_err()); // digit — invalid
/// ```
///
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct CountryCode(String);

impl ValueObject for CountryCode {
type Raw = String;
type Error = ValidationError;

fn new(value: Self::Raw) -> Result<Self, Self::Error> {
let normalized = value.trim().to_uppercase();

// ISO 3166-1 alpha-2 is exactly 2 ASCII letters, nothing else.
let valid = normalized.len() == 2 && normalized.chars().all(|c| c.is_ascii_alphabetic());

if !valid {
return Err(ValidationError::invalid("CountryCode", &normalized));
}

Ok(CountryCode(normalized))
}

fn value(&self) -> &Self::Raw {
&self.0
}

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

/// Allows ergonomic construction from a string literal: `"US".try_into()`
impl TryFrom<&str> for CountryCode {
type Error = ValidationError;

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

/// Displays the country code as an uppercase two-letter string.
impl std::fmt::Display for CountryCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}

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

#[test]
fn normalises_to_uppercase() {
let c = CountryCode::new("cz".into()).unwrap();
assert_eq!(c.value(), "CZ");
}

#[test]
fn trims_surrounding_whitespace() {
let c = CountryCode::new(" de ".into()).unwrap();
assert_eq!(c.value(), "DE");
}

#[test]
fn rejects_three_letter_code() {
assert!(CountryCode::new("USA".into()).is_err());
}

#[test]
fn rejects_single_letter() {
assert!(CountryCode::new("C".into()).is_err());
}

#[test]
fn rejects_digit_in_code() {
assert!(CountryCode::new("C1".into()).is_err());
}

#[test]
fn rejects_empty_string() {
assert!(CountryCode::new(String::new()).is_err());
}

#[test]
fn equal_after_normalisation() {
let a = CountryCode::new("cz".into()).unwrap();
let b = CountryCode::new("CZ".into()).unwrap();
assert_eq!(a, b);
}

#[test]
fn try_from_str() {
let c: CountryCode = "DE".try_into().unwrap();
assert_eq!(c.value(), "DE");
}
}
12 changes: 6 additions & 6 deletions src/contact/email_address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use regex::Regex;
static EMAIL_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]{2,}$").unwrap());

/// A validated, normalised email address.
/// A validated, normalized email address.
///
/// On construction the value is trimmed and lowercased, so
/// `"User@Example.COM"` and `"user@example.com"` produce equal instances.
Expand All @@ -36,17 +36,17 @@ impl ValueObject for EmailAddress {
type Error = ValidationError;

fn new(value: Self::Raw) -> Result<Self, Self::Error> {
let trimmed = value.trim().to_lowercase();
let normalized = value.trim().to_lowercase();

if trimmed.is_empty() {
if normalized.is_empty() {
return Err(ValidationError::empty("EmailAddress"));
}

if !EMAIL_REGEX.is_match(&trimmed) {
return Err(ValidationError::invalid("EmailAddress", &trimmed));
if !EMAIL_REGEX.is_match(&normalized) {
return Err(ValidationError::invalid("EmailAddress", &normalized));
}

Ok(Self(trimmed))
Ok(Self(normalized))
}

fn value(&self) -> &Self::Raw {
Expand Down
4 changes: 3 additions & 1 deletion src/contact/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
//! Contact-related value objects: email, phone, postal address, and names.
//! Contact-related value objects: email, phone, postal address, and names.p
mod country_code;
mod email_address;

pub use country_code::CountryCode;
pub use email_address::EmailAddress;
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,5 @@ pub mod prelude {
pub use crate::traits::ValueObject;

#[cfg(feature = "contact")]
pub use crate::contact::EmailAddress;
pub use crate::contact::{CountryCode, EmailAddress};
}
Loading