Skip to main content

rusty_money/
format.rs

1use crate::currency::FormattableCurrency;
2use crate::{Money, Round};
3use std::cmp::Ordering;
4
5/// Converts Money objects into human readable strings.
6pub struct Formatter;
7
8impl Formatter {
9    /// Returns a formatted Money String given parameters and a Money object.
10    pub fn money<'a, T: FormattableCurrency>(money: &Money<'a, T>, params: Params<'_>) -> String {
11        let mut decimal = *money.amount();
12
13        // Round the decimal and ensure it has the correct scale
14        if let Some(x) = params.rounding {
15            decimal = *money.round(x, Round::HalfEven).amount();
16            decimal.rescale(x);
17        }
18
19        // Format the Amount String
20        let amount = Formatter::amount(&format!("{}", decimal), &params);
21
22        // Position values in the Output String
23        let mut result = String::new();
24        for position in params.positions.iter() {
25            match position {
26                Position::Space => result.push(' '),
27                Position::Amount => result.push_str(&amount),
28                Position::Code => result.push_str(params.code.unwrap_or("")),
29                Position::Symbol => result.push_str(params.symbol.unwrap_or("")),
30                Position::Sign => result.push_str(if money.is_negative() { "-" } else { "" }),
31            }
32        }
33        result
34    }
35
36    /// Returns a formatted amount String, given the raw amount and formatting parameters.
37    fn amount(raw_amount: &str, params: &Params<'_>) -> String {
38        // Split amount into digits and exponent.
39        let amount_split: Vec<&str> = raw_amount.split('.').collect();
40        let mut amount_digits = amount_split[0].to_string();
41
42        // Format the digits
43        amount_digits.retain(|c| c != '-');
44        amount_digits = Formatter::digits(
45            &amount_digits,
46            params.digit_separator,
47            params.separator_pattern,
48        );
49        let mut result = amount_digits;
50
51        // Format the exponent, and add to digits
52        match amount_split.len().cmp(&2) {
53            Ordering::Equal => {
54                // Exponent found, concatenate to digits.
55                result.push(params.exponent_separator);
56                result += amount_split[1];
57            }
58            Ordering::Less => {
59                // No exponent, do nothing.
60            }
61            Ordering::Greater => {
62                unreachable!(
63                    "Decimal formatted string should never contain more than 1 exponent separator"
64                )
65            }
66        }
67
68        result
69    }
70
71    /// Returns a formatted digit component, given the digit string, separator and pattern of separation.
72    fn digits(raw_digits: &str, separator: char, pattern: &[usize]) -> String {
73        let mut digits = raw_digits.to_string();
74
75        let mut current_position: usize = 0;
76        for &position in pattern.iter() {
77            current_position += position;
78            if digits.len() > current_position {
79                digits.insert(digits.len() - current_position, separator);
80                current_position += 1;
81            }
82        }
83        digits
84    }
85}
86
87/// Items which must be positioned in a Money string.
88#[derive(Debug, Clone)]
89pub enum Position {
90    Space,
91    Amount,
92    Code,
93    Symbol,
94    Sign,
95}
96
97/// Group of formatting parameters consumed by `Formatter`.
98#[derive(Debug, Clone)]
99pub struct Params<'a> {
100    /// The character that separates grouped digits (e.g. 1,000,000)
101    pub digit_separator: char,
102    /// The character that separates minor units from major units (e.g. 1,000.00)
103    pub exponent_separator: char,
104    /// The grouping pattern that is applied to digits / major units (e.g. 1,000,000 vs 1,00,000)
105    pub separator_pattern: &'a [usize],
106    /// The relative positions of the elements in a currency string (e.g. -$1,000 vs $ -1,000)
107    pub positions: &'a [Position],
108    /// The number of minor unit digits should remain after Round::HalfEven is applied.
109    pub rounding: Option<u32>,
110    /// The symbol of the currency (e.g. $)
111    pub symbol: Option<&'static str>,
112    /// The currency's ISO code (e.g. USD)
113    pub code: Option<&'static str>,
114}
115
116// Default patterns as static arrays for zero-allocation formatting
117const DEFAULT_SEPARATOR_PATTERN: &[usize] = &[3, 3, 3];
118const DEFAULT_POSITIONS: &[Position] = &[Position::Sign, Position::Symbol, Position::Amount];
119
120impl Default for Params<'_> {
121    /// Defines the default parameters to format a Money string.
122    fn default() -> Self {
123        Params {
124            digit_separator: ',',
125            exponent_separator: '.',
126            separator_pattern: DEFAULT_SEPARATOR_PATTERN,
127            positions: DEFAULT_POSITIONS,
128            rounding: None,
129            symbol: None,
130            code: None,
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::define_currency_set;
139
140    define_currency_set!(
141        test {
142            USD: {
143                code: "USD",
144                exponent: 2,
145                locale: EnUs,
146                minor_units: 100,
147                name: "USD",
148                symbol: "$",
149                symbol_first: true,
150            }
151        }
152    );
153
154    #[test]
155    fn format_position() {
156        let _usd = test::find("USD"); // Prevents unused code warnings from the defined module.
157
158        let money = Money::from_major(-1000, test::USD);
159
160        // Test that you can position eSpace, Amount, Code, Symbol and Sign in different places
161        let params = Params {
162            symbol: Some("$"),
163            code: Some("USD"),
164            positions: &[
165                Position::Sign,
166                Position::Space,
167                Position::Symbol,
168                Position::Amount,
169                Position::Space,
170                Position::Code,
171            ],
172            ..Default::default()
173        };
174        assert_eq!("- $1,000 USD", Formatter::money(&money, params));
175
176        let params = Params {
177            symbol: Some("$"),
178            code: Some("USD"),
179            positions: &[
180                Position::Code,
181                Position::Space,
182                Position::Amount,
183                Position::Symbol,
184                Position::Space,
185                Position::Sign,
186            ],
187            ..Default::default()
188        };
189        assert_eq!("USD 1,000$ -", Formatter::money(&money, params));
190
191        // Test that you can omit some, and it works fine.
192        let params = Params {
193            positions: &[Position::Amount],
194            ..Default::default()
195        };
196        assert_eq!("1,000", Formatter::money(&money, params));
197
198        let params = Params {
199            symbol: Some("$"),
200            positions: &[Position::Symbol],
201            ..Default::default()
202        };
203        assert_eq!("$", Formatter::money(&money, params));
204
205        // Missing Optionals Insert Nothing
206        let params = Params {
207            positions: &[Position::Amount, Position::Symbol],
208            ..Default::default()
209        };
210        assert_eq!("1,000", Formatter::money(&money, params));
211
212        // Sign between symbol and amount
213        let params = Params {
214            symbol: Some("$"),
215            positions: &[Position::Symbol, Position::Sign, Position::Amount],
216            ..Default::default()
217        };
218        assert_eq!("$-1,000", Formatter::money(&money, params));
219    }
220
221    #[test]
222    fn format_digit_separators_with_custom_separators() {
223        let params = Params {
224            digit_separator: '/',
225            ..Default::default()
226        };
227
228        // For 1_000_000
229        let money = Money::from_major(1_000_000, test::USD);
230        assert_eq!("1/000/000", Formatter::money(&money, params.clone()));
231
232        // For 1_000
233        let money = Money::from_major(1_000, test::USD);
234        assert_eq!("1/000", Formatter::money(&money, params.clone()));
235
236        // For 0 Chars
237        let money = Money::from_major(0, test::USD);
238        assert_eq!("0", Formatter::money(&money, params));
239
240        // European style: swap digit and exponent separators
241        let params = Params {
242            rounding: Some(2),
243            exponent_separator: ',',
244            digit_separator: '.',
245            ..Default::default()
246        };
247        let money = Money::from_minor(123456, test::USD);
248        assert_eq!("1.234,56", Formatter::money(&money, params));
249    }
250
251    #[test]
252    fn format_digit_separators_with_custom_sequences() {
253        // Indian-style numbering: 3,2,2 pattern (e.g., 1,00,00,000)
254        let params = Params {
255            separator_pattern: &[3, 2, 2],
256            ..Default::default()
257        };
258
259        let money = Money::from_major(10_000_000, test::USD);
260        assert_eq!("1,00,00,000", Formatter::money(&money, params.clone()));
261
262        let money = Money::from_major(100_000, test::USD);
263        assert_eq!("1,00,000", Formatter::money(&money, params.clone()));
264
265        let money = Money::from_major(1_000, test::USD);
266        assert_eq!("1,000", Formatter::money(&money, params));
267    }
268
269    #[test]
270    fn format_zero_amount() {
271        let params = Params {
272            symbol: Some("$"),
273            positions: &[Position::Sign, Position::Symbol, Position::Amount],
274            ..Default::default()
275        };
276
277        let money = Money::from_major(0, test::USD);
278        // Zero should not have a sign
279        assert_eq!("$0", Formatter::money(&money, params));
280    }
281
282    #[test]
283    fn format_rounding() {
284        let money = Money::from_minor(1000, test::USD).div(3).unwrap();
285
286        // Rounding = Some (0)
287        let params = Params {
288            rounding: Some(0),
289            ..Default::default()
290        };
291        assert_eq!("3", Formatter::money(&money, params));
292
293        // Rounding = Some(2)
294        let params = Params {
295            rounding: Some(2),
296            ..Default::default()
297        };
298        assert_eq!("3.33", Formatter::money(&money, params));
299
300        // Rounding = None
301        let params = Params {
302            ..Default::default()
303        };
304        assert_eq!(
305            "3.3333333333333333333333333333",
306            Formatter::money(&money, params)
307        );
308    }
309}
310
311/// Golden tests for format output stability.
312/// These tests document expected Display output for real currencies.
313/// If these change, it's a breaking change for users.
314#[cfg(all(test, feature = "iso"))]
315mod golden_tests {
316    use crate::Money;
317    use crate::iso;
318
319    #[test]
320    fn usd_format_golden() {
321        // US Dollar: symbol first, comma digit separator, period decimal
322        assert_eq!(format!("{}", Money::from_minor(0, iso::USD)), "$0.00");
323        assert_eq!(format!("{}", Money::from_minor(1, iso::USD)), "$0.01");
324        assert_eq!(format!("{}", Money::from_minor(100, iso::USD)), "$1.00");
325        assert_eq!(
326            format!("{}", Money::from_minor(123456, iso::USD)),
327            "$1,234.56"
328        );
329        assert_eq!(
330            format!("{}", Money::from_minor(123456789, iso::USD)),
331            "$1,234,567.89"
332        );
333        // Negative amounts
334        assert_eq!(format!("{}", Money::from_minor(-100, iso::USD)), "-$1.00");
335        assert_eq!(
336            format!("{}", Money::from_minor(-123456, iso::USD)),
337            "-$1,234.56"
338        );
339    }
340
341    #[test]
342    fn eur_format_golden() {
343        // Euro: European locale - period digit separator, comma decimal
344        assert_eq!(format!("{}", Money::from_minor(0, iso::EUR)), "€0,00");
345        assert_eq!(
346            format!("{}", Money::from_minor(123456, iso::EUR)),
347            "€1.234,56"
348        );
349        assert_eq!(
350            format!("{}", Money::from_minor(-123456, iso::EUR)),
351            "-€1.234,56"
352        );
353    }
354
355    #[test]
356    fn gbp_format_golden() {
357        // British Pound: US-style formatting
358        assert_eq!(format!("{}", Money::from_minor(0, iso::GBP)), "£0.00");
359        assert_eq!(
360            format!("{}", Money::from_minor(123456, iso::GBP)),
361            "£1,234.56"
362        );
363    }
364
365    #[test]
366    fn jpy_format_golden() {
367        // Japanese Yen: no decimal places (exponent 0)
368        assert_eq!(format!("{}", Money::from_minor(0, iso::JPY)), "¥0");
369        assert_eq!(format!("{}", Money::from_minor(1, iso::JPY)), "¥1");
370        assert_eq!(format!("{}", Money::from_minor(1234, iso::JPY)), "¥1,234");
371        assert_eq!(
372            format!("{}", Money::from_minor(1234567, iso::JPY)),
373            "¥1,234,567"
374        );
375    }
376
377    #[test]
378    fn inr_format_golden() {
379        // Indian Rupee: Indian numbering (lakhs, crores) - 2,2,3 pattern
380        assert_eq!(format!("{}", Money::from_minor(0, iso::INR)), "₹0.00");
381        assert_eq!(format!("{}", Money::from_minor(100, iso::INR)), "₹1.00");
382        // 1,00,000 (1 lakh)
383        assert_eq!(
384            format!("{}", Money::from_minor(10000000, iso::INR)),
385            "₹1,00,000.00"
386        );
387        // 1,00,00,000 (1 crore)
388        assert_eq!(
389            format!("{}", Money::from_minor(1000000000, iso::INR)),
390            "₹1,00,00,000.00"
391        );
392    }
393
394    #[test]
395    fn bhd_format_golden() {
396        // Bahraini Dinar: 3 decimal places (exponent 3), Arabic symbol
397        assert_eq!(format!("{}", Money::from_minor(0, iso::BHD)), "د.ب0.000");
398        assert_eq!(format!("{}", Money::from_minor(1, iso::BHD)), "د.ب0.001");
399        assert_eq!(format!("{}", Money::from_minor(1000, iso::BHD)), "د.ب1.000");
400        assert_eq!(
401            format!("{}", Money::from_minor(1234567, iso::BHD)),
402            "د.ب1,234.567"
403        );
404    }
405
406    #[test]
407    fn byn_format_golden() {
408        // Belarusian Ruble: symbol after amount, space digit separator, comma decimal (EnBy locale)
409        assert_eq!(format!("{}", Money::from_minor(0, iso::BYN)), "0,00Br");
410        assert_eq!(
411            format!("{}", Money::from_minor(123456, iso::BYN)),
412            "1 234,56Br"
413        );
414        assert_eq!(
415            format!("{}", Money::from_minor(123456789, iso::BYN)),
416            "1 234 567,89Br"
417        );
418    }
419}