From 576debd06be8ac83862f852dbb5891c5c2d49204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Mon, 20 Apr 2026 20:50:32 +0200 Subject: [PATCH 1/4] Add primitives module (v0.3.0) 10 value objects: NonEmptyString, BoundedString, PositiveInt, NonNegativeInt, PositiveDecimal, NonNegativeDecimal, Probability, HexColor, Locale, Base64String. Closes #66, #67, #68, #69, #70, #71, #72, #73, #75, #77 Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 1 + Cargo.toml | 3 + README.md | 2 +- ROADMAP.md | 24 +- docs/primitives.md | 353 +++++++++++++++++++++++++++ src/lib.rs | 3 + src/primitives/base64string.rs | 122 +++++++++ src/primitives/boundedstring.rs | 128 ++++++++++ src/primitives/hexcolor.rs | 174 +++++++++++++ src/primitives/locale.rs | 160 ++++++++++++ src/primitives/mod.rs | 23 ++ src/primitives/nonemptystring.rs | 98 ++++++++ src/primitives/nonnegativedecimal.rs | 85 +++++++ src/primitives/nonnegativeint.rs | 82 +++++++ src/primitives/positivedecimal.rs | 84 +++++++ src/primitives/positiveint.rs | 88 +++++++ src/primitives/probability.rs | 104 ++++++++ 17 files changed, 1521 insertions(+), 13 deletions(-) create mode 100644 docs/primitives.md create mode 100644 src/primitives/base64string.rs create mode 100644 src/primitives/boundedstring.rs create mode 100644 src/primitives/hexcolor.rs create mode 100644 src/primitives/locale.rs create mode 100644 src/primitives/mod.rs create mode 100644 src/primitives/nonemptystring.rs create mode 100644 src/primitives/nonnegativedecimal.rs create mode 100644 src/primitives/nonnegativeint.rs create mode 100644 src/primitives/positivedecimal.rs create mode 100644 src/primitives/positiveint.rs create mode 100644 src/primitives/probability.rs diff --git a/Cargo.lock b/Cargo.lock index 1369add..afd7a7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,6 +53,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" name = "arvo" version = "0.2.0" dependencies = [ + "base64", "chrono", "once_cell", "regex", diff --git a/Cargo.toml b/Cargo.toml index 42d1205..b6fc30c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ default = [] # Domain modules — opt-in so you only pay for what you use contact = ["dep:once_cell", "dep:regex", "dep:url"] +primitives = ["dep:rust_decimal", "dep:base64"] # Cross-cutting concerns can be combined with any module serde = ["dep:serde"] @@ -27,6 +28,7 @@ sql = ["dep:sqlx"] # Everything at once full = [ "contact", + "primitives", ] [dependencies] @@ -38,6 +40,7 @@ chrono = { version = "0.4", optional = true, features = ["serde"] } uuid = { version = "1", optional = true, features = ["v4"] } ulid = { version = "1", optional = true } url = { version = "~2.4", optional = true } +base64 = { version = "0.22", optional = true } serde = { version = "1", optional = true, features = ["derive"] } sqlx = { version = "0.8", optional = true, features = ["postgres"] } diff --git a/README.md b/README.md index 93c7e91..f322e80 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ let parsed: EmailAddress = serde_json::from_str(r#""hello@example.com""#)?; | `geo` | `Latitude`, `Longitude`, `Coordinate`, `BoundingBox`, `TimeZone` | 6 | 0 / 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 | 0 / 10 | +| `primitives` | `NonEmptyString`, `BoundedString`, `Locale`, `HexColor` | 10 | 10 / 10 ✅ | → Full details and design rationale in [ROADMAP.md](ROADMAP.md) diff --git a/ROADMAP.md b/ROADMAP.md index 527a8ec..b0aa06b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -117,16 +117,16 @@ | Type | Status | Notes | |---|---|---| -| `NonEmptyString` | ⬜ | trimmed, at least 1 non-whitespace char | -| `BoundedString` | ⬜ | `BoundedString` via const generics | -| `PositiveInt` | ⬜ | `i64 > 0` | -| `NonNegativeInt` | ⬜ | `i64 >= 0` | -| `PositiveDecimal` | ⬜ | `Decimal > 0` | -| `NonNegativeDecimal` | ⬜ | `Decimal >= 0` | -| `Probability` | ⬜ | `f64` in range 0.0..=1.0 | -| `HexColor` | ⬜ | `#RRGGBB` or `#RGB`, normalised to uppercase | -| `Locale` | ⬜ | BCP 47 language tag (e.g. `en-US`, `cs-CZ`) | -| `Base64String` | ⬜ | valid base64-encoded string | +| `NonEmptyString` | ✅ | trimmed, at least 1 non-whitespace char | +| `BoundedString` | ✅ | `BoundedString` via const generics | +| `PositiveInt` | ✅ | `i64 > 0` | +| `NonNegativeInt` | ✅ | `i64 >= 0` | +| `PositiveDecimal` | ✅ | `Decimal > 0` | +| `NonNegativeDecimal` | ✅ | `Decimal >= 0` | +| `Probability` | ✅ | `f64` in range 0.0..=1.0 | +| `HexColor` | ✅ | `#RRGGBB` or `#RGB`, normalised to uppercase | +| `Locale` | ✅ | BCP 47 language tag (e.g. `en-US`, `cs-CZ`) | +| `Base64String` | ✅ | valid base64-encoded string | --- @@ -141,5 +141,5 @@ | `geo` | 6 | 0 | 6 | | `net` | 10 | 0 | 10 | | `measurement` | 10 | 0 | 10 | -| `primitives` | 10 | 0 | 10 | -| **Total** | **62** | **5** | **57** | +| `primitives` | 10 | 10 | 0 | +| **Total** | **62** | **15** | **47** | diff --git a/docs/primitives.md b/docs/primitives.md new file mode 100644 index 0000000..6c033c1 --- /dev/null +++ b/docs/primitives.md @@ -0,0 +1,353 @@ +# primitives module + +Feature flag: `primitives` + +```toml +[dependencies] +arvo = { version = "0.3", features = ["primitives"] } +``` + +--- + +## NonEmptyString + +A non-empty, trimmed string. + +**Normalisation:** surrounding whitespace trimmed. +**Validation:** must not be empty after trimming. + +```rust,ignore +use arvo::primitives::NonEmptyString; +use arvo::traits::ValueObject; + +let s = NonEmptyString::new(" hello ".into())?; +assert_eq!(s.value(), "hello"); + +let s: NonEmptyString = "world".try_into()?; +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&String` | `"hello"` | +| `into_inner()` | `String` | `"hello"` | + +### Errors + +| Input | Error | +|---|---| +| `""` | `ValidationError::Empty` | +| `" "` | `ValidationError::Empty` | + +--- + +## BoundedString + +A string whose length (in Unicode characters) is constrained at the type level. + +**Normalisation:** surrounding whitespace trimmed. +**Validation:** character count (not byte count) must be `>= MIN` and `<= MAX`. + +```rust,ignore +use arvo::primitives::BoundedString; +use arvo::traits::ValueObject; + +type Username = BoundedString<3, 32>; + +let name = Username::new("Alice".into())?; +assert_eq!(name.value(), "Alice"); + +assert!(Username::new("Al".into()).is_err()); // too short +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&String` | `"Alice"` | +| `into_inner()` | `String` | `"Alice"` | + +### Errors + +| Input | Error | +|---|---| +| value shorter than `MIN` | `ValidationError::OutOfRange` | +| value longer than `MAX` | `ValidationError::OutOfRange` | + +--- + +## PositiveInt + +A strictly positive integer (`i64 > 0`). + +**Normalisation:** none. +**Validation:** value must be `> 0`. Zero and negative values are rejected. + +```rust,ignore +use arvo::primitives::PositiveInt; +use arvo::traits::ValueObject; + +let n = PositiveInt::new(42)?; +assert_eq!(*n.value(), 42); + +assert!(PositiveInt::new(0).is_err()); +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&i64` | `42` | +| `into_inner()` | `i64` | `42` | + +### Errors + +| Input | Error | +|---|---| +| `0` | `ValidationError::OutOfRange` | +| `-1` | `ValidationError::OutOfRange` | + +--- + +## NonNegativeInt + +A non-negative integer (`i64 >= 0`). + +**Normalisation:** none. +**Validation:** value must be `>= 0`. Negative values are rejected. + +```rust,ignore +use arvo::primitives::NonNegativeInt; +use arvo::traits::ValueObject; + +let n = NonNegativeInt::new(0)?; +assert_eq!(*n.value(), 0); + +assert!(NonNegativeInt::new(-1).is_err()); +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&i64` | `0` | +| `into_inner()` | `i64` | `0` | + +### Errors + +| Input | Error | +|---|---| +| `-1` | `ValidationError::OutOfRange` | + +--- + +## PositiveDecimal + +A strictly positive decimal (`rust_decimal::Decimal > 0`). + +**Normalisation:** none. +**Validation:** value must be `> Decimal::ZERO`. + +```rust,ignore +use arvo::primitives::PositiveDecimal; +use arvo::traits::ValueObject; +use rust_decimal::Decimal; +use std::str::FromStr; + +let price = PositiveDecimal::new(Decimal::from_str("9.99").unwrap())?; +assert_eq!(price.value(), &Decimal::from_str("9.99").unwrap()); +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&Decimal` | `9.99` | +| `into_inner()` | `Decimal` | `9.99` | + +### Errors + +| Input | Error | +|---|---| +| `Decimal::ZERO` | `ValidationError::OutOfRange` | +| negative value | `ValidationError::OutOfRange` | + +--- + +## NonNegativeDecimal + +A non-negative decimal (`rust_decimal::Decimal >= 0`). + +**Normalisation:** none. +**Validation:** value must be `>= Decimal::ZERO`. + +```rust,ignore +use arvo::primitives::NonNegativeDecimal; +use arvo::traits::ValueObject; +use rust_decimal::Decimal; + +let amount = NonNegativeDecimal::new(Decimal::ZERO)?; +assert_eq!(amount.value(), &Decimal::ZERO); +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&Decimal` | `0` | +| `into_inner()` | `Decimal` | `0` | + +### Errors + +| Input | Error | +|---|---| +| negative value | `ValidationError::OutOfRange` | + +--- + +## Probability + +A probability value in the closed interval `[0.0, 1.0]`. + +**Normalisation:** none. +**Validation:** must be finite and in `0.0..=1.0`. NaN and infinity are rejected. + +```rust,ignore +use arvo::primitives::Probability; +use arvo::traits::ValueObject; + +let p = Probability::new(0.75)?; +assert_eq!(*p.value(), 0.75); + +assert!(Probability::new(1.5).is_err()); +assert!(Probability::new(f64::NAN).is_err()); +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&f64` | `0.75` | +| `into_inner()` | `f64` | `0.75` | + +### Errors + +| Input | Error | +|---|---| +| `1.5` | `ValidationError::OutOfRange` | +| `-0.1` | `ValidationError::OutOfRange` | +| `NaN` | `ValidationError::OutOfRange` | +| `∞` | `ValidationError::OutOfRange` | + +--- + +## HexColor + +A CSS hex color in canonical `#RRGGBB` form. + +**Normalisation:** trimmed; uppercased; 3-digit shorthand expanded to 6-digit form (`#F0A` → `#FF00AA`). +**Validation:** must start with `#`; remaining chars must be exactly 3 or 6 hexadecimal digits. + +```rust,ignore +use arvo::primitives::HexColor; +use arvo::traits::ValueObject; + +let red = HexColor::new("#f00".into())?; +assert_eq!(red.value(), "#FF0000"); +assert_eq!(red.r(), 255); +assert_eq!(red.g(), 0); +assert_eq!(red.b(), 0); + +let c: HexColor = "#1A2B3C".try_into()?; +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&String` | `"#FF0000"` | +| `r()` | `u8` | `255` | +| `g()` | `u8` | `0` | +| `b()` | `u8` | `0` | +| `into_inner()` | `String` | `"#FF0000"` | + +### Errors + +| Input | Error | +|---|---| +| `""` | `ValidationError::Empty` | +| `"FF0000"` | `ValidationError::InvalidFormat` (missing `#`) | +| `"#GGGGGG"` | `ValidationError::InvalidFormat` | +| `"#FFFF"` | `ValidationError::InvalidFormat` (wrong length) | + +--- + +## Locale + +A BCP 47 language tag (e.g. `"en-US"`, `"cs-CZ"`, `"fr"`). + +**Normalisation:** trimmed; `_` separator normalised to `-`; language subtag lowercased; region subtag uppercased. +**Validation (MVP):** language subtag must be 2–3 ASCII letters; optional region subtag must be 2 ASCII letters or 3 digits. + +```rust,ignore +use arvo::primitives::Locale; +use arvo::traits::ValueObject; + +let locale = Locale::new("en_us".into())?; +assert_eq!(locale.value(), "en-US"); + +let fr: Locale = "fr".try_into()?; +assert_eq!(fr.value(), "fr"); +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&String` | `"en-US"` | +| `into_inner()` | `String` | `"en-US"` | + +### Errors + +| Input | Error | +|---|---| +| `""` | `ValidationError::Empty` | +| `"e"` | `ValidationError::InvalidFormat` (language too short) | +| `"engl"` | `ValidationError::InvalidFormat` (language too long) | +| `"en-X1"` | `ValidationError::InvalidFormat` (invalid region) | + +--- + +## Base64String + +A validated standard Base64-encoded string. + +**Normalisation:** surrounding whitespace trimmed. +**Validation:** must decode successfully using the standard Base64 alphabet (`A–Z`, `a–z`, `0–9`, `+`, `/`) with correct `=` padding. + +```rust,ignore +use arvo::primitives::Base64String; +use arvo::traits::ValueObject; + +let b = Base64String::new("aGVsbG8=".into())?; +assert_eq!(b.decode(), b"hello"); + +let b: Base64String = "aGVsbG8=".try_into()?; +``` + +### Accessors + +| Method | Returns | Example | +|---|---|---| +| `value()` | `&String` | `"aGVsbG8="` | +| `decode()` | `Vec` | `[104, 101, 108, 108, 111]` | +| `into_inner()` | `String` | `"aGVsbG8="` | + +### Errors + +| Input | Error | +|---|---| +| `""` | `ValidationError::Empty` | +| `"not!!valid"` | `ValidationError::InvalidFormat` | +| `"aGVsbG8"` | `ValidationError::InvalidFormat` (invalid padding) | diff --git a/src/lib.rs b/src/lib.rs index ec85133..400a19e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,9 @@ pub mod traits; #[cfg(feature = "contact")] pub mod contact; +#[cfg(feature = "primitives")] +pub mod primitives; + /// Convenience re-exports for the most commonly used types. /// /// Add `use arvo::prelude::*;` to bring the `ValueObject` trait and diff --git a/src/primitives/base64string.rs b/src/primitives/base64string.rs new file mode 100644 index 0000000..0446f12 --- /dev/null +++ b/src/primitives/base64string.rs @@ -0,0 +1,122 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; + +/// Input type for [`Base64String`]. +pub type Base64StringInput = String; + +/// Output type for [`Base64String`]. +pub type Base64StringOutput = String; + +/// A validated standard Base64-encoded string. +/// +/// Accepts the standard alphabet (`A–Z`, `a–z`, `0–9`, `+`, `/`) with `=` +/// padding. Surrounding whitespace is trimmed. The length after trimming must +/// be a multiple of 4. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::primitives::Base64String; +/// use arvo::traits::ValueObject; +/// +/// let b = Base64String::new("aGVsbG8=".into()).unwrap(); +/// assert_eq!(b.decode(), b"hello"); +/// +/// assert!(Base64String::new("not!!base64".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 Base64String(String); + +impl ValueObject for Base64String { + type Input = Base64StringInput; + type Output = Base64StringOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + let trimmed = value.trim().to_owned(); + if trimmed.is_empty() { + return Err(ValidationError::empty("Base64String")); + } + STANDARD + .decode(&trimmed) + .map_err(|_| ValidationError::invalid("Base64String", &trimmed))?; + Ok(Self(trimmed)) + } + + fn value(&self) -> &Self::Output { + &self.0 + } + + fn into_inner(self) -> Self::Input { + self.0 + } +} + +impl Base64String { + /// Decodes the Base64 string and returns the raw bytes. + pub fn decode(&self) -> Vec { + STANDARD.decode(&self.0).expect("already validated") + } +} + +impl TryFrom<&str> for Base64String { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value.to_owned()) + } +} + +impl std::fmt::Display for Base64String { + 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_base64() { + let b = Base64String::new("aGVsbG8=".into()).unwrap(); + assert_eq!(b.value(), "aGVsbG8="); + } + + #[test] + fn trims_surrounding_whitespace() { + let b = Base64String::new(" aGVsbG8= ".into()).unwrap(); + assert_eq!(b.value(), "aGVsbG8="); + } + + #[test] + fn decode_returns_raw_bytes() { + let b = Base64String::new("aGVsbG8=".into()).unwrap(); + assert_eq!(b.decode(), b"hello"); + } + + #[test] + fn rejects_invalid_chars() { + assert!(Base64String::new("not!!valid".into()).is_err()); + } + + #[test] + fn rejects_wrong_padding() { + assert!(Base64String::new("aGVsbG8".into()).is_err()); + } + + #[test] + fn rejects_empty() { + assert!(Base64String::new(String::new()).is_err()); + } + + #[test] + fn try_from_str() { + let b: Base64String = "aGVsbG8=".try_into().unwrap(); + assert_eq!(b.decode(), b"hello"); + } +} diff --git a/src/primitives/boundedstring.rs b/src/primitives/boundedstring.rs new file mode 100644 index 0000000..2cf21b7 --- /dev/null +++ b/src/primitives/boundedstring.rs @@ -0,0 +1,128 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// A string whose length (in Unicode characters) is constrained to `MIN..=MAX`. +/// +/// Surrounding whitespace is stripped before the length check. The type encodes +/// the allowed range at compile time via const generics, making length +/// constraints self-documenting at the call site: +/// +/// ```rust,ignore +/// type Username = BoundedString<3, 32>; +/// ``` +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::primitives::BoundedString; +/// use arvo::traits::ValueObject; +/// +/// let name: BoundedString<2, 50> = BoundedString::new("Alice".into()).unwrap(); +/// assert_eq!(name.value(), "Alice"); +/// +/// assert!(BoundedString::<2, 50>::new("A".into()).is_err()); // too short +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct BoundedString(String); + +impl ValueObject for BoundedString { + type Input = String; + type Output = String; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + if MIN > MAX { + return Err(ValidationError::Custom { + type_name: "BoundedString", + message: format!("MIN ({MIN}) must be <= MAX ({MAX})"), + }); + } + let trimmed = value.trim().to_owned(); + let len = trimmed.chars().count(); + if len < MIN || len > MAX { + return Err(ValidationError::OutOfRange { + type_name: "BoundedString", + min: MIN.to_string(), + max: MAX.to_string(), + actual: len.to_string(), + }); + } + Ok(Self(trimmed)) + } + + fn value(&self) -> &Self::Output { + &self.0 + } + + fn into_inner(self) -> Self::Input { + self.0 + } +} + +impl TryFrom<&str> for BoundedString { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value.to_owned()) + } +} + +impl std::fmt::Display for BoundedString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_string_within_bounds() { + let s: BoundedString<2, 10> = BoundedString::new("hello".into()).unwrap(); + assert_eq!(s.value(), "hello"); + } + + #[test] + fn trims_surrounding_whitespace() { + let s: BoundedString<1, 10> = BoundedString::new(" hi ".into()).unwrap(); + assert_eq!(s.value(), "hi"); + } + + #[test] + fn rejects_too_short() { + assert!(BoundedString::<3, 10>::new("ab".into()).is_err()); + } + + #[test] + fn rejects_too_long() { + assert!(BoundedString::<1, 3>::new("toolong".into()).is_err()); + } + + #[test] + fn accepts_exact_min() { + let s: BoundedString<3, 10> = BoundedString::new("abc".into()).unwrap(); + assert_eq!(s.value(), "abc"); + } + + #[test] + fn accepts_exact_max() { + let s: BoundedString<1, 5> = BoundedString::new("hello".into()).unwrap(); + assert_eq!(s.value(), "hello"); + } + + #[test] + fn counts_unicode_chars_not_bytes() { + // "café" is 4 chars but 5 bytes + let s: BoundedString<1, 4> = BoundedString::new("café".into()).unwrap(); + assert_eq!(s.value(), "café"); + } + + #[test] + fn try_from_str() { + let s: BoundedString<1, 10> = "test".try_into().unwrap(); + assert_eq!(s.value(), "test"); + } +} diff --git a/src/primitives/hexcolor.rs b/src/primitives/hexcolor.rs new file mode 100644 index 0000000..669eed7 --- /dev/null +++ b/src/primitives/hexcolor.rs @@ -0,0 +1,174 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Input type for [`HexColor`]. +pub type HexColorInput = String; + +/// Output type for [`HexColor`] — always a 7-character `#RRGGBB` string. +pub type HexColorOutput = String; + +/// A CSS hex color in canonical `#RRGGBB` form, normalised to uppercase. +/// +/// Accepts both 6-digit (`#FF0000`) and 3-digit shorthand (`#F00`) input. +/// The 3-digit form is expanded by doubling each channel digit. +/// The `#` prefix is required. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::primitives::HexColor; +/// use arvo::traits::ValueObject; +/// +/// let red = HexColor::new("#f00".into()).unwrap(); +/// assert_eq!(red.value(), "#FF0000"); +/// assert_eq!(red.r(), 255); +/// +/// assert!(HexColor::new("FF0000".into()).is_err()); // missing # +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct HexColor(String); + +impl ValueObject for HexColor { + type Input = HexColorInput; + type Output = HexColorOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + let s = value.trim().to_uppercase(); + + if s.is_empty() { + return Err(ValidationError::empty("HexColor")); + } + + let hex = s + .strip_prefix('#') + .ok_or_else(|| ValidationError::invalid("HexColor", &s))?; + + let expanded = match hex.len() { + 3 => { + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(ValidationError::invalid("HexColor", &s)); + } + let chars: Vec = hex.chars().collect(); + format!("#{0}{0}{1}{1}{2}{2}", chars[0], chars[1], chars[2]) + } + 6 => { + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(ValidationError::invalid("HexColor", &s)); + } + s + } + _ => return Err(ValidationError::invalid("HexColor", &s)), + }; + + Ok(Self(expanded)) + } + + fn value(&self) -> &Self::Output { + &self.0 + } + + fn into_inner(self) -> Self::Input { + self.0 + } +} + +impl HexColor { + fn channel(s: &str, offset: usize) -> u8 { + u8::from_str_radix(&s[offset..offset + 2], 16).unwrap_or(0) + } + + /// Red channel value (0–255). + pub fn r(&self) -> u8 { + Self::channel(&self.0, 1) + } + + /// Green channel value (0–255). + pub fn g(&self) -> u8 { + Self::channel(&self.0, 3) + } + + /// Blue channel value (0–255). + pub fn b(&self) -> u8 { + Self::channel(&self.0, 5) + } +} + +impl TryFrom<&str> for HexColor { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value.to_owned()) + } +} + +impl std::fmt::Display for HexColor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_six_digit_form() { + let c = HexColor::new("#FF0000".into()).unwrap(); + assert_eq!(c.value(), "#FF0000"); + } + + #[test] + fn normalises_to_uppercase() { + let c = HexColor::new("#ff0000".into()).unwrap(); + assert_eq!(c.value(), "#FF0000"); + } + + #[test] + fn expands_three_digit_shorthand() { + let c = HexColor::new("#F0A".into()).unwrap(); + assert_eq!(c.value(), "#FF00AA"); + } + + #[test] + fn expands_three_digit_lowercase() { + let c = HexColor::new("#f0a".into()).unwrap(); + assert_eq!(c.value(), "#FF00AA"); + } + + #[test] + fn rejects_missing_hash() { + assert!(HexColor::new("FF0000".into()).is_err()); + } + + #[test] + fn rejects_invalid_chars() { + assert!(HexColor::new("#GGGGGG".into()).is_err()); + } + + #[test] + fn rejects_wrong_length() { + assert!(HexColor::new("#FFFF".into()).is_err()); + } + + #[test] + fn rejects_empty() { + assert!(HexColor::new(String::new()).is_err()); + } + + #[test] + fn rgb_channels() { + let c = HexColor::new("#1A2B3C".into()).unwrap(); + assert_eq!(c.r(), 0x1A); + assert_eq!(c.g(), 0x2B); + assert_eq!(c.b(), 0x3C); + } + + #[test] + fn try_from_str() { + let c: HexColor = "#ABC".try_into().unwrap(); + assert_eq!(c.value(), "#AABBCC"); + } +} diff --git a/src/primitives/locale.rs b/src/primitives/locale.rs new file mode 100644 index 0000000..7789746 --- /dev/null +++ b/src/primitives/locale.rs @@ -0,0 +1,160 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Input type for [`Locale`]. +pub type LocaleInput = String; + +/// Output type for [`Locale`] — BCP 47 canonical form, e.g. `"en-US"`. +pub type LocaleOutput = String; + +/// A BCP 47 language tag (e.g. `"en-US"`, `"cs-CZ"`, `"fr"`). +/// +/// Accepts both `-` and `_` as separators. On construction, the language +/// subtag is lowercased, the region subtag (if present) is uppercased, and +/// the separator is normalised to `-`. +/// +/// MVP scope: language subtag (2–3 letters) plus optional region subtag +/// (2 letters or 3 digits). Script, variant, and extension subtags are +/// out of scope. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::primitives::Locale; +/// use arvo::traits::ValueObject; +/// +/// let locale = Locale::new("en_us".into()).unwrap(); +/// assert_eq!(locale.value(), "en-US"); +/// +/// assert!(Locale::new("x".into()).is_err()); // language subtag too short +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct Locale(String); + +impl ValueObject for Locale { + type Input = LocaleInput; + type Output = LocaleOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + let trimmed = value.trim().to_owned(); + if trimmed.is_empty() { + return Err(ValidationError::empty("Locale")); + } + + let normalised = trimmed.replace('_', "-"); + let parts: Vec<&str> = normalised.splitn(2, '-').collect(); + + let lang = parts[0]; + if lang.len() < 2 || lang.len() > 3 || !lang.chars().all(|c| c.is_ascii_alphabetic()) { + return Err(ValidationError::invalid("Locale", &trimmed)); + } + let lang = lang.to_lowercase(); + + let canonical = if parts.len() == 2 { + let region = parts[1]; + let valid_region = (region.len() == 2 + && region.chars().all(|c| c.is_ascii_alphabetic())) + || (region.len() == 3 && region.chars().all(|c| c.is_ascii_digit())); + if !valid_region { + return Err(ValidationError::invalid("Locale", &trimmed)); + } + format!("{}-{}", lang, region.to_uppercase()) + } else { + lang + }; + + Ok(Self(canonical)) + } + + fn value(&self) -> &Self::Output { + &self.0 + } + + fn into_inner(self) -> Self::Input { + self.0 + } +} + +impl TryFrom<&str> for Locale { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value.to_owned()) + } +} + +impl std::fmt::Display for Locale { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_language_only() { + let l = Locale::new("en".into()).unwrap(); + assert_eq!(l.value(), "en"); + } + + #[test] + fn accepts_language_region_with_dash() { + let l = Locale::new("en-US".into()).unwrap(); + assert_eq!(l.value(), "en-US"); + } + + #[test] + fn normalises_underscore_separator() { + let l = Locale::new("en_us".into()).unwrap(); + assert_eq!(l.value(), "en-US"); + } + + #[test] + fn lowercases_language_subtag() { + let l = Locale::new("EN-US".into()).unwrap(); + assert_eq!(l.value(), "en-US"); + } + + #[test] + fn accepts_three_letter_language() { + let l = Locale::new("ces".into()).unwrap(); + assert_eq!(l.value(), "ces"); + } + + #[test] + fn accepts_numeric_region() { + let l = Locale::new("es-419".into()).unwrap(); + assert_eq!(l.value(), "es-419"); + } + + #[test] + fn rejects_too_short_language() { + assert!(Locale::new("e".into()).is_err()); + } + + #[test] + fn rejects_too_long_language() { + assert!(Locale::new("engl".into()).is_err()); + } + + #[test] + fn rejects_invalid_region() { + assert!(Locale::new("en-X1".into()).is_err()); + } + + #[test] + fn rejects_empty() { + assert!(Locale::new(String::new()).is_err()); + } + + #[test] + fn try_from_str() { + let l: Locale = "cs-CZ".try_into().unwrap(); + assert_eq!(l.value(), "cs-CZ"); + } +} diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs new file mode 100644 index 0000000..62446ce --- /dev/null +++ b/src/primitives/mod.rs @@ -0,0 +1,23 @@ +mod base64string; +mod boundedstring; +mod hexcolor; +mod locale; +mod nonemptystring; +mod nonnegativedecimal; +mod nonnegativeint; +mod positivedecimal; +mod positiveint; +mod probability; + +pub use base64string::{Base64String, Base64StringInput, Base64StringOutput}; +pub use boundedstring::BoundedString; +pub use hexcolor::{HexColor, HexColorInput, HexColorOutput}; +pub use locale::{Locale, LocaleInput, LocaleOutput}; +pub use nonemptystring::{NonEmptyString, NonEmptyStringInput, NonEmptyStringOutput}; +pub use nonnegativedecimal::{ + NonNegativeDecimal, NonNegativeDecimalInput, NonNegativeDecimalOutput, +}; +pub use nonnegativeint::{NonNegativeInt, NonNegativeIntInput, NonNegativeIntOutput}; +pub use positivedecimal::{PositiveDecimal, PositiveDecimalInput, PositiveDecimalOutput}; +pub use positiveint::{PositiveInt, PositiveIntInput, PositiveIntOutput}; +pub use probability::{Probability, ProbabilityInput, ProbabilityOutput}; diff --git a/src/primitives/nonemptystring.rs b/src/primitives/nonemptystring.rs new file mode 100644 index 0000000..925b71d --- /dev/null +++ b/src/primitives/nonemptystring.rs @@ -0,0 +1,98 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Input type for [`NonEmptyString`]. +pub type NonEmptyStringInput = String; + +/// Output type for [`NonEmptyString`]. +pub type NonEmptyStringOutput = String; + +/// A non-empty, trimmed string. +/// +/// Surrounding whitespace is stripped on construction. A string that consists +/// entirely of whitespace is rejected. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::primitives::NonEmptyString; +/// use arvo::traits::ValueObject; +/// +/// let s = NonEmptyString::new(" hello ".into()).unwrap(); +/// assert_eq!(s.value(), "hello"); +/// +/// assert!(NonEmptyString::new(" ".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 NonEmptyString(String); + +impl ValueObject for NonEmptyString { + type Input = NonEmptyStringInput; + type Output = NonEmptyStringOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + let trimmed = value.trim().to_owned(); + if trimmed.is_empty() { + return Err(ValidationError::empty("NonEmptyString")); + } + Ok(Self(trimmed)) + } + + fn value(&self) -> &Self::Output { + &self.0 + } + + fn into_inner(self) -> Self::Input { + self.0 + } +} + +impl TryFrom<&str> for NonEmptyString { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value.to_owned()) + } +} + +impl std::fmt::Display for NonEmptyString { + 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_string() { + let s = NonEmptyString::new("hello".into()).unwrap(); + assert_eq!(s.value(), "hello"); + } + + #[test] + fn trims_surrounding_whitespace() { + let s = NonEmptyString::new(" hello ".into()).unwrap(); + assert_eq!(s.value(), "hello"); + } + + #[test] + fn rejects_empty_string() { + assert!(NonEmptyString::new(String::new()).is_err()); + } + + #[test] + fn rejects_whitespace_only() { + assert!(NonEmptyString::new(" ".into()).is_err()); + } + + #[test] + fn try_from_str() { + let s: NonEmptyString = "world".try_into().unwrap(); + assert_eq!(s.value(), "world"); + } +} diff --git a/src/primitives/nonnegativedecimal.rs b/src/primitives/nonnegativedecimal.rs new file mode 100644 index 0000000..1d76560 --- /dev/null +++ b/src/primitives/nonnegativedecimal.rs @@ -0,0 +1,85 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; +use rust_decimal::Decimal; + +/// Input type for [`NonNegativeDecimal`]. +pub type NonNegativeDecimalInput = Decimal; + +/// Output type for [`NonNegativeDecimal`]. +pub type NonNegativeDecimalOutput = Decimal; + +/// A non-negative decimal number (`Decimal >= 0`). +/// +/// Negative values are rejected on construction. Zero is allowed. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::primitives::NonNegativeDecimal; +/// use arvo::traits::ValueObject; +/// use rust_decimal_macros::dec; +/// +/// let amount = NonNegativeDecimal::new(dec!(0)).unwrap(); +/// assert_eq!(*amount.value(), dec!(0)); +/// +/// assert!(NonNegativeDecimal::new(dec!(-0.01)).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 NonNegativeDecimal(Decimal); + +impl ValueObject for NonNegativeDecimal { + type Input = NonNegativeDecimalInput; + type Output = NonNegativeDecimalOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + if value < Decimal::ZERO { + return Err(ValidationError::OutOfRange { + type_name: "NonNegativeDecimal", + min: "0".into(), + max: "∞".into(), + actual: 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 NonNegativeDecimal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal::prelude::FromStr; + + #[test] + fn accepts_zero() { + let d = NonNegativeDecimal::new(Decimal::ZERO).unwrap(); + assert_eq!(d.value(), &Decimal::ZERO); + } + + #[test] + fn accepts_positive_value() { + let d = NonNegativeDecimal::new(Decimal::from_str("1.5").unwrap()).unwrap(); + assert_eq!(d.value(), &Decimal::from_str("1.5").unwrap()); + } + + #[test] + fn rejects_negative() { + assert!(NonNegativeDecimal::new(Decimal::from_str("-0.01").unwrap()).is_err()); + } +} diff --git a/src/primitives/nonnegativeint.rs b/src/primitives/nonnegativeint.rs new file mode 100644 index 0000000..926f8b4 --- /dev/null +++ b/src/primitives/nonnegativeint.rs @@ -0,0 +1,82 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Input type for [`NonNegativeInt`]. +pub type NonNegativeIntInput = i64; + +/// Output type for [`NonNegativeInt`]. +pub type NonNegativeIntOutput = i64; + +/// A non-negative integer (`i64 >= 0`). +/// +/// Negative values are rejected on construction. Zero is allowed. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::primitives::NonNegativeInt; +/// use arvo::traits::ValueObject; +/// +/// let n = NonNegativeInt::new(0).unwrap(); +/// assert_eq!(*n.value(), 0); +/// +/// assert!(NonNegativeInt::new(-1).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 NonNegativeInt(i64); + +impl ValueObject for NonNegativeInt { + type Input = NonNegativeIntInput; + type Output = NonNegativeIntOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + if value < 0 { + return Err(ValidationError::OutOfRange { + type_name: "NonNegativeInt", + min: "0".into(), + max: i64::MAX.to_string(), + actual: 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 NonNegativeInt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_zero() { + let n = NonNegativeInt::new(0).unwrap(); + assert_eq!(*n.value(), 0); + } + + #[test] + fn accepts_positive_value() { + let n = NonNegativeInt::new(100).unwrap(); + assert_eq!(*n.value(), 100); + } + + #[test] + fn rejects_negative() { + assert!(NonNegativeInt::new(-1).is_err()); + } +} diff --git a/src/primitives/positivedecimal.rs b/src/primitives/positivedecimal.rs new file mode 100644 index 0000000..63e8c0b --- /dev/null +++ b/src/primitives/positivedecimal.rs @@ -0,0 +1,84 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; +use rust_decimal::Decimal; + +/// Input type for [`PositiveDecimal`]. +pub type PositiveDecimalInput = Decimal; + +/// Output type for [`PositiveDecimal`]. +pub type PositiveDecimalOutput = Decimal; + +/// A strictly positive decimal number (`Decimal > 0`). +/// +/// Zero and negative values are rejected on construction. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::primitives::PositiveDecimal; +/// use arvo::traits::ValueObject; +/// use rust_decimal_macros::dec; +/// +/// let price = PositiveDecimal::new(dec!(9.99)).unwrap(); +/// assert_eq!(*price.value(), dec!(9.99)); +/// +/// assert!(PositiveDecimal::new(dec!(0)).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 PositiveDecimal(Decimal); + +impl ValueObject for PositiveDecimal { + type Input = PositiveDecimalInput; + type Output = PositiveDecimalOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + if value <= Decimal::ZERO { + return Err(ValidationError::OutOfRange { + type_name: "PositiveDecimal", + min: "0 (exclusive)".into(), + max: "∞".into(), + actual: 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 PositiveDecimal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal::prelude::FromStr; + + #[test] + fn accepts_positive_value() { + let d = PositiveDecimal::new(Decimal::from_str("9.99").unwrap()).unwrap(); + assert_eq!(d.value(), &Decimal::from_str("9.99").unwrap()); + } + + #[test] + fn rejects_zero() { + assert!(PositiveDecimal::new(Decimal::ZERO).is_err()); + } + + #[test] + fn rejects_negative() { + assert!(PositiveDecimal::new(Decimal::from_str("-1").unwrap()).is_err()); + } +} diff --git a/src/primitives/positiveint.rs b/src/primitives/positiveint.rs new file mode 100644 index 0000000..b589415 --- /dev/null +++ b/src/primitives/positiveint.rs @@ -0,0 +1,88 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Input type for [`PositiveInt`]. +pub type PositiveIntInput = i64; + +/// Output type for [`PositiveInt`]. +pub type PositiveIntOutput = i64; + +/// A strictly positive integer (`i64 > 0`). +/// +/// Zero and negative values are rejected on construction. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::primitives::PositiveInt; +/// use arvo::traits::ValueObject; +/// +/// let n = PositiveInt::new(42).unwrap(); +/// assert_eq!(*n.value(), 42); +/// +/// assert!(PositiveInt::new(0).is_err()); +/// assert!(PositiveInt::new(-1).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 PositiveInt(i64); + +impl ValueObject for PositiveInt { + type Input = PositiveIntInput; + type Output = PositiveIntOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + if value <= 0 { + return Err(ValidationError::OutOfRange { + type_name: "PositiveInt", + min: "1".into(), + max: i64::MAX.to_string(), + actual: 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 PositiveInt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_positive_value() { + let n = PositiveInt::new(1).unwrap(); + assert_eq!(*n.value(), 1); + } + + #[test] + fn accepts_large_value() { + let n = PositiveInt::new(i64::MAX).unwrap(); + assert_eq!(*n.value(), i64::MAX); + } + + #[test] + fn rejects_zero() { + assert!(PositiveInt::new(0).is_err()); + } + + #[test] + fn rejects_negative() { + assert!(PositiveInt::new(-1).is_err()); + } +} diff --git a/src/primitives/probability.rs b/src/primitives/probability.rs new file mode 100644 index 0000000..f4b5ad0 --- /dev/null +++ b/src/primitives/probability.rs @@ -0,0 +1,104 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Input type for [`Probability`]. +pub type ProbabilityInput = f64; + +/// Output type for [`Probability`]. +pub type ProbabilityOutput = f64; + +/// A probability value in the range `0.0..=1.0`. +/// +/// NaN, infinite values, and values outside `[0.0, 1.0]` are rejected. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::primitives::Probability; +/// use arvo::traits::ValueObject; +/// +/// let p = Probability::new(0.75).unwrap(); +/// assert_eq!(*p.value(), 0.75); +/// +/// assert!(Probability::new(1.5).is_err()); +/// assert!(Probability::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 Probability(f64); + +impl ValueObject for Probability { + type Input = ProbabilityInput; + type Output = ProbabilityOutput; + type Error = ValidationError; + + fn new(value: Self::Input) -> Result { + if !value.is_finite() || !(0.0..=1.0).contains(&value) { + return Err(ValidationError::OutOfRange { + type_name: "Probability", + min: "0.0".into(), + max: "1.0".into(), + actual: 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 Probability { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_zero() { + let p = Probability::new(0.0).unwrap(); + assert_eq!(*p.value(), 0.0); + } + + #[test] + fn accepts_one() { + let p = Probability::new(1.0).unwrap(); + assert_eq!(*p.value(), 1.0); + } + + #[test] + fn accepts_midpoint() { + let p = Probability::new(0.5).unwrap(); + assert_eq!(*p.value(), 0.5); + } + + #[test] + fn rejects_above_one() { + assert!(Probability::new(1.001).is_err()); + } + + #[test] + fn rejects_below_zero() { + assert!(Probability::new(-0.001).is_err()); + } + + #[test] + fn rejects_nan() { + assert!(Probability::new(f64::NAN).is_err()); + } + + #[test] + fn rejects_infinity() { + assert!(Probability::new(f64::INFINITY).is_err()); + } +} From 1d9f94de98d3abcf9a59a6b97bb7b190068e67ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Mon, 20 Apr 2026 20:51:45 +0200 Subject: [PATCH 2/4] Add primitives types to prelude Co-Authored-By: Claude Sonnet 4.6 --- src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 400a19e..9d823c4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,4 +57,10 @@ pub mod prelude { #[cfg(feature = "contact")] pub use crate::contact::{CountryCode, EmailAddress}; + + #[cfg(feature = "primitives")] + pub use crate::primitives::{ + Base64String, BoundedString, HexColor, Locale, NonEmptyString, NonNegativeDecimal, + NonNegativeInt, PositiveDecimal, PositiveInt, Probability, + }; } From 13460a87c2fa2953b59901fa97ba5f02e3ff725a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Mon, 20 Apr 2026 20:54:14 +0200 Subject: [PATCH 3/4] chore: rename primitives files to snake_case Rust convention: words in file names separated by underscores. Co-Authored-By: Claude Sonnet 4.6 --- .../{base64string.rs => base64_string.rs} | 0 .../{boundedstring.rs => bounded_string.rs} | 0 src/primitives/{hexcolor.rs => hex_color.rs} | 0 src/primitives/mod.rs | 32 +++++++++---------- ...{nonemptystring.rs => non_empty_string.rs} | 0 ...tivedecimal.rs => non_negative_decimal.rs} | 0 ...{nonnegativeint.rs => non_negative_int.rs} | 0 ...positivedecimal.rs => positive_decimal.rs} | 0 .../{positiveint.rs => positive_int.rs} | 0 9 files changed, 16 insertions(+), 16 deletions(-) rename src/primitives/{base64string.rs => base64_string.rs} (100%) rename src/primitives/{boundedstring.rs => bounded_string.rs} (100%) rename src/primitives/{hexcolor.rs => hex_color.rs} (100%) rename src/primitives/{nonemptystring.rs => non_empty_string.rs} (100%) rename src/primitives/{nonnegativedecimal.rs => non_negative_decimal.rs} (100%) rename src/primitives/{nonnegativeint.rs => non_negative_int.rs} (100%) rename src/primitives/{positivedecimal.rs => positive_decimal.rs} (100%) rename src/primitives/{positiveint.rs => positive_int.rs} (100%) diff --git a/src/primitives/base64string.rs b/src/primitives/base64_string.rs similarity index 100% rename from src/primitives/base64string.rs rename to src/primitives/base64_string.rs diff --git a/src/primitives/boundedstring.rs b/src/primitives/bounded_string.rs similarity index 100% rename from src/primitives/boundedstring.rs rename to src/primitives/bounded_string.rs diff --git a/src/primitives/hexcolor.rs b/src/primitives/hex_color.rs similarity index 100% rename from src/primitives/hexcolor.rs rename to src/primitives/hex_color.rs diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index 62446ce..feccd3d 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -1,23 +1,23 @@ -mod base64string; -mod boundedstring; -mod hexcolor; +mod base64_string; +mod bounded_string; +mod hex_color; mod locale; -mod nonemptystring; -mod nonnegativedecimal; -mod nonnegativeint; -mod positivedecimal; -mod positiveint; +mod non_negative_decimal; +mod non_negative_int; +mod non_empty_string; +mod positive_decimal; +mod positive_int; mod probability; -pub use base64string::{Base64String, Base64StringInput, Base64StringOutput}; -pub use boundedstring::BoundedString; -pub use hexcolor::{HexColor, HexColorInput, HexColorOutput}; +pub use base64_string::{Base64String, Base64StringInput, Base64StringOutput}; +pub use bounded_string::BoundedString; +pub use hex_color::{HexColor, HexColorInput, HexColorOutput}; pub use locale::{Locale, LocaleInput, LocaleOutput}; -pub use nonemptystring::{NonEmptyString, NonEmptyStringInput, NonEmptyStringOutput}; -pub use nonnegativedecimal::{ +pub use non_negative_decimal::{ NonNegativeDecimal, NonNegativeDecimalInput, NonNegativeDecimalOutput, }; -pub use nonnegativeint::{NonNegativeInt, NonNegativeIntInput, NonNegativeIntOutput}; -pub use positivedecimal::{PositiveDecimal, PositiveDecimalInput, PositiveDecimalOutput}; -pub use positiveint::{PositiveInt, PositiveIntInput, PositiveIntOutput}; +pub use non_negative_int::{NonNegativeInt, NonNegativeIntInput, NonNegativeIntOutput}; +pub use non_empty_string::{NonEmptyString, NonEmptyStringInput, NonEmptyStringOutput}; +pub use positive_decimal::{PositiveDecimal, PositiveDecimalInput, PositiveDecimalOutput}; +pub use positive_int::{PositiveInt, PositiveIntInput, PositiveIntOutput}; pub use probability::{Probability, ProbabilityInput, ProbabilityOutput}; diff --git a/src/primitives/nonemptystring.rs b/src/primitives/non_empty_string.rs similarity index 100% rename from src/primitives/nonemptystring.rs rename to src/primitives/non_empty_string.rs diff --git a/src/primitives/nonnegativedecimal.rs b/src/primitives/non_negative_decimal.rs similarity index 100% rename from src/primitives/nonnegativedecimal.rs rename to src/primitives/non_negative_decimal.rs diff --git a/src/primitives/nonnegativeint.rs b/src/primitives/non_negative_int.rs similarity index 100% rename from src/primitives/nonnegativeint.rs rename to src/primitives/non_negative_int.rs diff --git a/src/primitives/positivedecimal.rs b/src/primitives/positive_decimal.rs similarity index 100% rename from src/primitives/positivedecimal.rs rename to src/primitives/positive_decimal.rs diff --git a/src/primitives/positiveint.rs b/src/primitives/positive_int.rs similarity index 100% rename from src/primitives/positiveint.rs rename to src/primitives/positive_int.rs From 93b37f89cd937212671037b1370e5015b66b2bf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Mon, 20 Apr 2026 21:00:46 +0200 Subject: [PATCH 4/4] fix: pin rust_decimal to 1.26; sort mod.rs alphabetically - rust_decimal = "1" resolved to 1.0.0 under minimal-versions (Decimal::ZERO absent); pinned to 1.26 where all used constants exist - non_empty_string was misplaced in mod.rs causing cargo fmt failure; reordered lexicographically Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 2 +- src/primitives/mod.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b6fc30c..b21a93f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ full = [ thiserror = "2" once_cell = { version = "1", optional = true } regex = { version = "1", optional = true } -rust_decimal = { version = "1", optional = true } +rust_decimal = { version = "1.26", optional = true } chrono = { version = "0.4", optional = true, features = ["serde"] } uuid = { version = "1", optional = true, features = ["v4"] } ulid = { version = "1", optional = true } diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index feccd3d..8d45a4c 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -2,9 +2,9 @@ mod base64_string; mod bounded_string; mod hex_color; mod locale; +mod non_empty_string; mod non_negative_decimal; mod non_negative_int; -mod non_empty_string; mod positive_decimal; mod positive_int; mod probability; @@ -13,11 +13,11 @@ pub use base64_string::{Base64String, Base64StringInput, Base64StringOutput}; pub use bounded_string::BoundedString; pub use hex_color::{HexColor, HexColorInput, HexColorOutput}; pub use locale::{Locale, LocaleInput, LocaleOutput}; +pub use non_empty_string::{NonEmptyString, NonEmptyStringInput, NonEmptyStringOutput}; pub use non_negative_decimal::{ NonNegativeDecimal, NonNegativeDecimalInput, NonNegativeDecimalOutput, }; pub use non_negative_int::{NonNegativeInt, NonNegativeIntInput, NonNegativeIntOutput}; -pub use non_empty_string::{NonEmptyString, NonEmptyStringInput, NonEmptyStringOutput}; pub use positive_decimal::{PositiveDecimal, PositiveDecimalInput, PositiveDecimalOutput}; pub use positive_int::{PositiveInt, PositiveIntInput, PositiveIntOutput}; pub use probability::{Probability, ProbabilityInput, ProbabilityOutput};