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: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -32,6 +33,7 @@ sql = ["dep:sqlx"]
full = [
"contact",
"finance",
"geo",
"identifiers",
"primitives",
"temporal",
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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` |
Expand Down Expand Up @@ -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 ✅ |
Expand Down
16 changes: 8 additions & 8 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |

---

Expand Down Expand Up @@ -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** |
238 changes: 238 additions & 0 deletions docs/geo.md
Original file line number Diff line number Diff line change
@@ -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` |
Loading
Loading