diff --git a/Cargo.toml b/Cargo.toml index 360f351..3e04f72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ default = [] # Domain modules — opt-in so you only pay for what you use contact = ["dep:once_cell", "dep:regex", "dep:url"] finance = ["dep:rust_decimal", "dep:chrono"] +geo = [] identifiers = [] primitives = ["dep:rust_decimal", "dep:base64"] temporal = ["dep:chrono"] @@ -32,6 +33,7 @@ sql = ["dep:sqlx"] full = [ "contact", "finance", + "geo", "identifiers", "primitives", "temporal", diff --git a/README.md b/README.md index f4a5075..0f2ee66 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ let email: EmailAddress = "user@example.com".try_into()?; | [docs/implementing.md](docs/implementing.md) | How to implement the `ValueObject` trait for custom types | | [docs/contact.md](docs/contact.md) | Reference for all `contact` module types | | [docs/finance.md](docs/finance.md) | Reference for all `finance` module types | +| [docs/geo.md](docs/geo.md) | Reference for all `geo` module types | | [docs/identifiers.md](docs/identifiers.md) | Reference for all `identifiers` module types | | [docs/primitives.md](docs/primitives.md) | Reference for all `primitives` module types | | [docs/temporal.md](docs/temporal.md) | Reference for all `temporal` module types | @@ -69,6 +70,7 @@ Enable only the modules you need — unused features add zero dependencies. |:---|:---|:---| | `contact` | `EmailAddress`, `CountryCode`, `PhoneNumber`, `PostalAddress`, `Website` | `once_cell`, `regex`, `url` | | `finance` | `Money`, `CurrencyCode`, `Iban`, `Bic`, `VatNumber`, `Percentage`, `ExchangeRate`, `CreditCardNumber`, `CardExpiryDate` | `rust_decimal`, `chrono` | +| `geo` | `Latitude`, `Longitude`, `Coordinate`, `BoundingBox`, `TimeZone`, `CountryRegion` | — | | `identifiers` | `Slug`, `Ean13`, `Ean8`, `Isbn13`, `Isbn10`, `Issn`, `Vin` | — | | `primitives` | `NonEmptyString`, `BoundedString`, `PositiveInt`, `NonNegativeInt`, `PositiveDecimal`, `NonNegativeDecimal`, `Probability`, `HexColor`, `Locale`, `Base64String` | `rust_decimal`, `base64` | | `temporal` | `UnixTimestamp`, `BirthDate`, `ExpiryDate`, `TimeRange`, `BusinessHours` | `chrono` | @@ -207,7 +209,7 @@ let parsed: EmailAddress = serde_json::from_str(r#""hello@example.com""#)?; | `identifiers` | `Slug`, `Ean13`, `Isbn13`, `Vin` | 7 | 7 / 7 ✅ | | `finance` | `Money`, `CurrencyCode`, `Iban`, `Bic`, `VatNumber`, `Percentage`, `ExchangeRate`, `CreditCardNumber`, `CardExpiryDate` | 9 | 9 / 9 ✅ | | `temporal` | `UnixTimestamp`, `BirthDate`, `ExpiryDate`, `TimeRange`, `BusinessHours` | 5 | 5 / 5 ✅ | -| `geo` | `Latitude`, `Longitude`, `Coordinate`, `BoundingBox`, `TimeZone` | 6 | 0 / 6 | +| `geo` | `Latitude`, `Longitude`, `Coordinate`, `BoundingBox`, `TimeZone`, `CountryRegion` | 6 | 6 / 6 ✅ | | `net` | `Url`, `IpAddress`, `MacAddress`, `ApiKey`, `Port` | 10 | 0 / 10 | | `measurement` | `Length`, `Weight`, `Temperature`, `Speed` ⚠️ needs unit conversion design | 10 | 0 / 10 | | `primitives` | `NonEmptyString`, `BoundedString`, `Locale`, `HexColor` | 10 | 10 / 10 ✅ | diff --git a/ROADMAP.md b/ROADMAP.md index 4de9f3d..a270955 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -68,12 +68,12 @@ | Type | Status | Notes | |---|---|---| -| `Latitude` | ⬜ | `f64` in range −90.0..=90.0 | -| `Longitude` | ⬜ | `f64` in range −180.0..=180.0 | -| `Coordinate` | ⬜ | composite: `Latitude` + `Longitude` | -| `BoundingBox` | ⬜ | composite: SW `Coordinate` + NE `Coordinate` | -| `TimeZone` | ⬜ | IANA timezone name (e.g. `Europe/Prague`) | -| `CountryRegion` | ⬜ | ISO 3166-2 subdivision code (e.g. `CZ-PR`) | +| `Latitude` | ✅ | `f64` in range −90.0..=90.0 | +| `Longitude` | ✅ | `f64` in range −180.0..=180.0 | +| `Coordinate` | ✅ | composite: `Latitude` + `Longitude` | +| `BoundingBox` | ✅ | composite: SW `Coordinate` + NE `Coordinate` | +| `TimeZone` | ✅ | IANA timezone name (e.g. `Europe/Prague`) | +| `CountryRegion` | ✅ | ISO 3166-2 subdivision code (e.g. `CZ-PR`) | --- @@ -138,8 +138,8 @@ | `identifiers` | 7 | 7 | 0 | | `finance` | 9 | 9 | 0 | | `temporal` | 5 | 5 | 0 | -| `geo` | 6 | 0 | 6 | +| `geo` | 6 | 6 | 0 | | `net` | 10 | 0 | 10 | | `measurement` | 10 | 0 | 10 | | `primitives` | 10 | 10 | 0 | -| **Total** | **62** | **36** | **26** | +| **Total** | **62** | **42** | **20** | diff --git a/docs/geo.md b/docs/geo.md new file mode 100644 index 0000000..ce933f2 --- /dev/null +++ b/docs/geo.md @@ -0,0 +1,238 @@ +# geo module + +Feature flag: `geo` + +```toml +[dependencies] +arvo = { version = "0.7", features = ["geo"] } +``` + +--- + +## Latitude + +A validated geographic latitude in decimal degrees. + +**Validation:** must be finite, in the inclusive range `−90.0..=90.0`. + +```rust,ignore +use arvo::geo::Latitude; +use arvo::traits::ValueObject; + +let lat = Latitude::new(48.8588)?; +assert_eq!(*lat.value(), 48.8588); + +assert!(Latitude::new(91.0).is_err()); +assert!(Latitude::new(f64::NAN).is_err()); +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&f64` | `48.858844` | +| `into_inner()` | `f64` | `48.858844` | + +### Errors + +| Input | Error | +|---|---| +| `> 90.0` or `< -90.0` | `ValidationError::InvalidFormat` | +| `NaN` / `Infinity` | `ValidationError::InvalidFormat` | + +--- + +## Longitude + +A validated geographic longitude in decimal degrees. + +**Validation:** must be finite, in the inclusive range `−180.0..=180.0`. + +```rust,ignore +use arvo::geo::Longitude; +use arvo::traits::ValueObject; + +let lng = Longitude::new(14.4208)?; +assert_eq!(*lng.value(), 14.4208); + +assert!(Longitude::new(181.0).is_err()); +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&f64` | `14.420800` | +| `into_inner()` | `f64` | `14.420800` | + +### Errors + +| Input | Error | +|---|---| +| `> 180.0` or `< -180.0` | `ValidationError::InvalidFormat` | +| `NaN` / `Infinity` | `ValidationError::InvalidFormat` | + +--- + +## Coordinate + +A geographic coordinate (latitude + longitude pair). + +**Normalisation:** canonical string `"lat, lng"` with six decimal places. + +```rust,ignore +use arvo::geo::{Coordinate, CoordinateInput, Latitude, Longitude}; +use arvo::traits::ValueObject; + +let coord = Coordinate::new(CoordinateInput { + lat: Latitude::new(48.858844)?, + lng: Longitude::new(2.294351)?, +})?; + +assert_eq!(coord.value(), "48.858844, 2.294351"); +assert_eq!(*coord.lat().value(), 48.858844); +assert_eq!(*coord.lng().value(), 2.294351); +``` + +### Input struct + +```rust,ignore +pub struct CoordinateInput { + pub lat: Latitude, + pub lng: Longitude, +} +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&str` | `"48.858844, 2.294351"` | +| `lat()` | `&Latitude` | `Latitude(48.858844)` | +| `lng()` | `&Longitude` | `Longitude(2.294351)` | +| `into_inner()` | `CoordinateInput` | original input | + +--- + +## BoundingBox + +A geographic bounding box defined by a south-west and a north-east [`Coordinate`]. + +**Validation:** `sw.lat ≤ ne.lat` and `sw.lng ≤ ne.lng`. + +```rust,ignore +use arvo::geo::{BoundingBox, BoundingBoxInput, Coordinate, CoordinateInput, Latitude, Longitude}; +use arvo::traits::ValueObject; + +let sw = Coordinate::new(CoordinateInput { + lat: Latitude::new(48.0)?, + lng: Longitude::new(14.0)?, +})?; +let ne = Coordinate::new(CoordinateInput { + lat: Latitude::new(51.0)?, + lng: Longitude::new(18.0)?, +})?; + +let bbox = BoundingBox::new(BoundingBoxInput { sw, ne })?; +assert_eq!(bbox.value(), "SW: 48.000000, 14.000000 / NE: 51.000000, 18.000000"); +assert_eq!(*bbox.sw().lat().value(), 48.0); +``` + +### Input struct + +```rust,ignore +pub struct BoundingBoxInput { + pub sw: Coordinate, + pub ne: Coordinate, +} +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&str` | `"SW: 48.0, 14.0 / NE: 51.0, 18.0"` | +| `sw()` | `&Coordinate` | south-west corner | +| `ne()` | `&Coordinate` | north-east corner | +| `into_inner()` | `BoundingBoxInput` | original input | + +### Errors + +| Condition | Error | +|---|---| +| `sw.lat > ne.lat` or `sw.lng > ne.lng` | `ValidationError::InvalidFormat` | + +--- + +## TimeZone + +A validated IANA timezone name. + +**Validation:** must be present in the built-in list of canonical IANA timezone names. The name is trimmed but **case-sensitive** — IANA names are case-sensitive by specification. + +```rust,ignore +use arvo::geo::TimeZone; +use arvo::traits::ValueObject; + +let tz = TimeZone::new("Europe/Prague".into())?; +assert_eq!(tz.value(), "Europe/Prague"); + +let tz: TimeZone = "UTC".try_into()?; + +assert!(TimeZone::new("europe/prague".into()).is_err()); // wrong case +assert!(TimeZone::new("Fake/Zone".into()).is_err()); +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&String` | `"Europe/Prague"` | +| `into_inner()` | `String` | `"Europe/Prague"` | + +### Errors + +| Input | Error | +|---|---| +| `""` | `ValidationError::Empty` | +| unknown or wrong-case name | `ValidationError::InvalidFormat` | + +--- + +## CountryRegion + +A validated ISO 3166-2 subdivision code. + +**Format:** two uppercase ASCII letters (country code), hyphen, one to eight uppercase alphanumeric characters (subdivision code). Examples: `"CZ-PR"`, `"US-CA"`, `"GB-ENG"`. + +**Normalisation:** trimmed and uppercased. + +```rust,ignore +use arvo::geo::CountryRegion; +use arvo::traits::ValueObject; + +let region = CountryRegion::new("cz-pr".into())?; +assert_eq!(region.value(), "CZ-PR"); +assert_eq!(region.country_code(), "CZ"); +assert_eq!(region.subdivision_code(), "PR"); + +let region: CountryRegion = "US-CA".try_into()?; +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&String` | `"CZ-PR"` | +| `country_code()` | `&str` | `"CZ"` | +| `subdivision_code()` | `&str` | `"PR"` | +| `into_inner()` | `String` | `"CZ-PR"` | + +### Errors + +| Input | Error | +|---|---| +| `""` | `ValidationError::Empty` | +| missing `-` | `ValidationError::InvalidFormat` | +| country code ≠ 2 letters | `ValidationError::InvalidFormat` | +| subdivision empty or > 8 chars | `ValidationError::InvalidFormat` | diff --git a/src/geo/bounding_box.rs b/src/geo/bounding_box.rs new file mode 100644 index 0000000..424601a --- /dev/null +++ b/src/geo/bounding_box.rs @@ -0,0 +1,181 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +use super::Coordinate; + +/// Input for [`BoundingBox`]. +#[derive(Debug, Clone, PartialEq)] +pub struct BoundingBoxInput { + /// South-west corner (minimum lat/lng). + pub sw: Coordinate, + /// North-east corner (maximum lat/lng). + pub ne: Coordinate, +} + +/// A geographic bounding box defined by a south-west and a north-east [`Coordinate`]. +/// +/// **Validation:** `sw.lat ≤ ne.lat` and `sw.lng ≤ ne.lng`. +/// +/// The canonical string is `"SW: / NE: "`. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::geo::{BoundingBox, BoundingBoxInput, Coordinate, CoordinateInput, Latitude, Longitude}; +/// use arvo::traits::ValueObject; +/// +/// let sw = Coordinate::new(CoordinateInput { +/// lat: Latitude::new(48.0)?, +/// lng: Longitude::new(14.0)?, +/// })?; +/// let ne = Coordinate::new(CoordinateInput { +/// lat: Latitude::new(51.0)?, +/// lng: Longitude::new(18.0)?, +/// })?; +/// +/// let bbox = BoundingBox::new(BoundingBoxInput { sw, ne })?; +/// assert!(bbox.value().starts_with("SW:")); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct BoundingBox { + sw: Coordinate, + ne: Coordinate, + #[cfg_attr(feature = "serde", serde(skip))] + canonical: String, +} + +impl ValueObject for BoundingBox { + type Input = BoundingBoxInput; + type Output = str; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + let sw_lat = value.sw.lat().value(); + let sw_lng = value.sw.lng().value(); + let ne_lat = value.ne.lat().value(); + let ne_lng = value.ne.lng().value(); + + if sw_lat > ne_lat || sw_lng > ne_lng { + return Err(ValidationError::invalid( + "BoundingBox", + "sw must be south-west of ne (lat and lng must be ≤ ne)", + )); + } + + let canonical = format!("SW: {} / NE: {}", value.sw, value.ne); + Ok(Self { + sw: value.sw, + ne: value.ne, + canonical, + }) + } + + fn value(&self) -> &Self::Output { + &self.canonical + } + + fn into_inner(self) -> Self::Input { + BoundingBoxInput { + sw: self.sw, + ne: self.ne, + } + } +} + +impl BoundingBox { + /// Returns the south-west corner. + pub fn sw(&self) -> &Coordinate { + &self.sw + } + + /// Returns the north-east corner. + pub fn ne(&self) -> &Coordinate { + &self.ne + } +} + +impl std::fmt::Display for BoundingBox { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.canonical) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geo::{CoordinateInput, Latitude, Longitude}; + + fn coord(lat: f64, lng: f64) -> Coordinate { + Coordinate::new(CoordinateInput { + lat: Latitude::new(lat).unwrap(), + lng: Longitude::new(lng).unwrap(), + }) + .unwrap() + } + + #[test] + fn accepts_valid_bbox() { + let bbox = BoundingBox::new(BoundingBoxInput { + sw: coord(48.0, 14.0), + ne: coord(51.0, 18.0), + }) + .unwrap(); + assert!(bbox.value().starts_with("SW:")); + assert!(bbox.value().contains("NE:")); + } + + #[test] + fn rejects_sw_north_of_ne() { + assert!( + BoundingBox::new(BoundingBoxInput { + sw: coord(52.0, 14.0), + ne: coord(51.0, 18.0), + }) + .is_err() + ); + } + + #[test] + fn rejects_sw_east_of_ne() { + assert!( + BoundingBox::new(BoundingBoxInput { + sw: coord(48.0, 19.0), + ne: coord(51.0, 18.0), + }) + .is_err() + ); + } + + #[test] + fn accepts_equal_corners() { + assert!( + BoundingBox::new(BoundingBoxInput { + sw: coord(50.0, 14.0), + ne: coord(50.0, 14.0), + }) + .is_ok() + ); + } + + #[test] + fn accessors() { + let bbox = BoundingBox::new(BoundingBoxInput { + sw: coord(48.0, 14.0), + ne: coord(51.0, 18.0), + }) + .unwrap(); + assert_eq!(*bbox.sw().lat().value(), 48.0); + assert_eq!(*bbox.ne().lng().value(), 18.0); + } + + #[test] + fn display_matches_value() { + let bbox = BoundingBox::new(BoundingBoxInput { + sw: coord(48.0, 14.0), + ne: coord(51.0, 18.0), + }) + .unwrap(); + assert_eq!(bbox.to_string(), bbox.value()); + } +} diff --git a/src/geo/coordinate.rs b/src/geo/coordinate.rs new file mode 100644 index 0000000..0cf8999 --- /dev/null +++ b/src/geo/coordinate.rs @@ -0,0 +1,123 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +use super::{Latitude, Longitude}; + +/// Input for [`Coordinate`]. +#[derive(Debug, Clone, PartialEq)] +pub struct CoordinateInput { + pub lat: Latitude, + pub lng: Longitude, +} + +/// A geographic coordinate (latitude + longitude pair). +/// +/// Constructed from a validated [`Latitude`] and [`Longitude`]. The canonical +/// string representation is `"lat, lng"` with six decimal places each. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::geo::{Coordinate, CoordinateInput, Latitude, Longitude}; +/// use arvo::traits::ValueObject; +/// +/// let coord = Coordinate::new(CoordinateInput { +/// lat: Latitude::new(48.858844)?, +/// lng: Longitude::new(2.294351)?, +/// })?; +/// +/// assert_eq!(coord.value(), "48.858844, 2.294351"); +/// assert_eq!(*coord.lat().value(), 48.858844); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Coordinate { + lat: Latitude, + lng: Longitude, + #[cfg_attr(feature = "serde", serde(skip))] + canonical: String, +} + +impl ValueObject for Coordinate { + type Input = CoordinateInput; + type Output = str; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + let canonical = format!("{:.6}, {:.6}", value.lat.value(), value.lng.value()); + Ok(Self { + lat: value.lat, + lng: value.lng, + canonical, + }) + } + + fn value(&self) -> &Self::Output { + &self.canonical + } + + fn into_inner(self) -> Self::Input { + CoordinateInput { + lat: self.lat, + lng: self.lng, + } + } +} + +impl Coordinate { + /// Returns the latitude component. + pub fn lat(&self) -> &Latitude { + &self.lat + } + + /// Returns the longitude component. + pub fn lng(&self) -> &Longitude { + &self.lng + } +} + +impl std::fmt::Display for Coordinate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.canonical) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make(lat: f64, lng: f64) -> Coordinate { + Coordinate::new(CoordinateInput { + lat: Latitude::new(lat).unwrap(), + lng: Longitude::new(lng).unwrap(), + }) + .unwrap() + } + + #[test] + fn canonical_format() { + let c = make(48.858844, 2.294351); + assert_eq!(c.value(), "48.858844, 2.294351"); + } + + #[test] + fn accessors() { + let c = make(51.5074, -0.1278); + assert_eq!(*c.lat().value(), 51.5074); + assert_eq!(*c.lng().value(), -0.1278); + } + + #[test] + fn display_matches_value() { + let c = make(0.0, 0.0); + assert_eq!(c.to_string(), c.value()); + } + + #[test] + fn into_inner_roundtrip() { + let c = make(48.858844, 2.294351); + let inner = c.clone().into_inner(); + assert_eq!(*inner.lat.value(), 48.858844); + assert_eq!(*inner.lng.value(), 2.294351); + } +} diff --git a/src/geo/country_region.rs b/src/geo/country_region.rs new file mode 100644 index 0000000..e4b404f --- /dev/null +++ b/src/geo/country_region.rs @@ -0,0 +1,173 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Input type for [`CountryRegion`]. +pub type CountryRegionInput = String; + +/// Output type for [`CountryRegion`]. +pub type CountryRegionOutput = String; + +/// A validated ISO 3166-2 subdivision code. +/// +/// **Format:** two uppercase ASCII letters (country code), a hyphen, then +/// one to eight uppercase ASCII alphanumeric characters (subdivision code). +/// Example: `"CZ-PR"`, `"US-CA"`, `"GB-ENG"`. +/// +/// **Normalisation:** trimmed and uppercased on construction. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::geo::CountryRegion; +/// use arvo::traits::ValueObject; +/// +/// let region = CountryRegion::new("cz-pr".into())?; +/// assert_eq!(region.value(), "CZ-PR"); +/// +/// let region: CountryRegion = "US-CA".try_into()?; +/// assert_eq!(region.country_code(), "US"); +/// assert_eq!(region.subdivision_code(), "CA"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct CountryRegion(String); + +impl ValueObject for CountryRegion { + type Input = CountryRegionInput; + type Output = CountryRegionOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + let upper = value.trim().to_uppercase(); + + if upper.is_empty() { + return Err(ValidationError::empty("CountryRegion")); + } + + if !is_valid_iso3166_2(&upper) { + return Err(ValidationError::invalid("CountryRegion", &upper)); + } + + Ok(Self(upper)) + } + + fn value(&self) -> &Self::Output { + &self.0 + } + + fn into_inner(self) -> Self::Input { + self.0 + } +} + +fn is_valid_iso3166_2(s: &str) -> bool { + let Some(dash) = s.find('-') else { + return false; + }; + + let country = &s[..dash]; + let subdivision = &s[dash + 1..]; + + if country.len() != 2 || !country.chars().all(|c| c.is_ascii_uppercase()) { + return false; + } + + let sub_len = subdivision.len(); + if !(1..=8).contains(&sub_len) { + return false; + } + + subdivision.chars().all(|c| c.is_ascii_alphanumeric()) +} + +impl CountryRegion { + /// Returns the 2-letter country code portion, e.g. `"CZ"`. + pub fn country_code(&self) -> &str { + self.0.split('-').next().unwrap_or("") + } + + /// Returns the subdivision code portion, e.g. `"PR"`. + pub fn subdivision_code(&self) -> &str { + self.0.split('-').nth(1).unwrap_or("") + } +} + +impl TryFrom<&str> for CountryRegion { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value.to_owned()) + } +} + +impl std::fmt::Display for CountryRegion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid_code() { + let r = CountryRegion::new("CZ-PR".into()).unwrap(); + assert_eq!(r.value(), "CZ-PR"); + } + + #[test] + fn normalises_to_uppercase() { + let r = CountryRegion::new("cz-pr".into()).unwrap(); + assert_eq!(r.value(), "CZ-PR"); + } + + #[test] + fn accepts_longer_subdivision() { + assert!(CountryRegion::new("GB-ENG".into()).is_ok()); + } + + #[test] + fn accepts_numeric_subdivision() { + assert!(CountryRegion::new("CN-11".into()).is_ok()); + } + + #[test] + fn rejects_empty() { + assert!(CountryRegion::new(String::new()).is_err()); + } + + #[test] + fn rejects_missing_dash() { + assert!(CountryRegion::new("CZPR".into()).is_err()); + } + + #[test] + fn rejects_three_letter_country() { + assert!(CountryRegion::new("CZE-PR".into()).is_err()); + } + + #[test] + fn rejects_empty_subdivision() { + assert!(CountryRegion::new("CZ-".into()).is_err()); + } + + #[test] + fn rejects_subdivision_too_long() { + assert!(CountryRegion::new("CZ-TOOLONGCODE".into()).is_err()); + } + + #[test] + fn accessors() { + let r = CountryRegion::new("US-CA".into()).unwrap(); + assert_eq!(r.country_code(), "US"); + assert_eq!(r.subdivision_code(), "CA"); + } + + #[test] + fn try_from_str() { + let r: CountryRegion = "DE-BY".try_into().unwrap(); + assert_eq!(r.value(), "DE-BY"); + } +} diff --git a/src/geo/latitude.rs b/src/geo/latitude.rs new file mode 100644 index 0000000..fcf3a67 --- /dev/null +++ b/src/geo/latitude.rs @@ -0,0 +1,105 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Input type for [`Latitude`]. +pub type LatitudeInput = f64; + +/// Output type for [`Latitude`]. +pub type LatitudeOutput = f64; + +/// A validated geographic latitude in decimal degrees. +/// +/// The value must be finite and in the inclusive range `−90.0..=90.0`. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::geo::Latitude; +/// use arvo::traits::ValueObject; +/// +/// let lat = Latitude::new(48.8588).unwrap(); +/// assert_eq!(*lat.value(), 48.8588); +/// +/// assert!(Latitude::new(91.0).is_err()); +/// assert!(Latitude::new(f64::NAN).is_err()); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct Latitude(f64); + +impl ValueObject for Latitude { + type Input = LatitudeInput; + type Output = LatitudeOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + if !value.is_finite() { + return Err(ValidationError::invalid("Latitude", &value.to_string())); + } + if !(-90.0..=90.0).contains(&value) { + return Err(ValidationError::invalid("Latitude", &value.to_string())); + } + Ok(Self(value)) + } + + fn value(&self) -> &Self::Output { + &self.0 + } + + fn into_inner(self) -> Self::Input { + self.0 + } +} + +impl std::fmt::Display for Latitude { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:.6}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid_latitude() { + let lat = Latitude::new(48.8588).unwrap(); + assert_eq!(*lat.value(), 48.8588); + } + + #[test] + fn accepts_boundaries() { + assert!(Latitude::new(-90.0).is_ok()); + assert!(Latitude::new(90.0).is_ok()); + } + + #[test] + fn rejects_out_of_range() { + assert!(Latitude::new(90.001).is_err()); + assert!(Latitude::new(-90.001).is_err()); + } + + #[test] + fn rejects_nan() { + assert!(Latitude::new(f64::NAN).is_err()); + } + + #[test] + fn rejects_infinity() { + assert!(Latitude::new(f64::INFINITY).is_err()); + assert!(Latitude::new(f64::NEG_INFINITY).is_err()); + } + + #[test] + fn display_six_decimal_places() { + let lat = Latitude::new(48.858844).unwrap(); + assert_eq!(lat.to_string(), "48.858844"); + } + + #[test] + fn into_inner_roundtrip() { + let lat = Latitude::new(51.5074).unwrap(); + assert_eq!(lat.into_inner(), 51.5074); + } +} diff --git a/src/geo/longitude.rs b/src/geo/longitude.rs new file mode 100644 index 0000000..148b7c0 --- /dev/null +++ b/src/geo/longitude.rs @@ -0,0 +1,105 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Input type for [`Longitude`]. +pub type LongitudeInput = f64; + +/// Output type for [`Longitude`]. +pub type LongitudeOutput = f64; + +/// A validated geographic longitude in decimal degrees. +/// +/// The value must be finite and in the inclusive range `−180.0..=180.0`. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::geo::Longitude; +/// use arvo::traits::ValueObject; +/// +/// let lng = Longitude::new(14.4208).unwrap(); +/// assert_eq!(*lng.value(), 14.4208); +/// +/// assert!(Longitude::new(181.0).is_err()); +/// assert!(Longitude::new(f64::NAN).is_err()); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct Longitude(f64); + +impl ValueObject for Longitude { + type Input = LongitudeInput; + type Output = LongitudeOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + if !value.is_finite() { + return Err(ValidationError::invalid("Longitude", &value.to_string())); + } + if !(-180.0..=180.0).contains(&value) { + return Err(ValidationError::invalid("Longitude", &value.to_string())); + } + Ok(Self(value)) + } + + fn value(&self) -> &Self::Output { + &self.0 + } + + fn into_inner(self) -> Self::Input { + self.0 + } +} + +impl std::fmt::Display for Longitude { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:.6}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid_longitude() { + let lng = Longitude::new(14.4208).unwrap(); + assert_eq!(*lng.value(), 14.4208); + } + + #[test] + fn accepts_boundaries() { + assert!(Longitude::new(-180.0).is_ok()); + assert!(Longitude::new(180.0).is_ok()); + } + + #[test] + fn rejects_out_of_range() { + assert!(Longitude::new(180.001).is_err()); + assert!(Longitude::new(-180.001).is_err()); + } + + #[test] + fn rejects_nan() { + assert!(Longitude::new(f64::NAN).is_err()); + } + + #[test] + fn rejects_infinity() { + assert!(Longitude::new(f64::INFINITY).is_err()); + assert!(Longitude::new(f64::NEG_INFINITY).is_err()); + } + + #[test] + fn display_six_decimal_places() { + let lng = Longitude::new(14.420800).unwrap(); + assert_eq!(lng.to_string(), "14.420800"); + } + + #[test] + fn into_inner_roundtrip() { + let lng = Longitude::new(-0.1278).unwrap(); + assert_eq!(lng.into_inner(), -0.1278); + } +} diff --git a/src/geo/mod.rs b/src/geo/mod.rs new file mode 100644 index 0000000..d109125 --- /dev/null +++ b/src/geo/mod.rs @@ -0,0 +1,13 @@ +mod bounding_box; +mod coordinate; +mod country_region; +mod latitude; +mod longitude; +mod time_zone; + +pub use bounding_box::{BoundingBox, BoundingBoxInput}; +pub use coordinate::{Coordinate, CoordinateInput}; +pub use country_region::CountryRegion; +pub use latitude::Latitude; +pub use longitude::Longitude; +pub use time_zone::TimeZone; diff --git a/src/geo/time_zone.rs b/src/geo/time_zone.rs new file mode 100644 index 0000000..b71a913 --- /dev/null +++ b/src/geo/time_zone.rs @@ -0,0 +1,550 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Input type for [`TimeZone`]. +pub type TimeZoneInput = String; + +/// Output type for [`TimeZone`]. +pub type TimeZoneOutput = String; + +/// Sorted list of canonical IANA timezone names. +static IANA_TIMEZONES: &[&str] = &[ + "Africa/Abidjan", + "Africa/Accra", + "Africa/Addis_Ababa", + "Africa/Algiers", + "Africa/Asmara", + "Africa/Bamako", + "Africa/Bangui", + "Africa/Banjul", + "Africa/Bissau", + "Africa/Blantyre", + "Africa/Brazzaville", + "Africa/Bujumbura", + "Africa/Cairo", + "Africa/Casablanca", + "Africa/Ceuta", + "Africa/Conakry", + "Africa/Dakar", + "Africa/Dar_es_Salaam", + "Africa/Djibouti", + "Africa/Douala", + "Africa/El_Aaiun", + "Africa/Freetown", + "Africa/Gaborone", + "Africa/Harare", + "Africa/Johannesburg", + "Africa/Juba", + "Africa/Kampala", + "Africa/Khartoum", + "Africa/Kigali", + "Africa/Kinshasa", + "Africa/Lagos", + "Africa/Libreville", + "Africa/Lome", + "Africa/Luanda", + "Africa/Lubumbashi", + "Africa/Lusaka", + "Africa/Malabo", + "Africa/Maputo", + "Africa/Maseru", + "Africa/Mbabane", + "Africa/Mogadishu", + "Africa/Monrovia", + "Africa/Nairobi", + "Africa/Ndjamena", + "Africa/Niamey", + "Africa/Nouakchott", + "Africa/Ouagadougou", + "Africa/Porto-Novo", + "Africa/Sao_Tome", + "Africa/Tripoli", + "Africa/Tunis", + "Africa/Windhoek", + "America/Adak", + "America/Anchorage", + "America/Anguilla", + "America/Antigua", + "America/Araguaina", + "America/Argentina/Buenos_Aires", + "America/Argentina/Catamarca", + "America/Argentina/Cordoba", + "America/Argentina/Jujuy", + "America/Argentina/La_Rioja", + "America/Argentina/Mendoza", + "America/Argentina/Rio_Gallegos", + "America/Argentina/Salta", + "America/Argentina/San_Juan", + "America/Argentina/San_Luis", + "America/Argentina/Tucuman", + "America/Argentina/Ushuaia", + "America/Aruba", + "America/Asuncion", + "America/Atikokan", + "America/Bahia", + "America/Bahia_Banderas", + "America/Barbados", + "America/Belem", + "America/Belize", + "America/Blanc-Sablon", + "America/Boa_Vista", + "America/Bogota", + "America/Boise", + "America/Cambridge_Bay", + "America/Campo_Grande", + "America/Cancun", + "America/Caracas", + "America/Cayenne", + "America/Cayman", + "America/Chicago", + "America/Chihuahua", + "America/Costa_Rica", + "America/Creston", + "America/Cuiaba", + "America/Curacao", + "America/Danmarkshavn", + "America/Dawson", + "America/Dawson_Creek", + "America/Denver", + "America/Detroit", + "America/Dominica", + "America/Edmonton", + "America/Eirunepe", + "America/El_Salvador", + "America/Fortaleza", + "America/Glace_Bay", + "America/Goose_Bay", + "America/Grand_Turk", + "America/Grenada", + "America/Guadeloupe", + "America/Guatemala", + "America/Guayaquil", + "America/Guyana", + "America/Halifax", + "America/Havana", + "America/Hermosillo", + "America/Indiana/Indianapolis", + "America/Indiana/Knox", + "America/Indiana/Marengo", + "America/Indiana/Petersburg", + "America/Indiana/Tell_City", + "America/Indiana/Vevay", + "America/Indiana/Vincennes", + "America/Indiana/Winamac", + "America/Inuvik", + "America/Iqaluit", + "America/Jamaica", + "America/Juneau", + "America/Kentucky/Louisville", + "America/Kentucky/Monticello", + "America/Kralendijk", + "America/La_Paz", + "America/Lima", + "America/Los_Angeles", + "America/Lower_Princes", + "America/Maceio", + "America/Managua", + "America/Manaus", + "America/Marigot", + "America/Martinique", + "America/Matamoros", + "America/Mazatlan", + "America/Menominee", + "America/Merida", + "America/Metlakatla", + "America/Mexico_City", + "America/Miquelon", + "America/Moncton", + "America/Monterrey", + "America/Montevideo", + "America/Montserrat", + "America/Nassau", + "America/New_York", + "America/Nipigon", + "America/Nome", + "America/Noronha", + "America/North_Dakota/Beulah", + "America/North_Dakota/Center", + "America/North_Dakota/New_Salem", + "America/Nuuk", + "America/Ojinaga", + "America/Panama", + "America/Pangnirtung", + "America/Paramaribo", + "America/Phoenix", + "America/Port-au-Prince", + "America/Port_of_Spain", + "America/Porto_Velho", + "America/Puerto_Rico", + "America/Punta_Arenas", + "America/Rainy_River", + "America/Rankin_Inlet", + "America/Recife", + "America/Regina", + "America/Resolute", + "America/Rio_Branco", + "America/Santarem", + "America/Santiago", + "America/Santo_Domingo", + "America/Sao_Paulo", + "America/Scoresbysund", + "America/Sitka", + "America/St_Barthelemy", + "America/St_Johns", + "America/St_Kitts", + "America/St_Lucia", + "America/St_Thomas", + "America/St_Vincent", + "America/Swift_Current", + "America/Tegucigalpa", + "America/Thule", + "America/Thunder_Bay", + "America/Tijuana", + "America/Toronto", + "America/Tortola", + "America/Vancouver", + "America/Whitehorse", + "America/Winnipeg", + "America/Yakutat", + "America/Yellowknife", + "Antarctica/Casey", + "Antarctica/Davis", + "Antarctica/DumontDUrville", + "Antarctica/Macquarie", + "Antarctica/Mawson", + "Antarctica/McMurdo", + "Antarctica/Palmer", + "Antarctica/Rothera", + "Antarctica/Syowa", + "Antarctica/Troll", + "Antarctica/Vostok", + "Arctic/Longyearbyen", + "Asia/Aden", + "Asia/Almaty", + "Asia/Amman", + "Asia/Anadyr", + "Asia/Aqtau", + "Asia/Aqtobe", + "Asia/Ashgabat", + "Asia/Atyrau", + "Asia/Baghdad", + "Asia/Bahrain", + "Asia/Baku", + "Asia/Bangkok", + "Asia/Barnaul", + "Asia/Beirut", + "Asia/Bishkek", + "Asia/Brunei", + "Asia/Chita", + "Asia/Choibalsan", + "Asia/Colombo", + "Asia/Damascus", + "Asia/Dhaka", + "Asia/Dili", + "Asia/Dubai", + "Asia/Dushanbe", + "Asia/Famagusta", + "Asia/Gaza", + "Asia/Hebron", + "Asia/Ho_Chi_Minh", + "Asia/Hong_Kong", + "Asia/Hovd", + "Asia/Irkutsk", + "Asia/Jakarta", + "Asia/Jayapura", + "Asia/Jerusalem", + "Asia/Kabul", + "Asia/Kamchatka", + "Asia/Karachi", + "Asia/Kathmandu", + "Asia/Khandyga", + "Asia/Kolkata", + "Asia/Krasnoyarsk", + "Asia/Kuala_Lumpur", + "Asia/Kuching", + "Asia/Kuwait", + "Asia/Macau", + "Asia/Magadan", + "Asia/Makassar", + "Asia/Manila", + "Asia/Muscat", + "Asia/Nicosia", + "Asia/Novokuznetsk", + "Asia/Novosibirsk", + "Asia/Omsk", + "Asia/Oral", + "Asia/Phnom_Penh", + "Asia/Pontianak", + "Asia/Pyongyang", + "Asia/Qatar", + "Asia/Qostanay", + "Asia/Qyzylorda", + "Asia/Riyadh", + "Asia/Sakhalin", + "Asia/Samarkand", + "Asia/Seoul", + "Asia/Shanghai", + "Asia/Singapore", + "Asia/Srednekolymsk", + "Asia/Taipei", + "Asia/Tashkent", + "Asia/Tbilisi", + "Asia/Tehran", + "Asia/Thimphu", + "Asia/Tokyo", + "Asia/Tomsk", + "Asia/Ulaanbaatar", + "Asia/Urumqi", + "Asia/Ust-Nera", + "Asia/Vientiane", + "Asia/Vladivostok", + "Asia/Yakutsk", + "Asia/Yangon", + "Asia/Yekaterinburg", + "Asia/Yerevan", + "Atlantic/Azores", + "Atlantic/Bermuda", + "Atlantic/Canary", + "Atlantic/Cape_Verde", + "Atlantic/Faroe", + "Atlantic/Madeira", + "Atlantic/Reykjavik", + "Atlantic/South_Georgia", + "Atlantic/St_Helena", + "Atlantic/Stanley", + "Australia/Adelaide", + "Australia/Brisbane", + "Australia/Broken_Hill", + "Australia/Darwin", + "Australia/Eucla", + "Australia/Hobart", + "Australia/Lindeman", + "Australia/Lord_Howe", + "Australia/Melbourne", + "Australia/Perth", + "Australia/Sydney", + "Europe/Amsterdam", + "Europe/Andorra", + "Europe/Astrakhan", + "Europe/Athens", + "Europe/Belgrade", + "Europe/Berlin", + "Europe/Bratislava", + "Europe/Brussels", + "Europe/Bucharest", + "Europe/Budapest", + "Europe/Busingen", + "Europe/Chisinau", + "Europe/Copenhagen", + "Europe/Dublin", + "Europe/Gibraltar", + "Europe/Guernsey", + "Europe/Helsinki", + "Europe/Isle_of_Man", + "Europe/Istanbul", + "Europe/Jersey", + "Europe/Kaliningrad", + "Europe/Kiev", + "Europe/Kirov", + "Europe/Lisbon", + "Europe/Ljubljana", + "Europe/London", + "Europe/Luxembourg", + "Europe/Madrid", + "Europe/Malta", + "Europe/Mariehamn", + "Europe/Minsk", + "Europe/Monaco", + "Europe/Moscow", + "Europe/Nicosia", + "Europe/Oslo", + "Europe/Paris", + "Europe/Podgorica", + "Europe/Prague", + "Europe/Riga", + "Europe/Rome", + "Europe/Samara", + "Europe/San_Marino", + "Europe/Sarajevo", + "Europe/Saratov", + "Europe/Simferopol", + "Europe/Skopje", + "Europe/Sofia", + "Europe/Stockholm", + "Europe/Tallinn", + "Europe/Tirane", + "Europe/Ulyanovsk", + "Europe/Uzhgorod", + "Europe/Vaduz", + "Europe/Vatican", + "Europe/Vienna", + "Europe/Vilnius", + "Europe/Volgograd", + "Europe/Warsaw", + "Europe/Zagreb", + "Europe/Zaporozhye", + "Europe/Zurich", + "Indian/Antananarivo", + "Indian/Chagos", + "Indian/Christmas", + "Indian/Cocos", + "Indian/Comoro", + "Indian/Kerguelen", + "Indian/Mahe", + "Indian/Maldives", + "Indian/Mauritius", + "Indian/Mayotte", + "Indian/Reunion", + "Pacific/Apia", + "Pacific/Auckland", + "Pacific/Bougainville", + "Pacific/Chatham", + "Pacific/Chuuk", + "Pacific/Easter", + "Pacific/Efate", + "Pacific/Enderbury", + "Pacific/Fakaofo", + "Pacific/Fiji", + "Pacific/Funafuti", + "Pacific/Galapagos", + "Pacific/Gambier", + "Pacific/Guadalcanal", + "Pacific/Guam", + "Pacific/Honolulu", + "Pacific/Kiritimati", + "Pacific/Kosrae", + "Pacific/Kwajalein", + "Pacific/Majuro", + "Pacific/Marquesas", + "Pacific/Midway", + "Pacific/Nauru", + "Pacific/Niue", + "Pacific/Norfolk", + "Pacific/Noumea", + "Pacific/Pago_Pago", + "Pacific/Palau", + "Pacific/Pitcairn", + "Pacific/Pohnpei", + "Pacific/Port_Moresby", + "Pacific/Rarotonga", + "Pacific/Saipan", + "Pacific/Tahiti", + "Pacific/Tarawa", + "Pacific/Tongatapu", + "Pacific/Wake", + "Pacific/Wallis", + "UTC", +]; + +/// A validated IANA timezone name. +/// +/// Validated against a built-in list of canonical IANA timezone names +/// (e.g. `"Europe/Prague"`, `"America/New_York"`, `"UTC"`). The name is +/// trimmed but case-sensitive — IANA names are case-sensitive by specification. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::geo::TimeZone; +/// use arvo::traits::ValueObject; +/// +/// let tz = TimeZone::new("Europe/Prague".into())?; +/// assert_eq!(tz.value(), "Europe/Prague"); +/// +/// let tz: TimeZone = "UTC".try_into()?; +/// assert!(TimeZone::new("Fake/Zone".into()).is_err()); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct TimeZone(String); + +impl ValueObject for TimeZone { + type Input = TimeZoneInput; + type Output = TimeZoneOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + let trimmed = value.trim(); + + if trimmed.is_empty() { + return Err(ValidationError::empty("TimeZone")); + } + + if IANA_TIMEZONES.binary_search(&trimmed).is_err() { + return Err(ValidationError::invalid("TimeZone", trimmed)); + } + + Ok(Self(trimmed.to_owned())) + } + + fn value(&self) -> &Self::Output { + &self.0 + } + + fn into_inner(self) -> Self::Input { + self.0 + } +} + +impl TryFrom<&str> for TimeZone { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value.to_owned()) + } +} + +impl std::fmt::Display for TimeZone { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid_timezone() { + let tz = TimeZone::new("Europe/Prague".into()).unwrap(); + assert_eq!(tz.value(), "Europe/Prague"); + } + + #[test] + fn accepts_utc() { + assert!(TimeZone::new("UTC".into()).is_ok()); + } + + #[test] + fn accepts_american_timezone() { + assert!(TimeZone::new("America/New_York".into()).is_ok()); + } + + #[test] + fn rejects_empty() { + assert!(TimeZone::new(String::new()).is_err()); + } + + #[test] + fn rejects_unknown_timezone() { + assert!(TimeZone::new("Fake/Zone".into()).is_err()); + } + + #[test] + fn case_sensitive() { + assert!(TimeZone::new("europe/prague".into()).is_err()); + } + + #[test] + fn trims_whitespace() { + let tz = TimeZone::new(" Europe/Prague ".into()).unwrap(); + assert_eq!(tz.value(), "Europe/Prague"); + } + + #[test] + fn try_from_str() { + let tz: TimeZone = "Asia/Tokyo".try_into().unwrap(); + assert_eq!(tz.value(), "Asia/Tokyo"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 72810d0..8236cd3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,6 +47,9 @@ pub mod contact; #[cfg(feature = "finance")] pub mod finance; +#[cfg(feature = "geo")] +pub mod geo; + #[cfg(feature = "identifiers")] pub mod identifiers;