Skip to main content

basis_points/
lib.rs

1//! `basis-points` provides a validated [`BasisPoints`] newtype for percentage-like
2//! values represented in basis points (`0..=10_000`).
3//!
4//! The crate includes integer conversions and optional
5//! [`rust_decimal::Decimal`](https://docs.rs/rust_decimal/latest/rust_decimal/struct.Decimal.html)
6//! conversions behind the `decimal` feature.
7
8use bytemuck::{Pod, Zeroable};
9#[cfg(feature = "decimal")]
10use rust_decimal::Decimal;
11
12use crate::error::BasisPointsError;
13pub mod error;
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Zeroable, Pod)]
16#[cfg_attr(
17    feature = "anchor",
18    derive(
19        anchor_lang::InitSpace,
20        anchor_lang::prelude::AnchorSerialize,
21        anchor_lang::prelude::AnchorDeserialize
22    )
23)]
24#[repr(C)]
25/// A validated basis-points value in the inclusive range `0..=10_000`.
26pub struct BasisPoints(u16);
27
28impl BasisPoints {
29    /// The maximum valid basis-points value (`10000`, equal to 100%).
30    pub const MAX: u16 = 10000;
31
32    /// Creates a new [`BasisPoints`] after validating the input range.
33    ///
34    /// Returns [`BasisPointsError::InvalidBasisPoints`] if `bps > 10_000`.
35    pub fn new(bps: u16) -> Result<Self, BasisPointsError> {
36        if bps > Self::MAX {
37            return Err(BasisPointsError::InvalidBasisPoints);
38        }
39
40        Ok(Self(bps))
41    }
42
43    /// Checked addition. Returns `None` if the result exceeds `10_000`.
44    pub fn checked_add(self, rhs: Self) -> Option<Self> {
45        let lhs = u16::from(self);
46        let rhs = u16::from(rhs);
47        let result = lhs.checked_add(rhs)?;
48        Self::new(result).ok()
49    }
50
51    /// Checked subtraction. Returns `None` if underflow occurs.
52    pub fn checked_sub(self, rhs: Self) -> Option<Self> {
53        let lhs = u16::from(self);
54        let rhs = u16::from(rhs);
55        let result = lhs.checked_sub(rhs)?;
56        Self::new(result).ok()
57    }
58
59    /// Checked multiplication. Returns `None` if the result exceeds `10_000`.
60    pub fn checked_mul(self, rhs: Self) -> Option<Self> {
61        let lhs = u16::from(self);
62        let rhs = u16::from(rhs);
63        let result = lhs.checked_mul(rhs)?;
64        Self::new(result).ok()
65    }
66
67    /// Checked division. Returns `None` on division-by-zero.
68    pub fn checked_div(self, rhs: Self) -> Option<Self> {
69        let lhs = u16::from(self);
70        let rhs = u16::from(rhs);
71        let result = lhs.checked_div(rhs)?;
72        Self::new(result).ok()
73    }
74}
75
76impl From<BasisPoints> for u16 {
77    fn from(bps: BasisPoints) -> Self {
78        bps.0
79    }
80}
81
82impl From<BasisPoints> for u32 {
83    fn from(bps: BasisPoints) -> Self {
84        u32::from(bps.0)
85    }
86}
87
88impl From<BasisPoints> for u64 {
89    fn from(bps: BasisPoints) -> Self {
90        u64::from(bps.0)
91    }
92}
93
94impl From<BasisPoints> for u128 {
95    fn from(bps: BasisPoints) -> Self {
96        u128::from(bps.0)
97    }
98}
99
100#[cfg(feature = "decimal")]
101impl From<BasisPoints> for Decimal {
102    fn from(bps: BasisPoints) -> Self {
103        Decimal::from(bps.0)
104    }
105}
106
107#[cfg(feature = "decimal")]
108impl TryFrom<Decimal> for BasisPoints {
109    type Error = BasisPointsError;
110
111    fn try_from(decimal: Decimal) -> Result<Self, Self::Error> {
112        let bps = decimal
113            .try_into()
114            .map_err(|_| BasisPointsError::ConversionFailed)?;
115        BasisPoints::new(bps)
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn test_new() {
125        assert_eq!(BasisPoints::new(10000), Ok(BasisPoints(10000)));
126    }
127
128    #[test]
129    fn test_new_error() {
130        assert_eq!(
131            BasisPoints::new(10001),
132            Err(BasisPointsError::InvalidBasisPoints)
133        );
134    }
135
136    #[test]
137    fn test_checked_add() {
138        let lhs = BasisPoints::new(2500).unwrap();
139        let rhs = BasisPoints::new(2500).unwrap();
140
141        assert_eq!(lhs.checked_add(rhs), BasisPoints::new(5000).ok());
142    }
143
144    #[test]
145    fn test_checked_add_overflow() {
146        let lhs = BasisPoints::new(9000).unwrap();
147        let rhs = BasisPoints::new(2000).unwrap();
148
149        assert_eq!(lhs.checked_add(rhs), None);
150    }
151
152    #[test]
153    fn test_checked_sub() {
154        let lhs = BasisPoints::new(2500).unwrap();
155        let rhs = BasisPoints::new(500).unwrap();
156
157        assert_eq!(lhs.checked_sub(rhs), BasisPoints::new(2000).ok());
158    }
159
160    #[test]
161    fn test_checked_sub_underflow() {
162        let lhs = BasisPoints::new(500).unwrap();
163        let rhs = BasisPoints::new(2500).unwrap();
164
165        assert_eq!(lhs.checked_sub(rhs), None);
166    }
167
168    #[test]
169    fn test_checked_mul() {
170        let lhs = BasisPoints::new(100).unwrap();
171        let rhs = BasisPoints::new(50).unwrap();
172
173        assert_eq!(lhs.checked_mul(rhs), BasisPoints::new(5000).ok());
174    }
175
176    #[test]
177    fn test_checked_mul_overflow() {
178        let lhs = BasisPoints::new(200).unwrap();
179        let rhs = BasisPoints::new(100).unwrap();
180
181        assert_eq!(lhs.checked_mul(rhs), None);
182    }
183
184    #[test]
185    fn test_checked_div() {
186        let lhs = BasisPoints::new(5000).unwrap();
187        let rhs = BasisPoints::new(100).unwrap();
188
189        assert_eq!(lhs.checked_div(rhs), BasisPoints::new(50).ok());
190    }
191
192    #[test]
193    fn test_checked_div_by_zero() {
194        let lhs = BasisPoints::new(5000).unwrap();
195        let rhs = BasisPoints::new(0).unwrap();
196
197        assert_eq!(lhs.checked_div(rhs), None);
198    }
199
200    #[cfg(feature = "decimal")]
201    #[test]
202    fn test_from_basis_points_to_decimal() {
203        let bps = BasisPoints::new(1234).unwrap();
204        let decimal: Decimal = bps.into();
205
206        assert_eq!(decimal, Decimal::new(1234, 0));
207    }
208
209    #[cfg(feature = "decimal")]
210    #[test]
211    fn test_try_from_decimal_to_basis_points() {
212        let decimal = Decimal::new(4321, 0);
213        let bps = BasisPoints::try_from(decimal);
214
215        assert_eq!(bps, Ok(BasisPoints(4321)));
216    }
217
218    #[cfg(feature = "decimal")]
219    #[test]
220    fn test_round_trip_basis_points_decimal_basis_points() {
221        let original = BasisPoints::new(9999).unwrap();
222        let decimal: Decimal = original.into();
223        let round_trip = BasisPoints::try_from(decimal);
224
225        assert_eq!(round_trip, Ok(original));
226    }
227
228    #[cfg(feature = "decimal")]
229    #[test]
230    fn test_try_from_decimal_fractional_truncates() {
231        let decimal = Decimal::new(12345, 1); // 1234.5
232        let bps = BasisPoints::try_from(decimal);
233
234        assert_eq!(bps, Ok(BasisPoints(1234)));
235    }
236
237    #[cfg(feature = "decimal")]
238    #[test]
239    fn test_try_from_decimal_above_basis_points_max_error() {
240        let decimal = Decimal::new(10001, 0);
241        let bps = BasisPoints::try_from(decimal);
242
243        assert_eq!(bps, Err(BasisPointsError::InvalidBasisPoints));
244    }
245
246    #[cfg(feature = "decimal")]
247    #[test]
248    fn test_try_from_decimal_u16_overflow_error() {
249        let decimal = Decimal::new(70000, 0);
250        let bps = BasisPoints::try_from(decimal);
251
252        assert_eq!(bps, Err(BasisPointsError::ConversionFailed));
253    }
254}