Skip to main content

sl_types/
money.rs

1//! Money related data types
2
3#[cfg(feature = "chumsky")]
4use chumsky::{IterParser as _, Parser, prelude::just, text::digits};
5
6/// represents a L$ amount
7#[derive(
8    Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
9)]
10pub struct LindenAmount(pub u64);
11
12impl std::fmt::Display for LindenAmount {
13    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14        let Self(value) = self;
15        write!(f, "{value} L$")
16    }
17}
18
19impl std::ops::Add for LindenAmount {
20    type Output = Self;
21
22    fn add(self, rhs: Self) -> Self::Output {
23        let Self(lhs) = self;
24        let Self(rhs) = rhs;
25        #[expect(
26            clippy::arithmetic_side_effects,
27            reason = "this results in the exact same arithmetic side-effects as the same operation on integers which is probably the most expected result for the user"
28        )]
29        Self(lhs + rhs)
30    }
31}
32
33impl std::ops::Sub for LindenAmount {
34    type Output = Self;
35
36    fn sub(self, rhs: Self) -> Self::Output {
37        let Self(lhs) = self;
38        let Self(rhs) = rhs;
39        #[expect(
40            clippy::arithmetic_side_effects,
41            reason = "this results in the exact same arithmetic side-effects as the same operation on integers which is probably the most expected result for the user"
42        )]
43        Self(lhs - rhs)
44    }
45}
46
47impl std::ops::Mul<u8> for LindenAmount {
48    type Output = Self;
49
50    fn mul(self, rhs: u8) -> Self::Output {
51        let Self(lhs) = self;
52        #[expect(
53            clippy::arithmetic_side_effects,
54            reason = "this results in the exact same arithmetic side-effects as the same operation on integers which is probably the most expected result for the user"
55        )]
56        Self(lhs * u64::from(rhs))
57    }
58}
59
60impl std::ops::Mul<u16> for LindenAmount {
61    type Output = Self;
62
63    fn mul(self, rhs: u16) -> Self::Output {
64        let Self(lhs) = self;
65        #[expect(
66            clippy::arithmetic_side_effects,
67            reason = "this results in the exact same arithmetic side-effects as the same operation on integers which is probably the most expected result for the user"
68        )]
69        Self(lhs * u64::from(rhs))
70    }
71}
72
73impl std::ops::Mul<u32> for LindenAmount {
74    type Output = Self;
75
76    fn mul(self, rhs: u32) -> Self::Output {
77        let Self(lhs) = self;
78        #[expect(
79            clippy::arithmetic_side_effects,
80            reason = "this results in the exact same arithmetic side-effects as the same operation on integers which is probably the most expected result for the user"
81        )]
82        Self(lhs * u64::from(rhs))
83    }
84}
85
86impl std::ops::Mul<u64> for LindenAmount {
87    type Output = Self;
88
89    fn mul(self, rhs: u64) -> Self::Output {
90        let Self(lhs) = self;
91        #[expect(
92            clippy::arithmetic_side_effects,
93            reason = "this results in the exact same arithmetic side-effects as the same operation on integers which is probably the most expected result for the user"
94        )]
95        Self(lhs * rhs)
96    }
97}
98
99impl std::ops::Div<u8> for LindenAmount {
100    type Output = Self;
101
102    fn div(self, rhs: u8) -> Self::Output {
103        let Self(lhs) = self;
104        #[expect(
105            clippy::arithmetic_side_effects,
106            reason = "this results in the exact same arithmetic side-effects as the same operation on integers which is probably the most expected result for the user"
107        )]
108        Self(lhs / u64::from(rhs))
109    }
110}
111
112impl std::ops::Div<u16> for LindenAmount {
113    type Output = Self;
114
115    fn div(self, rhs: u16) -> Self::Output {
116        let Self(lhs) = self;
117        #[expect(
118            clippy::arithmetic_side_effects,
119            reason = "this results in the exact same arithmetic side-effects as the same operation on integers which is probably the most expected result for the user"
120        )]
121        Self(lhs / u64::from(rhs))
122    }
123}
124
125impl std::ops::Div<u32> for LindenAmount {
126    type Output = Self;
127
128    fn div(self, rhs: u32) -> Self::Output {
129        let Self(lhs) = self;
130        #[expect(
131            clippy::arithmetic_side_effects,
132            reason = "this results in the exact same arithmetic side-effects as the same operation on integers which is probably the most expected result for the user"
133        )]
134        Self(lhs / u64::from(rhs))
135    }
136}
137
138impl std::ops::Div<u64> for LindenAmount {
139    type Output = Self;
140
141    fn div(self, rhs: u64) -> Self::Output {
142        let Self(lhs) = self;
143        #[expect(
144            clippy::arithmetic_side_effects,
145            reason = "this results in the exact same arithmetic side-effects as the same operation on integers which is probably the most expected result for the user"
146        )]
147        Self(lhs / rhs)
148    }
149}
150
151impl std::ops::Rem<u8> for LindenAmount {
152    type Output = Self;
153
154    fn rem(self, rhs: u8) -> Self::Output {
155        let Self(lhs) = self;
156        #[expect(
157            clippy::arithmetic_side_effects,
158            reason = "this results in the exact same arithmetic side-effects as the same operation on integers which is probably the most expected result for the user"
159        )]
160        Self(lhs % u64::from(rhs))
161    }
162}
163
164impl std::ops::Rem<u16> for LindenAmount {
165    type Output = Self;
166
167    fn rem(self, rhs: u16) -> Self::Output {
168        let Self(lhs) = self;
169        #[expect(
170            clippy::arithmetic_side_effects,
171            reason = "this results in the exact same arithmetic side-effects as the same operation on integers which is probably the most expected result for the user"
172        )]
173        Self(lhs % u64::from(rhs))
174    }
175}
176
177impl std::ops::Rem<u32> for LindenAmount {
178    type Output = Self;
179
180    fn rem(self, rhs: u32) -> Self::Output {
181        let Self(lhs) = self;
182        #[expect(
183            clippy::arithmetic_side_effects,
184            reason = "this results in the exact same arithmetic side-effects as the same operation on integers which is probably the most expected result for the user"
185        )]
186        Self(lhs % u64::from(rhs))
187    }
188}
189
190impl std::ops::Rem<u64> for LindenAmount {
191    type Output = Self;
192
193    fn rem(self, rhs: u64) -> Self::Output {
194        let Self(lhs) = self;
195        #[expect(
196            clippy::arithmetic_side_effects,
197            reason = "this results in the exact same arithmetic side-effects as the same operation on integers which is probably the most expected result for the user"
198        )]
199        Self(lhs % rhs)
200    }
201}
202
203/// parse a Linden amount
204///
205/// "L$1234"
206///
207/// # Errors
208///
209/// returns an error if the string could not be parsed
210#[cfg(feature = "chumsky")]
211#[must_use]
212pub fn linden_amount_parser<'src>()
213-> impl Parser<'src, &'src str, LindenAmount, chumsky::extra::Err<chumsky::error::Rich<'src, char>>>
214{
215    just("L$")
216        .ignore_then(digits(10).collect::<String>())
217        .try_map(|x: String, span: chumsky::span::SimpleSpan| {
218            Ok(LindenAmount(x.parse().map_err(|e| {
219                chumsky::error::Rich::custom(span, format!("{e:?}"))
220            })?))
221        })
222}
223
224/// A signed Second Life L$ value: a balance or a signed transaction amount.
225///
226/// [`LindenAmount`] models a non-negative quantity (a price, a credit total).
227/// Some values are, however, legitimately *signed* — a group's current balance
228/// can be negative, a group-accounting transaction is a positive credit or a
229/// negative debit, a refund is a delta either way.
230///
231/// `LindenBalance` is the signed sibling of [`LindenAmount`]: a sign and a
232/// non-negative magnitude. The two compose by type — adding a [`LindenAmount`]
233/// to a `LindenBalance` is a `LindenBalance`, and a non-negative balance
234/// converts back to a [`LindenAmount`] (a negative one is rejected). Zero is
235/// always canonically non-negative, so there is no distinct "negative zero"
236/// representation and equality matches ordering.
237#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
238pub struct LindenBalance {
239    /// Whether the value is negative. Canonically `false` when the magnitude is
240    /// zero, so there is no negative-zero representation.
241    negative: bool,
242    /// The absolute value, in L$.
243    magnitude: LindenAmount,
244}
245
246impl LindenBalance {
247    /// A zero balance.
248    pub const ZERO: Self = Self {
249        negative: false,
250        magnitude: LindenAmount(0),
251    };
252
253    /// Builds a balance from a sign and a magnitude, normalising a zero
254    /// magnitude to non-negative so there is no negative-zero representation.
255    #[must_use]
256    pub const fn new(negative: bool, magnitude: LindenAmount) -> Self {
257        let LindenAmount(value) = magnitude;
258        Self {
259            negative: negative && value != 0,
260            magnitude,
261        }
262    }
263
264    /// Whether this balance is strictly negative.
265    #[must_use]
266    pub const fn is_negative(&self) -> bool {
267        self.negative
268    }
269
270    /// Whether this balance is zero.
271    #[must_use]
272    pub const fn is_zero(&self) -> bool {
273        let LindenAmount(value) = self.magnitude;
274        value == 0
275    }
276
277    /// The absolute value of this balance, in L$.
278    #[must_use]
279    pub fn magnitude(&self) -> LindenAmount {
280        self.magnitude.clone()
281    }
282
283    /// The value as a 128-bit signed integer — wide enough that the full
284    /// `u64` magnitude (positive or negated) never overflows. The basis for
285    /// ordering and arithmetic.
286    fn signed_i128(&self) -> i128 {
287        let LindenAmount(value) = self.magnitude;
288        let magnitude = i128::from(value);
289        if self.negative {
290            magnitude.wrapping_neg()
291        } else {
292            magnitude
293        }
294    }
295
296    /// Rebuilds a balance from a 128-bit signed value, saturating the magnitude
297    /// at `u64::MAX` (a bound no real L$ value approaches).
298    fn from_signed_i128(value: i128) -> Self {
299        let magnitude = u64::try_from(value.unsigned_abs()).unwrap_or(u64::MAX);
300        Self::new(value.is_negative(), LindenAmount(magnitude))
301    }
302
303    /// The value as a 64-bit signed integer, or `None` if the magnitude exceeds
304    /// the `i64` range.
305    fn signed_i64(&self) -> Option<i64> {
306        let LindenAmount(value) = self.magnitude;
307        let magnitude = i64::try_from(value).ok()?;
308        Some(if self.negative {
309            magnitude.wrapping_neg()
310        } else {
311            magnitude
312        })
313    }
314
315    /// Decodes a signed 32-bit L$ wire field into a balance. Total: every `i32`
316    /// is a valid balance.
317    #[must_use]
318    pub fn from_i32(value: i32) -> Self {
319        Self::new(
320            value.is_negative(),
321            LindenAmount(u64::from(value.unsigned_abs())),
322        )
323    }
324
325    /// Encodes a balance into a signed 32-bit L$ wire field, or `None` if the
326    /// value is outside the `i32` range a wire field can hold.
327    #[must_use]
328    pub fn to_i32(&self) -> Option<i32> {
329        i32::try_from(self.signed_i64()?).ok()
330    }
331
332    /// Decodes a signed 64-bit L$ value into a balance. Total: every `i64` is a
333    /// valid balance.
334    #[must_use]
335    pub const fn from_i64(value: i64) -> Self {
336        Self::new(value.is_negative(), LindenAmount(value.unsigned_abs()))
337    }
338
339    /// Encodes a balance into a signed 64-bit L$ value, or `None` if the
340    /// magnitude exceeds the `i64` range.
341    #[must_use]
342    pub fn to_i64(&self) -> Option<i64> {
343        self.signed_i64()
344    }
345}
346
347impl std::fmt::Display for LindenBalance {
348    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349        if self.negative {
350            write!(f, "-{}", self.magnitude)
351        } else {
352            write!(f, "{}", self.magnitude)
353        }
354    }
355}
356
357impl Ord for LindenBalance {
358    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
359        self.signed_i128().cmp(&other.signed_i128())
360    }
361}
362
363impl PartialOrd for LindenBalance {
364    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
365        Some(self.cmp(other))
366    }
367}
368
369impl From<LindenAmount> for LindenBalance {
370    fn from(amount: LindenAmount) -> Self {
371        Self::new(false, amount)
372    }
373}
374
375/// The error from converting a negative [`LindenBalance`] into a non-negative
376/// [`LindenAmount`].
377#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
378#[error("L$ balance {balance} is negative and has no LindenAmount representation")]
379#[non_exhaustive]
380pub struct NegativeBalanceError {
381    /// The negative balance that could not be represented as a [`LindenAmount`].
382    pub balance: LindenBalance,
383}
384
385impl TryFrom<LindenBalance> for LindenAmount {
386    type Error = NegativeBalanceError;
387
388    fn try_from(balance: LindenBalance) -> Result<Self, Self::Error> {
389        if balance.negative {
390            Err(NegativeBalanceError { balance })
391        } else {
392            Ok(balance.magnitude)
393        }
394    }
395}
396
397impl std::ops::Add<LindenAmount> for LindenBalance {
398    type Output = Self;
399
400    fn add(self, rhs: LindenAmount) -> Self::Output {
401        let LindenAmount(value) = rhs;
402        #[expect(
403            clippy::arithmetic_side_effects,
404            reason = "operands are bounded by u64::MAX, so the sum fits in i128 without overflow"
405        )]
406        Self::from_signed_i128(self.signed_i128() + i128::from(value))
407    }
408}
409
410impl std::ops::Sub<LindenAmount> for LindenBalance {
411    type Output = Self;
412
413    fn sub(self, rhs: LindenAmount) -> Self::Output {
414        let LindenAmount(value) = rhs;
415        #[expect(
416            clippy::arithmetic_side_effects,
417            reason = "operands are bounded by u64::MAX, so the difference fits in i128 without overflow"
418        )]
419        Self::from_signed_i128(self.signed_i128() - i128::from(value))
420    }
421}
422
423impl std::ops::Add for LindenBalance {
424    type Output = Self;
425
426    fn add(self, rhs: Self) -> Self::Output {
427        #[expect(
428            clippy::arithmetic_side_effects,
429            reason = "operands are bounded by u64::MAX in magnitude, so the sum fits in i128 without overflow"
430        )]
431        Self::from_signed_i128(self.signed_i128() + rhs.signed_i128())
432    }
433}
434
435impl std::ops::Sub for LindenBalance {
436    type Output = Self;
437
438    fn sub(self, rhs: Self) -> Self::Output {
439        #[expect(
440            clippy::arithmetic_side_effects,
441            reason = "operands are bounded by u64::MAX in magnitude, so the difference fits in i128 without overflow"
442        )]
443        Self::from_signed_i128(self.signed_i128() - rhs.signed_i128())
444    }
445}
446
447impl std::ops::Neg for LindenBalance {
448    type Output = Self;
449
450    fn neg(self) -> Self::Output {
451        Self::new(!self.negative, self.magnitude)
452    }
453}
454
455impl std::ops::AddAssign<LindenAmount> for LindenBalance {
456    fn add_assign(&mut self, rhs: LindenAmount) {
457        #[expect(
458            clippy::arithmetic_side_effects,
459            reason = "delegates to the annotated Add impl, which cannot overflow for u64-bounded operands"
460        )]
461        {
462            *self = self.clone() + rhs;
463        }
464    }
465}
466
467impl std::ops::SubAssign<LindenAmount> for LindenBalance {
468    fn sub_assign(&mut self, rhs: LindenAmount) {
469        #[expect(
470            clippy::arithmetic_side_effects,
471            reason = "delegates to the annotated Sub impl, which cannot overflow for u64-bounded operands"
472        )]
473        {
474            *self = self.clone() - rhs;
475        }
476    }
477}
478
479impl std::ops::AddAssign for LindenBalance {
480    fn add_assign(&mut self, rhs: Self) {
481        #[expect(
482            clippy::arithmetic_side_effects,
483            reason = "delegates to the annotated Add impl, which cannot overflow for u64-bounded operands"
484        )]
485        {
486            *self = self.clone() + rhs;
487        }
488    }
489}
490
491impl std::ops::SubAssign for LindenBalance {
492    fn sub_assign(&mut self, rhs: Self) {
493        #[expect(
494            clippy::arithmetic_side_effects,
495            reason = "delegates to the annotated Sub impl, which cannot overflow for u64-bounded operands"
496        )]
497        {
498            *self = self.clone() - rhs;
499        }
500    }
501}
502
503/// parse a signed Linden balance
504///
505/// "L$1234" or "-L$1234"
506///
507/// # Errors
508///
509/// returns an error if the string could not be parsed
510#[cfg(feature = "chumsky")]
511#[must_use]
512pub fn linden_balance_parser<'src>()
513-> impl Parser<'src, &'src str, LindenBalance, chumsky::extra::Err<chumsky::error::Rich<'src, char>>>
514{
515    just("-")
516        .or_not()
517        .then(linden_amount_parser())
518        .map(|(sign, magnitude)| LindenBalance::new(sign.is_some(), magnitude))
519}
520
521#[cfg(test)]
522mod tests {
523    use super::{LindenAmount, LindenBalance, NegativeBalanceError};
524    use pretty_assertions::assert_eq;
525
526    #[test]
527    fn i32_wire_round_trips_bit_identically() {
528        for wire in [0_i32, 1, -1, 250, -250, i32::MAX, i32::MIN] {
529            let balance = LindenBalance::from_i32(wire);
530            assert_eq!(balance.to_i32(), Some(wire));
531        }
532    }
533
534    #[test]
535    fn negative_zero_normalises_to_non_negative() {
536        let from_ctor = LindenBalance::new(true, LindenAmount(0));
537        assert!(!from_ctor.is_negative());
538        assert!(from_ctor.is_zero());
539        assert_eq!(from_ctor, LindenBalance::ZERO);
540        assert_eq!(from_ctor, LindenBalance::from_i32(0));
541    }
542
543    #[test]
544    fn ordering_is_by_signed_value() {
545        let neg = LindenBalance::from_i32(-100);
546        let zero = LindenBalance::ZERO;
547        let pos = LindenBalance::from_i32(100);
548        assert!(neg < zero);
549        assert!(zero < pos);
550        assert!(neg < pos);
551        // Among negatives, a larger magnitude is the smaller balance.
552        assert!(LindenBalance::from_i32(-200) < LindenBalance::from_i32(-100));
553    }
554
555    #[test]
556    fn amount_and_balance_compose_by_type() {
557        let balance = LindenBalance::from_i32(-30);
558        assert_eq!(balance + LindenAmount(100), LindenBalance::from_i32(70));
559        assert_eq!(
560            LindenBalance::from_i32(50) - LindenAmount(80),
561            LindenBalance::from_i32(-30)
562        );
563        assert_eq!(
564            LindenBalance::from_i32(10) + LindenBalance::from_i32(-25),
565            LindenBalance::from_i32(-15)
566        );
567        assert_eq!(-LindenBalance::from_i32(42), LindenBalance::from_i32(-42));
568    }
569
570    #[test]
571    fn linden_amount_interconverts() {
572        assert_eq!(
573            LindenBalance::from(LindenAmount(500)),
574            LindenBalance::from_i32(500)
575        );
576        assert_eq!(
577            LindenAmount::try_from(LindenBalance::from_i32(500)),
578            Ok(LindenAmount(500))
579        );
580        let err = LindenAmount::try_from(LindenBalance::from_i32(-1));
581        assert!(matches!(err, Err(NegativeBalanceError { .. })));
582    }
583
584    #[test]
585    fn out_of_i32_range_does_not_encode() {
586        let too_big = LindenBalance::from_i64(i64::from(i32::MAX) + 1);
587        assert_eq!(too_big.to_i32(), None);
588        assert_eq!(too_big.to_i64(), Some(i64::from(i32::MAX) + 1));
589    }
590
591    #[cfg(feature = "chumsky")]
592    #[test]
593    fn balance_parser_round_trips() {
594        use chumsky::Parser as _;
595        assert_eq!(
596            super::linden_balance_parser().parse("L$1234").into_result(),
597            Ok(LindenBalance::from_i32(1234))
598        );
599        assert_eq!(
600            super::linden_balance_parser()
601                .parse("-L$1234")
602                .into_result(),
603            Ok(LindenBalance::from_i32(-1234))
604        );
605    }
606}