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:
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
+ }
+ );
+}