typed_money/
error.rs

1//! Error types for monetary operations.
2//!
3//! This module provides comprehensive error handling for all fallible operations
4//! in the typed-money library. All errors include rich context for debugging
5//! and recovery suggestions.
6//!
7//! # Error Types
8//!
9//! The main error type is [`MoneyError`], which covers:
10//! - Currency mismatches
11//! - Precision errors
12//! - Invalid rates
13//! - Arithmetic overflow
14//! - Parsing errors
15//! - Rounding errors
16//! - Serialization errors
17//!
18//! # Examples
19//!
20//! ## Handling Parse Errors
21//!
22//! ```
23//! use typed_money::{Amount, USD, MoneyError};
24//! use std::str::FromStr;
25//!
26//! match Amount::<USD>::from_str("invalid") {
27//!     Ok(amount) => println!("Parsed: {}", amount),
28//!     Err(MoneyError::ParseError { input, .. }) => {
29//!         println!("Failed to parse: {}", input);
30//!     }
31//!     Err(e) => println!("Other error: {}", e),
32//! }
33//! ```
34//!
35//! ## Checking Precision
36//!
37//! ```
38//! use typed_money::{Amount, USD, MoneyError};
39//!
40//! let amount = Amount::<USD>::from_major(10) / 3;  // Creates excess precision
41//!
42//! match amount.check_precision() {
43//!     Ok(_) => println!("Precision OK"),
44//!     Err(MoneyError::PrecisionError { expected, actual, suggestion, .. }) => {
45//!         println!("Expected {} decimals, got {}. {}", expected, actual, suggestion);
46//!         // Can recover by normalizing
47//!         let fixed = amount.normalize();
48//!         assert!(fixed.check_precision().is_ok());
49//!     }
50//!     Err(e) => println!("Other error: {}", e),
51//! }
52//! ```
53//!
54//! ## Creating Invalid Rates
55//!
56//! ```
57//! use typed_money::{Rate, USD, EUR, MoneyError};
58//!
59//! // Rates must be positive
60//! match Rate::<USD, EUR>::try_new(-1.0) {
61//!     Ok(_) => unreachable!(),
62//!     Err(MoneyError::InvalidRate { reason, .. }) => {
63//!         assert!(reason.contains("positive"));
64//!     }
65//!     Err(e) => panic!("Unexpected error: {}", e),
66//! }
67//! ```
68
69use std::fmt;
70
71/// Result type alias for money operations.
72///
73/// This is a convenience alias for `Result<T, MoneyError>`.
74///
75/// # Examples
76///
77/// ```
78/// use typed_money::{MoneyResult, Amount, USD};
79///
80/// fn parse_amount(s: &str) -> MoneyResult<Amount<USD>> {
81///     // Parsing logic would go here
82///     Ok(Amount::<USD>::from_major(100))
83/// }
84/// ```
85pub type MoneyResult<T> = Result<T, MoneyError>;
86
87/// Errors that can occur during monetary operations.
88///
89/// All error variants include context to help diagnose and fix issues.
90#[derive(Debug, Clone, PartialEq)]
91pub enum MoneyError {
92    /// Attempted to perform an operation between incompatible currencies.
93    ///
94    /// This error is primarily for runtime checks. The type system usually
95    /// prevents this at compile time.
96    CurrencyMismatch {
97        /// The expected currency code
98        expected: &'static str,
99        /// The actual currency code found
100        found: &'static str,
101        /// Additional context about the operation
102        context: String,
103    },
104
105    /// No conversion rate available for the requested currency pair.
106    ConversionRateMissing {
107        /// The source currency code
108        from: &'static str,
109        /// The target currency code
110        to: &'static str,
111    },
112
113    /// Precision would be lost in the operation.
114    ///
115    /// This warning indicates that an amount has more decimal places
116    /// than the currency supports.
117    PrecisionError {
118        /// The currency code
119        currency: &'static str,
120        /// Expected precision (number of decimal places)
121        expected: u8,
122        /// Actual precision found
123        actual: u32,
124        /// Suggestion for fixing the error
125        suggestion: String,
126    },
127
128    /// Invalid amount value (NaN, Infinity, or other invalid state).
129    InvalidAmount {
130        /// Description of what makes the amount invalid
131        reason: String,
132        /// The currency code if available
133        currency: Option<&'static str>,
134    },
135
136    /// Failed to parse a string into a monetary amount.
137    ParseError {
138        /// The input string that failed to parse
139        input: String,
140        /// The expected currency code
141        expected_currency: Option<&'static str>,
142        /// Description of why parsing failed
143        reason: String,
144    },
145
146    /// Rounding operation failed.
147    RoundingError {
148        /// The currency code
149        currency: &'static str,
150        /// Description of what went wrong
151        reason: String,
152    },
153
154    /// Invalid exchange rate value.
155    InvalidRate {
156        /// The rate value that was invalid
157        value: String,
158        /// Description of why the rate is invalid
159        reason: String,
160    },
161
162    /// Arithmetic overflow occurred.
163    Overflow {
164        /// The operation that caused overflow
165        operation: String,
166        /// The currency code
167        currency: &'static str,
168    },
169
170    /// Arithmetic underflow occurred.
171    Underflow {
172        /// The operation that caused underflow
173        operation: String,
174        /// The currency code
175        currency: &'static str,
176    },
177}
178
179impl MoneyError {
180    /// Returns a suggestion for how to fix this error.
181    ///
182    /// # Examples
183    ///
184    /// ```
185    /// use typed_money::MoneyError;
186    ///
187    /// let error = MoneyError::InvalidRate {
188    ///     value: "0.0".to_string(),
189    ///     reason: "Rate must be positive".to_string(),
190    /// };
191    ///
192    /// println!("{}", error.suggestion());
193    /// ```
194    pub fn suggestion(&self) -> &str {
195        match self {
196            MoneyError::CurrencyMismatch { .. } => {
197                "Ensure both amounts use the same currency, or use explicit conversion with a Rate"
198            }
199            MoneyError::ConversionRateMissing { .. } => {
200                "Provide a Rate instance for the currency conversion"
201            }
202            MoneyError::PrecisionError { suggestion, .. } => suggestion,
203            MoneyError::InvalidAmount { .. } => "Check that the amount is a valid finite number",
204            MoneyError::ParseError { .. } => {
205                "Ensure the input string is in a valid format (e.g., '12.34' or '$12.34 USD')"
206            }
207            MoneyError::RoundingError { .. } => {
208                "Try a different rounding mode or check the amount precision"
209            }
210            MoneyError::InvalidRate { .. } => "Exchange rates must be positive, finite numbers",
211            MoneyError::Overflow { .. } => {
212                "Use smaller values or check for logical errors in calculations"
213            }
214            MoneyError::Underflow { .. } => {
215                "Use larger values or check for logical errors in calculations"
216            }
217        }
218    }
219
220    /// Returns the currency code associated with this error, if any.
221    pub fn currency(&self) -> Option<&'static str> {
222        match self {
223            MoneyError::CurrencyMismatch { expected, .. } => Some(expected),
224            MoneyError::ConversionRateMissing { from, .. } => Some(from),
225            MoneyError::PrecisionError { currency, .. } => Some(currency),
226            MoneyError::InvalidAmount { currency, .. } => *currency,
227            MoneyError::ParseError {
228                expected_currency, ..
229            } => *expected_currency,
230            MoneyError::RoundingError { currency, .. } => Some(currency),
231            MoneyError::InvalidRate { .. } => None,
232            MoneyError::Overflow { currency, .. } => Some(currency),
233            MoneyError::Underflow { currency, .. } => Some(currency),
234        }
235    }
236}
237
238impl fmt::Display for MoneyError {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        match self {
241            MoneyError::CurrencyMismatch {
242                expected,
243                found,
244                context,
245            } => {
246                write!(
247                    f,
248                    "Currency mismatch: expected {}, found {} ({})",
249                    expected, found, context
250                )
251            }
252            MoneyError::ConversionRateMissing { from, to } => {
253                write!(f, "No conversion rate available from {} to {}", from, to)
254            }
255            MoneyError::PrecisionError {
256                currency,
257                expected,
258                actual,
259                ..
260            } => {
261                write!(
262                    f,
263                    "Precision error for {}: expected {} decimal places, found {}",
264                    currency, expected, actual
265                )
266            }
267            MoneyError::InvalidAmount { reason, currency } => {
268                if let Some(curr) = currency {
269                    write!(f, "Invalid amount for {}: {}", curr, reason)
270                } else {
271                    write!(f, "Invalid amount: {}", reason)
272                }
273            }
274            MoneyError::ParseError {
275                input,
276                expected_currency,
277                reason,
278            } => {
279                if let Some(curr) = expected_currency {
280                    write!(f, "Failed to parse '{}' as {}: {}", input, curr, reason)
281                } else {
282                    write!(f, "Failed to parse '{}': {}", input, reason)
283                }
284            }
285            MoneyError::RoundingError { currency, reason } => {
286                write!(f, "Rounding error for {}: {}", currency, reason)
287            }
288            MoneyError::InvalidRate { value, reason } => {
289                write!(f, "Invalid exchange rate '{}': {}", value, reason)
290            }
291            MoneyError::Overflow {
292                operation,
293                currency,
294            } => {
295                write!(
296                    f,
297                    "Arithmetic overflow in {} operation for {}",
298                    operation, currency
299                )
300            }
301            MoneyError::Underflow {
302                operation,
303                currency,
304            } => {
305                write!(
306                    f,
307                    "Arithmetic underflow in {} operation for {}",
308                    operation, currency
309                )
310            }
311        }
312    }
313}
314
315impl std::error::Error for MoneyError {
316    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
317        // None of our errors wrap other errors currently
318        None
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_currency_mismatch_display() {
328        let error = MoneyError::CurrencyMismatch {
329            expected: "USD",
330            found: "EUR",
331            context: "addition".to_string(),
332        };
333
334        assert_eq!(
335            error.to_string(),
336            "Currency mismatch: expected USD, found EUR (addition)"
337        );
338    }
339
340    #[test]
341    fn test_conversion_rate_missing_display() {
342        let error = MoneyError::ConversionRateMissing {
343            from: "USD",
344            to: "JPY",
345        };
346
347        assert_eq!(
348            error.to_string(),
349            "No conversion rate available from USD to JPY"
350        );
351    }
352
353    #[test]
354    fn test_precision_error_display() {
355        let error = MoneyError::PrecisionError {
356            currency: "USD",
357            expected: 2,
358            actual: 5,
359            suggestion: "Use normalize() or round()".to_string(),
360        };
361
362        assert_eq!(
363            error.to_string(),
364            "Precision error for USD: expected 2 decimal places, found 5"
365        );
366    }
367
368    #[test]
369    fn test_invalid_amount_display() {
370        let error = MoneyError::InvalidAmount {
371            reason: "Value is NaN".to_string(),
372            currency: Some("EUR"),
373        };
374
375        assert_eq!(error.to_string(), "Invalid amount for EUR: Value is NaN");
376    }
377
378    #[test]
379    fn test_parse_error_display() {
380        let error = MoneyError::ParseError {
381            input: "not a number".to_string(),
382            expected_currency: Some("USD"),
383            reason: "Contains non-numeric characters".to_string(),
384        };
385
386        assert_eq!(
387            error.to_string(),
388            "Failed to parse 'not a number' as USD: Contains non-numeric characters"
389        );
390    }
391
392    #[test]
393    fn test_invalid_rate_display() {
394        let error = MoneyError::InvalidRate {
395            value: "0.0".to_string(),
396            reason: "Rate must be positive".to_string(),
397        };
398
399        assert_eq!(
400            error.to_string(),
401            "Invalid exchange rate '0.0': Rate must be positive"
402        );
403    }
404
405    #[test]
406    fn test_overflow_display() {
407        let error = MoneyError::Overflow {
408            operation: "multiplication".to_string(),
409            currency: "BTC",
410        };
411
412        assert_eq!(
413            error.to_string(),
414            "Arithmetic overflow in multiplication operation for BTC"
415        );
416    }
417
418    #[test]
419    fn test_suggestion() {
420        let error = MoneyError::CurrencyMismatch {
421            expected: "USD",
422            found: "EUR",
423            context: "test".to_string(),
424        };
425
426        assert!(error.suggestion().contains("same currency"));
427    }
428
429    #[test]
430    fn test_currency_extraction() {
431        let error = MoneyError::PrecisionError {
432            currency: "USD",
433            expected: 2,
434            actual: 5,
435            suggestion: "test".to_string(),
436        };
437
438        assert_eq!(error.currency(), Some("USD"));
439    }
440
441    #[test]
442    fn test_error_trait_implementation() {
443        let error = MoneyError::InvalidAmount {
444            reason: "test".to_string(),
445            currency: None,
446        };
447
448        // Should implement Error trait
449        let _: &dyn std::error::Error = &error;
450    }
451
452    #[test]
453    fn test_money_result_alias() {
454        fn example() -> MoneyResult<i32> {
455            Ok(42)
456        }
457
458        assert_eq!(example().unwrap(), 42);
459    }
460
461    #[test]
462    fn test_error_clone() {
463        let error = MoneyError::InvalidRate {
464            value: "0".to_string(),
465            reason: "test".to_string(),
466        };
467
468        let cloned = error.clone();
469        assert_eq!(error, cloned);
470    }
471
472    #[test]
473    fn test_error_debug() {
474        let error = MoneyError::InvalidAmount {
475            reason: "test".to_string(),
476            currency: Some("USD"),
477        };
478
479        let debug_str = format!("{:?}", error);
480        assert!(debug_str.contains("InvalidAmount"));
481    }
482}