lib

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

commit 87d778705b1a33798f17f8f68bcd320ac7981ddb
parent 1ecff77ac9914f546df3c8bc846f90bc4c0b341f
Author: triesap <tyson@radroots.org>
Date:   Sun, 21 Dec 2025 22:54:59 +0000

core: harden APIs and add test suite


- make no_std gating consistent for serde/typeshare and String usage
- tighten currency/unit conversions and exact minor-unit handling
- remove panic-y ops from money/quantity and update decimal conversion API
- add comprehensive core integration tests and dev deps

Diffstat:
AAGENTS.md | 40++++++++++++++++++++++++++++++++++++++++
MCargo.lock | 1+
Mcore/Cargo.toml | 7+++++--
Mcore/src/currency.rs | 25+++++++++++++++++++++----
Mcore/src/decimal.rs | 6+++---
Mcore/src/discount.rs | 13+++++++++----
Mcore/src/lib.rs | 9+++++++--
Mcore/src/money.rs | 34+++-------------------------------
Mcore/src/percent.rs | 2+-
Mcore/src/quantity.rs | 43+++++++------------------------------------
Mcore/src/quantity_price.rs | 20+++++++++++++-------
Mcore/src/serde_ext.rs | 5+++++
Mcore/src/unit.rs | 36+++++++++++++++++++++++++++++++++---
Acore/tests/common/mod.rs | 28++++++++++++++++++++++++++++
Acore/tests/currency.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/tests/decimal.rs | 32++++++++++++++++++++++++++++++++
Acore/tests/discount.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/tests/money.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/tests/percent.rs | 39+++++++++++++++++++++++++++++++++++++++
Acore/tests/quantity.rs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/tests/quantity_price.rs | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/tests/serde.rs | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/tests/unit.rs | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
23 files changed, 744 insertions(+), 93 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -0,0 +1,40 @@ +# Rad Roots - Code Directives + +## Purpose +- The crates are a shared Rust library layer used by Radroots networking apps and libraries across web (wasm), native, daemons, and embedded systems. Prioritize portability, correctness, and low overhead. + +## Scope +- Applies to the workspace in this repository. + +## Workspace Architecture +- core: no_std core value types (money, currency, quantity, percent, discount, unit) with serde/typeshare gates. +- types: API wrapper types (IError, IResult, IResultList) with ts-rs support. +- events: Nostr event models (post, profile, job, tags, kinds) with ts-rs support. +- events-codec: encode/decode for events (jobs, profiles) for nostr payloads. +- events-indexed: manifest/checkpoint/types for indexed event archives (typeshare + serde gates). +- nostr: Nostr utilities (filters, tags, relays, parsing) and SDK adapters. +- log: tracing-based logging helpers with std/no_std split. +- runtime: config loading, JSON IO, tracing init, signals, CLI helpers. +- identity: identity spec + load/generate utilities, built on runtime. +- net-core: networking core, build info, config, optional tokio runtime and Nostr client. +- net: thin re-export of net-core. +- sql-core: SQL executor trait + migrations for native/web/embedded targets. +- sql-wasm-bridge: wasm JS bridge for exec/query and savepoint transactions. +- sql-wasm-core: wasm-bindgen exports + error marshaling for SQL. +- tangle-schema: Tangle schema models and relation types (ts-rs bindings). +- tangle-sql: SQL access layer for Tangle schema, migrations, backup/restore. +- tangle-sql-wasm: wasm-bindgen exports for Tangle SQL operations. +- trade: trade/listing domain models and tags. + +## Rust Code Directives +- Toolchain: Rust 1.86, edition 2024; use workspace versions from the root Cargo.toml. +- Portability: preserve no_std patterns; gate std usage with cfg(feature = "std") and use alloc when needed. +- Safety: avoid unsafe; prefer safe, explicit APIs. Add #![forbid(unsafe_code)] on new crates/modules. +- Public API: keep Radroots* prefix; avoid hidden panics; return Result/Option for fallible ops; use precise error enums (thiserror where appropriate). +- Features: keep serde/typeshare/ts-rs derives behind existing feature gates and in the current style; ensure feature combinations compile (no_std, std, wasm). +- Generated outputs: treat */bindings/ts/src/types.ts as generated; do not hand-edit. +- Performance: borrow over clone, avoid intermediate allocations, preallocate when sizes are known, and prefer iterators over indexing loops. +- DRY: consolidate shared logic into core/types/events-codec or dedicated helpers. +- Parity: maintain feature parity across native/wasm layers when adding SQL or Tangle APIs. +- Module layout: keep lib.rs as a module manifest and re-export surface; avoid heavy logic in lib.rs. +- Testing: add or update unit tests for new behavior and edge cases, especially around parsing, invariants, conversions, and rounding. diff --git a/Cargo.lock b/Cargo.lock @@ -1696,6 +1696,7 @@ dependencies = [ "rust_decimal", "rust_decimal_macros", "serde", + "serde_json", "typeshare", ] diff --git a/core/Cargo.toml b/core/Cargo.toml @@ -16,4 +16,8 @@ typeshare = ["dep:typeshare"] rust_decimal = { workspace = true, default-features = false } rust_decimal_macros = { workspace = true } serde = { workspace = true, default-features = false, features = ["alloc", "derive"], optional = true } -typeshare = { workspace = true, optional = true } -\ No newline at end of file +typeshare = { workspace = true, optional = true } + +[dev-dependencies] +serde_json = { workspace = true } +rust_decimal = { workspace = true } diff --git a/core/src/currency.rs b/core/src/currency.rs @@ -2,16 +2,33 @@ use core::fmt; use core::str::FromStr; #[cfg(feature = "serde")] +#[cfg(feature = "std")] +use std::string::String; +#[cfg(all(feature = "serde", not(feature = "std")))] +use alloc::string::String; + +#[cfg(feature = "serde")] use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as DeError}; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct RadrootsCoreCurrency([u8; 3]); impl RadrootsCoreCurrency { #[inline] - pub const fn from_const(bytes: [u8; 3]) -> Self { - Self(bytes) + pub const fn from_const(bytes: [u8; 3]) -> Result<Self, RadrootsCoreCurrencyParseError> { + 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) + } + } + + const fn is_ascii_upper(byte: u8) -> bool { + byte >= b'A' && byte <= b'Z' } #[inline] @@ -25,7 +42,7 @@ impl RadrootsCoreCurrency { #[inline] pub fn as_str(&self) -> &str { - core::str::from_utf8(&self.0).expect("currency bytes are validated on construction") + core::str::from_utf8(&self.0).unwrap_or("???") } pub const USD: RadrootsCoreCurrency = RadrootsCoreCurrency(*b"USD"); diff --git a/core/src/decimal.rs b/core/src/decimal.rs @@ -7,7 +7,7 @@ use rust_decimal::prelude::ToPrimitive; #[cfg(feature = "serde")] use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as DeError}; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] pub struct RadrootsCoreDecimal(pub Decimal); @@ -48,8 +48,8 @@ impl RadrootsCoreDecimal { Decimal::from_str(&s).map(Self) } #[inline] - pub fn to_f64_lossy(&self) -> f64 { - self.normalize().to_string().parse::<f64>().unwrap_or(0.0) + pub fn to_f64_lossy(&self) -> Option<f64> { + self.0.to_f64() } #[inline] diff --git a/core/src/discount.rs b/core/src/discount.rs @@ -1,16 +1,21 @@ -#[typeshare::typeshare] +#[cfg(feature = "std")] +use std::string::String; +#[cfg(not(feature = "std"))] +use alloc::string::String; + +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] -#[serde(rename_all = "snake_case", tag = "kind", content = "amount")] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount"))] pub enum RadrootsCoreDiscountValue { Money(crate::RadrootsCoreMoney), Percent(crate::RadrootsCorePercent), } -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] -#[serde(rename_all = "snake_case", tag = "kind", content = "amount")] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount"))] pub enum RadrootsCoreDiscount { QuantityThreshold { ref_key: Option<String>, diff --git a/core/src/lib.rs b/core/src/lib.rs @@ -20,5 +20,10 @@ pub use discount::{RadrootsCoreDiscount, RadrootsCoreDiscountValue}; pub use money::{RadrootsCoreMoney, RadrootsCoreMoneyInvariantError}; pub use percent::{RadrootsCorePercent, RadrootsCorePercentParseError}; pub use quantity::{RadrootsCoreQuantity, RadrootsCoreQuantityInvariantError}; -pub use quantity_price::{RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceOps}; -pub use unit::{RadrootsCoreUnit, RadrootsCoreUnitParseError}; +pub use quantity_price::{ + RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError, RadrootsCoreQuantityPriceOps, +}; +pub use unit::{ + convert_mass_decimal, parse_mass_unit, RadrootsCoreUnit, RadrootsCoreUnitConvertError, + RadrootsCoreUnitParseError, +}; diff --git a/core/src/money.rs b/core/src/money.rs @@ -3,7 +3,7 @@ use rust_decimal::Decimal; use rust_decimal::RoundingStrategy; use rust_decimal::prelude::ToPrimitive; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsCoreMoney { @@ -132,11 +132,7 @@ impl RadrootsCoreMoney { #[inline] pub fn to_minor_units_u64_exact(&self) -> Result<u64, RadrootsCoreMoneyInvariantError> { let e = self.currency.minor_unit_exponent(); - let scaled = self - .amount - .0 - .round_dp_with_strategy(e, RoundingStrategy::MidpointAwayFromZero); - let as_minor = scaled * Self::pow10(e); + let as_minor = self.amount.0 * Self::pow10(e); if !as_minor.fract().is_zero() { return Err(RadrootsCoreMoneyInvariantError::NotWholeMinorUnits); @@ -184,31 +180,7 @@ impl fmt::Display for RadrootsCoreMoney { } } -use core::ops::{Add, Div, Mul, Sub}; - -impl Add for RadrootsCoreMoney { - type Output = Self; - fn add(self, rhs: Self) -> Self { - assert_eq!( - self.currency, rhs.currency, - "money currency mismatch: {} vs {}", - self.currency, rhs.currency - ); - Self::new(self.amount + rhs.amount, self.currency) - } -} - -impl Sub for RadrootsCoreMoney { - type Output = Self; - fn sub(self, rhs: Self) -> Self { - assert_eq!( - self.currency, rhs.currency, - "money currency mismatch: {} vs {}", - self.currency, rhs.currency - ); - Self::new(self.amount - rhs.amount, self.currency) - } -} +use core::ops::{Div, Mul}; impl Mul<crate::RadrootsCoreDecimal> for RadrootsCoreMoney { type Output = Self; diff --git a/core/src/percent.rs b/core/src/percent.rs @@ -4,7 +4,7 @@ use core::str::FromStr; use crate::RadrootsCoreDecimal; use crate::money::RadrootsCoreMoney; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsCorePercent { diff --git a/core/src/quantity.rs b/core/src/quantity.rs @@ -3,7 +3,12 @@ use core::fmt; use crate::RadrootsCoreDecimal; use crate::unit::RadrootsCoreUnit; -#[typeshare::typeshare] +#[cfg(feature = "std")] +use std::string::String; +#[cfg(not(feature = "std"))] +use alloc::string::String; + +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsCoreQuantity { @@ -176,41 +181,7 @@ impl fmt::Display for RadrootsCoreQuantityInvariantError { #[cfg(feature = "std")] impl std::error::Error for RadrootsCoreQuantityInvariantError {} -use core::ops::{Add, Div, Mul, Sub}; - -impl Add for RadrootsCoreQuantity { - type Output = RadrootsCoreQuantity; - fn add(self, rhs: RadrootsCoreQuantity) -> RadrootsCoreQuantity { - assert!( - self.unit == rhs.unit, - "quantity unit mismatch: {} vs {}", - self.unit, - rhs.unit - ); - RadrootsCoreQuantity { - amount: self.amount + rhs.amount, - unit: self.unit, - label: self.label, - } - } -} - -impl Sub for RadrootsCoreQuantity { - type Output = RadrootsCoreQuantity; - fn sub(self, rhs: RadrootsCoreQuantity) -> RadrootsCoreQuantity { - assert!( - self.unit == rhs.unit, - "quantity unit mismatch: {} vs {}", - self.unit, - rhs.unit - ); - RadrootsCoreQuantity { - amount: self.amount - rhs.amount, - unit: self.unit, - label: self.label, - } - } -} +use core::ops::{Div, Mul}; impl Mul<RadrootsCoreDecimal> for RadrootsCoreQuantity { type Output = RadrootsCoreQuantity; diff --git a/core/src/quantity_price.rs b/core/src/quantity_price.rs @@ -1,6 +1,6 @@ use crate::{RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreUnit}; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsCoreQuantityPrice { @@ -62,13 +62,13 @@ impl RadrootsCoreQuantityPrice { let normalized = if unit == target { amount - } else if unit.is_mass() && target.is_mass() { - convert_mass_decimal(amount, unit, target) } else { - return Err(RadrootsCoreQuantityPriceError::NonConvertibleUnits { - from: unit, - to: target, - }); + convert_mass_decimal(amount, unit, target).map_err(|_| { + RadrootsCoreQuantityPriceError::NonConvertibleUnits { + from: unit, + to: target, + } + })? }; let qty = RadrootsCoreQuantity::new(normalized, target); @@ -85,6 +85,9 @@ impl RadrootsCoreQuantityPriceOps for RadrootsCoreQuantityPrice { if self.quantity.amount.is_zero() { return RadrootsCoreMoney::zero(self.amount.currency); } + if qty.unit != self.quantity.unit { + return RadrootsCoreMoney::zero(self.amount.currency); + } let ratio = qty.amount / self.quantity.amount; self.amount.mul_decimal(ratio) @@ -103,6 +106,9 @@ impl RadrootsCoreQuantityPriceOps for RadrootsCoreQuantityPrice { if self.quantity.amount.is_zero() { return RadrootsCoreMoney::zero(self.amount.currency); } + if qty.unit != self.quantity.unit { + return RadrootsCoreMoney::zero(self.amount.currency); + } let unit_price_q = self.amount.clone().quantize_to_currency(); unit_price_q.mul_decimal(qty.amount / self.quantity.amount) } diff --git a/core/src/serde_ext.rs b/core/src/serde_ext.rs @@ -1,5 +1,10 @@ #![cfg(feature = "serde")] +#[cfg(feature = "std")] +use std::string::String; +#[cfg(not(feature = "std"))] +use alloc::string::String; + use serde::{Deserialize, Deserializer, Serializer, de::Error as DeError}; pub mod decimal_str { diff --git a/core/src/unit.rs b/core/src/unit.rs @@ -3,11 +3,17 @@ use core::str::FromStr; use rust_decimal_macros::dec; #[cfg(feature = "serde")] +#[cfg(feature = "std")] +use std::string::String; +#[cfg(all(feature = "serde", not(feature = "std")))] +use alloc::string::String; + +#[cfg(feature = "serde")] use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as DeError}; use crate::RadrootsCoreDecimal; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum RadrootsCoreUnit { Each, @@ -94,6 +100,27 @@ impl fmt::Display for RadrootsCoreUnitParseError { #[cfg(feature = "std")] impl std::error::Error for RadrootsCoreUnitParseError {} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RadrootsCoreUnitConvertError { + NotMassUnit { + from: RadrootsCoreUnit, + to: RadrootsCoreUnit, + }, +} + +impl fmt::Display for RadrootsCoreUnitConvertError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RadrootsCoreUnitConvertError::NotMassUnit { from, to } => { + write!(f, "unit conversion requires mass units: {from} -> {to}") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsCoreUnitConvertError {} + impl FromStr for RadrootsCoreUnit { type Err = RadrootsCoreUnitParseError; @@ -155,7 +182,10 @@ pub fn convert_mass_decimal( amount: RadrootsCoreDecimal, from: RadrootsCoreUnit, to: RadrootsCoreUnit, -) -> RadrootsCoreDecimal { +) -> Result<RadrootsCoreDecimal, RadrootsCoreUnitConvertError> { + if !from.is_mass() || !to.is_mass() { + return Err(RadrootsCoreUnitConvertError::NotMassUnit { from, to }); + } let amount_g = amount * grams_factor_decimal(from); - amount_g / grams_factor_decimal(to) + Ok(amount_g / grams_factor_decimal(to)) } diff --git a/core/tests/common/mod.rs b/core/tests/common/mod.rs @@ -0,0 +1,28 @@ +#![allow(dead_code)] + +use core::str::FromStr; + +use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCorePercent, + RadrootsCoreQuantity, RadrootsCoreUnit, +}; + +pub fn dec(s: &str) -> RadrootsCoreDecimal { + RadrootsCoreDecimal::from_str(s).expect("valid decimal") +} + +pub fn currency(code: &str) -> RadrootsCoreCurrency { + RadrootsCoreCurrency::from_str(code).expect("valid currency") +} + +pub fn money(amount: &str, code: &str) -> RadrootsCoreMoney { + RadrootsCoreMoney::new(dec(amount), currency(code)) +} + +pub fn qty(amount: &str, unit: RadrootsCoreUnit) -> RadrootsCoreQuantity { + RadrootsCoreQuantity::new(dec(amount), unit) +} + +pub fn percent(s: &str) -> RadrootsCorePercent { + RadrootsCorePercent::from_str(s).expect("valid percent") +} diff --git a/core/tests/currency.rs b/core/tests/currency.rs @@ -0,0 +1,55 @@ +use core::str::FromStr; + +use radroots_core::{RadrootsCoreCurrency, RadrootsCoreCurrencyParseError}; + +#[test] +fn from_str_upper_accepts_valid() { + let usd = RadrootsCoreCurrency::from_str_upper("USD").unwrap(); + assert_eq!(usd.as_str(), "USD"); +} + +#[test] +fn from_str_upper_rejects_invalid() { + assert_eq!( + RadrootsCoreCurrency::from_str_upper("Usd"), + Err(RadrootsCoreCurrencyParseError::InvalidFormat) + ); + assert_eq!( + RadrootsCoreCurrency::from_str_upper("US"), + Err(RadrootsCoreCurrencyParseError::InvalidFormat) + ); +} + +#[test] +fn from_str_trims_and_uppercases() { + let usd = RadrootsCoreCurrency::from_str(" usd ").unwrap(); + assert_eq!(usd.as_str(), "USD"); +} + +#[test] +fn from_str_rejects_non_alpha() { + assert_eq!( + RadrootsCoreCurrency::from_str("US1"), + Err(RadrootsCoreCurrencyParseError::InvalidFormat) + ); +} + +#[test] +fn from_const_validates_bytes() { + assert!(RadrootsCoreCurrency::from_const(*b"USD").is_ok()); + assert_eq!( + RadrootsCoreCurrency::from_const(*b"usd"), + Err(RadrootsCoreCurrencyParseError::InvalidFormat) + ); +} + +#[test] +fn minor_unit_exponent_matches_known_currencies() { + let jpy = RadrootsCoreCurrency::from_str_upper("JPY").unwrap(); + let kwd = RadrootsCoreCurrency::from_str_upper("KWD").unwrap(); + let usd = RadrootsCoreCurrency::from_str_upper("USD").unwrap(); + + assert_eq!(jpy.minor_unit_exponent(), 0); + assert_eq!(kwd.minor_unit_exponent(), 3); + assert_eq!(usd.minor_unit_exponent(), 2); +} diff --git a/core/tests/decimal.rs b/core/tests/decimal.rs @@ -0,0 +1,32 @@ +mod common; + +use core::str::FromStr; + +use radroots_core::RadrootsCoreDecimal; + +#[test] +fn display_normalizes_trailing_zeros() { + let d = RadrootsCoreDecimal::from_str("1.2300").unwrap(); + assert_eq!(d.to_string(), "1.23"); +} + +#[test] +fn scale_reflects_input_precision() { + let d = RadrootsCoreDecimal::from_str("1.2300").unwrap(); + assert_eq!(d.scale(), 4); +} + +#[test] +fn to_u64_exact_requires_whole_number() { + let whole = common::dec("42.0"); + let frac = common::dec("42.5"); + assert_eq!(whole.to_u64_exact(), Some(42)); + assert_eq!(frac.to_u64_exact(), None); +} + +#[test] +fn from_f64_display_roundtrips_reasonably() { + let d = RadrootsCoreDecimal::from_f64_display(1.25).unwrap(); + let v = d.to_f64_lossy().expect("f64 conversion"); + assert!((v - 1.25).abs() < 1e-12); +} diff --git a/core/tests/discount.rs b/core/tests/discount.rs @@ -0,0 +1,51 @@ +mod common; + +use radroots_core::{RadrootsCoreDiscount, RadrootsCoreDiscountValue, RadrootsCorePercent}; + +#[test] +fn is_non_negative_validates_all_discount_shapes() { + let money_pos = common::money("1", "USD"); + let money_neg = common::money("-1", "USD"); + let qty_pos = common::qty("1", radroots_core::RadrootsCoreUnit::Each); + let qty_neg = common::qty("-1", radroots_core::RadrootsCoreUnit::Each); + let pct_pos = RadrootsCorePercent::new(common::dec("10")); + let pct_neg = RadrootsCorePercent::new(common::dec("-5")); + + let d = RadrootsCoreDiscount::QuantityThreshold { + ref_key: None, + threshold: qty_pos.clone(), + value: money_pos.clone(), + }; + assert!(d.is_non_negative()); + + let d = RadrootsCoreDiscount::QuantityThreshold { + ref_key: None, + threshold: qty_neg, + value: money_pos.clone(), + }; + assert!(!d.is_non_negative()); + + let d = RadrootsCoreDiscount::MassThreshold { + threshold: qty_pos.clone(), + value: money_neg.clone(), + }; + assert!(!d.is_non_negative()); + + let d = RadrootsCoreDiscount::SubtotalThreshold { + threshold: money_pos.clone(), + value: RadrootsCoreDiscountValue::Percent(pct_pos.clone()), + }; + assert!(d.is_non_negative()); + + let d = RadrootsCoreDiscount::SubtotalThreshold { + threshold: money_pos.clone(), + value: RadrootsCoreDiscountValue::Percent(pct_neg), + }; + assert!(!d.is_non_negative()); + + let d = RadrootsCoreDiscount::TotalThreshold { + total_min: money_pos, + value: pct_pos, + }; + assert!(d.is_non_negative()); +} diff --git a/core/tests/money.rs b/core/tests/money.rs @@ -0,0 +1,86 @@ +mod common; + +use radroots_core::{RadrootsCoreCurrency, RadrootsCoreMoney, RadrootsCoreMoneyInvariantError}; +use rust_decimal::RoundingStrategy; + +#[test] +fn zero_and_is_zero() { + let usd = RadrootsCoreCurrency::USD; + let zero = RadrootsCoreMoney::zero(usd); + assert!(zero.is_zero()); + assert_eq!(zero.currency, usd); +} + +#[test] +fn ensure_non_negative_rejects_negative_amount() { + let money = RadrootsCoreMoney::new(common::dec("-1"), RadrootsCoreCurrency::USD); + assert_eq!( + money.ensure_non_negative(), + Err(RadrootsCoreMoneyInvariantError::NegativeAmount) + ); +} + +#[test] +fn quantize_to_currency_rounds_midpoint_away_from_zero() { + let usd = RadrootsCoreCurrency::USD; + let a = RadrootsCoreMoney::new(common::dec("1.234"), usd).quantize_to_currency(); + let b = RadrootsCoreMoney::new(common::dec("1.235"), usd).quantize_to_currency(); + let c = RadrootsCoreMoney::new(common::dec("-1.235"), usd).quantize_to_currency(); + + assert_eq!(a.amount, common::dec("1.23")); + assert_eq!(b.amount, common::dec("1.24")); + assert_eq!(c.amount, common::dec("-1.24")); +} + +#[test] +fn checked_add_and_sub_require_currency_match() { + let usd = RadrootsCoreCurrency::USD; + let eur = RadrootsCoreCurrency::EUR; + let a = RadrootsCoreMoney::new(common::dec("1.00"), usd); + let b = RadrootsCoreMoney::new(common::dec("2.00"), usd); + let c = RadrootsCoreMoney::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!(b.checked_sub(&a).unwrap().amount, common::dec("1.00")); +} + +#[test] +fn minor_units_exact_and_rounded() { + let usd = RadrootsCoreCurrency::USD; + let exact = RadrootsCoreMoney::new(common::dec("1.23"), usd); + let frac = RadrootsCoreMoney::new(common::dec("1.234"), usd); + let rounded = RadrootsCoreMoney::new(common::dec("1.235"), usd); + + assert_eq!(exact.to_minor_units_u64_exact().unwrap(), 123); + assert_eq!( + frac.to_minor_units_u64_exact(), + Err(RadrootsCoreMoneyInvariantError::NotWholeMinorUnits) + ); + assert_eq!( + rounded + .to_minor_units_u64_rounded(RoundingStrategy::MidpointAwayFromZero) + .unwrap(), + 124 + ); +} + +#[test] +fn minor_units_u32_overflow_is_detected() { + let usd = RadrootsCoreCurrency::USD; + let too_large = RadrootsCoreMoney::from_minor_units_u64(u64::from(u32::MAX) + 1, usd); + assert_eq!( + too_large.to_minor_units_u32_exact(), + Err(RadrootsCoreMoneyInvariantError::AmountOverflow) + ); +} + +#[test] +fn from_minor_units_roundtrips() { + let usd = RadrootsCoreCurrency::USD; + let money = RadrootsCoreMoney::from_minor_units_u64(12345, usd); + assert_eq!(money.to_minor_units_u64_exact().unwrap(), 12345); +} diff --git a/core/tests/percent.rs b/core/tests/percent.rs @@ -0,0 +1,39 @@ +mod common; + +use core::str::FromStr; + +use radroots_core::{RadrootsCorePercent, RadrootsCorePercentParseError}; + +#[test] +fn ratio_roundtrip() { + let pct = RadrootsCorePercent::from_ratio(common::dec("0.125")); + assert_eq!(pct.value, common::dec("12.5")); + assert_eq!(pct.to_ratio(), common::dec("0.125")); +} + +#[test] +fn parses_percent_strings() { + let pct = RadrootsCorePercent::from_str("12.5%").unwrap(); + assert_eq!(pct.value, common::dec("12.5")); + + let pct = RadrootsCorePercent::from_str(" 12.5 ").unwrap(); + assert_eq!(pct.value, common::dec("12.5")); + + assert_eq!( + RadrootsCorePercent::from_str("nope"), + Err(RadrootsCorePercentParseError::InvalidNumber) + ); +} + +#[test] +fn of_money_and_quantized() { + let base = common::money("20.00", "USD"); + let pct = RadrootsCorePercent::from_str("10").unwrap(); + let out = pct.of_money(&base); + assert_eq!(out.amount, common::dec("2.00")); + + let tiny = common::money("0.05", "USD"); + let pct = RadrootsCorePercent::from_str("10").unwrap(); + let rounded = pct.of_money_quantized(&tiny); + assert_eq!(rounded.amount, common::dec("0.01")); +} diff --git a/core/tests/quantity.rs b/core/tests/quantity.rs @@ -0,0 +1,75 @@ +mod common; + +use radroots_core::{RadrootsCoreQuantityInvariantError, RadrootsCoreUnit}; + +#[test] +fn label_helpers_set_and_clear() { + let q = common::qty("1", RadrootsCoreUnit::Each).with_label("box"); + assert_eq!(q.label.as_deref(), Some("box")); + + let q = q.clear_label(); + assert!(q.label.is_none()); + + let q = common::qty("1", RadrootsCoreUnit::Each).with_optional_label(Some("case")); + assert_eq!(q.label.as_deref(), Some("case")); + + let q = q.with_optional_label::<&str>(None); + assert!(q.label.is_none()); +} + +#[test] +fn ensure_non_negative_rejects_negative_amount() { + let q = common::qty("-1", RadrootsCoreUnit::Each); + assert_eq!( + q.ensure_non_negative(), + Err(RadrootsCoreQuantityInvariantError::NegativeAmount) + ); +} + +#[test] +fn try_add_and_try_sub_require_matching_units() { + let a = common::qty("1", RadrootsCoreUnit::Each).with_label("lhs"); + let b = common::qty("2", RadrootsCoreUnit::Each); + let c = common::qty("1", RadrootsCoreUnit::MassKg); + + let sum = a.try_add(&b).unwrap(); + 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) + ); +} + +#[test] +fn checked_add_and_sub_return_none_on_mismatch() { + let a = common::qty("1", RadrootsCoreUnit::Each); + let b = common::qty("2", RadrootsCoreUnit::MassG); + assert!(a.checked_add(&b).is_none()); + assert!(a.checked_sub(&b).is_none()); +} + +#[test] +fn mul_and_div_preserve_unit_and_label() { + let q = common::qty("2", RadrootsCoreUnit::Each).with_label("unit"); + let scaled = q.clone().mul_decimal(common::dec("2.5")); + assert_eq!(scaled.amount, common::dec("5")); + assert_eq!(scaled.unit, RadrootsCoreUnit::Each); + assert_eq!(scaled.label.as_deref(), Some("unit")); + + let divided = q.div_decimal(common::dec("2")); + assert_eq!(divided.amount, common::dec("1")); + assert_eq!(divided.unit, RadrootsCoreUnit::Each); + assert_eq!(divided.label.as_deref(), Some("unit")); +} + +#[test] +fn display_includes_label_when_present() { + let q = common::qty("1.5", RadrootsCoreUnit::Each).with_label("bag"); + assert_eq!(q.to_string(), "1.5 each (bag)"); +} diff --git a/core/tests/quantity_price.rs b/core/tests/quantity_price.rs @@ -0,0 +1,91 @@ +mod common; + +use radroots_core::{ + RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError, RadrootsCoreQuantityPriceOps, + RadrootsCoreUnit, +}; + +#[test] +fn cost_for_scales_by_ratio() { + let price = RadrootsCoreQuantityPrice::new( + common::money("10", "USD"), + common::qty("1", RadrootsCoreUnit::MassKg), + ); + let cost = price.cost_for(&common::qty("2", RadrootsCoreUnit::MassKg)); + assert_eq!(cost.amount, common::dec("20")); +} + +#[test] +fn cost_for_returns_zero_on_unit_mismatch() { + let price = RadrootsCoreQuantityPrice::new( + common::money("10", "USD"), + common::qty("1", RadrootsCoreUnit::MassKg), + ); + let cost = price.cost_for(&common::qty("1", RadrootsCoreUnit::Each)); + assert!(cost.amount.is_zero()); +} + +#[test] +fn cost_for_rounded_and_quantized_price_differ() { + let price = RadrootsCoreQuantityPrice::new( + common::money("1.005", "USD"), + common::qty("1", RadrootsCoreUnit::Each), + ); + let qty = common::qty("2", RadrootsCoreUnit::Each); + let rounded = price.cost_for_rounded(&qty); + let quantized = price.cost_for_with_quantized_price(&qty); + + assert_eq!(rounded.amount, common::dec("2.01")); + assert_eq!(quantized.amount, common::dec("2.02")); +} + +#[test] +fn try_cost_for_validates_quantity_and_units() { + let price = RadrootsCoreQuantityPrice::new( + common::money("10", "USD"), + common::qty("1", RadrootsCoreUnit::Each), + ); + let zero_price = RadrootsCoreQuantityPrice::new( + common::money("10", "USD"), + common::qty("0", RadrootsCoreUnit::Each), + ); + + assert_eq!( + zero_price.try_cost_for(&common::qty("1", RadrootsCoreUnit::Each)), + Err(RadrootsCoreQuantityPriceError::PerQuantityZero) + ); + assert_eq!( + price.try_cost_for(&common::qty("1", RadrootsCoreUnit::MassKg)), + Err(RadrootsCoreQuantityPriceError::UnitMismatch { + have: RadrootsCoreUnit::MassKg, + want: RadrootsCoreUnit::Each + }) + ); +} + +#[test] +fn try_cost_for_amount_in_converts_mass_units() { + let price = RadrootsCoreQuantityPrice::new( + common::money("10", "USD"), + common::qty("1", RadrootsCoreUnit::MassKg), + ); + let cost = price + .try_cost_for_amount_in(common::dec("500"), RadrootsCoreUnit::MassG) + .unwrap(); + assert_eq!(cost.amount, common::dec("5")); +} + +#[test] +fn try_cost_for_amount_in_rejects_non_convertible_units() { + let price = RadrootsCoreQuantityPrice::new( + common::money("10", "USD"), + common::qty("1", RadrootsCoreUnit::MassKg), + ); + assert_eq!( + price.try_cost_for_amount_in(common::dec("1"), RadrootsCoreUnit::Each), + Err(RadrootsCoreQuantityPriceError::NonConvertibleUnits { + from: RadrootsCoreUnit::Each, + to: RadrootsCoreUnit::MassKg + }) + ); +} diff --git a/core/tests/serde.rs b/core/tests/serde.rs @@ -0,0 +1,50 @@ +#![cfg(feature = "serde")] + +mod common; + +use core::str::FromStr; + +use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCorePercent, + RadrootsCoreQuantity, RadrootsCoreUnit, +}; +use serde_json::Value; + +#[test] +fn decimal_serializes_as_string() { + let d = common::dec("1.2300"); + let json = serde_json::to_string(&d).unwrap(); + assert_eq!(json, "\"1.23\""); + + let back: RadrootsCoreDecimal = serde_json::from_str(&json).unwrap(); + assert_eq!(back, common::dec("1.23")); +} + +#[test] +fn quantity_uses_decimal_str_and_omits_empty_label() { + let q = RadrootsCoreQuantity::new(common::dec("1.2300"), RadrootsCoreUnit::MassKg); + let value = serde_json::to_value(&q).unwrap(); + + assert_eq!(value["amount"], Value::String("1.23".to_string())); + assert_eq!(value["unit"], Value::String("kg".to_string())); + assert!(value.get("label").is_none()); +} + +#[test] +fn money_and_percent_roundtrip_with_strings() { + let money = RadrootsCoreMoney::new(common::dec("2.50"), RadrootsCoreCurrency::USD); + let value = serde_json::to_value(&money).unwrap(); + assert_eq!(value["amount"], Value::String("2.5".to_string())); + assert_eq!(value["currency"], Value::String("USD".to_string())); + + let pct = RadrootsCorePercent::new(common::dec("12.5")); + let value = serde_json::to_value(&pct).unwrap(); + assert_eq!(value["value"], Value::String("12.5".to_string())); +} + +#[test] +fn currency_serializes_as_code() { + let c = RadrootsCoreCurrency::from_str("usd").unwrap(); + let json = serde_json::to_string(&c).unwrap(); + assert_eq!(json, "\"USD\""); +} diff --git a/core/tests/unit.rs b/core/tests/unit.rs @@ -0,0 +1,89 @@ +mod common; + +use core::str::FromStr; + +use radroots_core::{ + RadrootsCoreUnit, RadrootsCoreUnitConvertError, RadrootsCoreUnitParseError, + convert_mass_decimal, parse_mass_unit, +}; + +#[test] +fn parses_units_and_synonyms() { + use RadrootsCoreUnit::*; + let cases = [ + ("each", Each), + ("ea", Each), + ("count", Each), + ("kg", MassKg), + ("kilograms", MassKg), + ("g", MassG), + ("grams", MassG), + ("oz", MassOz), + ("ounces", MassOz), + ("lb", MassLb), + ("pounds", MassLb), + ("l", VolumeL), + ("liters", VolumeL), + ("ml", VolumeMl), + ("milliliters", VolumeMl), + ]; + for (input, expected) in cases { + assert_eq!(RadrootsCoreUnit::from_str(input).unwrap(), expected); + } +} + +#[test] +fn rejects_unknown_units() { + assert_eq!( + RadrootsCoreUnit::from_str("unknown"), + Err(RadrootsCoreUnitParseError::UnknownUnit) + ); +} + +#[test] +fn same_dimension_matches_mass_and_volume_groups() { + use RadrootsCoreUnit::*; + assert!(RadrootsCoreUnit::same_dimension(MassKg, MassG)); + assert!(RadrootsCoreUnit::same_dimension(VolumeL, VolumeMl)); + assert!(RadrootsCoreUnit::same_dimension(Each, Each)); + assert!(!RadrootsCoreUnit::same_dimension(MassKg, VolumeL)); + assert!(!RadrootsCoreUnit::same_dimension(Each, MassG)); +} + +#[test] +fn parse_mass_unit_enforces_mass_only() { + assert_eq!(parse_mass_unit("kg"), Ok(RadrootsCoreUnit::MassKg)); + assert_eq!( + parse_mass_unit("each"), + Err(RadrootsCoreUnitParseError::NotAMassUnit) + ); +} + +#[test] +fn convert_mass_decimal_converts_between_mass_units() { + use RadrootsCoreUnit::*; + let kg_to_g = convert_mass_decimal(common::dec("1"), MassKg, MassG).unwrap(); + let g_to_kg = convert_mass_decimal(common::dec("1000"), MassG, MassKg).unwrap(); + let lb_to_g = convert_mass_decimal(common::dec("1"), MassLb, MassG).unwrap(); + + assert_eq!(kg_to_g, common::dec("1000")); + assert_eq!(g_to_kg, common::dec("1")); + assert_eq!(lb_to_g, common::dec("453.59237")); +} + +#[test] +fn convert_mass_decimal_rejects_non_mass_units() { + let err = convert_mass_decimal( + common::dec("1"), + RadrootsCoreUnit::Each, + RadrootsCoreUnit::MassG, + ) + .unwrap_err(); + assert_eq!( + err, + RadrootsCoreUnitConvertError::NotMassUnit { + from: RadrootsCoreUnit::Each, + to: RadrootsCoreUnit::MassG + } + ); +}