lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 4cc5510d1a74a9bd5961ad329857bda9e29118ae
parent 4e426dfbaa0bb44e59cd4b896d1a9662522e19a5
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 14:47:52 +0000

core: tighten numeric invariants and error ownership

- Add Radroots-owned aggregate and scoped numeric error contracts
- Introduce checked construction and arithmetic across core values
- Normalize pricing, discount, rounding, and unit conversion behavior
- Cover boundary, overflow, conversion, and compatibility paths

Diffstat:
Mcrates/core/src/currency.rs | 33++++++++++++++++++++++-----------
Mcrates/core/src/decimal.rs | 151+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/core/src/discount.rs | 105++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Acrates/core/src/error.rs | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/core/src/generated/dto_roots.rs | 9++++-----
Mcrates/core/src/lib.rs | 37++++++++++++++++++++++++-------------
Mcrates/core/src/money.rs | 141++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/core/src/percent.rs | 42++++++++++++++++++++++++++++++++++++------
Mcrates/core/src/pricing.rs | 5++++-
Mcrates/core/src/quantity.rs | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mcrates/core/src/quantity_price.rs | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mcrates/core/src/unit.rs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mcrates/core/tests/currency.rs | 26++++++++++----------------
Mcrates/core/tests/decimal.rs | 75++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/core/tests/discount.rs | 94++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Acrates/core/tests/error.rs | 38++++++++++++++++++++++++++++++++++++++
Mcrates/core/tests/money.rs | 96++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mcrates/core/tests/package_boundary.rs | 19++++++++++++++-----
Mcrates/core/tests/percent.rs | 39+++++++++++++++++++++++++++++++++------
Mcrates/core/tests/quantity.rs | 62+++++++++++++++++++++++++++++++++++++++++++++-----------------
Mcrates/core/tests/quantity_price.rs | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/core/tests/unit.rs | 98++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcrates/event_store/contracts/source_maintenance_v1.manifest.json | 28++++++++++++++--------------
Mcrates/event_store/contracts/source_maintenance_v1.manifest.sha256 | 2+-
Mcrates/event_store/src/generated/source_maintenance_manifest.rs | 6+++---
25 files changed, 1347 insertions(+), 365 deletions(-)

diff --git a/crates/core/src/currency.rs b/crates/core/src/currency.rs @@ -16,15 +16,16 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as DeErr pub struct Currency([u8; 3]); impl Currency { + /// Builds a currency from its canonical three-byte ASCII code. #[inline] - pub const fn from_const(bytes: [u8; 3]) -> Result<Self, RadrootsCoreCurrencyParseError> { + pub const fn from_const(bytes: [u8; 3]) -> Result<Self, ParseError> { if Self::is_ascii_upper(bytes[0]) && Self::is_ascii_upper(bytes[1]) && Self::is_ascii_upper(bytes[2]) { Ok(Self(bytes)) } else { - Err(RadrootsCoreCurrencyParseError::InvalidFormat) + Err(ParseError::InvalidFormat) } } @@ -33,10 +34,10 @@ impl Currency { } #[inline] - pub fn from_str_upper(s: &str) -> Result<Self, RadrootsCoreCurrencyParseError> { + pub fn from_str_upper(s: &str) -> Result<Self, ParseError> { let b = s.as_bytes(); if b.len() != 3 || b.iter().any(|c| !c.is_ascii_uppercase()) { - return Err(RadrootsCoreCurrencyParseError::InvalidFormat); + return Err(ParseError::InvalidFormat); } Ok(Self([b[0], b[1], b[2]])) } @@ -46,6 +47,11 @@ impl Currency { core::str::from_utf8(&self.0).unwrap_or("???") } + #[inline] + pub const fn as_bytes(&self) -> &[u8; 3] { + &self.0 + } + pub const USD: Currency = Currency(*b"USD"); pub const EUR: Currency = Currency(*b"EUR"); pub const GBP: Currency = Currency(*b"GBP"); @@ -82,19 +88,21 @@ impl fmt::Display for Currency { } impl TryFrom<&str> for Currency { - type Error = RadrootsCoreCurrencyParseError; + type Error = ParseError; fn try_from(s: &str) -> Result<Self, Self::Error> { s.parse() } } impl FromStr for Currency { - type Err = RadrootsCoreCurrencyParseError; + type Err = ParseError; fn from_str(s: &str) -> Result<Self, Self::Err> { + // Currency input is canonicalized to uppercase ASCII. Serialization + // and Display always emit that canonical representation. let s = s.trim(); if s.len() != 3 || !s.chars().all(|c| c.is_ascii_alphabetic()) { - return Err(RadrootsCoreCurrencyParseError::InvalidFormat); + return Err(ParseError::InvalidFormat); } let upper = s.to_ascii_uppercase(); Self::from_str_upper(&upper) @@ -103,14 +111,14 @@ impl FromStr for Currency { #[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsCoreCurrencyParseError { +pub enum ParseError { InvalidFormat, } -impl fmt::Display for RadrootsCoreCurrencyParseError { +impl fmt::Display for ParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - RadrootsCoreCurrencyParseError::InvalidFormat => { + ParseError::InvalidFormat => { write!(f, "currency must be a 3-letter code") } } @@ -118,7 +126,7 @@ impl fmt::Display for RadrootsCoreCurrencyParseError { } #[cfg(feature = "std")] -impl std::error::Error for RadrootsCoreCurrencyParseError {} +impl std::error::Error for ParseError {} #[cfg(feature = "serde")] impl Serialize for Currency { @@ -137,3 +145,6 @@ impl<'de> Deserialize<'de> for Currency { #[deprecated(since = "0.1.0", note = "renamed to `Currency`")] pub use self::Currency as RadrootsCoreCurrency; + +#[deprecated(since = "0.1.0", note = "renamed to `currency::ParseError`")] +pub use self::ParseError as RadrootsCoreCurrencyParseError; diff --git a/crates/core/src/decimal.rs b/crates/core/src/decimal.rs @@ -17,9 +17,63 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as DeErr #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] pub struct Decimal(pub RustDecimal); +/// Errors produced while constructing or calculating with a [`Decimal`]. +/// +/// This type intentionally normalizes `rust_decimal` failures so dependency +/// implementation details do not become part of the Radroots public contract. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Error { + InvalidFormat, + OutOfRange, + ScaleOutOfRange, + PrecisionLoss, + ArithmeticOverflow, + DivisionByZero, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFormat => f.write_str("invalid decimal string"), + Self::OutOfRange => f.write_str("decimal is outside the supported range"), + Self::ScaleOutOfRange => f.write_str("decimal scale is outside the supported range"), + Self::PrecisionLoss => f.write_str("decimal operation would lose precision"), + Self::ArithmeticOverflow => f.write_str("decimal arithmetic overflow"), + Self::DivisionByZero => f.write_str("decimal division by zero"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} + +fn normalize_parse_error(error: rust_decimal::Error) -> Error { + match error { + rust_decimal::Error::ExceedsMaximumPossibleValue + | rust_decimal::Error::LessThanMinimumPossibleValue + | rust_decimal::Error::Underflow + | rust_decimal::Error::ScaleExceedsMaximumPrecision(_) + | rust_decimal::Error::ConversionTo(_) => Error::OutOfRange, + rust_decimal::Error::ErrorString(_) => Error::InvalidFormat, + } +} + +#[inline] +fn canonicalize_zero(value: RustDecimal) -> RustDecimal { + if value.is_zero() { + RustDecimal::ZERO + } else { + value + } +} + impl Decimal { pub const ZERO: Self = Self(RustDecimal::ZERO); pub const ONE: Self = Self(RustDecimal::ONE); + pub const MAX: Self = Self(RustDecimal::MAX); + pub const MIN: Self = Self(RustDecimal::MIN); + pub const MAX_SCALE: u32 = RustDecimal::MAX_SCALE; #[inline] pub fn is_zero(&self) -> bool { @@ -29,10 +83,38 @@ impl Decimal { pub fn is_sign_negative(&self) -> bool { self.0.is_sign_negative() } + /// Rescales with deterministic midpoint-away-from-zero rounding when + /// reducing precision. When increasing precision, the closest + /// representable scale is used. Use [`Self::try_rescale_exact`] when + /// either behavior would be ambiguous at a boundary. #[inline] pub fn rescale(&mut self, scale: u32) { self.0.rescale(scale); } + + /// Changes the scale only when the requested representation is exact. + /// + /// Unlike [`Self::rescale`], this method never rounds and never silently + /// substitutes a smaller scale. The value is left unchanged on error. + #[inline] + pub fn try_rescale_exact(&mut self, scale: u32) -> Result<(), Error> { + if scale > Self::MAX_SCALE { + return Err(Error::ScaleOutOfRange); + } + + let original = self.0; + let mut candidate = original; + candidate.rescale(scale); + if candidate.scale() != scale { + return Err(Error::ScaleOutOfRange); + } + if candidate != original { + return Err(Error::PrecisionLoss); + } + + self.0 = candidate; + Ok(()) + } #[inline] pub fn normalize(&self) -> RustDecimal { self.0.normalize() @@ -44,14 +126,23 @@ impl Decimal { } #[inline] - pub fn from_str_exact(s: &str) -> Result<Self, rust_decimal::Error> { - RustDecimal::from_str_exact(s).map(Self) + pub fn from_str_exact(s: &str) -> Result<Self, Error> { + RustDecimal::from_str_exact(s) + .map(canonicalize_zero) + .map(Self) + .map_err(normalize_parse_error) } + /// Converts the shortest deterministic 17-digit display of a finite + /// `f64`. This is a decimal representation of the displayed float, not a + /// promise to preserve the float's binary representation exactly. #[inline] - pub fn from_f64_display(n: f64) -> Result<Self, rust_decimal::Error> { + pub fn from_f64_display(n: f64) -> Result<Self, Error> { + if !n.is_finite() { + return Err(Error::InvalidFormat); + } let s = format!("{:.17}", n); - RustDecimal::from_str(&s).map(Self) + s.parse() } #[inline] pub fn to_f64_lossy(&self) -> Option<f64> { @@ -66,6 +157,45 @@ impl Decimal { None } } + + #[inline] + pub fn checked_add(self, rhs: Self) -> Result<Self, Error> { + self.0 + .checked_add(rhs.0) + .map(canonicalize_zero) + .map(Self) + .ok_or(Error::ArithmeticOverflow) + } + + #[inline] + pub fn checked_sub(self, rhs: Self) -> Result<Self, Error> { + self.0 + .checked_sub(rhs.0) + .map(canonicalize_zero) + .map(Self) + .ok_or(Error::ArithmeticOverflow) + } + + #[inline] + pub fn checked_mul(self, rhs: Self) -> Result<Self, Error> { + self.0 + .checked_mul(rhs.0) + .map(canonicalize_zero) + .map(Self) + .ok_or(Error::ArithmeticOverflow) + } + + #[inline] + pub fn checked_div(self, rhs: Self) -> Result<Self, Error> { + if rhs.is_zero() { + return Err(Error::DivisionByZero); + } + self.0 + .checked_div(rhs.0) + .map(canonicalize_zero) + .map(Self) + .ok_or(Error::ArithmeticOverflow) + } } #[cfg(feature = "serde")] @@ -79,9 +209,7 @@ impl Serialize for Decimal { impl<'de> Deserialize<'de> for Decimal { fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { let s = String::deserialize(deserializer)?; - RustDecimal::from_str(&s) - .map(Decimal) - .map_err(D::Error::custom) + s.parse::<Decimal>().map_err(D::Error::custom) } } @@ -93,7 +221,7 @@ impl fmt::Display for Decimal { impl From<RustDecimal> for Decimal { fn from(d: RustDecimal) -> Self { - Self(d) + Self(canonicalize_zero(d)) } } impl From<Decimal> for RustDecimal { @@ -148,9 +276,12 @@ impl Div for Decimal { } impl FromStr for Decimal { - type Err = rust_decimal::Error; + type Err = Error; fn from_str(s: &str) -> Result<Self, Self::Err> { - RustDecimal::from_str(s).map(Decimal) + RustDecimal::from_str(s) + .map(canonicalize_zero) + .map(Decimal) + .map_err(normalize_parse_error) } } diff --git a/crates/core/src/discount.rs b/crates/core/src/discount.rs @@ -8,7 +8,7 @@ use std::string::String; #[cfg_attr(feature = "dto-bindgen", dto(export))] #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] -pub enum RadrootsCoreDiscountScope { +pub enum DiscountScope { Bin, OrderTotal, } @@ -21,7 +21,7 @@ pub enum RadrootsCoreDiscountScope { feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount") )] -pub enum RadrootsCoreDiscountThreshold { +pub enum DiscountThreshold { BinCount { bin_id: String, min: u32 }, OrderQuantity { min: crate::Quantity }, } @@ -34,7 +34,7 @@ pub enum RadrootsCoreDiscountThreshold { feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount") )] -pub enum RadrootsCoreDiscountValue { +pub enum DiscountValue { MoneyPerBin(crate::Money), Percent(crate::Percent), } @@ -44,22 +44,93 @@ pub enum RadrootsCoreDiscountValue { #[cfg_attr(feature = "dto-bindgen", dto(export))] #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] -pub struct RadrootsCoreDiscount { - pub scope: RadrootsCoreDiscountScope, - pub threshold: RadrootsCoreDiscountThreshold, - pub value: RadrootsCoreDiscountValue, +pub struct Discount { + pub scope: DiscountScope, + pub threshold: DiscountThreshold, + pub value: DiscountValue, } -impl RadrootsCoreDiscount { - pub fn is_non_negative(&self) -> bool { - let threshold_ok = match &self.threshold { - RadrootsCoreDiscountThreshold::BinCount { .. } => true, - RadrootsCoreDiscountThreshold::OrderQuantity { min } => !min.amount.is_sign_negative(), - }; - let value_ok = match &self.value { - RadrootsCoreDiscountValue::MoneyPerBin(m) => !m.amount.is_sign_negative(), - RadrootsCoreDiscountValue::Percent(p) => !p.value.is_sign_negative(), +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Error { + NegativeThreshold, + NegativeValue, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NegativeThreshold => f.write_str("discount threshold must be non-negative"), + Self::NegativeValue => f.write_str("discount value must be non-negative"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} + +impl Discount { + #[inline] + pub fn try_new( + scope: DiscountScope, + threshold: DiscountThreshold, + value: DiscountValue, + ) -> Result<Self, Error> { + let discount = Self { + scope, + threshold, + value, }; - threshold_ok && value_ok + discount.validate()?; + Ok(discount) + } + + #[inline] + pub fn scope(&self) -> &DiscountScope { + &self.scope + } + + #[inline] + pub fn threshold(&self) -> &DiscountThreshold { + &self.threshold + } + + #[inline] + pub fn value(&self) -> &DiscountValue { + &self.value + } + + pub fn validate(&self) -> Result<(), Error> { + if let DiscountThreshold::OrderQuantity { min } = &self.threshold { + min.ensure_non_negative() + .map_err(|_| Error::NegativeThreshold)?; + } + match &self.value { + DiscountValue::MoneyPerBin(money) => money + .ensure_non_negative() + .map_err(|_| Error::NegativeValue)?, + DiscountValue::Percent(percent) if percent.value.is_sign_negative() => { + return Err(Error::NegativeValue); + } + DiscountValue::Percent(_) => {} + } + Ok(()) + } + + pub fn is_non_negative(&self) -> bool { + self.validate().is_ok() } } + +#[deprecated(since = "0.1.0", note = "renamed to `pricing::Discount`")] +pub use self::Discount as RadrootsCoreDiscount; + +#[deprecated(since = "0.1.0", note = "renamed to `pricing::DiscountScope`")] +pub use self::DiscountScope as RadrootsCoreDiscountScope; + +#[deprecated(since = "0.1.0", note = "renamed to `pricing::DiscountThreshold`")] +pub use self::DiscountThreshold as RadrootsCoreDiscountThreshold; + +#[deprecated(since = "0.1.0", note = "renamed to `pricing::DiscountValue`")] +pub use self::DiscountValue as RadrootsCoreDiscountValue; +use core::fmt; diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs @@ -0,0 +1,106 @@ +use core::fmt; + +/// Crate-wide error for callers that prefer one Radroots-owned error type. +/// +/// Focused module APIs retain their narrower errors; each converts into this +/// aggregate without exposing errors owned by implementation dependencies. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Error { + Currency(crate::currency::ParseError), + Decimal(crate::decimal::Error), + Discount(crate::pricing::DiscountError), + Money(crate::money::Error), + Percent(crate::percent::ParseError), + Pricing(crate::pricing::Error), + Quantity(crate::quantity::Error), + UnitParse(crate::unit::ParseError), + UnitConversion(crate::unit::ConvertError), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Currency(error) => write!(f, "currency: {error}"), + Self::Decimal(error) => write!(f, "decimal: {error}"), + Self::Discount(error) => write!(f, "discount: {error}"), + Self::Money(error) => write!(f, "money: {error}"), + Self::Percent(error) => write!(f, "percent: {error}"), + Self::Pricing(error) => write!(f, "pricing: {error}"), + Self::Quantity(error) => write!(f, "quantity: {error}"), + Self::UnitParse(error) => write!(f, "unit parse: {error}"), + Self::UnitConversion(error) => write!(f, "unit conversion: {error}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Currency(error) => Some(error), + Self::Decimal(error) => Some(error), + Self::Discount(error) => Some(error), + Self::Money(error) => Some(error), + Self::Percent(error) => Some(error), + Self::Pricing(error) => Some(error), + Self::Quantity(error) => Some(error), + Self::UnitParse(error) => Some(error), + Self::UnitConversion(error) => Some(error), + } + } +} + +impl From<crate::currency::ParseError> for Error { + fn from(error: crate::currency::ParseError) -> Self { + Self::Currency(error) + } +} + +impl From<crate::decimal::Error> for Error { + fn from(error: crate::decimal::Error) -> Self { + Self::Decimal(error) + } +} + +impl From<crate::discount::Error> for Error { + fn from(error: crate::discount::Error) -> Self { + Self::Discount(error) + } +} + +impl From<crate::money::Error> for Error { + fn from(error: crate::money::Error) -> Self { + Self::Money(error) + } +} + +impl From<crate::percent::ParseError> for Error { + fn from(error: crate::percent::ParseError) -> Self { + Self::Percent(error) + } +} + +impl From<crate::quantity_price::Error> for Error { + fn from(error: crate::quantity_price::Error) -> Self { + Self::Pricing(error) + } +} + +impl From<crate::quantity::Error> for Error { + fn from(error: crate::quantity::Error) -> Self { + Self::Quantity(error) + } +} + +impl From<crate::unit::ParseError> for Error { + fn from(error: crate::unit::ParseError) -> Self { + Self::UnitParse(error) + } +} + +impl From<crate::unit::ConvertError> for Error { + fn from(error: crate::unit::ConvertError) -> Self { + Self::UnitConversion(error) + } +} diff --git a/crates/core/src/generated/dto_roots.rs b/crates/core/src/generated/dto_roots.rs @@ -3,11 +3,10 @@ pub fn dto_bindgen_roots() -> ::std::vec::Vec<::dto_bindgen::export::RootDescriptor> { ::std::vec![ - ::dto_bindgen::export::RootDescriptor::new::<crate::discount::RadrootsCoreDiscount>(), - ::dto_bindgen::export::RootDescriptor::new::<crate::discount::RadrootsCoreDiscountScope>(), - ::dto_bindgen::export::RootDescriptor::new::<crate::discount::RadrootsCoreDiscountThreshold>( - ), - ::dto_bindgen::export::RootDescriptor::new::<crate::discount::RadrootsCoreDiscountValue>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::discount::Discount>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::discount::DiscountScope>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::discount::DiscountThreshold>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::discount::DiscountValue>(), ::dto_bindgen::export::RootDescriptor::new::<crate::money::Money>(), ::dto_bindgen::export::RootDescriptor::new::<crate::percent::Percent>(), ::dto_bindgen::export::RootDescriptor::new::<crate::quantity::Quantity>(), diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs @@ -5,34 +5,45 @@ extern crate alloc; pub mod currency; pub mod decimal; -pub mod discount; +mod discount; #[cfg(feature = "dto-bindgen")] pub mod dto; +mod error; pub mod money; pub mod percent; pub mod pricing; pub mod quantity; -pub mod quantity_price; +mod quantity_price; #[cfg(feature = "serde")] pub mod serde_ext; pub mod unit; -pub use currency::{Currency, RadrootsCoreCurrencyParseError}; +pub use currency::Currency; pub use decimal::Decimal; +pub use error::Error; +pub use money::Money; +pub use percent::Percent; +pub use pricing::QuantityPrice; +pub use quantity::Quantity; +pub use unit::Unit; + +#[allow(deprecated)] +pub use currency::RadrootsCoreCurrencyParseError; +#[allow(deprecated)] pub use discount::{ RadrootsCoreDiscount, RadrootsCoreDiscountScope, RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, }; -pub use money::{Money, RadrootsCoreMoneyInvariantError}; -pub use percent::{Percent, RadrootsCorePercentParseError}; -pub use quantity::{Quantity, RadrootsCoreQuantityInvariantError}; -pub use quantity_price::{ - QuantityPrice, RadrootsCoreQuantityPriceError, RadrootsCoreQuantityPriceOps, -}; -pub use unit::{ - RadrootsCoreUnitConvertError, RadrootsCoreUnitParseError, Unit, convert_mass_decimal, - convert_unit_decimal, convert_volume_decimal, parse_mass_unit, parse_volume_unit, -}; +#[allow(deprecated)] +pub use money::RadrootsCoreMoneyInvariantError; +#[allow(deprecated)] +pub use percent::RadrootsCorePercentParseError; +#[allow(deprecated)] +pub use quantity::RadrootsCoreQuantityInvariantError; +#[allow(deprecated)] +pub use quantity_price::{RadrootsCoreQuantityPriceError, RadrootsCoreQuantityPriceOps}; +#[allow(deprecated)] +pub use unit::{RadrootsCoreUnitConvertError, RadrootsCoreUnitParseError}; #[allow(deprecated)] pub use currency::RadrootsCoreCurrency; diff --git a/crates/core/src/money.rs b/crates/core/src/money.rs @@ -14,34 +14,82 @@ pub struct Money { #[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsCoreMoneyInvariantError { +pub enum Error { NegativeAmount, NotWholeMinorUnits, AmountOverflow, CurrencyMismatch, + ArithmeticOverflow, + DivisionByZero, + ScaleOutOfRange, + PrecisionLoss, } -impl fmt::Display for RadrootsCoreMoneyInvariantError { +impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::NegativeAmount => write!(f, "money amount must be ≥ 0"), Self::NotWholeMinorUnits => write!(f, "money not a whole number of minor units"), Self::AmountOverflow => write!(f, "money minor-unit conversion overflow"), Self::CurrencyMismatch => write!(f, "money currency mismatch"), + Self::ArithmeticOverflow => write!(f, "money arithmetic overflow"), + Self::DivisionByZero => write!(f, "money division by zero"), + Self::ScaleOutOfRange => write!(f, "money scale is outside the supported range"), + Self::PrecisionLoss => write!(f, "money operation would lose precision"), } } } #[cfg(feature = "std")] -impl std::error::Error for RadrootsCoreMoneyInvariantError {} +impl std::error::Error for Error {} + +impl From<crate::decimal::Error> for Error { + fn from(error: crate::decimal::Error) -> Self { + match error { + crate::decimal::Error::DivisionByZero => Self::DivisionByZero, + crate::decimal::Error::ScaleOutOfRange => Self::ScaleOutOfRange, + crate::decimal::Error::PrecisionLoss => Self::PrecisionLoss, + _ => Self::ArithmeticOverflow, + } + } +} impl Money { + /// Compatibility constructor for already-validated internal values. + /// + /// New boundary code should use [`Self::try_new`]. The unchecked shape is + /// retained only until the scheduled first-party consumer migration. #[inline] pub fn new(amount: crate::Decimal, currency: crate::Currency) -> Self { Self { amount, currency } } #[inline] + pub fn try_new(amount: crate::Decimal, currency: crate::Currency) -> Result<Self, Error> { + if amount.is_sign_negative() && !amount.is_zero() { + return Err(Error::NegativeAmount); + } + Ok(Self { + amount: if amount.is_zero() { + crate::Decimal::ZERO + } else { + amount + }, + currency, + }) + } + + #[inline] + pub const fn amount(&self) -> crate::Decimal { + self.amount + } + + #[inline] + pub const fn currency(&self) -> crate::Currency { + self.currency + } + + #[inline] pub fn zero(currency: crate::Currency) -> Self { Self { amount: crate::Decimal::ZERO, @@ -55,9 +103,9 @@ impl Money { } #[inline] - pub fn ensure_non_negative(&self) -> Result<(), RadrootsCoreMoneyInvariantError> { - if self.amount.is_sign_negative() { - return Err(RadrootsCoreMoneyInvariantError::NegativeAmount); + pub fn ensure_non_negative(&self) -> Result<(), Error> { + if self.amount.is_sign_negative() && !self.amount.is_zero() { + return Err(Error::NegativeAmount); } Ok(()) } @@ -67,6 +115,9 @@ impl Money { self.quantize_to_currency_with_strategy(RoundingStrategy::MidpointAwayFromZero) } + /// Rounds to the currency's minor-unit exponent with the supplied + /// strategy. [`Self::quantize_to_currency`] uses + /// [`RoundingStrategy::MidpointAwayFromZero`]. #[inline] pub fn quantize_to_currency_with_strategy(mut self, strategy: RoundingStrategy) -> Self { let e = self.currency.minor_unit_exponent(); @@ -80,20 +131,43 @@ impl Money { self } + /// Changes the amount scale without rounding or changing its value. + #[inline] + pub fn try_with_scale_exact(mut self, scale: u32) -> Result<Self, Error> { + self.amount.try_rescale_exact(scale)?; + Ok(self) + } + #[inline] - pub fn checked_add(&self, rhs: &Self) -> Result<Self, RadrootsCoreMoneyInvariantError> { + pub fn checked_add(&self, rhs: &Self) -> Result<Self, Error> { + self.ensure_non_negative()?; + rhs.ensure_non_negative()?; if self.currency != rhs.currency { - return Err(RadrootsCoreMoneyInvariantError::CurrencyMismatch); + return Err(Error::CurrencyMismatch); } - Ok(Self::new(self.amount + rhs.amount, self.currency)) + Self::try_new(self.amount.checked_add(rhs.amount)?, self.currency) } #[inline] - pub fn checked_sub(&self, rhs: &Self) -> Result<Self, RadrootsCoreMoneyInvariantError> { + pub fn checked_sub(&self, rhs: &Self) -> Result<Self, Error> { + self.ensure_non_negative()?; + rhs.ensure_non_negative()?; if self.currency != rhs.currency { - return Err(RadrootsCoreMoneyInvariantError::CurrencyMismatch); + return Err(Error::CurrencyMismatch); } - Ok(Self::new(self.amount - rhs.amount, self.currency)) + Self::try_new(self.amount.checked_sub(rhs.amount)?, self.currency) + } + + #[inline] + pub fn checked_mul_decimal(&self, factor: crate::Decimal) -> Result<Self, Error> { + self.ensure_non_negative()?; + Self::try_new(self.amount.checked_mul(factor)?, self.currency) + } + + #[inline] + pub fn checked_div_decimal(&self, divisor: crate::Decimal) -> Result<Self, Error> { + self.ensure_non_negative()?; + Self::try_new(self.amount.checked_div(divisor)?, self.currency) } #[inline] @@ -133,45 +207,43 @@ impl Money { } #[inline] - pub fn to_minor_units_u64_exact(&self) -> Result<u64, RadrootsCoreMoneyInvariantError> { + pub fn to_minor_units_u64_exact(&self) -> Result<u64, Error> { + self.ensure_non_negative()?; let e = self.currency.minor_unit_exponent(); - let as_minor = self.amount.0 * Self::pow10(e); + let as_minor = self + .amount + .checked_mul(crate::Decimal(Self::pow10(e))) + .map_err(|_| Error::AmountOverflow)? + .0; if !as_minor.fract().is_zero() { - return Err(RadrootsCoreMoneyInvariantError::NotWholeMinorUnits); + return Err(Error::NotWholeMinorUnits); } - as_minor - .to_u64() - .ok_or(RadrootsCoreMoneyInvariantError::AmountOverflow) + as_minor.to_u64().ok_or(Error::AmountOverflow) } #[inline] - pub fn to_minor_units_u64_rounded( - &self, - strategy: RoundingStrategy, - ) -> Result<u64, RadrootsCoreMoneyInvariantError> { + pub fn to_minor_units_u64_rounded(&self, strategy: RoundingStrategy) -> Result<u64, Error> { + self.ensure_non_negative()?; let e = self.currency.minor_unit_exponent(); let scaled = self.amount.0.round_dp_with_strategy(e, strategy); - let as_minor = scaled * Self::pow10(e); + let as_minor = scaled + .checked_mul(Self::pow10(e)) + .ok_or(Error::AmountOverflow)?; debug_assert!(as_minor.fract().is_zero()); - as_minor - .to_u64() - .ok_or(RadrootsCoreMoneyInvariantError::AmountOverflow) + as_minor.to_u64().ok_or(Error::AmountOverflow) } #[inline] - pub fn to_minor_units_u32_exact(&self) -> Result<u32, RadrootsCoreMoneyInvariantError> { + pub fn to_minor_units_u32_exact(&self) -> Result<u32, Error> { let v = self.to_minor_units_u64_exact()?; - u32::try_from(v).map_err(|_| RadrootsCoreMoneyInvariantError::AmountOverflow) + u32::try_from(v).map_err(|_| Error::AmountOverflow) } #[inline] - pub fn to_minor_units_u32_rounded( - &self, - strategy: RoundingStrategy, - ) -> Result<u32, RadrootsCoreMoneyInvariantError> { + pub fn to_minor_units_u32_rounded(&self, strategy: RoundingStrategy) -> Result<u32, Error> { let v = self.to_minor_units_u64_rounded(strategy)?; - u32::try_from(v).map_err(|_| RadrootsCoreMoneyInvariantError::AmountOverflow) + u32::try_from(v).map_err(|_| Error::AmountOverflow) } } @@ -200,6 +272,9 @@ impl Div<crate::Decimal> for Money { #[deprecated(since = "0.1.0", note = "renamed to `Money`")] pub use self::Money as RadrootsCoreMoney; +#[deprecated(since = "0.1.0", note = "renamed to `money::Error`")] +pub use self::Error as RadrootsCoreMoneyInvariantError; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/core/src/percent.rs b/crates/core/src/percent.rs @@ -15,6 +15,11 @@ pub struct Percent { } impl Percent { + /// Creates a percentage value. + /// + /// `Percent` is intentionally signed and unbounded because it is also + /// used for deltas. Domain-specific non-negative rules belong to values + /// such as [`crate::pricing::Discount`]. #[inline] pub fn new(value: Decimal) -> Self { Self { value } @@ -28,11 +33,23 @@ impl Percent { } #[inline] + pub fn try_from_ratio(ratio_0_to_1: Decimal) -> Result<Self, crate::decimal::Error> { + Ok(Self { + value: ratio_0_to_1.checked_mul(Decimal::from(100u32))?, + }) + } + + #[inline] pub fn to_ratio(&self) -> Decimal { self.value / Decimal::from(100u32) } #[inline] + pub fn try_to_ratio(&self) -> Result<Decimal, crate::decimal::Error> { + self.value.checked_div(Decimal::from(100u32)) + } + + #[inline] pub fn of_money(&self, base: &Money) -> Money { base.mul_decimal(self.to_ratio()) } @@ -41,6 +58,16 @@ impl Percent { pub fn of_money_quantized(&self, base: &Money) -> Money { base.mul_decimal(self.to_ratio()).quantize_to_currency() } + + #[inline] + pub fn try_of_money(&self, base: &Money) -> Result<Money, crate::money::Error> { + base.checked_mul_decimal(self.try_to_ratio().map_err(crate::money::Error::from)?) + } + + #[inline] + pub fn try_of_money_quantized(&self, base: &Money) -> Result<Money, crate::money::Error> { + Ok(self.try_of_money(base)?.quantize_to_currency()) + } } impl fmt::Display for Percent { @@ -50,34 +77,37 @@ impl fmt::Display for Percent { } impl FromStr for Percent { - type Err = RadrootsCorePercentParseError; + type Err = ParseError; fn from_str(s: &str) -> Result<Self, Self::Err> { let trimmed = s.trim_end(); let no_pct = trimmed.strip_suffix('%').unwrap_or(trimmed).trim(); let dec = no_pct .parse::<Decimal>() - .map_err(|_| RadrootsCorePercentParseError::InvalidNumber)?; + .map_err(|_| ParseError::InvalidNumber)?; Ok(Percent::new(dec)) } } #[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsCorePercentParseError { +pub enum ParseError { InvalidNumber, } -impl fmt::Display for RadrootsCorePercentParseError { +impl fmt::Display for ParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - RadrootsCorePercentParseError::InvalidNumber => write!(f, "invalid percent string"), + ParseError::InvalidNumber => write!(f, "invalid percent string"), } } } #[cfg(feature = "std")] -impl std::error::Error for RadrootsCorePercentParseError {} +impl std::error::Error for ParseError {} #[deprecated(since = "0.1.0", note = "renamed to `Percent`")] pub use self::Percent as RadrootsCorePercent; + +#[deprecated(since = "0.1.0", note = "renamed to `percent::ParseError`")] +pub use self::ParseError as RadrootsCorePercentParseError; diff --git a/crates/core/src/pricing.rs b/crates/core/src/pricing.rs @@ -1,3 +1,6 @@ //! Deterministic pricing value objects and operations. -const _: () = (); +pub use crate::discount::{ + Discount, DiscountScope, DiscountThreshold, DiscountValue, Error as DiscountError, +}; +pub use crate::quantity_price::{Error, QuantityPrice, QuantityPriceOps}; diff --git a/crates/core/src/quantity.rs b/crates/core/src/quantity.rs @@ -1,7 +1,7 @@ use core::fmt; use crate::Decimal; -use crate::unit::{RadrootsCoreUnitConvertError, Unit, convert_unit_decimal}; +use crate::unit::{ConvertError, Unit, convert_unit_decimal}; #[cfg(not(feature = "std"))] use alloc::string::String; @@ -22,6 +22,10 @@ pub struct Quantity { } impl Quantity { + /// Compatibility constructor for already-validated internal values. + /// + /// New boundary code should use [`Self::try_new`]. The unchecked shape is + /// retained only until the scheduled first-party consumer migration. #[inline] pub fn new(amount: Decimal, unit: Unit) -> Self { Self { @@ -32,6 +36,37 @@ impl Quantity { } #[inline] + pub fn try_new(amount: Decimal, unit: Unit) -> Result<Self, Error> { + if amount.is_sign_negative() && !amount.is_zero() { + return Err(Error::NegativeAmount); + } + Ok(Self { + amount: if amount.is_zero() { + Decimal::ZERO + } else { + amount + }, + unit, + label: None, + }) + } + + #[inline] + pub const fn amount(&self) -> Decimal { + self.amount + } + + #[inline] + pub const fn unit(&self) -> Unit { + self.unit + } + + #[inline] + pub fn label(&self) -> Option<&str> { + self.label.as_deref() + } + + #[inline] pub fn with_label<S: Into<String>>(mut self, label: S) -> Self { self.label = Some(label.into()); self @@ -74,7 +109,7 @@ impl Quantity { } #[inline] - pub fn try_convert_to(&self, unit: Unit) -> Result<Quantity, RadrootsCoreUnitConvertError> { + pub fn try_convert_to(&self, unit: Unit) -> Result<Quantity, ConvertError> { if self.unit == unit { return Ok(self.clone()); } @@ -87,14 +122,14 @@ impl Quantity { } #[inline] - pub fn to_canonical(&self) -> Result<Quantity, RadrootsCoreUnitConvertError> { + pub fn to_canonical(&self) -> Result<Quantity, ConvertError> { self.try_convert_to(self.unit.canonical_unit()) } #[inline] - pub fn ensure_non_negative(&self) -> Result<(), RadrootsCoreQuantityInvariantError> { - if self.amount.is_sign_negative() { - return Err(RadrootsCoreQuantityInvariantError::NegativeAmount); + pub fn ensure_non_negative(&self) -> Result<(), Error> { + if self.amount.is_sign_negative() && !self.amount.is_zero() { + return Err(Error::NegativeAmount); } Ok(()) } @@ -106,51 +141,71 @@ impl Quantity { } #[inline] - pub fn try_add(&self, rhs: &Quantity) -> Result<Quantity, RadrootsCoreQuantityInvariantError> { + pub fn try_add(&self, rhs: &Quantity) -> Result<Quantity, Error> { + self.ensure_non_negative()?; + rhs.ensure_non_negative()?; if self.unit != rhs.unit { - return Err(RadrootsCoreQuantityInvariantError::UnitMismatch); + return Err(Error::UnitMismatch); } Ok(Quantity { - amount: self.amount + rhs.amount, + amount: self.amount.checked_add(rhs.amount)?, unit: self.unit, label: self.label.clone(), }) } #[inline] - pub fn try_sub(&self, rhs: &Quantity) -> Result<Quantity, RadrootsCoreQuantityInvariantError> { + pub fn try_sub(&self, rhs: &Quantity) -> Result<Quantity, Error> { + self.ensure_non_negative()?; + rhs.ensure_non_negative()?; if self.unit != rhs.unit { - return Err(RadrootsCoreQuantityInvariantError::UnitMismatch); + return Err(Error::UnitMismatch); + } + let amount = self.amount.checked_sub(rhs.amount)?; + if amount.is_sign_negative() { + return Err(Error::NegativeAmount); } Ok(Quantity { - amount: self.amount - rhs.amount, + amount, unit: self.unit, label: self.label.clone(), }) } pub fn checked_add(&self, rhs: &Quantity) -> Option<Quantity> { - if self.unit == rhs.unit { - Some(Quantity { - amount: self.amount + rhs.amount, - unit: self.unit, - label: self.label.clone(), - }) - } else { - None - } + self.try_add(rhs).ok() } pub fn checked_sub(&self, rhs: &Quantity) -> Option<Quantity> { - if self.unit == rhs.unit { - Some(Quantity { - amount: self.amount - rhs.amount, - unit: self.unit, - label: self.label.clone(), - }) - } else { - None + self.try_sub(rhs).ok() + } + + #[inline] + pub fn checked_mul_decimal(&self, factor: Decimal) -> Result<Quantity, Error> { + self.ensure_non_negative()?; + let amount = self.amount.checked_mul(factor)?; + if amount.is_sign_negative() { + return Err(Error::NegativeAmount); + } + Ok(Quantity { + amount, + unit: self.unit, + label: self.label.clone(), + }) + } + + #[inline] + pub fn checked_div_decimal(&self, divisor: Decimal) -> Result<Quantity, Error> { + self.ensure_non_negative()?; + let amount = self.amount.checked_div(divisor)?; + if amount.is_sign_negative() { + return Err(Error::NegativeAmount); } + Ok(Quantity { + amount, + unit: self.unit, + label: self.label.clone(), + }) } #[inline] @@ -184,26 +239,39 @@ impl fmt::Display for Quantity { #[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsCoreQuantityInvariantError { +pub enum Error { NegativeAmount, UnitMismatch, + ArithmeticOverflow, + DivisionByZero, } -impl fmt::Display for RadrootsCoreQuantityInvariantError { +impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - RadrootsCoreQuantityInvariantError::NegativeAmount => { + Error::NegativeAmount => { write!(f, "quantity amount must be ≥ 0") } - RadrootsCoreQuantityInvariantError::UnitMismatch => { + Error::UnitMismatch => { write!(f, "quantity unit mismatch") } + Error::ArithmeticOverflow => write!(f, "quantity arithmetic overflow"), + Error::DivisionByZero => write!(f, "quantity division by zero"), } } } #[cfg(feature = "std")] -impl std::error::Error for RadrootsCoreQuantityInvariantError {} +impl std::error::Error for Error {} + +impl From<crate::decimal::Error> for Error { + fn from(error: crate::decimal::Error) -> Self { + match error { + crate::decimal::Error::DivisionByZero => Self::DivisionByZero, + _ => Self::ArithmeticOverflow, + } + } +} use core::ops::{Div, Mul}; @@ -231,3 +299,6 @@ impl Div<Decimal> for Quantity { #[deprecated(since = "0.1.0", note = "renamed to `Quantity`")] pub use self::Quantity as RadrootsCoreQuantity; + +#[deprecated(since = "0.1.0", note = "renamed to `quantity::Error`")] +pub use self::Error as RadrootsCoreQuantityInvariantError; diff --git a/crates/core/src/quantity_price.rs b/crates/core/src/quantity_price.rs @@ -1,3 +1,5 @@ +use core::fmt; + use crate::{Decimal, Money, Quantity, Unit}; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -10,40 +12,117 @@ pub struct QuantityPrice { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsCoreQuantityPriceError { +pub enum Error { PerQuantityZero, + PerQuantityNegative, + NegativePrice, + NegativeRequestedQuantity, UnitMismatch { have: Unit, want: Unit }, NonConvertibleUnits { from: Unit, to: Unit }, + ArithmeticOverflow, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PerQuantityZero => f.write_str("price quantity must be greater than zero"), + Self::PerQuantityNegative => f.write_str("price quantity must not be negative"), + Self::NegativePrice => f.write_str("price amount must not be negative"), + Self::NegativeRequestedQuantity => { + f.write_str("requested quantity must not be negative") + } + Self::UnitMismatch { have, want } => { + write!(f, "price quantity unit mismatch: have {have}, want {want}") + } + Self::NonConvertibleUnits { from, to } => { + write!(f, "price units are not convertible: {from} -> {to}") + } + Self::ArithmeticOverflow => f.write_str("price arithmetic overflow"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} + +fn map_conversion_error(error: crate::unit::ConvertError, from: Unit, to: Unit) -> Error { + match error { + crate::unit::ConvertError::ArithmeticOverflow { .. } => Error::ArithmeticOverflow, + _ => Error::NonConvertibleUnits { from, to }, + } } -pub trait RadrootsCoreQuantityPriceOps { +mod sealed { + pub trait Sealed {} + + impl Sealed for super::QuantityPrice {} +} + +/// Sealed pricing operations implemented by [`QuantityPrice`]. +/// +/// The `try_*` methods are the canonical checked surface. The infallible +/// methods remain temporarily for first-party source compatibility and return +/// zero when the corresponding checked operation fails. +pub trait QuantityPriceOps: sealed::Sealed { + /// Compatibility operation that returns zero on invalid input. #[must_use] fn cost_for(&self, qty: &Quantity) -> Money; + /// Calculates first, then rounds the final result to the currency exponent + /// with midpoint-away-from-zero rounding. #[must_use] fn cost_for_rounded(&self, qty: &Quantity) -> Money; + /// Rounds the price first, then calculates the requested cost without a + /// second quantization. #[must_use] fn cost_for_with_quantized_price(&self, qty: &Quantity) -> Money; - fn try_cost_for(&self, qty: &Quantity) -> Result<Money, RadrootsCoreQuantityPriceError>; + fn try_cost_for(&self, qty: &Quantity) -> Result<Money, Error>; - fn try_cost_for_rounded(&self, qty: &Quantity) - -> Result<Money, RadrootsCoreQuantityPriceError>; + fn try_cost_for_rounded(&self, qty: &Quantity) -> Result<Money, Error>; } impl QuantityPrice { + /// Compatibility constructor for already-validated internal values. + /// + /// New boundary code should use [`Self::try_new`]. #[inline] pub fn new(amount: Money, quantity: Quantity) -> Self { Self { amount, quantity } } #[inline] - pub fn try_cost_for_amount_in( - &self, - amount: Decimal, - unit: Unit, - ) -> Result<Money, RadrootsCoreQuantityPriceError> { + pub fn try_new(amount: Money, quantity: Quantity) -> Result<Self, Error> { + amount + .ensure_non_negative() + .map_err(|_| Error::NegativePrice)?; + if quantity.amount.is_sign_negative() { + return Err(Error::PerQuantityNegative); + } + if quantity.amount.is_zero() { + return Err(Error::PerQuantityZero); + } + Ok(Self { amount, quantity }) + } + + #[inline] + pub fn amount(&self) -> &Money { + &self.amount + } + + #[inline] + pub fn quantity(&self) -> &Quantity { + &self.quantity + } + + #[inline] + pub fn validate(&self) -> Result<(), Error> { + Self::try_new(self.amount.clone(), self.quantity.clone()).map(|_| ()) + } + + #[inline] + pub fn try_cost_for_amount_in(&self, amount: Decimal, unit: Unit) -> Result<Money, Error> { use crate::unit::convert_unit_decimal; let target = self.quantity.unit; @@ -51,23 +130,17 @@ impl QuantityPrice { let normalized = if unit == target { amount } else { - convert_unit_decimal(amount, unit, target).map_err(|_| { - RadrootsCoreQuantityPriceError::NonConvertibleUnits { - from: unit, - to: target, - } - })? + convert_unit_decimal(amount, unit, target) + .map_err(|error| map_conversion_error(error, unit, target))? }; - let qty = Quantity::new(normalized, target); + let qty = + Quantity::try_new(normalized, target).map_err(|_| Error::NegativeRequestedQuantity)?; self.try_cost_for_rounded(&qty) } #[inline] - pub fn try_cost_for_quantity_in( - &self, - qty: &Quantity, - ) -> Result<Money, RadrootsCoreQuantityPriceError> { + pub fn try_cost_for_quantity_in(&self, qty: &Quantity) -> Result<Money, Error> { self.try_cost_for_amount_in(qty.amount, qty.unit) } @@ -78,47 +151,36 @@ impl QuantityPrice { } #[inline] - pub fn try_to_unit_price( - &self, - unit: Unit, - ) -> Result<QuantityPrice, RadrootsCoreQuantityPriceError> { + pub fn try_to_unit_price(&self, unit: Unit) -> Result<QuantityPrice, Error> { use crate::unit::convert_unit_decimal; - if self.quantity.amount.is_zero() { - return Err(RadrootsCoreQuantityPriceError::PerQuantityZero); - } + self.validate()?; let normalized = if self.quantity.unit == unit { self.quantity.amount } else { - convert_unit_decimal(self.quantity.amount, self.quantity.unit, unit).map_err(|_| { - RadrootsCoreQuantityPriceError::NonConvertibleUnits { - from: self.quantity.unit, - to: unit, - } - })? + convert_unit_decimal(self.quantity.amount, self.quantity.unit, unit) + .map_err(|error| map_conversion_error(error, self.quantity.unit, unit))? }; if normalized.is_zero() { - return Err(RadrootsCoreQuantityPriceError::PerQuantityZero); + return Err(Error::PerQuantityZero); } - let amount = self.amount.div_decimal(normalized); - Ok(QuantityPrice { - amount, - quantity: Quantity::new(Decimal::ONE, unit), - }) + let amount = self + .amount + .checked_div_decimal(normalized) + .map_err(|_| Error::ArithmeticOverflow)?; + Self::try_new(amount, Quantity::new(Decimal::ONE, unit)) } #[inline] - pub fn try_to_canonical_unit_price( - &self, - ) -> Result<QuantityPrice, RadrootsCoreQuantityPriceError> { + pub fn try_to_canonical_unit_price(&self) -> Result<QuantityPrice, Error> { self.try_to_unit_price(self.quantity.unit.canonical_unit()) } } -impl RadrootsCoreQuantityPriceOps for QuantityPrice { +impl QuantityPriceOps for QuantityPrice { #[inline] fn cost_for(&self, qty: &Quantity) -> Money { if qty.amount.is_zero() { @@ -156,28 +218,36 @@ impl RadrootsCoreQuantityPriceOps for QuantityPrice { } #[inline] - fn try_cost_for(&self, qty: &Quantity) -> Result<Money, RadrootsCoreQuantityPriceError> { - if self.quantity.amount.is_zero() { - return Err(RadrootsCoreQuantityPriceError::PerQuantityZero); - } + fn try_cost_for(&self, qty: &Quantity) -> Result<Money, Error> { + self.validate()?; + qty.ensure_non_negative() + .map_err(|_| Error::NegativeRequestedQuantity)?; if qty.unit != self.quantity.unit { - return Err(RadrootsCoreQuantityPriceError::UnitMismatch { + return Err(Error::UnitMismatch { have: qty.unit, want: self.quantity.unit, }); } - let ratio = qty.amount / self.quantity.amount; - Ok(self.amount.mul_decimal(ratio)) + let ratio = qty + .amount + .checked_div(self.quantity.amount) + .map_err(|_| Error::ArithmeticOverflow)?; + self.amount + .checked_mul_decimal(ratio) + .map_err(|_| Error::ArithmeticOverflow) } #[inline] - fn try_cost_for_rounded( - &self, - qty: &Quantity, - ) -> Result<Money, RadrootsCoreQuantityPriceError> { + fn try_cost_for_rounded(&self, qty: &Quantity) -> Result<Money, Error> { Ok(self.try_cost_for(qty)?.quantize_to_currency()) } } #[deprecated(since = "0.1.0", note = "renamed to `QuantityPrice`")] pub use self::QuantityPrice as RadrootsCoreQuantityPrice; + +#[deprecated(since = "0.1.0", note = "renamed to `pricing::Error`")] +pub use self::Error as RadrootsCoreQuantityPriceError; + +#[deprecated(since = "0.1.0", note = "renamed to `pricing::QuantityPriceOps`")] +pub use self::QuantityPriceOps as RadrootsCoreQuantityPriceOps; diff --git a/crates/core/src/unit.rs b/crates/core/src/unit.rs @@ -109,13 +109,13 @@ impl fmt::Display for Unit { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsCoreUnitParseError { +pub enum ParseError { UnknownUnit, NotAMassUnit, NotAVolumeUnit, } -impl fmt::Display for RadrootsCoreUnitParseError { +impl fmt::Display for ParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::UnknownUnit => write!(f, "unknown unit string"), @@ -126,39 +126,43 @@ impl fmt::Display for RadrootsCoreUnitParseError { } #[cfg(feature = "std")] -impl std::error::Error for RadrootsCoreUnitParseError {} +impl std::error::Error for ParseError {} #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsCoreUnitConvertError { +pub enum ConvertError { NotMassUnit { from: Unit, to: Unit }, NotVolumeUnit { from: Unit, to: Unit }, NotConvertibleUnits { from: Unit, to: Unit }, + ArithmeticOverflow { from: Unit, to: Unit }, } -impl fmt::Display for RadrootsCoreUnitConvertError { +impl fmt::Display for ConvertError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - RadrootsCoreUnitConvertError::NotMassUnit { from, to } => { + ConvertError::NotMassUnit { from, to } => { write!(f, "unit conversion requires mass units: {from} -> {to}") } - RadrootsCoreUnitConvertError::NotVolumeUnit { from, to } => { + ConvertError::NotVolumeUnit { from, to } => { write!(f, "unit conversion requires volume units: {from} -> {to}") } - RadrootsCoreUnitConvertError::NotConvertibleUnits { from, to } => { + ConvertError::NotConvertibleUnits { from, to } => { write!( f, "unit conversion requires matching dimensions: {from} -> {to}" ) } + ConvertError::ArithmeticOverflow { from, to } => { + write!(f, "unit conversion arithmetic overflow: {from} -> {to}") + } } } } #[cfg(feature = "std")] -impl std::error::Error for RadrootsCoreUnitConvertError {} +impl std::error::Error for ConvertError {} impl FromStr for Unit { - type Err = RadrootsCoreUnitParseError; + type Err = ParseError; fn from_str(s: &str) -> Result<Self, Self::Err> { let s = s.trim().to_ascii_lowercase(); @@ -172,7 +176,7 @@ impl FromStr for Unit { "ml" | "milliliter" | "millilitre" | "milliliters" | "millilitres" => { Ok(Unit::VolumeMl) } - _ => Err(RadrootsCoreUnitParseError::UnknownUnit), + _ => Err(ParseError::UnknownUnit), } } } @@ -198,39 +202,57 @@ pub use self::UnitDimension as RadrootsCoreUnitDimension; #[deprecated(since = "0.1.0", note = "renamed to `Unit`")] pub use self::Unit as RadrootsCoreUnit; +#[deprecated(since = "0.1.0", note = "renamed to `unit::ParseError`")] +pub use self::ParseError as RadrootsCoreUnitParseError; + +#[deprecated(since = "0.1.0", note = "renamed to `unit::ConvertError`")] +pub use self::ConvertError as RadrootsCoreUnitConvertError; + #[inline] -pub fn parse_mass_unit(s: &str) -> Result<Unit, RadrootsCoreUnitParseError> { +pub fn parse_mass_unit(s: &str) -> Result<Unit, ParseError> { let u: Unit = Unit::from_str(s)?; if u.is_mass() { Ok(u) } else { - Err(RadrootsCoreUnitParseError::NotAMassUnit) + Err(ParseError::NotAMassUnit) } } #[inline] -pub fn parse_volume_unit(s: &str) -> Result<Unit, RadrootsCoreUnitParseError> { +pub fn parse_volume_unit(s: &str) -> Result<Unit, ParseError> { let u: Unit = Unit::from_str(s)?; if u.is_volume() { Ok(u) } else { - Err(RadrootsCoreUnitParseError::NotAVolumeUnit) + Err(ParseError::NotAVolumeUnit) } } #[inline] +/// Converts mass using exact decimal factors expressed in grams. +/// +/// The pound and ounce factors are exact definitions. Arithmetic is checked; +/// division uses the decimal backend's deterministic precision and performs no +/// additional application-level rounding. pub fn convert_mass_decimal( amount: Decimal, from: Unit, to: Unit, -) -> Result<Decimal, RadrootsCoreUnitConvertError> { +) -> Result<Decimal, ConvertError> { + let arithmetic_error = || ConvertError::ArithmeticOverflow { from, to }; let amount_g = match from { Unit::MassG => amount, - Unit::MassKg => amount * Decimal::from(1000u32), - Unit::MassOz => amount * Decimal(dec!(28.349523125)), - Unit::MassLb => amount * Decimal(dec!(453.59237)), + Unit::MassKg => amount + .checked_mul(Decimal::from(1000u32)) + .map_err(|_| arithmetic_error())?, + Unit::MassOz => amount + .checked_mul(Decimal(dec!(28.349523125))) + .map_err(|_| arithmetic_error())?, + Unit::MassLb => amount + .checked_mul(Decimal(dec!(453.59237))) + .map_err(|_| arithmetic_error())?, _ => { - return Err(RadrootsCoreUnitConvertError::NotMassUnit { from, to }); + return Err(ConvertError::NotMassUnit { from, to }); } }; @@ -240,24 +262,32 @@ pub fn convert_mass_decimal( Unit::MassOz => Decimal(dec!(28.349523125)), Unit::MassLb => Decimal(dec!(453.59237)), _ => { - return Err(RadrootsCoreUnitConvertError::NotMassUnit { from, to }); + return Err(ConvertError::NotMassUnit { from, to }); } }; - Ok(amount_g / to_factor) + amount_g + .checked_div(to_factor) + .map_err(|_| arithmetic_error()) } #[inline] +/// Converts volume using the exact relation `1 L = 1000 mL`. +/// +/// Arithmetic is checked and no application-level rounding is applied. pub fn convert_volume_decimal( amount: Decimal, from: Unit, to: Unit, -) -> Result<Decimal, RadrootsCoreUnitConvertError> { +) -> Result<Decimal, ConvertError> { + let arithmetic_error = || ConvertError::ArithmeticOverflow { from, to }; let amount_ml = match from { Unit::VolumeMl => amount, - Unit::VolumeL => amount * Decimal::from(1000u32), + Unit::VolumeL => amount + .checked_mul(Decimal::from(1000u32)) + .map_err(|_| arithmetic_error())?, _ => { - return Err(RadrootsCoreUnitConvertError::NotVolumeUnit { from, to }); + return Err(ConvertError::NotVolumeUnit { from, to }); } }; @@ -265,11 +295,13 @@ pub fn convert_volume_decimal( Unit::VolumeMl => Decimal::ONE, Unit::VolumeL => Decimal::from(1000u32), _ => { - return Err(RadrootsCoreUnitConvertError::NotVolumeUnit { from, to }); + return Err(ConvertError::NotVolumeUnit { from, to }); } }; - Ok(amount_ml / to_factor) + amount_ml + .checked_div(to_factor) + .map_err(|_| arithmetic_error()) } #[inline] @@ -277,9 +309,9 @@ pub fn convert_unit_decimal( amount: Decimal, from: Unit, to: Unit, -) -> Result<Decimal, RadrootsCoreUnitConvertError> { +) -> Result<Decimal, ConvertError> { if !Unit::same_dimension(from, to) { - return Err(RadrootsCoreUnitConvertError::NotConvertibleUnits { from, to }); + return Err(ConvertError::NotConvertibleUnits { from, to }); } match from.dimension() { UnitDimension::Count => Ok(amount), @@ -296,14 +328,14 @@ mod tests { fn convert_paths_cover_unit_branches() { assert_eq!( convert_mass_decimal(Decimal::ONE, Unit::Each, Unit::MassG), - Err(RadrootsCoreUnitConvertError::NotMassUnit { + Err(ConvertError::NotMassUnit { from: Unit::Each, to: Unit::MassG }) ); assert_eq!( convert_volume_decimal(Decimal::ONE, Unit::Each, Unit::VolumeMl), - Err(RadrootsCoreUnitConvertError::NotVolumeUnit { + Err(ConvertError::NotVolumeUnit { from: Unit::Each, to: Unit::VolumeMl }) diff --git a/crates/core/tests/currency.rs b/crates/core/tests/currency.rs @@ -1,6 +1,6 @@ use core::str::FromStr; -use radroots_core::{Currency, RadrootsCoreCurrencyParseError}; +use radroots_core::{Currency, currency::ParseError}; #[test] fn from_str_upper_accepts_valid() { @@ -12,11 +12,11 @@ fn from_str_upper_accepts_valid() { fn from_str_upper_rejects_invalid() { assert_eq!( Currency::from_str_upper("Usd"), - Err(RadrootsCoreCurrencyParseError::InvalidFormat) + Err(ParseError::InvalidFormat) ); assert_eq!( Currency::from_str_upper("US"), - Err(RadrootsCoreCurrencyParseError::InvalidFormat) + Err(ParseError::InvalidFormat) ); } @@ -28,14 +28,8 @@ fn from_str_trims_and_uppercases() { #[test] fn from_str_rejects_non_alpha() { - assert_eq!( - Currency::from_str("US1"), - Err(RadrootsCoreCurrencyParseError::InvalidFormat) - ); - assert_eq!( - Currency::from_str("US"), - Err(RadrootsCoreCurrencyParseError::InvalidFormat) - ); + assert_eq!(Currency::from_str("US1"), Err(ParseError::InvalidFormat)); + assert_eq!(Currency::from_str("US"), Err(ParseError::InvalidFormat)); } #[test] @@ -43,19 +37,19 @@ fn from_const_validates_bytes() { assert!(Currency::from_const(*b"USD").is_ok()); assert_eq!( Currency::from_const(*b"1SD"), - Err(RadrootsCoreCurrencyParseError::InvalidFormat) + Err(ParseError::InvalidFormat) ); assert_eq!( Currency::from_const(*b"Usd"), - Err(RadrootsCoreCurrencyParseError::InvalidFormat) + Err(ParseError::InvalidFormat) ); assert_eq!( Currency::from_const(*b"USd"), - Err(RadrootsCoreCurrencyParseError::InvalidFormat) + Err(ParseError::InvalidFormat) ); assert_eq!( Currency::from_const(*b"usd"), - Err(RadrootsCoreCurrencyParseError::InvalidFormat) + Err(ParseError::InvalidFormat) ); } @@ -78,7 +72,7 @@ fn display_debug_tryfrom_and_error_display_paths_are_exercised() { let via_try_from = Currency::try_from("usd").unwrap(); assert_eq!(via_try_from, usd); assert_eq!( - RadrootsCoreCurrencyParseError::InvalidFormat.to_string(), + ParseError::InvalidFormat.to_string(), "currency must be a 3-letter code" ); } diff --git a/crates/core/tests/decimal.rs b/crates/core/tests/decimal.rs @@ -2,7 +2,7 @@ mod common; use core::str::FromStr; -use radroots_core::Decimal; +use radroots_core::{Decimal, decimal::Error}; #[test] fn display_normalizes_trailing_zeros() { @@ -32,6 +32,79 @@ fn from_f64_display_roundtrips_reasonably() { } #[test] +fn parsing_normalizes_zero_and_owns_parse_errors() { + let zero = Decimal::from_str("-0.000").unwrap(); + assert_eq!(zero, Decimal::ZERO); + assert_eq!(zero.scale(), 0); + assert_eq!( + Decimal::from_str("not-a-decimal"), + Err(Error::InvalidFormat) + ); + assert_eq!( + Decimal::from_str_exact("0.00000000000000000000000000001"), + Err(Error::OutOfRange) + ); +} + +#[test] +fn checked_arithmetic_reports_overflow_and_zero_division() { + assert_eq!( + Decimal::MAX.checked_add(Decimal::ONE), + Err(Error::ArithmeticOverflow) + ); + assert_eq!( + Decimal::MIN.checked_sub(Decimal::ONE), + Err(Error::ArithmeticOverflow) + ); + assert_eq!( + Decimal::MAX.checked_mul(Decimal::from(2u32)), + Err(Error::ArithmeticOverflow) + ); + assert_eq!( + Decimal::ONE.checked_div(Decimal::ZERO), + Err(Error::DivisionByZero) + ); +} + +#[test] +fn exact_rescale_rejects_rounding_and_unrepresentable_scales() { + let mut exact = Decimal::from_str("1.2300").unwrap(); + exact.try_rescale_exact(2).unwrap(); + assert_eq!(exact.scale(), 2); + assert_eq!(exact, common::dec("1.23")); + + let mut would_round = common::dec("1.25"); + assert_eq!(would_round.try_rescale_exact(1), Err(Error::PrecisionLoss)); + assert_eq!(would_round, common::dec("1.25")); + + assert_eq!( + would_round.try_rescale_exact(Decimal::MAX_SCALE + 1), + Err(Error::ScaleOutOfRange) + ); +} + +#[test] +fn display_parse_roundtrips_normalized_values() { + for input in ["0", "-42.1250", "999999.000001", "0.00000001"] { + let value = Decimal::from_str(input).unwrap(); + let reparsed = Decimal::from_str(&value.to_string()).unwrap(); + assert_eq!(reparsed, value); + } +} + +#[test] +fn float_conversion_rejects_non_finite_values() { + assert_eq!( + Decimal::from_f64_display(f64::NAN), + Err(Error::InvalidFormat) + ); + assert_eq!( + Decimal::from_f64_display(f64::INFINITY), + Err(Error::InvalidFormat) + ); +} + +#[test] fn from_str_exact_and_conversion_impl_paths_are_exercised() { let exact = Decimal::from_str_exact("42.000").unwrap(); assert_eq!(exact, common::dec("42")); diff --git a/crates/core/tests/discount.rs b/crates/core/tests/discount.rs @@ -1,8 +1,8 @@ mod common; use radroots_core::{ - Percent, RadrootsCoreDiscount, RadrootsCoreDiscountScope, RadrootsCoreDiscountThreshold, - RadrootsCoreDiscountValue, Unit, + Percent, Unit, + pricing::{Discount, DiscountError, DiscountScope, DiscountThreshold, DiscountValue}, }; #[test] @@ -14,46 +14,96 @@ fn is_non_negative_validates_discount_shapes() { let pct_pos = Percent::new(common::dec("10")); let pct_neg = Percent::new(common::dec("-5")); - let d = RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::Bin, - threshold: RadrootsCoreDiscountThreshold::BinCount { + let d = Discount { + scope: DiscountScope::Bin, + threshold: DiscountThreshold::BinCount { bin_id: "bin-1".to_string(), min: 2, }, - value: RadrootsCoreDiscountValue::MoneyPerBin(money_pos.clone()), + value: DiscountValue::MoneyPerBin(money_pos.clone()), }; assert!(d.is_non_negative()); - let d = RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::Bin, - threshold: RadrootsCoreDiscountThreshold::BinCount { + let d = Discount { + scope: DiscountScope::Bin, + threshold: DiscountThreshold::BinCount { bin_id: "bin-1".to_string(), min: 2, }, - value: RadrootsCoreDiscountValue::MoneyPerBin(money_neg), + value: DiscountValue::MoneyPerBin(money_neg), }; assert!(!d.is_non_negative()); - let d = RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::OrderTotal, - threshold: RadrootsCoreDiscountThreshold::OrderQuantity { + let d = Discount { + scope: DiscountScope::OrderTotal, + threshold: DiscountThreshold::OrderQuantity { min: qty_pos.clone(), }, - value: RadrootsCoreDiscountValue::Percent(pct_pos.clone()), + value: DiscountValue::Percent(pct_pos.clone()), }; assert!(d.is_non_negative()); - let d = RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::OrderTotal, - threshold: RadrootsCoreDiscountThreshold::OrderQuantity { min: qty_neg }, - value: RadrootsCoreDiscountValue::Percent(pct_pos), + let d = Discount { + scope: DiscountScope::OrderTotal, + threshold: DiscountThreshold::OrderQuantity { min: qty_neg }, + value: DiscountValue::Percent(pct_pos), }; assert!(!d.is_non_negative()); - let d = RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::OrderTotal, - threshold: RadrootsCoreDiscountThreshold::OrderQuantity { min: qty_pos }, - value: RadrootsCoreDiscountValue::Percent(pct_neg), + let d = Discount { + scope: DiscountScope::OrderTotal, + threshold: DiscountThreshold::OrderQuantity { min: qty_pos }, + value: DiscountValue::Percent(pct_neg), }; assert!(!d.is_non_negative()); } + +#[test] +fn checked_constructor_rejects_negative_thresholds_and_values() { + let negative_threshold = Discount::try_new( + DiscountScope::OrderTotal, + DiscountThreshold::OrderQuantity { + min: common::qty("-1", Unit::Each), + }, + DiscountValue::Percent(Percent::new(common::dec("10"))), + ); + assert_eq!(negative_threshold, Err(DiscountError::NegativeThreshold)); + + let negative_value = Discount::try_new( + DiscountScope::Bin, + DiscountThreshold::BinCount { + bin_id: "bin-1".to_string(), + min: 1, + }, + DiscountValue::Percent(Percent::new(common::dec("-0.01"))), + ); + assert_eq!(negative_value, Err(DiscountError::NegativeValue)); +} + +#[test] +fn checked_constructor_and_accessors_preserve_valid_shape() { + let discount = Discount::try_new( + DiscountScope::Bin, + DiscountThreshold::BinCount { + bin_id: "bin-1".to_string(), + min: 2, + }, + DiscountValue::MoneyPerBin(common::money("1.25", "USD")), + ) + .unwrap(); + assert_eq!(discount.scope(), &DiscountScope::Bin); + assert!(matches!( + discount.threshold(), + DiscountThreshold::BinCount { min: 2, .. } + )); + assert!(matches!(discount.value(), DiscountValue::MoneyPerBin(_))); + assert_eq!(discount.validate(), Ok(())); + assert_eq!( + DiscountError::NegativeThreshold.to_string(), + "discount threshold must be non-negative" + ); + assert_eq!( + DiscountError::NegativeValue.to_string(), + "discount value must be non-negative" + ); +} diff --git a/crates/core/tests/error.rs b/crates/core/tests/error.rs @@ -0,0 +1,38 @@ +use std::error::Error as _; + +use radroots_core::{Error, Unit, currency, decimal, money, percent, pricing, quantity, unit}; + +#[test] +fn aggregate_error_accepts_every_scoped_error() { + let errors = [ + Error::from(currency::ParseError::InvalidFormat), + Error::from(decimal::Error::ArithmeticOverflow), + Error::from(pricing::DiscountError::NegativeValue), + Error::from(money::Error::CurrencyMismatch), + Error::from(percent::ParseError::InvalidNumber), + Error::from(pricing::Error::PerQuantityZero), + Error::from(quantity::Error::UnitMismatch), + Error::from(unit::ParseError::UnknownUnit), + Error::from(unit::ConvertError::NotConvertibleUnits { + from: Unit::Each, + to: Unit::MassG, + }), + ]; + + for error in errors { + assert!(error.source().is_some()); + assert!(!error.to_string().is_empty()); + } +} + +#[test] +fn aggregate_error_display_identifies_the_owning_module() { + assert_eq!( + Error::from(decimal::Error::DivisionByZero).to_string(), + "decimal: decimal division by zero" + ); + assert_eq!( + Error::from(unit::ParseError::UnknownUnit).to_string(), + "unit parse: unknown unit string" + ); +} diff --git a/crates/core/tests/money.rs b/crates/core/tests/money.rs @@ -1,6 +1,6 @@ mod common; -use radroots_core::{Currency, Money, RadrootsCoreMoneyInvariantError}; +use radroots_core::{Currency, Decimal, Money, money::Error}; use rust_decimal::RoundingStrategy; #[test] @@ -14,10 +14,7 @@ fn zero_and_is_zero() { #[test] fn ensure_non_negative_rejects_negative_amount() { let money = Money::new(common::dec("-1"), Currency::USD); - assert_eq!( - money.ensure_non_negative(), - Err(RadrootsCoreMoneyInvariantError::NegativeAmount) - ); + assert_eq!(money.ensure_non_negative(), Err(Error::NegativeAmount)); } #[test] @@ -29,6 +26,17 @@ fn ensure_non_negative_accepts_zero_and_positive() { } #[test] +fn checked_constructor_rejects_negative_and_canonicalizes_zero() { + assert_eq!( + Money::try_new(common::dec("-0.01"), Currency::USD), + Err(Error::NegativeAmount) + ); + let zero = Money::try_new(common::dec("-0.00"), Currency::USD).unwrap(); + assert_eq!(zero.amount(), Decimal::ZERO); + assert_eq!(zero.currency(), Currency::USD); +} + +#[test] fn quantize_to_currency_rounds_midpoint_away_from_zero() { let usd = Currency::USD; let a = Money::new(common::dec("1.234"), usd).quantize_to_currency(); @@ -60,10 +68,7 @@ fn checked_add_and_sub_require_currency_match() { let c = Money::new(common::dec("3.00"), eur); assert_eq!(a.checked_add(&b).unwrap().amount, common::dec("3.00")); - assert_eq!( - a.checked_add(&c), - Err(RadrootsCoreMoneyInvariantError::CurrencyMismatch) - ); + assert_eq!(a.checked_add(&c), Err(Error::CurrencyMismatch)); assert_eq!(b.checked_sub(&a).unwrap().amount, common::dec("1.00")); } @@ -73,9 +78,25 @@ fn checked_sub_mismatch_returns_currency_error() { let eur = Currency::EUR; let a = Money::new(common::dec("1.00"), usd); let b = Money::new(common::dec("2.00"), eur); + assert_eq!(a.checked_sub(&b), Err(Error::CurrencyMismatch)); +} + +#[test] +fn checked_arithmetic_rejects_invalid_results() { + let usd = Currency::USD; + let one = Money::try_new(Decimal::ONE, usd).unwrap(); + let two = Money::try_new(Decimal::from(2u32), usd).unwrap(); + assert_eq!(one.checked_sub(&two), Err(Error::NegativeAmount)); + + let max = Money::try_new(Decimal::MAX, usd).unwrap(); + assert_eq!(max.checked_add(&one), Err(Error::ArithmeticOverflow)); + assert_eq!( + one.checked_div_decimal(Decimal::ZERO), + Err(Error::DivisionByZero) + ); assert_eq!( - a.checked_sub(&b), - Err(RadrootsCoreMoneyInvariantError::CurrencyMismatch) + one.checked_mul_decimal(common::dec("-1")), + Err(Error::NegativeAmount) ); } @@ -89,7 +110,7 @@ fn minor_units_exact_and_rounded() { assert_eq!(exact.to_minor_units_u64_exact().unwrap(), 123); assert_eq!( frac.to_minor_units_u64_exact(), - Err(RadrootsCoreMoneyInvariantError::NotWholeMinorUnits) + Err(Error::NotWholeMinorUnits) ); assert_eq!( rounded @@ -119,7 +140,7 @@ fn minor_units_u32_overflow_is_detected() { let too_large = Money::from_minor_units_u64(u64::from(u32::MAX) + 1, usd); assert_eq!( too_large.to_minor_units_u32_exact(), - Err(RadrootsCoreMoneyInvariantError::AmountOverflow) + Err(Error::AmountOverflow) ); } @@ -131,7 +152,7 @@ fn minor_units_u32_exact_success_path_is_exercised() { let fractional = Money::new(common::dec("1.001"), usd); assert_eq!( fractional.to_minor_units_u32_exact(), - Err(RadrootsCoreMoneyInvariantError::NotWholeMinorUnits) + Err(Error::NotWholeMinorUnits) ); } @@ -154,12 +175,12 @@ fn minor_units_u32_rounded_overflow_is_detected() { let too_large = Money::from_minor_units_u64(u64::from(u32::MAX) + 1, usd); assert_eq!( too_large.to_minor_units_u32_rounded(RoundingStrategy::MidpointAwayFromZero), - Err(RadrootsCoreMoneyInvariantError::AmountOverflow) + Err(Error::AmountOverflow) ); let negative = Money::new(common::dec("-1.00"), usd); assert_eq!( negative.to_minor_units_u32_rounded(RoundingStrategy::MidpointAwayFromZero), - Err(RadrootsCoreMoneyInvariantError::AmountOverflow) + Err(Error::NegativeAmount) ); } @@ -178,6 +199,28 @@ fn from_minor_units_roundtrips() { } #[test] +fn minor_units_roundtrip_across_supported_exponents() { + for currency in [Currency::JPY, Currency::USD, common::currency("KWD")] { + for minor in [0, 1, 99, 12_345, u32::MAX as u64] { + let money = Money::from_minor_units_u64(minor, currency); + assert_eq!(money.to_minor_units_u64_exact().unwrap(), minor); + } + } +} + +#[test] +fn exact_scale_change_never_rounds() { + let money = Money::try_new(common::dec("1.2300"), Currency::USD).unwrap(); + assert_eq!(money.try_with_scale_exact(2).unwrap().amount().scale(), 2); + assert_eq!( + Money::try_new(common::dec("1.235"), Currency::USD) + .unwrap() + .try_with_scale_exact(2), + Err(Error::PrecisionLoss) + ); +} + +#[test] fn display_and_operator_impl_paths_are_exercised() { let usd = Currency::USD; let m = Money::new(common::dec("10"), usd); @@ -192,19 +235,32 @@ fn display_and_operator_impl_paths_are_exercised() { #[test] fn invariant_error_display_variants_are_exercised() { assert_eq!( - RadrootsCoreMoneyInvariantError::NegativeAmount.to_string(), + Error::NegativeAmount.to_string(), "money amount must be ≥ 0" ); assert_eq!( - RadrootsCoreMoneyInvariantError::NotWholeMinorUnits.to_string(), + Error::NotWholeMinorUnits.to_string(), "money not a whole number of minor units" ); assert_eq!( - RadrootsCoreMoneyInvariantError::AmountOverflow.to_string(), + Error::AmountOverflow.to_string(), "money minor-unit conversion overflow" ); assert_eq!( - RadrootsCoreMoneyInvariantError::CurrencyMismatch.to_string(), + Error::CurrencyMismatch.to_string(), "money currency mismatch" ); + assert_eq!( + Error::ArithmeticOverflow.to_string(), + "money arithmetic overflow" + ); + assert_eq!(Error::DivisionByZero.to_string(), "money division by zero"); + assert_eq!( + Error::ScaleOutOfRange.to_string(), + "money scale is outside the supported range" + ); + assert_eq!( + Error::PrecisionLoss.to_string(), + "money operation would lose precision" + ); } diff --git a/crates/core/tests/package_boundary.rs b/crates/core/tests/package_boundary.rs @@ -1,8 +1,8 @@ use radroots_core::unit::UnitDimension; #[allow(unused_imports)] use radroots_core::{ - Currency, Decimal, Money, Percent, Quantity, QuantityPrice, Unit, currency as _, decimal as _, - money as _, percent as _, pricing as _, quantity as _, unit as _, + Currency, Decimal, Error, Money, Percent, Quantity, QuantityPrice, Unit, currency as _, + decimal as _, money as _, percent as _, pricing as _, quantity as _, unit as _, }; #[test] @@ -17,7 +17,11 @@ fn canonical_value_paths_are_public() { let _ = core::mem::size_of::<Quantity>(); let _ = core::mem::size_of::<QuantityPrice>(); let _ = core::mem::size_of::<Unit>(); + let _ = core::mem::size_of::<Error>(); let _ = UnitDimension::Count; + let _ = core::mem::size_of::<radroots_core::pricing::Discount>(); + let _ = core::mem::size_of::<radroots_core::pricing::DiscountError>(); + let _ = core::mem::size_of::<radroots_core::pricing::Error>(); } #[allow(deprecated)] @@ -25,8 +29,8 @@ fn canonical_value_paths_are_public() { fn temporary_legacy_aliases_remain_source_compatible() { use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCorePercent, - RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, - RadrootsCoreUnitDimension, + RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError, + RadrootsCoreQuantityPriceOps, RadrootsCoreUnit, RadrootsCoreUnitDimension, }; let decimal = RadrootsCoreDecimal(rust_decimal::Decimal::ONE); @@ -34,6 +38,11 @@ fn temporary_legacy_aliases_remain_source_compatible() { let money = RadrootsCoreMoney::new(decimal, currency); let quantity = RadrootsCoreQuantity::new(decimal, RadrootsCoreUnit::Each); let _ = RadrootsCorePercent::new(decimal); - let _ = RadrootsCoreQuantityPrice::new(money, quantity); + let price = RadrootsCoreQuantityPrice::new(money, quantity); + let _ = RadrootsCoreQuantityPriceOps::try_cost_for( + &price, + &RadrootsCoreQuantity::new(decimal, RadrootsCoreUnit::Each), + ); + let _ = RadrootsCoreQuantityPriceError::PerQuantityZero; let _ = RadrootsCoreUnitDimension::Count; } diff --git a/crates/core/tests/percent.rs b/crates/core/tests/percent.rs @@ -2,7 +2,7 @@ mod common; use core::str::FromStr; -use radroots_core::{Percent, RadrootsCorePercentParseError}; +use radroots_core::{Decimal, Percent, decimal, money, percent::ParseError}; #[test] fn ratio_roundtrip() { @@ -12,6 +12,19 @@ fn ratio_roundtrip() { } #[test] +fn checked_ratio_paths_report_overflow_and_roundtrip() { + assert_eq!( + Percent::try_from_ratio(Decimal::MAX), + Err(decimal::Error::ArithmeticOverflow) + ); + for ratio in ["-0.125", "0", "0.5", "1", "2.75"] { + let ratio = common::dec(ratio); + let percent = Percent::try_from_ratio(ratio).unwrap(); + assert_eq!(percent.try_to_ratio().unwrap(), ratio); + } +} + +#[test] fn parses_percent_strings() { let pct = Percent::from_str("12.5%").unwrap(); assert_eq!(pct.value, common::dec("12.5")); @@ -19,10 +32,7 @@ fn parses_percent_strings() { let pct = Percent::from_str(" 12.5 ").unwrap(); assert_eq!(pct.value, common::dec("12.5")); - assert_eq!( - Percent::from_str("nope"), - Err(RadrootsCorePercentParseError::InvalidNumber) - ); + assert_eq!(Percent::from_str("nope"), Err(ParseError::InvalidNumber)); } #[test] @@ -39,11 +49,28 @@ fn of_money_and_quantized() { } #[test] +fn checked_money_calculation_rejects_invalid_base_and_overflow() { + let negative = common::money("-10", "USD"); + let pct = Percent::new(common::dec("10")); + assert_eq!( + pct.try_of_money(&negative), + Err(money::Error::NegativeAmount) + ); + + let max = radroots_core::Money::try_new(Decimal::MAX, radroots_core::Currency::USD).unwrap(); + let two_hundred = Percent::new(common::dec("200")); + assert_eq!( + two_hundred.try_of_money(&max), + Err(money::Error::ArithmeticOverflow) + ); +} + +#[test] fn display_and_parse_error_display_paths_are_exercised() { let pct = Percent::from_str("12.5%").unwrap(); assert_eq!(pct.to_string(), "12.5%"); assert_eq!( - RadrootsCorePercentParseError::InvalidNumber.to_string(), + ParseError::InvalidNumber.to_string(), "invalid percent string" ); } diff --git a/crates/core/tests/quantity.rs b/crates/core/tests/quantity.rs @@ -1,6 +1,6 @@ mod common; -use radroots_core::{RadrootsCoreQuantityInvariantError, Unit}; +use radroots_core::{Decimal, Quantity, Unit, quantity::Error}; #[test] fn zero_helpers_and_scale_paths_are_exercised() { @@ -31,10 +31,7 @@ fn label_helpers_set_and_clear() { #[test] fn ensure_non_negative_rejects_negative_amount() { let q = common::qty("-1", Unit::Each); - assert_eq!( - q.ensure_non_negative(), - Err(RadrootsCoreQuantityInvariantError::NegativeAmount) - ); + assert_eq!(q.ensure_non_negative(), Err(Error::NegativeAmount)); } #[test] @@ -44,6 +41,20 @@ fn ensure_non_negative_accepts_non_negative_amount() { } #[test] +fn checked_constructor_rejects_negative_and_exposes_fields() { + assert_eq!( + Quantity::try_new(common::dec("-1"), Unit::Each), + Err(Error::NegativeAmount) + ); + let quantity = Quantity::try_new(common::dec("2"), Unit::Each) + .unwrap() + .with_label("bundle"); + assert_eq!(quantity.amount(), common::dec("2")); + assert_eq!(quantity.unit(), Unit::Each); + assert_eq!(quantity.label(), Some("bundle")); +} + +#[test] fn try_add_and_try_sub_require_matching_units() { let a = common::qty("1", Unit::Each).with_label("lhs"); let b = common::qty("2", Unit::Each); @@ -53,14 +64,8 @@ fn try_add_and_try_sub_require_matching_units() { assert_eq!(sum.amount, common::dec("3")); assert_eq!(sum.label.as_deref(), Some("lhs")); - assert_eq!( - a.try_add(&c), - Err(RadrootsCoreQuantityInvariantError::UnitMismatch) - ); - assert_eq!( - b.try_sub(&c), - Err(RadrootsCoreQuantityInvariantError::UnitMismatch) - ); + assert_eq!(a.try_add(&c), Err(Error::UnitMismatch)); + assert_eq!(b.try_sub(&c), Err(Error::UnitMismatch)); } #[test] @@ -94,6 +99,24 @@ fn checked_add_and_sub_return_some_on_matching_units() { } #[test] +fn checked_arithmetic_reports_negative_overflow_and_zero_division() { + let one = Quantity::try_new(Decimal::ONE, Unit::Each).unwrap(); + let two = Quantity::try_new(Decimal::from(2u32), Unit::Each).unwrap(); + assert_eq!(one.try_sub(&two), Err(Error::NegativeAmount)); + + let max = Quantity::try_new(Decimal::MAX, Unit::Each).unwrap(); + assert_eq!(max.try_add(&one), Err(Error::ArithmeticOverflow)); + assert_eq!( + one.checked_div_decimal(Decimal::ZERO), + Err(Error::DivisionByZero) + ); + assert_eq!( + one.checked_mul_decimal(common::dec("-1")), + Err(Error::NegativeAmount) + ); +} + +#[test] fn mul_and_div_preserve_unit_and_label() { let q = common::qty("2", Unit::Each).with_label("unit"); let scaled = q.clone().mul_decimal(common::dec("2.5")); @@ -131,12 +154,17 @@ fn display_without_label_and_error_display_are_exercised() { assert_eq!(q.to_string(), "1.5 each"); assert_eq!( - RadrootsCoreQuantityInvariantError::NegativeAmount.to_string(), + Error::NegativeAmount.to_string(), "quantity amount must be ≥ 0" ); + assert_eq!(Error::UnitMismatch.to_string(), "quantity unit mismatch"); + assert_eq!( + Error::ArithmeticOverflow.to_string(), + "quantity arithmetic overflow" + ); assert_eq!( - RadrootsCoreQuantityInvariantError::UnitMismatch.to_string(), - "quantity unit mismatch" + Error::DivisionByZero.to_string(), + "quantity division by zero" ); } @@ -202,7 +230,7 @@ fn try_convert_to_rejects_mismatched_dimensions() { let err = q.try_convert_to(Unit::MassG).unwrap_err(); assert_eq!( err, - radroots_core::RadrootsCoreUnitConvertError::NotConvertibleUnits { + radroots_core::unit::ConvertError::NotConvertibleUnits { from: Unit::Each, to: Unit::MassG } diff --git a/crates/core/tests/quantity_price.rs b/crates/core/tests/quantity_price.rs @@ -1,7 +1,8 @@ mod common; use radroots_core::{ - QuantityPrice, RadrootsCoreQuantityPriceError, RadrootsCoreQuantityPriceOps, Unit, + Currency, Decimal, Money, Quantity, QuantityPrice, Unit, + pricing::{Error, QuantityPriceOps}, }; #[test] @@ -36,11 +37,11 @@ fn try_cost_for_validates_quantity_and_units() { assert_eq!( zero_price.try_cost_for(&common::qty("1", Unit::Each)), - Err(RadrootsCoreQuantityPriceError::PerQuantityZero) + Err(Error::PerQuantityZero) ); assert_eq!( price.try_cost_for(&common::qty("1", Unit::MassKg)), - Err(RadrootsCoreQuantityPriceError::UnitMismatch { + Err(Error::UnitMismatch { have: Unit::MassKg, want: Unit::Each }) @@ -48,11 +49,57 @@ fn try_cost_for_validates_quantity_and_units() { } #[test] +fn checked_constructor_rejects_invalid_price_shapes() { + assert_eq!( + QuantityPrice::try_new(common::money("-1", "USD"), common::qty("1", Unit::Each),), + Err(Error::NegativePrice) + ); + assert_eq!( + QuantityPrice::try_new(common::money("1", "USD"), common::qty("0", Unit::Each),), + Err(Error::PerQuantityZero) + ); + assert_eq!( + QuantityPrice::try_new(common::money("1", "USD"), common::qty("-1", Unit::Each),), + Err(Error::PerQuantityNegative) + ); +} + +#[test] +fn checked_cost_paths_never_silently_convert_errors_to_zero() { + let price = + QuantityPrice::try_new(common::money("10", "USD"), common::qty("1", Unit::Each)).unwrap(); + assert_eq!( + price.try_cost_for(&common::qty("1", Unit::MassKg)), + Err(Error::UnitMismatch { + have: Unit::MassKg, + want: Unit::Each, + }) + ); + assert_eq!( + price.try_cost_for(&common::qty("-1", Unit::Each)), + Err(Error::NegativeRequestedQuantity) + ); +} + +#[test] +fn checked_cost_paths_report_arithmetic_overflow() { + let price = QuantityPrice::try_new( + Money::try_new(Decimal::MAX, Currency::USD).unwrap(), + Quantity::try_new(Decimal::ONE, Unit::Each).unwrap(), + ) + .unwrap(); + assert_eq!( + price.try_cost_for(&Quantity::try_new(Decimal::from(2u32), Unit::Each).unwrap()), + Err(Error::ArithmeticOverflow) + ); +} + +#[test] fn try_cost_for_rounded_error_path_is_exercised() { let price = QuantityPrice::new(common::money("10", "USD"), common::qty("1", Unit::Each)); assert_eq!( price.try_cost_for_rounded(&common::qty("1", Unit::MassKg)), - Err(RadrootsCoreQuantityPriceError::UnitMismatch { + Err(Error::UnitMismatch { have: Unit::MassKg, want: Unit::Each }) @@ -82,7 +129,7 @@ fn try_cost_for_amount_in_rejects_non_convertible_units() { let price = QuantityPrice::new(common::money("10", "USD"), common::qty("1", Unit::MassKg)); assert_eq!( price.try_cost_for_amount_in(common::dec("1"), Unit::Each), - Err(RadrootsCoreQuantityPriceError::NonConvertibleUnits { + Err(Error::NonConvertibleUnits { from: Unit::Each, to: Unit::MassKg }) @@ -111,7 +158,7 @@ fn try_to_unit_price_error_and_same_unit_paths_are_exercised() { let zero = QuantityPrice::new(common::money("10", "USD"), common::qty("0", Unit::MassKg)); assert_eq!( zero.try_to_unit_price(Unit::MassG), - Err(RadrootsCoreQuantityPriceError::PerQuantityZero) + Err(Error::PerQuantityZero) ); let base = QuantityPrice::new(common::money("5", "USD"), common::qty("2", Unit::MassKg)); @@ -123,7 +170,7 @@ fn try_to_unit_price_error_and_same_unit_paths_are_exercised() { let err = base.try_to_unit_price(Unit::VolumeMl).unwrap_err(); assert_eq!( err, - RadrootsCoreQuantityPriceError::NonConvertibleUnits { + Error::NonConvertibleUnits { from: Unit::MassKg, to: Unit::VolumeMl } @@ -166,7 +213,7 @@ fn try_to_unit_price_detects_underflow_to_zero_normalized_amount() { common::qty("0.0000000000000000000000000001", Unit::VolumeMl), ); let err = tiny.try_to_unit_price(Unit::VolumeL).unwrap_err(); - assert_eq!(err, RadrootsCoreQuantityPriceError::PerQuantityZero); + assert_eq!(err, Error::PerQuantityZero); } #[test] @@ -187,3 +234,27 @@ fn is_price_per_canonical_unit_detects_canonical() { let price = QuantityPrice::new(common::money("1.00", "USD"), common::qty("1", Unit::MassKg)); assert!(!price.is_price_per_canonical_unit()); } + +#[test] +fn pricing_error_display_is_stable() { + assert_eq!( + Error::PerQuantityZero.to_string(), + "price quantity must be greater than zero" + ); + assert_eq!( + Error::PerQuantityNegative.to_string(), + "price quantity must not be negative" + ); + assert_eq!( + Error::NegativePrice.to_string(), + "price amount must not be negative" + ); + assert_eq!( + Error::NegativeRequestedQuantity.to_string(), + "requested quantity must not be negative" + ); + assert_eq!( + Error::ArithmeticOverflow.to_string(), + "price arithmetic overflow" + ); +} diff --git a/crates/core/tests/unit.rs b/crates/core/tests/unit.rs @@ -3,8 +3,11 @@ mod common; use core::str::FromStr; use radroots_core::{ - RadrootsCoreUnitConvertError, RadrootsCoreUnitParseError, Unit, convert_mass_decimal, - convert_unit_decimal, convert_volume_decimal, parse_mass_unit, parse_volume_unit, + Decimal, Unit, + unit::{ + ConvertError, ParseError, convert_mass_decimal, convert_unit_decimal, + convert_volume_decimal, parse_mass_unit, parse_volume_unit, + }, }; #[test] @@ -34,10 +37,7 @@ fn parses_units_and_synonyms() { #[test] fn rejects_unknown_units() { - assert_eq!( - Unit::from_str("unknown"), - Err(RadrootsCoreUnitParseError::UnknownUnit) - ); + assert_eq!(Unit::from_str("unknown"), Err(ParseError::UnknownUnit)); } #[test] @@ -81,27 +81,15 @@ fn code_and_predicate_helpers_cover_all_variants() { #[test] fn parse_mass_unit_enforces_mass_only() { assert_eq!(parse_mass_unit("kg"), Ok(Unit::MassKg)); - assert_eq!( - parse_mass_unit("each"), - Err(RadrootsCoreUnitParseError::NotAMassUnit) - ); - assert_eq!( - parse_mass_unit("bogus"), - Err(RadrootsCoreUnitParseError::UnknownUnit) - ); + assert_eq!(parse_mass_unit("each"), Err(ParseError::NotAMassUnit)); + assert_eq!(parse_mass_unit("bogus"), Err(ParseError::UnknownUnit)); } #[test] fn parse_volume_unit_enforces_volume_only() { assert_eq!(parse_volume_unit("l"), Ok(Unit::VolumeL)); - assert_eq!( - parse_volume_unit("kg"), - Err(RadrootsCoreUnitParseError::NotAVolumeUnit) - ); - assert_eq!( - parse_volume_unit("bogus"), - Err(RadrootsCoreUnitParseError::UnknownUnit) - ); + assert_eq!(parse_volume_unit("kg"), Err(ParseError::NotAVolumeUnit)); + assert_eq!(parse_volume_unit("bogus"), Err(ParseError::UnknownUnit)); } #[test] @@ -127,7 +115,7 @@ fn convert_mass_decimal_rejects_non_mass_units() { let err = convert_mass_decimal(common::dec("1"), Unit::Each, Unit::MassG).unwrap_err(); assert_eq!( err, - RadrootsCoreUnitConvertError::NotMassUnit { + ConvertError::NotMassUnit { from: Unit::Each, to: Unit::MassG } @@ -136,7 +124,7 @@ fn convert_mass_decimal_rejects_non_mass_units() { let err = convert_mass_decimal(common::dec("1"), Unit::MassKg, Unit::Each).unwrap_err(); assert_eq!( err, - RadrootsCoreUnitConvertError::NotMassUnit { + ConvertError::NotMassUnit { from: Unit::MassKg, to: Unit::Each } @@ -157,7 +145,7 @@ fn convert_volume_decimal_rejects_non_volume_units() { let err = convert_volume_decimal(common::dec("1"), Unit::Each, Unit::VolumeMl).unwrap_err(); assert_eq!( err, - RadrootsCoreUnitConvertError::NotVolumeUnit { + ConvertError::NotVolumeUnit { from: Unit::Each, to: Unit::VolumeMl } @@ -166,7 +154,7 @@ fn convert_volume_decimal_rejects_non_volume_units() { let err = convert_volume_decimal(common::dec("1"), Unit::VolumeMl, Unit::Each).unwrap_err(); assert_eq!( err, - RadrootsCoreUnitConvertError::NotVolumeUnit { + ConvertError::NotVolumeUnit { from: Unit::VolumeMl, to: Unit::Each } @@ -185,22 +173,52 @@ fn convert_unit_decimal_converts_matching_dimensions() { } #[test] -fn display_paths_for_unit_and_errors_are_exercised() { - assert_eq!(Unit::MassOz.to_string(), "oz"); +fn conversions_report_overflow_without_panicking() { assert_eq!( - RadrootsCoreUnitParseError::UnknownUnit.to_string(), - "unknown unit string" + convert_mass_decimal(Decimal::MAX, Unit::MassKg, Unit::MassG), + Err(ConvertError::ArithmeticOverflow { + from: Unit::MassKg, + to: Unit::MassG, + }) ); assert_eq!( - RadrootsCoreUnitParseError::NotAMassUnit.to_string(), + convert_volume_decimal(Decimal::MAX, Unit::VolumeL, Unit::VolumeMl), + Err(ConvertError::ArithmeticOverflow { + from: Unit::VolumeL, + to: Unit::VolumeMl, + }) + ); +} + +#[test] +fn exact_conversion_pairs_roundtrip() { + let cases = [ + (common::dec("12.5"), Unit::MassKg, Unit::MassG), + (common::dec("3"), Unit::MassLb, Unit::MassG), + (common::dec("7"), Unit::MassOz, Unit::MassG), + (common::dec("1.25"), Unit::VolumeL, Unit::VolumeMl), + ]; + for (amount, from, to) in cases { + let converted = convert_unit_decimal(amount, from, to).unwrap(); + let roundtrip = convert_unit_decimal(converted, to, from).unwrap(); + assert_eq!(roundtrip, amount, "roundtrip for {from} -> {to}"); + } +} + +#[test] +fn display_paths_for_unit_and_errors_are_exercised() { + assert_eq!(Unit::MassOz.to_string(), "oz"); + assert_eq!(ParseError::UnknownUnit.to_string(), "unknown unit string"); + assert_eq!( + ParseError::NotAMassUnit.to_string(), "unit is not a mass unit" ); assert_eq!( - RadrootsCoreUnitParseError::NotAVolumeUnit.to_string(), + ParseError::NotAVolumeUnit.to_string(), "unit is not a volume unit" ); assert_eq!( - RadrootsCoreUnitConvertError::NotMassUnit { + ConvertError::NotMassUnit { from: Unit::Each, to: Unit::MassG } @@ -208,7 +226,7 @@ fn display_paths_for_unit_and_errors_are_exercised() { "unit conversion requires mass units: each -> g" ); assert_eq!( - RadrootsCoreUnitConvertError::NotVolumeUnit { + ConvertError::NotVolumeUnit { from: Unit::Each, to: Unit::VolumeL } @@ -216,13 +234,21 @@ fn display_paths_for_unit_and_errors_are_exercised() { "unit conversion requires volume units: each -> l" ); assert_eq!( - RadrootsCoreUnitConvertError::NotConvertibleUnits { + ConvertError::NotConvertibleUnits { from: Unit::Each, to: Unit::MassG } .to_string(), "unit conversion requires matching dimensions: each -> g" ); + assert_eq!( + ConvertError::ArithmeticOverflow { + from: Unit::MassKg, + to: Unit::MassG, + } + .to_string(), + "unit conversion arithmetic overflow: kg -> g" + ); } #[test] @@ -230,7 +256,7 @@ fn convert_unit_decimal_rejects_mismatched_dimensions() { let err = convert_unit_decimal(common::dec("1"), Unit::Each, Unit::MassG).unwrap_err(); assert_eq!( err, - RadrootsCoreUnitConvertError::NotConvertibleUnits { + ConvertError::NotConvertibleUnits { from: Unit::Each, to: Unit::MassG } diff --git a/crates/event_store/contracts/source_maintenance_v1.manifest.json b/crates/event_store/contracts/source_maintenance_v1.manifest.json @@ -159,50 +159,50 @@ { "role": "core_currency_value_authority", "path": "crates/core/src/currency.rs", - "byte_length": 4095, - "sha256": "fdd7ccb88abdf7b91a362fdeeded752346331c5510ed1c769ec69a2780fa333a", + "byte_length": 4306, + "sha256": "c298f121cdf42cfa28d1446968e026e62a34f19a185365c96ab1effcc3e65146", "hash_algorithm": "sha256_bytes_v1" }, { "role": "core_decimal_value_authority", "path": "crates/core/src/decimal.rs", - "byte_length": 3851, - "sha256": "741c16a8cad7a769a7cf74458ef56141f04badb62de107966a9be85d241faa39", + "byte_length": 8223, + "sha256": "00af637869f0833f12323fffb133d832b24c9e1f72663cb98f09f09df8b92d59", "hash_algorithm": "sha256_bytes_v1" }, { "role": "core_money_value_authority", "path": "crates/core/src/money.rs", - "byte_length": 6615, - "sha256": "8e830a3a01a2252d06275306848bf2389b5e4fb6a88bb85015bc6a072da969d0", + "byte_length": 9141, + "sha256": "3dd85dbd42c2c3c097c64b516e9b79848c542ccf39ac957e0ca03916e74c92bc", "hash_algorithm": "sha256_bytes_v1" }, { "role": "core_percent_value_authority", "path": "crates/core/src/percent.rs", - "byte_length": 2266, - "sha256": "f0d7d9c66734666070620e95b9d46a22f5b21b881aa93a9a8b914fb2b5f86d69", + "byte_length": 3259, + "sha256": "432f43bd22bdf5b2114d85d3818cae9246172dfb0e02ead23820f3f632164fe1", "hash_algorithm": "sha256_bytes_v1" }, { "role": "core_quantity_value_authority", "path": "crates/core/src/quantity.rs", - "byte_length": 6189, - "sha256": "9b5450b602f6da4bf85e644bc362cec777638b4a95b6d4d4af791ec9f501d3ae", + "byte_length": 8026, + "sha256": "ce63e41aee3c48d23ecf7a9b1b9f64d74c7d45b302884f1a44e8e618fb3f2b24", "hash_algorithm": "sha256_bytes_v1" }, { "role": "core_quantity_price_value_authority", "path": "crates/core/src/quantity_price.rs", - "byte_length": 5579, - "sha256": "166a3a6a43733dcafc39f917f166710fa24f408dbaf1c5133f9a51e5732d6d20", + "byte_length": 8304, + "sha256": "a6b3d8956798979c2e9e32e4cd694c4fb3eddcd7f39cc4f343ebb21d937a4e23", "hash_algorithm": "sha256_bytes_v1" }, { "role": "core_unit_value_authority", "path": "crates/core/src/unit.rs", - "byte_length": 9280, - "sha256": "736239f31fd0864fd1c1862aa6df49d56d4d0d8991ef6e7a25d83ab1b99a5896", + "byte_length": 10306, + "sha256": "698dbe66e848b3aa92237a549690c7f6ae598be860eabbb754d2c6375a379dc8", "hash_algorithm": "sha256_bytes_v1" }, { diff --git a/crates/event_store/contracts/source_maintenance_v1.manifest.sha256 b/crates/event_store/contracts/source_maintenance_v1.manifest.sha256 @@ -1 +1 @@ -32670d9ee4b33052c41ce808b3d9ec8471dd67919ed415ce4f68ce9408ead11c +64653f0e07cbc3e3deb6a57cbba2bb5c839c4cb7258d50aa17288696997f73ca diff --git a/crates/event_store/src/generated/source_maintenance_manifest.rs b/crates/event_store/src/generated/source_maintenance_manifest.rs @@ -1,8 +1,8 @@ // @generated by `cargo xtask contract source-maintenance-manifest --write`; do not edit. -pub(crate) const SOURCE_MAINTENANCE_MANIFEST_JSON: &str = "{\n \"schema_version\": 1,\n \"contract_id\": \"radroots_event_store.source_maintenance_v1\",\n \"hook_id\": \"source_maintenance_v1\",\n \"manifest_schema\": {\n \"path\": \"crates/event_store/contracts/source_maintenance_v1.manifest.schema.json\",\n \"byte_length\": 12315,\n \"sha256\": \"ad4a6c8ae9488fc8033792bc6952af04687f312901c1847d8c668a62913bb642\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"predecessor\": {\n \"hook_id\": \"food_availability_projection_v1\",\n \"manifest\": {\n \"path\": \"crates/event_store/contracts/food_availability_projection_v1.manifest.json\",\n \"byte_length\": 17455,\n \"sha256\": \"33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n },\n \"migration\": {\n \"version\": 4,\n \"name\": \"source_maintenance\",\n \"up\": {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.up.sql\",\n \"byte_length\": 19841,\n \"sha256\": \"425dc799f392b87f265a6fb81f89c4a1c7a5db8391ab0380970708cb9c66704d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"down\": {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.down.sql\",\n \"byte_length\": 5172,\n \"sha256\": \"fe44fd53c51545c08ea479b385e6781079dab70fc63da2a3c205d727a00ce860\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"schema_sha256\": \"d526d96ea02be12b4b0aed99e97cfdde17c4474ace67111506a7b900ee78b186\",\n \"catalog\": {\n \"objects\": [\n \"radroots_event_store_source_capacity_delete_guard\",\n \"radroots_event_store_source_capacity_insert_guard\",\n \"radroots_event_store_source_capacity_marker_close_guard\",\n \"radroots_event_store_source_capacity_update_guard\",\n \"radroots_event_store_source_capacity_v1\",\n \"radroots_event_store_source_generation_capacity_advance\",\n \"radroots_event_store_source_generation_capacity_guard\"\n ],\n \"replaced_objects\": [\n \"radroots_event_store_food_availability_image_delete_guard\",\n \"radroots_event_store_food_availability_projection_delete_guard\",\n \"radroots_event_store_source_rebuild_marker_insert_guard\"\n ],\n \"tables\": [\n \"radroots_event_store_source_capacity_v1\"\n ],\n \"fts5_tables\": []\n }\n },\n \"source_maintenance\": {\n \"version\": 1,\n \"event_contract_registry_version\": 7,\n \"capacity_authority_id\": \"radroots_event_store_source_capacity_v1\",\n \"accounting\": {\n \"algorithm\": \"sqlite_cast_blob_octet_sum_v1\",\n \"raw_event_columns\": [\n \"event_id\",\n \"pubkey\",\n \"tags_json\",\n \"content\",\n \"sig\",\n \"raw_json\"\n ],\n \"raw_tag_columns\": [\n \"event_id\",\n \"tag_name\",\n \"tag_value\",\n \"tag_json\"\n ],\n \"nullable_raw_tag_columns\": [\n \"tag_value\"\n ]\n },\n \"limits\": {\n \"raw_events\": 25000,\n \"raw_tags\": 250000,\n \"raw_event_text_bytes\": 67108864,\n \"raw_tag_text_bytes\": 33554432,\n \"retained_source_generations\": 8\n },\n \"reopen_validation\": {\n \"mode\": \"bounded_full_raw_recount_v1\",\n \"raw_event_rejection_scan_bound\": 25001,\n \"raw_tag_rejection_scan_bound\": 250001,\n \"generation_history_validation\": \"bounded_count_plus_active_ordinal_v1\",\n \"retained_generation_rejection_scan_bound\": 9\n },\n \"rebuild_seal\": {\n \"nip09_hook_id\": \"nip09_reconciliation_v1\",\n \"nip09_manifest_sha256\": \"74af832420ffbaa9805e89df3c0b34f126a443e1598f757e3372f407f9003b77\",\n \"food_hook_id\": \"food_availability_projection_v1\",\n \"food_manifest_sha256\": \"33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23\",\n \"food_scope_fingerprint_sha256\": \"8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0\",\n \"active_generation_authority\": \"radroots_event_store_source_state\",\n \"marker_close_authority\": \"radroots_event_store_source_capacity_marker_close_guard\"\n }\n },\n \"entry_points\": [\n {\n \"role\": \"migration_registry\",\n \"rust_path\": \"radroots_event_store::migrations::EVENT_STORE_MIGRATIONS[3]\"\n },\n {\n \"role\": \"migration_apply_hook\",\n \"rust_path\": \"radroots_event_store::schema::apply_migration_hook\"\n },\n {\n \"role\": \"migration_validation_hook\",\n \"rust_path\": \"radroots_event_store::schema::validate_migration_hook_state\"\n },\n {\n \"role\": \"capacity_query\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::source_capacity_v1\"\n },\n {\n \"role\": \"raw_append_preflight\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::preflight_unique_raw_source_append_v1\"\n },\n {\n \"role\": \"raw_append_advance\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::advance_source_capacity_after_insert_v1\"\n },\n {\n \"role\": \"generation_append_preflight\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::preflight_source_generation_append_v1\"\n },\n {\n \"role\": \"generation_rebuild_bind\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::bind_source_capacity_to_generation_v1\"\n },\n {\n \"role\": \"sqlite_encoding_preflight\",\n \"rust_path\": \"radroots_event_store::store::validate_main_database_encoding\"\n },\n {\n \"role\": \"source_generation_history_rollback_guard\",\n \"rust_path\": \"radroots_event_store::schema::validate_rollback_preserves_source_generation_history\"\n },\n {\n \"role\": \"result_vector_executor\",\n \"rust_path\": \"source_maintenance_v1_result_vector\"\n }\n ],\n \"source_files\": [\n {\n \"role\": \"workspace_dependency_authority\",\n \"path\": \"Cargo.toml\",\n \"byte_length\": 11546,\n \"sha256\": \"0378afe281a46e02e83e128324efefc3da950abf3e1d3d06c75b628f69810bd2\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_currency_value_authority\",\n \"path\": \"crates/core/src/currency.rs\",\n \"byte_length\": 4095,\n \"sha256\": \"fdd7ccb88abdf7b91a362fdeeded752346331c5510ed1c769ec69a2780fa333a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_decimal_value_authority\",\n \"path\": \"crates/core/src/decimal.rs\",\n \"byte_length\": 3851,\n \"sha256\": \"741c16a8cad7a769a7cf74458ef56141f04badb62de107966a9be85d241faa39\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_money_value_authority\",\n \"path\": \"crates/core/src/money.rs\",\n \"byte_length\": 6615,\n \"sha256\": \"8e830a3a01a2252d06275306848bf2389b5e4fb6a88bb85015bc6a072da969d0\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_percent_value_authority\",\n \"path\": \"crates/core/src/percent.rs\",\n \"byte_length\": 2266,\n \"sha256\": \"f0d7d9c66734666070620e95b9d46a22f5b21b881aa93a9a8b914fb2b5f86d69\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_quantity_value_authority\",\n \"path\": \"crates/core/src/quantity.rs\",\n \"byte_length\": 6189,\n \"sha256\": \"9b5450b602f6da4bf85e644bc362cec777638b4a95b6d4d4af791ec9f501d3ae\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_quantity_price_value_authority\",\n \"path\": \"crates/core/src/quantity_price.rs\",\n \"byte_length\": 5579,\n \"sha256\": \"166a3a6a43733dcafc39f917f166710fa24f408dbaf1c5133f9a51e5732d6d20\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_unit_value_authority\",\n \"path\": \"crates/core/src/unit.rs\",\n \"byte_length\": 9280,\n \"sha256\": \"736239f31fd0864fd1c1862aa6df49d56d4d0d8991ef6e7a25d83ab1b99a5896\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_error_and_limits\",\n \"path\": \"crates/event_store/src/error.rs\",\n \"byte_length\": 19421,\n \"sha256\": \"4772e041cb20a4963afb2f3159804c777e2f2be61bfdb6ee267e7a7c04258972\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"generated_descriptor_registration\",\n \"path\": \"crates/event_store/src/generated.rs\",\n \"byte_length\": 144,\n \"sha256\": \"6b0a8d6f249bd4fc3f878d37cb5e418680f0f1be2d9eec2518dedf03efc47121\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_surface\",\n \"path\": \"crates/event_store/src/lib.rs\",\n \"byte_length\": 3844,\n \"sha256\": \"3cd9653bcb752fb3c4442d4904b98a0a6208011a9a238125b7b7073d7f4e312b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"migration_registry\",\n \"path\": \"crates/event_store/src/migrations.rs\",\n \"byte_length\": 73585,\n \"sha256\": \"a47477d04759ec6e71d14d3d05459864eae78fce0e3c2481a1b03147f04637f7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_model_public_surface\",\n \"path\": \"crates/event_store/src/model.rs\",\n \"byte_length\": 33617,\n \"sha256\": \"79296b8f263aa06d17005795e4515f769f064ea6fd971eeb1296e1151debaf20\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_generation_rebuild_authority\",\n \"path\": \"crates/event_store/src/nip09/reconciliation_v1.rs\",\n \"byte_length\": 184407,\n \"sha256\": \"c455d40fc736e3db264f567c7809af7bd897d89be8a33dfb21667a6ef6b8d6c6\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"schema_migration_and_reopen_authority\",\n \"path\": \"crates/event_store/src/schema.rs\",\n \"byte_length\": 146146,\n \"sha256\": \"93b060e80d3edd73f86208e4bf698fa9d53eaf1eeb04526c9261fb8b5726fb0d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_store_and_transaction_authority\",\n \"path\": \"crates/event_store/src/store.rs\",\n \"byte_length\": 394574,\n \"sha256\": \"db57dc3e35e64c7194683142fe55edba853671a829269449dd2273056dfc3a0e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"raw_ingest_capacity_authority\",\n \"path\": \"crates/event_store/src/store/protocol_reconciliation_v1.rs\",\n \"byte_length\": 30140,\n \"sha256\": \"210112eeaa6975a3b4fbb97d5c52588f8c6d8d07975e531d39737fd11235de51\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_maintenance_runtime\",\n \"path\": \"crates/event_store/src/source_maintenance_v1.rs\",\n \"byte_length\": 51756,\n \"sha256\": \"f8d5b62f0613104aa86658d5bf1baade92c7df83f00ef0cddadd734b9797afca\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"artifact_transaction_authority\",\n \"path\": \"tools/xtask/src/contract/artifact_bundle.rs\",\n \"byte_length\": 38279,\n \"sha256\": \"f326ea57b56d40135f95b6b1e15961f66eed363337180a6d12a5ea903e1a9a29\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_successor_governance\",\n \"path\": \"tools/xtask/src/contract/food_availability_projection.rs\",\n \"byte_length\": 194594,\n \"sha256\": \"4b2961fc69c8204ad5470c0e51c93533063e49d7a14da00a53598c3e8d0cdf6b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"transitive_predecessor_membership_governance\",\n \"path\": \"tools/xtask/src/contract/nip09_reconciliation.rs\",\n \"byte_length\": 835585,\n \"sha256\": \"51d524cb00d2d96dc0da35fc9133c54f2e5328a830d56b8d5e83a7f1e9654ddc\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_maintenance_governance\",\n \"path\": \"tools/xtask/src/contract/source_maintenance.rs\",\n \"byte_length\": 177996,\n \"sha256\": \"c309da38f12101d77c4de1caa49ef443252cac6238b49692182851c2cf894bda\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"contract_command_authority\",\n \"path\": \"tools/xtask/src/contract.rs\",\n \"byte_length\": 500241,\n \"sha256\": \"972e2a6b1099267e11d9a34e73fd38b20fc217a3dd79bd9a0f1f060b680ba2ba\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"xtask_dispatch_and_release_preflight\",\n \"path\": \"tools/xtask/src/main.rs\",\n \"byte_length\": 14976,\n \"sha256\": \"42b59a8576e4f86b091941a64349ac9eaa20b1e3b52aef367bb3d5947d8ccd1d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n ],\n \"public_api\": {\n \"inherited_predecessor_symbols\": [\n \"RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_D_TAG_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_RAW_JSON_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_BYTES_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1\",\n \"RadrootsAddressableTransitionCauseV1\",\n \"RadrootsAddressableTransitionCoordinateV1\",\n \"RadrootsAddressableTransitionCursorV1\",\n \"RadrootsAddressableTransitionEventReferenceV1\",\n \"RadrootsAddressableTransitionOriginV1\",\n \"RadrootsAddressableTransitionPageV1\",\n \"RadrootsAddressableTransitionRawHeadDecisionV1\",\n \"RadrootsAddressableTransitionScopeFingerprintV1\",\n \"RadrootsAddressableTransitionScopeV1\",\n \"RadrootsAddressableTransitionV1\",\n \"RadrootsAddressableTransitionVisibilityV1\",\n \"RadrootsCurrentEventVisibilityV1\",\n \"RadrootsCurrentVisibilityDecisionV1\",\n \"RadrootsFoodAvailabilitySearchQueryV1\",\n \"RadrootsFoodAvailabilityStatusFilterV1\",\n \"RadrootsNip09SuppressionEvidenceV1\",\n \"RadrootsNip09SuppressionOutcome\",\n \"RadrootsNip09SuppressionReason\",\n \"RadrootsStoreProducedCanonicalEventV1\",\n \"RadrootsStoredFoodAvailabilityImageV1\",\n \"RadrootsStoredFoodAvailabilityV1\"\n ],\n \"added_symbols\": [\n \"RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1\",\n \"RadrootsEventStoreSourceCapacityResourceV1\",\n \"RadrootsEventStoreSourceCapacityV1\"\n ],\n \"methods\": [\n \"RadrootsEventStore::source_capacity_v1\",\n \"RadrootsEventStoreSourceCapacityResourceV1::as_str\",\n \"RadrootsEventStoreSourceCapacityV1::source_generation\",\n \"RadrootsEventStoreSourceCapacityV1::raw_event_count\",\n \"RadrootsEventStoreSourceCapacityV1::raw_tag_count\",\n \"RadrootsEventStoreSourceCapacityV1::raw_event_text_bytes\",\n \"RadrootsEventStoreSourceCapacityV1::raw_tag_text_bytes\",\n \"RadrootsEventStoreSourceCapacityV1::raw_high_water_seq\",\n \"RadrootsEventStoreSourceCapacityV1::retained_generation_count\",\n \"RadrootsEventStoreSourceCapacityV1::retained_generation_limit\"\n ],\n \"error_variants\": [\n \"SourceCapacityExceeded\",\n \"SourceGenerationHistoryLimitReached\",\n \"PersistedEphemeralRawEvent\",\n \"SourceCapacityStateDrift\",\n \"SqliteMainDatabaseEncodingNotUtf8\",\n \"RollbackWouldDiscardSourceGenerationHistory\"\n ],\n \"removed_symbols\": [\n \"RadrootsEventStoreReconciliationResource\",\n \"RadrootsEventStoreError::ReconciliationCapacityExceeded\"\n ],\n \"breaking_replacements\": [\n {\n \"removed\": \"RadrootsEventStoreReconciliationResource\",\n \"replacement\": \"RadrootsEventStoreSourceCapacityResourceV1\"\n },\n {\n \"removed\": \"RadrootsEventStoreError::ReconciliationCapacityExceeded\",\n \"replacement\": \"RadrootsEventStoreError::SourceCapacityExceeded\"\n }\n ]\n },\n \"result_vector\": {\n \"canonical_path\": \"contracts/conformance/vectors/event_store/source_maintenance.v1.json\",\n \"mirror_path\": \"crates/event_store/tests/fixtures/source_maintenance.v1.json\",\n \"byte_length\": 16253,\n \"sha256\": \"997aba2604a2b9d199fb87dc9d07942ca50d91863aeadcf3eeacf16d191dd71f\",\n \"hash_algorithm\": \"sha256_bytes_v1\",\n \"executor_id\": \"radroots_event_store.source_maintenance_v1.result_vector_executor.v1\",\n \"executor_path\": \"crates/event_store/tests/source_maintenance_v1_result_vector.rs\",\n \"executor_test\": \"source_maintenance_v1_result_vector\",\n \"executor_byte_length\": 23510,\n \"executor_sha256\": \"a7487afdfe19fc5fc794811d0f0e6035203e1aabcf0a33a1d398f6b3555d38f3\",\n \"executor_hash_algorithm\": \"sha256_bytes_v1\"\n }\n}\n"; -pub(crate) const SOURCE_MAINTENANCE_MANIFEST_BYTE_LENGTH: usize = 16259; +pub(crate) const SOURCE_MAINTENANCE_MANIFEST_JSON: &str = "{\n \"schema_version\": 1,\n \"contract_id\": \"radroots_event_store.source_maintenance_v1\",\n \"hook_id\": \"source_maintenance_v1\",\n \"manifest_schema\": {\n \"path\": \"crates/event_store/contracts/source_maintenance_v1.manifest.schema.json\",\n \"byte_length\": 12315,\n \"sha256\": \"ad4a6c8ae9488fc8033792bc6952af04687f312901c1847d8c668a62913bb642\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"predecessor\": {\n \"hook_id\": \"food_availability_projection_v1\",\n \"manifest\": {\n \"path\": \"crates/event_store/contracts/food_availability_projection_v1.manifest.json\",\n \"byte_length\": 17455,\n \"sha256\": \"33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n },\n \"migration\": {\n \"version\": 4,\n \"name\": \"source_maintenance\",\n \"up\": {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.up.sql\",\n \"byte_length\": 19841,\n \"sha256\": \"425dc799f392b87f265a6fb81f89c4a1c7a5db8391ab0380970708cb9c66704d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"down\": {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.down.sql\",\n \"byte_length\": 5172,\n \"sha256\": \"fe44fd53c51545c08ea479b385e6781079dab70fc63da2a3c205d727a00ce860\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"schema_sha256\": \"d526d96ea02be12b4b0aed99e97cfdde17c4474ace67111506a7b900ee78b186\",\n \"catalog\": {\n \"objects\": [\n \"radroots_event_store_source_capacity_delete_guard\",\n \"radroots_event_store_source_capacity_insert_guard\",\n \"radroots_event_store_source_capacity_marker_close_guard\",\n \"radroots_event_store_source_capacity_update_guard\",\n \"radroots_event_store_source_capacity_v1\",\n \"radroots_event_store_source_generation_capacity_advance\",\n \"radroots_event_store_source_generation_capacity_guard\"\n ],\n \"replaced_objects\": [\n \"radroots_event_store_food_availability_image_delete_guard\",\n \"radroots_event_store_food_availability_projection_delete_guard\",\n \"radroots_event_store_source_rebuild_marker_insert_guard\"\n ],\n \"tables\": [\n \"radroots_event_store_source_capacity_v1\"\n ],\n \"fts5_tables\": []\n }\n },\n \"source_maintenance\": {\n \"version\": 1,\n \"event_contract_registry_version\": 7,\n \"capacity_authority_id\": \"radroots_event_store_source_capacity_v1\",\n \"accounting\": {\n \"algorithm\": \"sqlite_cast_blob_octet_sum_v1\",\n \"raw_event_columns\": [\n \"event_id\",\n \"pubkey\",\n \"tags_json\",\n \"content\",\n \"sig\",\n \"raw_json\"\n ],\n \"raw_tag_columns\": [\n \"event_id\",\n \"tag_name\",\n \"tag_value\",\n \"tag_json\"\n ],\n \"nullable_raw_tag_columns\": [\n \"tag_value\"\n ]\n },\n \"limits\": {\n \"raw_events\": 25000,\n \"raw_tags\": 250000,\n \"raw_event_text_bytes\": 67108864,\n \"raw_tag_text_bytes\": 33554432,\n \"retained_source_generations\": 8\n },\n \"reopen_validation\": {\n \"mode\": \"bounded_full_raw_recount_v1\",\n \"raw_event_rejection_scan_bound\": 25001,\n \"raw_tag_rejection_scan_bound\": 250001,\n \"generation_history_validation\": \"bounded_count_plus_active_ordinal_v1\",\n \"retained_generation_rejection_scan_bound\": 9\n },\n \"rebuild_seal\": {\n \"nip09_hook_id\": \"nip09_reconciliation_v1\",\n \"nip09_manifest_sha256\": \"74af832420ffbaa9805e89df3c0b34f126a443e1598f757e3372f407f9003b77\",\n \"food_hook_id\": \"food_availability_projection_v1\",\n \"food_manifest_sha256\": \"33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23\",\n \"food_scope_fingerprint_sha256\": \"8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0\",\n \"active_generation_authority\": \"radroots_event_store_source_state\",\n \"marker_close_authority\": \"radroots_event_store_source_capacity_marker_close_guard\"\n }\n },\n \"entry_points\": [\n {\n \"role\": \"migration_registry\",\n \"rust_path\": \"radroots_event_store::migrations::EVENT_STORE_MIGRATIONS[3]\"\n },\n {\n \"role\": \"migration_apply_hook\",\n \"rust_path\": \"radroots_event_store::schema::apply_migration_hook\"\n },\n {\n \"role\": \"migration_validation_hook\",\n \"rust_path\": \"radroots_event_store::schema::validate_migration_hook_state\"\n },\n {\n \"role\": \"capacity_query\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::source_capacity_v1\"\n },\n {\n \"role\": \"raw_append_preflight\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::preflight_unique_raw_source_append_v1\"\n },\n {\n \"role\": \"raw_append_advance\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::advance_source_capacity_after_insert_v1\"\n },\n {\n \"role\": \"generation_append_preflight\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::preflight_source_generation_append_v1\"\n },\n {\n \"role\": \"generation_rebuild_bind\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::bind_source_capacity_to_generation_v1\"\n },\n {\n \"role\": \"sqlite_encoding_preflight\",\n \"rust_path\": \"radroots_event_store::store::validate_main_database_encoding\"\n },\n {\n \"role\": \"source_generation_history_rollback_guard\",\n \"rust_path\": \"radroots_event_store::schema::validate_rollback_preserves_source_generation_history\"\n },\n {\n \"role\": \"result_vector_executor\",\n \"rust_path\": \"source_maintenance_v1_result_vector\"\n }\n ],\n \"source_files\": [\n {\n \"role\": \"workspace_dependency_authority\",\n \"path\": \"Cargo.toml\",\n \"byte_length\": 11546,\n \"sha256\": \"0378afe281a46e02e83e128324efefc3da950abf3e1d3d06c75b628f69810bd2\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_currency_value_authority\",\n \"path\": \"crates/core/src/currency.rs\",\n \"byte_length\": 4306,\n \"sha256\": \"c298f121cdf42cfa28d1446968e026e62a34f19a185365c96ab1effcc3e65146\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_decimal_value_authority\",\n \"path\": \"crates/core/src/decimal.rs\",\n \"byte_length\": 8223,\n \"sha256\": \"00af637869f0833f12323fffb133d832b24c9e1f72663cb98f09f09df8b92d59\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_money_value_authority\",\n \"path\": \"crates/core/src/money.rs\",\n \"byte_length\": 9141,\n \"sha256\": \"3dd85dbd42c2c3c097c64b516e9b79848c542ccf39ac957e0ca03916e74c92bc\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_percent_value_authority\",\n \"path\": \"crates/core/src/percent.rs\",\n \"byte_length\": 3259,\n \"sha256\": \"432f43bd22bdf5b2114d85d3818cae9246172dfb0e02ead23820f3f632164fe1\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_quantity_value_authority\",\n \"path\": \"crates/core/src/quantity.rs\",\n \"byte_length\": 8026,\n \"sha256\": \"ce63e41aee3c48d23ecf7a9b1b9f64d74c7d45b302884f1a44e8e618fb3f2b24\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_quantity_price_value_authority\",\n \"path\": \"crates/core/src/quantity_price.rs\",\n \"byte_length\": 8304,\n \"sha256\": \"a6b3d8956798979c2e9e32e4cd694c4fb3eddcd7f39cc4f343ebb21d937a4e23\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_unit_value_authority\",\n \"path\": \"crates/core/src/unit.rs\",\n \"byte_length\": 10306,\n \"sha256\": \"698dbe66e848b3aa92237a549690c7f6ae598be860eabbb754d2c6375a379dc8\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_error_and_limits\",\n \"path\": \"crates/event_store/src/error.rs\",\n \"byte_length\": 19421,\n \"sha256\": \"4772e041cb20a4963afb2f3159804c777e2f2be61bfdb6ee267e7a7c04258972\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"generated_descriptor_registration\",\n \"path\": \"crates/event_store/src/generated.rs\",\n \"byte_length\": 144,\n \"sha256\": \"6b0a8d6f249bd4fc3f878d37cb5e418680f0f1be2d9eec2518dedf03efc47121\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_surface\",\n \"path\": \"crates/event_store/src/lib.rs\",\n \"byte_length\": 3844,\n \"sha256\": \"3cd9653bcb752fb3c4442d4904b98a0a6208011a9a238125b7b7073d7f4e312b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"migration_registry\",\n \"path\": \"crates/event_store/src/migrations.rs\",\n \"byte_length\": 73585,\n \"sha256\": \"a47477d04759ec6e71d14d3d05459864eae78fce0e3c2481a1b03147f04637f7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_model_public_surface\",\n \"path\": \"crates/event_store/src/model.rs\",\n \"byte_length\": 33617,\n \"sha256\": \"79296b8f263aa06d17005795e4515f769f064ea6fd971eeb1296e1151debaf20\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_generation_rebuild_authority\",\n \"path\": \"crates/event_store/src/nip09/reconciliation_v1.rs\",\n \"byte_length\": 184407,\n \"sha256\": \"c455d40fc736e3db264f567c7809af7bd897d89be8a33dfb21667a6ef6b8d6c6\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"schema_migration_and_reopen_authority\",\n \"path\": \"crates/event_store/src/schema.rs\",\n \"byte_length\": 146146,\n \"sha256\": \"93b060e80d3edd73f86208e4bf698fa9d53eaf1eeb04526c9261fb8b5726fb0d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_store_and_transaction_authority\",\n \"path\": \"crates/event_store/src/store.rs\",\n \"byte_length\": 394574,\n \"sha256\": \"db57dc3e35e64c7194683142fe55edba853671a829269449dd2273056dfc3a0e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"raw_ingest_capacity_authority\",\n \"path\": \"crates/event_store/src/store/protocol_reconciliation_v1.rs\",\n \"byte_length\": 30140,\n \"sha256\": \"210112eeaa6975a3b4fbb97d5c52588f8c6d8d07975e531d39737fd11235de51\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_maintenance_runtime\",\n \"path\": \"crates/event_store/src/source_maintenance_v1.rs\",\n \"byte_length\": 51756,\n \"sha256\": \"f8d5b62f0613104aa86658d5bf1baade92c7df83f00ef0cddadd734b9797afca\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"artifact_transaction_authority\",\n \"path\": \"tools/xtask/src/contract/artifact_bundle.rs\",\n \"byte_length\": 38279,\n \"sha256\": \"f326ea57b56d40135f95b6b1e15961f66eed363337180a6d12a5ea903e1a9a29\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_successor_governance\",\n \"path\": \"tools/xtask/src/contract/food_availability_projection.rs\",\n \"byte_length\": 194594,\n \"sha256\": \"4b2961fc69c8204ad5470c0e51c93533063e49d7a14da00a53598c3e8d0cdf6b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"transitive_predecessor_membership_governance\",\n \"path\": \"tools/xtask/src/contract/nip09_reconciliation.rs\",\n \"byte_length\": 835585,\n \"sha256\": \"51d524cb00d2d96dc0da35fc9133c54f2e5328a830d56b8d5e83a7f1e9654ddc\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_maintenance_governance\",\n \"path\": \"tools/xtask/src/contract/source_maintenance.rs\",\n \"byte_length\": 177996,\n \"sha256\": \"c309da38f12101d77c4de1caa49ef443252cac6238b49692182851c2cf894bda\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"contract_command_authority\",\n \"path\": \"tools/xtask/src/contract.rs\",\n \"byte_length\": 500241,\n \"sha256\": \"972e2a6b1099267e11d9a34e73fd38b20fc217a3dd79bd9a0f1f060b680ba2ba\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"xtask_dispatch_and_release_preflight\",\n \"path\": \"tools/xtask/src/main.rs\",\n \"byte_length\": 14976,\n \"sha256\": \"42b59a8576e4f86b091941a64349ac9eaa20b1e3b52aef367bb3d5947d8ccd1d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n ],\n \"public_api\": {\n \"inherited_predecessor_symbols\": [\n \"RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_D_TAG_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_RAW_JSON_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_BYTES_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1\",\n \"RadrootsAddressableTransitionCauseV1\",\n \"RadrootsAddressableTransitionCoordinateV1\",\n \"RadrootsAddressableTransitionCursorV1\",\n \"RadrootsAddressableTransitionEventReferenceV1\",\n \"RadrootsAddressableTransitionOriginV1\",\n \"RadrootsAddressableTransitionPageV1\",\n \"RadrootsAddressableTransitionRawHeadDecisionV1\",\n \"RadrootsAddressableTransitionScopeFingerprintV1\",\n \"RadrootsAddressableTransitionScopeV1\",\n \"RadrootsAddressableTransitionV1\",\n \"RadrootsAddressableTransitionVisibilityV1\",\n \"RadrootsCurrentEventVisibilityV1\",\n \"RadrootsCurrentVisibilityDecisionV1\",\n \"RadrootsFoodAvailabilitySearchQueryV1\",\n \"RadrootsFoodAvailabilityStatusFilterV1\",\n \"RadrootsNip09SuppressionEvidenceV1\",\n \"RadrootsNip09SuppressionOutcome\",\n \"RadrootsNip09SuppressionReason\",\n \"RadrootsStoreProducedCanonicalEventV1\",\n \"RadrootsStoredFoodAvailabilityImageV1\",\n \"RadrootsStoredFoodAvailabilityV1\"\n ],\n \"added_symbols\": [\n \"RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1\",\n \"RadrootsEventStoreSourceCapacityResourceV1\",\n \"RadrootsEventStoreSourceCapacityV1\"\n ],\n \"methods\": [\n \"RadrootsEventStore::source_capacity_v1\",\n \"RadrootsEventStoreSourceCapacityResourceV1::as_str\",\n \"RadrootsEventStoreSourceCapacityV1::source_generation\",\n \"RadrootsEventStoreSourceCapacityV1::raw_event_count\",\n \"RadrootsEventStoreSourceCapacityV1::raw_tag_count\",\n \"RadrootsEventStoreSourceCapacityV1::raw_event_text_bytes\",\n \"RadrootsEventStoreSourceCapacityV1::raw_tag_text_bytes\",\n \"RadrootsEventStoreSourceCapacityV1::raw_high_water_seq\",\n \"RadrootsEventStoreSourceCapacityV1::retained_generation_count\",\n \"RadrootsEventStoreSourceCapacityV1::retained_generation_limit\"\n ],\n \"error_variants\": [\n \"SourceCapacityExceeded\",\n \"SourceGenerationHistoryLimitReached\",\n \"PersistedEphemeralRawEvent\",\n \"SourceCapacityStateDrift\",\n \"SqliteMainDatabaseEncodingNotUtf8\",\n \"RollbackWouldDiscardSourceGenerationHistory\"\n ],\n \"removed_symbols\": [\n \"RadrootsEventStoreReconciliationResource\",\n \"RadrootsEventStoreError::ReconciliationCapacityExceeded\"\n ],\n \"breaking_replacements\": [\n {\n \"removed\": \"RadrootsEventStoreReconciliationResource\",\n \"replacement\": \"RadrootsEventStoreSourceCapacityResourceV1\"\n },\n {\n \"removed\": \"RadrootsEventStoreError::ReconciliationCapacityExceeded\",\n \"replacement\": \"RadrootsEventStoreError::SourceCapacityExceeded\"\n }\n ]\n },\n \"result_vector\": {\n \"canonical_path\": \"contracts/conformance/vectors/event_store/source_maintenance.v1.json\",\n \"mirror_path\": \"crates/event_store/tests/fixtures/source_maintenance.v1.json\",\n \"byte_length\": 16253,\n \"sha256\": \"997aba2604a2b9d199fb87dc9d07942ca50d91863aeadcf3eeacf16d191dd71f\",\n \"hash_algorithm\": \"sha256_bytes_v1\",\n \"executor_id\": \"radroots_event_store.source_maintenance_v1.result_vector_executor.v1\",\n \"executor_path\": \"crates/event_store/tests/source_maintenance_v1_result_vector.rs\",\n \"executor_test\": \"source_maintenance_v1_result_vector\",\n \"executor_byte_length\": 23510,\n \"executor_sha256\": \"a7487afdfe19fc5fc794811d0f0e6035203e1aabcf0a33a1d398f6b3555d38f3\",\n \"executor_hash_algorithm\": \"sha256_bytes_v1\"\n }\n}\n"; +pub(crate) const SOURCE_MAINTENANCE_MANIFEST_BYTE_LENGTH: usize = 16260; pub(crate) const SOURCE_MAINTENANCE_MANIFEST_SHA256: &str = - "32670d9ee4b33052c41ce808b3d9ec8471dd67919ed415ce4f68ce9408ead11c"; + "64653f0e07cbc3e3deb6a57cbba2bb5c839c4cb7258d50aa17288696997f73ca"; pub(crate) const SOURCE_MAINTENANCE_MANIFEST_SCHEMA_VERSION: u32 = 1; pub(crate) const SOURCE_MAINTENANCE_CONTRACT_ID: &str = "radroots_event_store.source_maintenance_v1";