Skip to main content

num2words2_core/
currency.rs

1//! Port of `currency.py` plus the currency half of `base.py`.
2//!
3//! # Why values arrive as strings
4//!
5//! Python's `parse_currency_parts` does `Decimal(str(value))` — it stringifies
6//! the float *first*, so `12.34` becomes the exact decimal 12.34 rather than
7//! the binary double 12.3399999...  Reproducing `repr(float)` in Rust would be
8//! a second implementation of shortest-round-trip formatting and a permanent
9//! source of drift.
10//!
11//! So the Python shim passes `str(value)` across the boundary and the core
12//! parses that. The stringification stays in the one place that already
13//! defines it, and the two sides cannot disagree about it.
14
15use crate::base::{Lang, N2WError, Result};
16use bigdecimal::BigDecimal;
17use num_bigint::BigInt;
18use num_traits::{Signed, Zero};
19use std::str::FromStr;
20
21/// A `CURRENCY_FORMS` entry: `(unit_forms, subunit_forms)`.
22///
23/// Python stores each side as a tuple of 1-3 strings — `("euro", "euros")`,
24/// or `("zloty", "zlotys", "zlotu")` for languages with a third plural form.
25/// `pluralize` indexes into it, so the arity is load-bearing and a language
26/// must keep however many forms Python has.
27#[derive(Debug, Clone)]
28pub struct CurrencyForms {
29    pub unit: Vec<String>,
30    pub subunit: Vec<String>,
31}
32
33impl CurrencyForms {
34    pub fn new(unit: &[&str], subunit: &[&str]) -> Self {
35        CurrencyForms {
36            unit: unit.iter().map(|s| s.to_string()).collect(),
37            subunit: subunit.iter().map(|s| s.to_string()).collect(),
38        }
39    }
40}
41
42/// The value passed to `to_currency`, mirroring what Python's dispatcher saw.
43///
44/// The int/non-int distinction is not cosmetic: `base.to_currency` branches on
45/// `isinstance(val, int)` and skips the cents segment entirely for true ints,
46/// while a float that happens to be whole (`1.0`) still renders cents.
47#[derive(Debug, Clone)]
48pub enum CurrencyValue {
49    /// Caller passed a Python `int`.
50    Int(BigInt),
51    /// Caller passed a float/Decimal.
52    ///
53    /// `has_decimal` mirrors Python's
54    /// `isinstance(val, float) or str(val).find(".") != -1` — the guard that
55    /// decides whether the cents segment appears at all. It cannot be derived
56    /// from the parsed value: `Decimal("5")` and `Decimal("5.00")` are equal
57    /// numerically but only the second prints cents, while `1e21` is a float
58    /// whose repr has no dot yet still counts as decimal. So the flag is
59    /// computed on the Python side, where `str(val)` already lives.
60    Decimal {
61        value: BigDecimal,
62        has_decimal: bool,
63        /// `isinstance(val, float)` on the Python side. A handful of
64        /// converters (SQ) branch on the *origin type*, not the value:
65        /// float 12.34 renders cents, Decimal("12.34") does not. Cannot be
66        /// derived from `value`/`has_decimal` — Decimal("5.00") has a dot
67        /// yet is not a float.
68        is_float: bool,
69    },
70}
71
72impl CurrencyValue {
73    /// `Int` from a decimal string, else `Decimal`. `is_int` and `has_decimal`
74    /// come from the Python side because only it can tell `1` from `1.0`, or
75    /// `Decimal("5")` from `Decimal("5.00")`.
76    pub fn parse(s: &str, is_int: bool, has_decimal: bool, is_float: bool) -> Result<Self> {
77        if is_int {
78            BigInt::from_str(s)
79                .map(CurrencyValue::Int)
80                .map_err(|e| N2WError::Value(e.to_string()))
81        } else {
82            BigDecimal::from_str(s)
83                .map(|value| CurrencyValue::Decimal { value, has_decimal, is_float })
84                .map_err(|e| N2WError::Value(e.to_string()))
85        }
86    }
87
88    pub fn is_negative(&self) -> bool {
89        match self {
90            CurrencyValue::Int(i) => i.is_negative(),
91            CurrencyValue::Decimal { value, .. } => value.is_negative(),
92        }
93    }
94}
95
96/// Python's `prefix_currency(prefix, base)`.
97pub fn prefix_currency(prefix: &str, base: &[String]) -> Vec<String> {
98    base.iter().map(|i| format!("{} {}", prefix, i)).collect()
99}
100
101/// `ROUND_HALF_UP` at `scale` decimal places.
102///
103/// `BigDecimal`'s own rounding is half-even, so this is open-coded: Python's
104/// `quantize(..., rounding=ROUND_HALF_UP)` takes .5 away from zero, which
105/// differs from half-even on exact ties (2.675 -> 2.68, not 2.68 vs 2.67).
106fn round_half_up(v: &BigDecimal, scale: i64) -> BigDecimal {
107    let neg = v.is_negative();
108    let abs = v.abs();
109    let shift = BigDecimal::from(BigInt::from(10).pow(scale.max(0) as u32));
110    let shifted = &abs * &shift;
111    // Truncate, then bump when the discarded remainder is >= 0.5.
112    let floor = shifted.with_scale(0);
113    let frac = &shifted - &floor;
114    let half = BigDecimal::from_str("0.5").unwrap();
115    let rounded = if frac >= half { floor + BigDecimal::from(1) } else { floor };
116    let out = rounded / shift;
117    if neg {
118        -out
119    } else {
120        out
121    }
122}
123
124/// Python's `parse_currency_parts`. Returns `(integer, subunits, negative)`.
125///
126/// `keep_precision` returns fractional subunits (e.g. 65.3 cents) as a
127/// `BigDecimal`; otherwise they are truncated to whole subunits after the
128/// ROUND_HALF_UP quantize.
129pub fn parse_currency_parts(
130    value: &CurrencyValue,
131    is_int_with_cents: bool,
132    keep_precision: bool,
133    divisor: i64,
134) -> (BigInt, BigDecimal, bool) {
135    match value {
136        CurrencyValue::Int(v) => {
137            let negative = v.is_negative();
138            let v = v.abs();
139            if is_int_with_cents && divisor != 0 {
140                let d = BigInt::from(divisor);
141                (&v / &d, BigDecimal::from(&v % &d), negative)
142            } else {
143                (v, BigDecimal::zero(), negative)
144            }
145        }
146        CurrencyValue::Decimal { value: v, .. } => {
147            let mut v = v.clone();
148            if !keep_precision && divisor > 1 {
149                // quant = Decimal(1)/Decimal(divisor) — i.e. 100 -> 2 places.
150                let scale = (divisor as f64).log10().round() as i64;
151                v = round_half_up(&v, scale);
152            }
153            let negative = v.is_negative();
154            let v = v.abs();
155            let integer = v.with_scale(0); // divmod(value, 1) -> floor for +ve
156            let fraction = &v - &integer;
157            let cents = fraction * BigDecimal::from(divisor);
158            let cents = if keep_precision {
159                cents
160            } else {
161                cents.with_scale(0) // int(fraction * divisor) truncates
162            };
163            (integer.as_bigint_and_exponent().0, cents, negative)
164        }
165    }
166}
167
168/// Python's `Num2Word_Base.to_currency`.
169///
170/// Kept as a free function taking `&dyn Lang` so a language can override
171/// `to_currency` wholesale (134 of them do) while still delegating here.
172pub fn default_to_currency<L: Lang + ?Sized>(
173    lang: &L,
174    val: &CurrencyValue,
175    currency: &str,
176    cents: bool,
177    separator: &str,
178    adjective: bool,
179) -> Result<String> {
180    let divisor = lang.currency_precision(currency);
181
182    // Zero-decimal currencies (JPY, KRW): any fractional input rounds to a
183    // whole unit and the cents segment is skipped entirely.
184    let val = if divisor == 1 {
185        match val {
186            CurrencyValue::Decimal { value, .. } => {
187                CurrencyValue::Int(round_half_up(value, 0).as_bigint_and_exponent().0)
188            }
189            other => other.clone(),
190        }
191    } else {
192        val.clone()
193    };
194
195    let forms = lang
196        .currency_forms(currency)
197        .ok_or_else(|| N2WError::NotImplemented(format!(
198            "Currency code \"{}\" not implemented for \"{}\"",
199            currency,
200            lang.lang_name()
201        )))?;
202
203    let mut cr1 = forms.unit.clone();
204    let cr2 = forms.subunit.clone();
205    if adjective {
206        if let Some(adj) = lang.currency_adjective(currency) {
207            cr1 = prefix_currency(adj, &cr1);
208        }
209    }
210
211    // Pure ints never show cents.
212    if let CurrencyValue::Int(v) = &val {
213        let minus = if v.is_negative() {
214            format!("{} ", lang.negword().trim())
215        } else {
216            String::new()
217        };
218        let abs = v.abs();
219        let money = lang.money_verbose(&abs, currency)?;
220        return Ok(format!(
221            "{}{} {}",
222            minus,
223            money,
224            lang.pluralize(&abs, &cr1)?
225        ));
226    }
227
228    let (d, has_decimal, is_float) = match &val {
229        CurrencyValue::Decimal { value, has_decimal, is_float } => (value.clone(), *has_decimal, *is_float),
230        _ => unreachable!(),
231    };
232    // has_fractional_cents: (value * divisor) % 1 != 0
233    let scaled = &d * BigDecimal::from(divisor);
234    let has_fractional_cents = &scaled - scaled.with_scale(0) != BigDecimal::zero();
235
236    let (left, right, is_negative) = parse_currency_parts(
237        &CurrencyValue::Decimal { value: d, has_decimal, is_float },
238        false,
239        has_fractional_cents,
240        divisor,
241    );
242
243    let minus = if is_negative {
244        format!("{} ", lang.negword().trim())
245    } else {
246        String::new()
247    };
248    let money = lang.money_verbose(&left, currency)?;
249    let right_int = right.as_bigint_and_exponent().0;
250
251    // Python: `if has_decimal or right > 0: <with cents> else: <no cents>`.
252    // Dropping this guard makes `Decimal("5")` render "five dollars, zero
253    // cents" where Python says "five dollars".
254    if !has_decimal && right_int.is_zero() {
255        return Ok(format!(
256            "{}{} {}",
257            minus,
258            money,
259            lang.pluralize(&left, &cr1)?
260        ));
261    }
262    if has_fractional_cents {
263        // Python takes `cr2[1] if len(cr2) > 1 else cr2` here — the plural
264        // form unconditionally, without consulting pluralize(). At 1.011 USD
265        // that is "one point one cents", not "...cent".
266        let sub = cr2.get(1).or_else(|| cr2.first()).cloned().unwrap_or_default();
267        return Ok(format!(
268            "{}{} {}{} {} {}",
269            minus,
270            money,
271            lang.pluralize(&left, &cr1)?,
272            separator,
273            lang.cardinal_from_decimal(&right)?,
274            sub
275        ));
276    }
277    let cents_str = if cents {
278        lang.cents_verbose(&right_int, currency)?
279    } else {
280        lang.cents_terse(&right_int, currency)?
281    };
282
283    Ok(format!(
284        "{}{} {}{} {} {}",
285        minus,
286        money,
287        lang.pluralize(&left, &cr1)?,
288        separator,
289        cents_str,
290        lang.pluralize(&right_int, &cr2)?
291    ))
292}
293
294/// Python's `Num2Word_Base._cents_terse`.
295pub fn default_cents_terse(n: &BigInt, divisor: i64) -> String {
296    if divisor <= 1 {
297        return n.to_string();
298    }
299    let width = divisor.to_string().len() - 1;
300    format!("{:0>width$}", n.to_string(), width = width)
301}
302
303/// Python's `Num2Word_Base.to_cheque`.
304pub fn default_to_cheque<L: Lang + ?Sized>(
305    lang: &L,
306    val: &BigDecimal,
307    currency: &str,
308) -> Result<String> {
309    let forms = lang
310        .currency_forms(currency)
311        .ok_or_else(|| N2WError::NotImplemented(format!(
312            "Currency code \"{}\" not implemented for \"{}\"",
313            currency,
314            lang.lang_name()
315        )))?;
316
317    let divisor = lang.currency_precision(currency);
318    let is_negative = val.is_negative();
319    let abs_val = val.abs();
320    let whole = abs_val.with_scale(0).as_bigint_and_exponent().0;
321
322    let fraction_str = if divisor > 1 {
323        let sub = (&abs_val - BigDecimal::from(whole.clone())) * BigDecimal::from(divisor);
324        let sub = sub.with_scale(0).as_bigint_and_exponent().0;
325        let digits = divisor.to_string().len() - 1;
326        format!(
327            "{:0>width$}/{}",
328            sub.to_string(),
329            divisor,
330            width = digits
331        )
332    } else {
333        String::new()
334    };
335
336    let words = lang.money_verbose(&whole, currency)?;
337    // Cheque convention always takes the plural form.
338    let unit = forms.unit.last().cloned().unwrap_or_default();
339    let sign = if is_negative { "MINUS " } else { "" };
340    let body = if fraction_str.is_empty() {
341        format!("{} {}", words, unit)
342    } else {
343        format!("{} AND {} {}", words, fraction_str, unit)
344    };
345    Ok(format!("{}{}", sign, body).to_uppercase())
346}