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:
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";