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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---
Expand Down Expand Up @@ -134,12 +134,12 @@

| Feature | Total | Done | Remaining |
|---|---|---|---|
| `contact` | 5 | 4 | 1 |
| `contact` | 5 | 5 | 0 |
| `identifiers` | 7 | 0 | 7 |
| `finance` | 9 | 0 | 9 |
| `temporal` | 5 | 0 | 5 |
| `geo` | 6 | 0 | 6 |
| `net` | 10 | 0 | 10 |
| `measurement` | 10 | 0 | 10 |
| `primitives` | 10 | 0 | 10 |
| **Total** | **62** | **4** | **58** |
| **Total** | **62** | **5** | **57** |
58 changes: 53 additions & 5 deletions docs/contact.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
3 changes: 3 additions & 0 deletions src/contact/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
236 changes: 236 additions & 0 deletions src/contact/postal_address.rs
Original file line number Diff line number Diff line change
@@ -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
/// <street>
/// <zip> <city>
/// <COUNTRY>
/// ```
///
/// # 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<Self, Self::Error> {
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);
}
}
Loading