diff --git a/README.md b/README.md index e075492..42dea11 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ let parsed: EmailAddress = serde_json::from_str(r#""hello@example.com""#)?; | Feature | Highlights | Types | Status | |:---|:---|:---:|:---:| -| `contact` | `EmailAddress`, `PhoneNumber`, `CountryCode`, `Website` | 5 | 4 / 5 | +| `contact` | `EmailAddress`, `PhoneNumber`, `CountryCode`, `PostalAddress`, `Website` | 5 | 5 / 5 ✅ | | `identifiers` | `Slug`, `Ean13`, `Isbn13`, `Vin` | 7 | 0 / 7 | | `finance` | `Money`, `Iban`, `Bic`, `VatNumber`, `CreditCardNumber` | 9 | 0 / 9 | | `temporal` | `BirthDate`, `ExpiryDate`, `TimeRange`, `BusinessHours` | 5 | 0 / 5 | diff --git a/ROADMAP.md b/ROADMAP.md index b6d1a4a..527a8ec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,7 +13,7 @@ | `EmailAddress` | ✅ | normalised to lowercase, regex validated | | `PhoneNumber` | ✅ | E.164, strips spaces/dashes | | `CountryCode` | ✅ | ISO 3166-1 alpha-2, normalised to uppercase | -| `PostalAddress` | ⬜ | composite: street + city + zip + `CountryCode`; fields validated as non-empty | +| `PostalAddress` | ✅ | composite: street + city + zip + `CountryCode`; fields validated as non-empty | | `Website` | ✅ | valid URL, http/https only, normalised | --- @@ -134,7 +134,7 @@ | Feature | Total | Done | Remaining | |---|---|---|---| -| `contact` | 5 | 4 | 1 | +| `contact` | 5 | 5 | 0 | | `identifiers` | 7 | 0 | 7 | | `finance` | 9 | 0 | 9 | | `temporal` | 5 | 0 | 5 | @@ -142,4 +142,4 @@ | `net` | 10 | 0 | 10 | | `measurement` | 10 | 0 | 10 | | `primitives` | 10 | 0 | 10 | -| **Total** | **62** | **4** | **58** | +| **Total** | **62** | **5** | **57** | diff --git a/docs/contact.md b/docs/contact.md index 37ef72a..5970ef6 100644 --- a/docs/contact.md +++ b/docs/contact.md @@ -172,10 +172,58 @@ let site: Website = "https://example.com".try_into()?; --- -## Planned +## PostalAddress -| Type | Notes | -|---|---| -| `PostalAddress` | composite: street + city + zip + `CountryCode` | +A validated composite postal address. All text fields are trimmed; empty or whitespace-only values are rejected. The `country` field requires a valid [`CountryCode`]. + +**Normalisation:** `street`, `city`, and `zip` are trimmed. +**Validation:** all fields must be non-empty after trimming. + +```rust,ignore +use arvo::contact::{CountryCode, PostalAddress, PostalAddressInput}; +use arvo::traits::ValueObject; + +let addr = PostalAddress::new(PostalAddressInput { + street: "Václavské náměstí 1".into(), + city: "Prague".into(), + zip: "110 00".into(), + country: CountryCode::new("CZ".into())?, +})?; + +assert_eq!(addr.street(), "Václavské náměstí 1"); +assert_eq!(addr.zip(), "110 00"); +assert_eq!(addr.country().value(), "CZ"); + +// value() / Display — multi-line canonical form +assert_eq!(addr.value(), "Václavské náměstí 1\n110 00 Prague\nCZ"); +``` + +### Input struct -See [ROADMAP.md](../ROADMAP.md) for full details. +```rust,ignore +pub struct PostalAddressInput { + pub street: String, + pub city: String, + pub zip: String, + pub country: CountryCode, +} +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&String` | `"Main St 1\n10115 Berlin\nDE"` | +| `street()` | `&str` | `"Main St 1"` | +| `city()` | `&str` | `"Berlin"` | +| `zip()` | `&str` | `"10115"` | +| `country()` | `&CountryCode` | `CountryCode("DE")` | +| `into_inner()` | `PostalAddressInput` | original input fields | + +### Errors + +| Field | Input | Error | +|---|---|---| +| `street` | `""` or whitespace | `ValidationError::Empty` | +| `city` | `""` or whitespace | `ValidationError::Empty` | +| `zip` | `""` or whitespace | `ValidationError::Empty` | diff --git a/src/contact/mod.rs b/src/contact/mod.rs index 26ee637..441abc7 100644 --- a/src/contact/mod.rs +++ b/src/contact/mod.rs @@ -2,10 +2,13 @@ mod country_code; mod email_address; mod phone_number; +mod postal_address; mod website; pub use country_code::CountryCode; pub use email_address::EmailAddress; pub use phone_number::PhoneNumber; +pub use postal_address::PostalAddress; +pub use postal_address::PostalAddressInput; pub use website::Website; pub use website::WebsiteInput; diff --git a/src/contact/postal_address.rs b/src/contact/postal_address.rs new file mode 100644 index 0000000..21ca2a5 --- /dev/null +++ b/src/contact/postal_address.rs @@ -0,0 +1,236 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +use super::country_code::CountryCode; + +/// Input type for [`PostalAddress`] construction. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PostalAddressInput { + /// Street name and number, e.g. `"Václavské náměstí 1"`. + pub street: String, + /// City or locality name, e.g. `"Prague"`. + pub city: String, + /// Postal / ZIP code, e.g. `"110 00"`. + pub zip: String, + /// ISO 3166-1 alpha-2 country code. + pub country: CountryCode, +} + +/// Output type for [`PostalAddress`] — a human-readable multi-line string. +pub type PostalAddressOutput = String; + +/// A validated postal address. +/// +/// All text fields (`street`, `city`, `zip`) must be non-empty after trimming. +/// The `country` field is a validated [`CountryCode`]. On construction the text +/// fields are trimmed; no further normalisation is applied. +/// +/// The `Display` / `value()` output is a multi-line string in the format: +/// ```text +/// +/// +/// +/// ``` +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::contact::{CountryCode, PostalAddress, PostalAddressInput}; +/// use arvo::traits::ValueObject; +/// +/// let addr = PostalAddress::new(PostalAddressInput { +/// street: "Václavské náměstí 1".into(), +/// city: "Prague".into(), +/// zip: "110 00".into(), +/// country: CountryCode::new("CZ".into()).unwrap(), +/// }).unwrap(); +/// +/// assert_eq!(addr.street(), "Václavské náměstí 1"); +/// assert_eq!(addr.city(), "Prague"); +/// assert_eq!(addr.zip(), "110 00"); +/// assert_eq!(addr.country().value(), "CZ"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct PostalAddress { + street: String, + city: String, + zip: String, + country: CountryCode, + /// Pre-computed display string. + #[cfg_attr(feature = "serde", serde(skip))] + formatted: String, +} + +impl ValueObject for PostalAddress { + type Input = PostalAddressInput; + type Output = PostalAddressOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + let street = value.street.trim().to_owned(); + let city = value.city.trim().to_owned(); + let zip = value.zip.trim().to_owned(); + + if street.is_empty() { + return Err(ValidationError::empty("PostalAddress.street")); + } + if city.is_empty() { + return Err(ValidationError::empty("PostalAddress.city")); + } + if zip.is_empty() { + return Err(ValidationError::empty("PostalAddress.zip")); + } + + let formatted = format!("{}\n{} {}\n{}", street, zip, city, value.country.value()); + + Ok(Self { + street, + city, + zip, + country: value.country, + formatted, + }) + } + + fn value(&self) -> &Self::Output { + &self.formatted + } + + fn into_inner(self) -> Self::Input { + PostalAddressInput { + street: self.street, + city: self.city, + zip: self.zip, + country: self.country, + } + } +} + +impl PostalAddress { + /// Returns the street field, e.g. `"Václavské náměstí 1"`. + pub fn street(&self) -> &str { + &self.street + } + + /// Returns the city field, e.g. `"Prague"`. + pub fn city(&self) -> &str { + &self.city + } + + /// Returns the ZIP/postal code field, e.g. `"110 00"`. + pub fn zip(&self) -> &str { + &self.zip + } + + /// Returns the country code, e.g. `CountryCode("CZ")`. + pub fn country(&self) -> &CountryCode { + &self.country + } +} + +/// Displays the address in a human-readable multi-line format. +impl std::fmt::Display for PostalAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.formatted) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::ValueObject; + + fn cz() -> CountryCode { + CountryCode::new("CZ".into()).unwrap() + } + + fn valid_input() -> PostalAddressInput { + PostalAddressInput { + street: "Václavské náměstí 1".into(), + city: "Prague".into(), + zip: "110 00".into(), + country: cz(), + } + } + + #[test] + fn constructs_valid_address() { + let addr = PostalAddress::new(valid_input()).unwrap(); + assert_eq!(addr.street(), "Václavské náměstí 1"); + assert_eq!(addr.city(), "Prague"); + assert_eq!(addr.zip(), "110 00"); + assert_eq!(addr.country().value(), "CZ"); + } + + #[test] + fn value_is_formatted_string() { + let addr = PostalAddress::new(valid_input()).unwrap(); + assert_eq!(addr.value(), "Václavské náměstí 1\n110 00 Prague\nCZ"); + } + + #[test] + fn display_matches_value() { + let addr = PostalAddress::new(valid_input()).unwrap(); + assert_eq!(addr.to_string(), addr.value().to_owned()); + } + + #[test] + fn trims_whitespace_from_fields() { + let addr = PostalAddress::new(PostalAddressInput { + street: " Main St 1 ".into(), + city: " Berlin ".into(), + zip: " 10115 ".into(), + country: CountryCode::new("DE".into()).unwrap(), + }) + .unwrap(); + assert_eq!(addr.street(), "Main St 1"); + assert_eq!(addr.city(), "Berlin"); + assert_eq!(addr.zip(), "10115"); + } + + #[test] + fn rejects_empty_street() { + let mut input = valid_input(); + input.street = String::new(); + assert!(PostalAddress::new(input).is_err()); + } + + #[test] + fn rejects_whitespace_only_street() { + let mut input = valid_input(); + input.street = " ".into(); + assert!(PostalAddress::new(input).is_err()); + } + + #[test] + fn rejects_empty_city() { + let mut input = valid_input(); + input.city = String::new(); + assert!(PostalAddress::new(input).is_err()); + } + + #[test] + fn rejects_empty_zip() { + let mut input = valid_input(); + input.zip = String::new(); + assert!(PostalAddress::new(input).is_err()); + } + + #[test] + fn into_inner_returns_original_fields() { + let addr = PostalAddress::new(valid_input()).unwrap(); + let inner = addr.into_inner(); + assert_eq!(inner.street, "Václavské náměstí 1"); + assert_eq!(inner.city, "Prague"); + assert_eq!(inner.zip, "110 00"); + assert_eq!(inner.country.value(), "CZ"); + } + + #[test] + fn equal_addresses_are_equal() { + let a = PostalAddress::new(valid_input()).unwrap(); + let b = PostalAddress::new(valid_input()).unwrap(); + assert_eq!(a, b); + } +}