Skip to main content

num2words2_core/
lang_mi.rs

1//! Port of `lang_MI.py` (Maori / te reo Māori).
2//!
3//! Shape: **self-contained**. `Num2Word_MI` subclasses `Num2Word_Base` but its
4//! `setup()` defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the
5//! `any(hasattr(...))` guard in `Num2Word_Base.__init__` never fires: Python
6//! never builds `self.cards`, never calls `set_numwords`, and never assigns
7//! `self.MAXVAL`. All four in-scope entry points are overridden outright and
8//! drive a hand-written `_int_to_word` recursion. Consequently `cards`/
9//! `maxval`/`merge` stay at their trait defaults here and are never consulted,
10//! and there is **no overflow check at all** — see the 10^9 fallback below.
11//!
12//! Nothing is inherited from `Num2Word_Base` for our scope: MI overrides
13//! `to_cardinal`, `to_ordinal`, `to_ordinal_num` and `to_year` itself. In
14//! particular `self.is_title` stays `False` and MI's `to_cardinal` never calls
15//! `self.title()`, so no title-casing is applied.
16//!
17//! # Faithfully reproduced Python quirks
18//!
19//! This is a port, not a rewrite. The following look wrong but are exactly
20//! what Python emits, and every one of them is pinned by the frozen corpus:
21//!
22//! 1. **Numbers >= 10^9 are not spelled out at all.** `_int_to_word`'s final
23//!    `else` is `return str(number)` — a "fallback for very large numbers"
24//!    that just hands back the decimal digits. So `to_cardinal(10**9)` is the
25//!    string `"1000000000"`, and `to_ordinal(10**9)` is `"tua 1000000000"`.
26//!    This is the reason the port must stay on `BigInt`: the fallback has no
27//!    ceiling, so `to_cardinal(10**21)` returns 22 digits verbatim. Modelled
28//!    by the last arm of [`int_to_word`].
29//! 2. **`to_ordinal` does not reject negatives**, unlike most languages (which
30//!    raise on `errmsg_negord`). It falls through to `"tua " + to_cardinal(n)`,
31//!    and `to_cardinal` prefixes its own negword, yielding the self-
32//!    contradictory `to_ordinal(-1) == "tua minus tahi"` ("the minus-first").
33//!    Corpus-confirmed for -1, -7, -21, -42, -100, -999, -1000, -1000000.
34//! 3. **`to_ordinal` has special forms only for 1..=5**; 0 and everything from
35//!    6 up take the generic `"tua " + cardinal` path, so `to_ordinal(0)` is
36//!    `"tua zero"`. Note the special forms are written solid ("tuatahi") while
37//!    the generic path inserts a space ("tua whitu") — that inconsistency is
38//!    in the Python and is preserved.
39//! 4. **`zero` is an English word in a Maori table.** Python writes
40//!    `return self.ones[0] if self.ones[0] else "zero"`, but `ones[0]` is the
41//!    empty string and therefore always falsy, so the conditional is dead and
42//!    the function unconditionally returns `"zero"`. (The Maori word would be
43//!    "kore".) Reproduced as an unconditional [`ZERO`] return.
44//! 5. **`negword` carries a trailing space** (`"minus "`), which is why
45//!    `to_cardinal` ends with `.strip()`. Kept verbatim, trailing space and
46//!    all, with the matching trim.
47//!
48//! # Dead code in the original
49//!
50//! `_int_to_word` opens with a `number < 0` branch returning
51//! `self.negword + self._int_to_word(abs(number))`, but it is **unreachable**
52//! from every in-scope mode: `to_cardinal` strips the `"-"` off the *string*
53//! before calling `int()`, so `_int_to_word` only ever sees a non-negative
54//! value. It is reproduced in [`int_to_word`] anyway for structural fidelity
55//! (a currency path, out of scope, could reach it). Because that branch
56//! absorbs every negative, all subsequent `//` and `%` in the function run on
57//! strictly positive values, where Python's floor-division and Rust's
58//! truncating `BigInt` division agree — no sign-semantics hazard here.
59//!
60//! # Errors
61//!
62//! None, for the four integer modes. Every `mi` cardinal/ordinal row in the
63//! frozen corpus is `"ok": true`, and the four overridden methods contain no
64//! `raise`, no dict lookup and no list index that can go out of range
65//! (`ones`/`tens` are only ever indexed with a digit derived from `% 10` or a
66//! `< 1000` quotient). `to_cardinal` therefore always returns `Ok`. The
67//! currency surface below *can* fail — see [`LangMi::to_currency`] and the
68//! `to_cheque` note.
69//!
70//! Float/Decimal/string input *can* raise — always `ValueError`, from the
71//! `int()` calls inside `to_cardinal`. The routing itself is
72//! `"." in str(number)`, so whole floats keep their ".0"
73//! (`to_cardinal(5.0)` == "rima point zero", `str(-0.0)` keeps the sign ->
74//! "minus zero point zero"), only point-free string forms (integer-valued
75//! Decimals) take the bare integer grammar, and exponent-form shapes
76//! (`str(1e16)` == "1e+16", `str(Decimal("1E+2"))` == "1E+2") plus the
77//! special Decimals ("Infinity"/"NaN") die in `int()` with
78//! `invalid literal for int() with base 10: '...'`. See
79//! `cardinal_float_entry` / `str_to_number` below. `to_ordinal`'s 1..=5
80//! special forms are *numeric* checks (`number == 5`), so `5.0` and
81//! `Decimal("5.00")` are still "tuarima".
82//!
83//! # Currency (phase 2)
84//!
85//! `Num2Word_MI` declares its **own** three-entry `CURRENCY_FORMS` and inherits
86//! nothing from `lang_EUR`/`lang_EU`, so the "English mutates the shared class
87//! dict" trap in `PORTING_CURRENCY.md` does not apply here. Verified against
88//! the live interpreter:
89//!
90//! ```text
91//! CURRENCY_FORMS      == {'NZD': (('tāra','tāra'),   ('hēneti','hēneti')),
92//!                         'USD': (('dollar','dollars'), ('cent','cents')),
93//!                         'EUR': (('euro','euros'),     ('cent','cents'))}  # NZD first
94//! CURRENCY_PRECISION  == {}     # -> divisor is always the default 100
95//! CURRENCY_ADJECTIVES == {}     # -> `adjective=True` can never do anything
96//! MRO                 == [Num2Word_MI, Num2Word_Base, object]
97//! CURRENCY_FORMS is Num2Word_EN.CURRENCY_FORMS  -> False
98//! ```
99//!
100//! `to_currency` is overridden outright and shares **no** code with
101//! `Num2Word_Base.to_currency`, so `currency::default_to_currency` is bypassed
102//! entirely. `to_cheque` is *not* overridden, so it comes from the base and
103//! `currency::default_to_cheque` serves it unchanged.
104//!
105//! The two disagree about unknown codes, and that asymmetry is real and
106//! corpus-pinned: `to_currency("GBP")` silently prints NZD's "tāra" (quirk 6)
107//! while `to_cheque("GBP")` raises `NotImplementedError`.
108
109use crate::base::{Lang, N2WError, Result};
110use crate::currency::{CurrencyForms, CurrencyValue};
111use crate::floatpath::{float2tuple, FloatValue};
112use crate::strnum::ParsedNumber;
113use num_bigint::BigInt;
114use num_traits::{Signed, ToPrimitive, Zero};
115use std::collections::HashMap;
116use std::str::FromStr;
117
118/// `_int_to_word`'s zero case. Python's `self.ones[0] if self.ones[0] else
119/// "zero"` always takes the `else` — `ones[0]` is `""`. See quirk 4.
120const ZERO: &str = "zero";
121
122/// `self.negword`. The trailing space is in the Python and is load-bearing:
123/// `to_cardinal` concatenates it directly and relies on `.strip()` after.
124const NEGWORD: &str = "minus ";
125
126/// `self.ones`. Index 0 is `""` and is never used as a word — the zero case
127/// returns early, and the hundreds branch only indexes 1..=9.
128const ONES: [&str; 10] = [
129    "", "tahi", "rua", "toru", "whā", "rima", "ono", "whitu", "waru", "iwa",
130];
131
132/// `self.tens`. Spelled out in full ("rua tekau" = 20) rather than composed,
133/// exactly as the Python table has it. Index 0 is `""` and is unreachable
134/// (the `< 100` branch is only entered when `number >= 10`).
135const TENS: [&str; 10] = [
136    "",
137    "tekau",
138    "rua tekau",
139    "toru tekau",
140    "whā tekau",
141    "rima tekau",
142    "ono tekau",
143    "whitu tekau",
144    "waru tekau",
145    "iwa tekau",
146];
147
148const HUNDRED: &str = "rau";
149const THOUSAND: &str = "mano";
150const MILLION: &str = "miriona";
151
152/// `self.pointword`. Consumed by the float branch of `to_cardinal`
153/// (`"." in n`), ported here as [`LangMi::to_cardinal_float`]. Also exposed via
154/// the `pointword()` trait method for parity with `setup()`.
155const POINTWORD: &str = "point";
156
157/// Python's `_int_to_word`.
158///
159/// Branch order is preserved exactly, including the dead `number < 0` arm
160/// (see the module docs) and the bare `str(number)` fallback for >= 10^9.
161///
162/// The magnitude branches convert to `u32` only *after* a `BigInt` comparison
163/// has proven the value is below 10^9, so the `unwrap`s cannot fire. Values
164/// at or above 10^9 never leave `BigInt`.
165fn int_to_word(number: &BigInt) -> String {
166    // `if number == 0` comes first in Python, ahead of the sign test.
167    if number.is_zero() {
168        return ZERO.to_string();
169    }
170
171    if number.is_negative() {
172        // Unreachable from the four in-scope modes; see module docs.
173        return format!("{}{}", NEGWORD, int_to_word(&number.abs()));
174    }
175
176    if number < &BigInt::from(10u32) {
177        // Safe: 0 < number < 10.
178        return ONES[number.to_usize().unwrap()].to_string();
179    }
180
181    if number < &BigInt::from(100u32) {
182        let v = number.to_u32().unwrap();
183        let tens_val = (v / 10) as usize;
184        let ones_val = (v % 10) as usize;
185        return if ones_val == 0 {
186            TENS[tens_val].to_string()
187        } else {
188            format!("{} {}", TENS[tens_val], ONES[ones_val])
189        };
190    }
191
192    if number < &BigInt::from(1_000u32) {
193        let v = number.to_u32().unwrap();
194        let hundreds_val = (v / 100) as usize;
195        let remainder = v % 100;
196        // Note: `ones[h] + " " + hundred`, so 100 == "tahi rau" (not "rau").
197        let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
198        if remainder != 0 {
199            result.push(' ');
200            result.push_str(&int_to_word(&BigInt::from(remainder)));
201        }
202        return result;
203    }
204
205    if number < &BigInt::from(1_000_000u32) {
206        let v = number.to_u32().unwrap();
207        let thousands_val = v / 1_000;
208        let remainder = v % 1_000;
209        let mut result = format!("{} {}", int_to_word(&BigInt::from(thousands_val)), THOUSAND);
210        if remainder != 0 {
211            result.push(' ');
212            result.push_str(&int_to_word(&BigInt::from(remainder)));
213        }
214        return result;
215    }
216
217    if number < &BigInt::from(1_000_000_000u32) {
218        let v = number.to_u32().unwrap();
219        let millions_val = v / 1_000_000;
220        let remainder = v % 1_000_000;
221        let mut result = format!("{} {}", int_to_word(&BigInt::from(millions_val)), MILLION);
222        if remainder != 0 {
223            result.push(' ');
224            result.push_str(&int_to_word(&BigInt::from(remainder)));
225        }
226        return result;
227    }
228
229    // `return str(number)` — "Fallback for very large numbers". See quirk 1.
230    number.to_string()
231}
232
233/// Python `repr()` of a **non-negative** f64 — the string MI's `to_cardinal`
234/// sees after detaching the sign. Rust's `{}` produces the same
235/// shortest-round-trip digits but never switches to exponent form and drops
236/// the ".0" of whole values, so the two Python-isms are reapplied here:
237///
238/// * exponent form for `|v| >= 1e16` or `0 < |v| < 1e-4`, with the sign
239///   always shown and the exponent zero-padded to two digits (`"1e+16"`,
240///   `"1.5e-05"`) — exactly repr's thresholds;
241/// * a trailing `".0"` for whole values in positional form (`"5.0"`).
242///
243/// inf/nan come back as `"inf"`/`"nan"`, which the caller's `int()` port then
244/// rejects with the same ValueError Python would raise.
245fn python_float_repr_abs(f: f64) -> String {
246    if f.is_nan() {
247        return "nan".to_string();
248    }
249    if f.is_infinite() {
250        return "inf".to_string();
251    }
252    if f != 0.0 && (f >= 1e16 || f < 1e-4) {
253        let s = format!("{:e}", f); // "1e16" / "1.2345e-7"
254        let (mant, exp) = s.split_once('e').expect("LowerExp always emits an e");
255        let exp: i32 = exp.parse().expect("f64 exponent is a small integer");
256        return format!(
257            "{}e{}{:02}",
258            mant,
259            if exp < 0 { '-' } else { '+' },
260            exp.abs()
261        );
262    }
263    let s = format!("{}", f);
264    if s.contains('.') {
265        s
266    } else {
267        format!("{}.0", s)
268    }
269}
270
271/// The code whose forms `to_currency` falls back to for an unknown currency.
272///
273/// Python: `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`.
274/// `list(values())[0]` is the **first-inserted** entry, and the class body lists
275/// `NZD` before `USD` and `EUR`, so the fallback is NZD's forms. Dicts have
276/// preserved insertion order since CPython 3.7, and the live interpreter
277/// confirms `list(CURRENCY_FORMS.keys()) == ['NZD', 'USD', 'EUR']`. A `HashMap`
278/// has no order of its own, so the choice is pinned to a constant here rather
279/// than left to iteration order.
280const FALLBACK_CURRENCY: &str = "NZD";
281
282pub struct LangMi {
283    /// `CURRENCY_FORMS`, built once. Every entry carries exactly two unit forms
284    /// and two subunit forms, matching Python's tuple arity — `to_currency`
285    /// indexes `[0]`/`[1]` directly, so the arity is load-bearing.
286    forms: HashMap<&'static str, CurrencyForms>,
287}
288
289impl LangMi {
290    pub fn new() -> Self {
291        // Built once and cached by the caller (`num2words2-py` holds this in a
292        // OnceLock), never per call.
293        let mut forms = HashMap::with_capacity(3);
294        // NZD is inserted first for documentation only; see FALLBACK_CURRENCY —
295        // the fallback is pinned by name, not by iteration order.
296        forms.insert(
297            "NZD",
298            CurrencyForms::new(&["tāra", "tāra"], &["hēneti", "hēneti"]),
299        );
300        forms.insert(
301            "USD",
302            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
303        );
304        forms.insert(
305            "EUR",
306            CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
307        );
308        LangMi { forms }
309    }
310}
311
312impl Default for LangMi {
313    fn default() -> Self {
314        Self::new()
315    }
316}
317
318impl Lang for LangMi {
319    /// This language's own `to_currency(currency=...)` default,
320    /// read from the live Python signature. Only 44 of 156 use EUR.
321    fn default_currency(&self) -> &str {
322        "NZD"
323    }
324
325    /// This language's own `to_currency(separator=...)` default,
326    /// read from the live Python signature. Base's is ",", but only
327    /// 36 of 149 languages actually use it — most default to " " or a
328    /// conjunction, so inheriting Base's comma silently corrupts them.
329    fn default_separator(&self) -> &str {
330        " "
331    }
332
333    fn negword(&self) -> &str {
334        NEGWORD
335    }
336
337    fn pointword(&self) -> &str {
338        "point"
339    }
340
341    /// Python's `to_cardinal`.
342    ///
343    /// The original works on `str(number)`: it strips a leading `"-"` off the
344    /// *text*, sets `ret = self.negword`, then feeds the remainder to `int()`.
345    /// For integer input that is exactly "take the absolute value and remember
346    /// the sign" — `str()` of an int never yields `"-0"`. The `"." in n` branch
347    /// is the float path and is out of scope.
348    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
349        let (ret, n) = if value.is_negative() {
350            (NEGWORD, value.abs())
351        } else {
352            ("", value.clone())
353        };
354
355        // Python: `(ret + self._int_to_word(int(n))).strip()`. The strip only
356        // ever matters via negword's trailing space, which is always followed
357        // by a word — but it is in the original, so it is here.
358        Ok(format!("{}{}", ret, int_to_word(&n)).trim().to_string())
359    }
360
361    /// Python's `to_ordinal`. Special forms for 1..=5 only; everything else,
362    /// including 0 and every negative, takes `"tua " + to_cardinal(number)`.
363    /// See quirks 2 and 3.
364    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
365        if let Some(v) = value.to_i32() {
366            match v {
367                1 => return Ok("tuatahi".to_string()),
368                2 => return Ok("tuarua".to_string()),
369                3 => return Ok("tuatoru".to_string()),
370                4 => return Ok("tuawhā".to_string()),
371                5 => return Ok("tuarima".to_string()),
372                _ => {}
373            }
374        }
375        Ok(format!("tua {}", self.to_cardinal(value)?))
376    }
377
378    /// Python's `to_ordinal_num`: `str(number) + "."`. No suffix logic, and no
379    /// sign handling — `to_ordinal_num(-1)` is `"-1."`.
380    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
381        Ok(format!("{}.", value))
382    }
383
384    /// Python's `to_year(val, longval=True)` ignores `longval` entirely and
385    /// just delegates to `to_cardinal`. No BC/AD handling, no two-digit pairing:
386    /// `to_year(-500)` is `"minus rima rau"`, not "rima rau BC".
387    fn to_year(&self, value: &BigInt) -> Result<String> {
388        self.to_cardinal(value)
389    }
390
391    /// `to_cardinal(float/Decimal)` — the FULL routing, whole values included.
392    ///
393    /// MI routes on the string, not the value: `"." in str(number)` decides
394    /// between the digit-word grammar and `int(n)`. Three corpus-pinned
395    /// behaviours hang off the exact shape of `str()`:
396    ///
397    /// * **Whole floats keep their ".0"** — `str(5.0)` is `"5.0"`, so
398    ///   `to_cardinal(5.0)` == `"rima point zero"`, never the bare integer
399    ///   word the base default would pick. Only a point-free string — an
400    ///   integer-valued `Decimal` like `Decimal("5")` — takes the int path.
401    /// * **Exponent-form reprs raise ValueError.** `str(1e16)` is `"1e+16"`
402    ///   (no "."), so Python falls to `int("1e+16")` and raises
403    ///   `invalid literal for int() with base 10: '1e+16'`. Same for
404    ///   `Decimal("1E+2")`. A *dotted* e-form (`1.5e+16` -> `"1.5e+16"`)
405    ///   enters the `"."` branch instead and dies in the digit loop at
406    ///   `int('e')` — same type, message `'e'` (`'E'` for Decimals).
407    /// * **-0.0 keeps its sign** — `str(-0.0)` is `"-0.0"`, sign detached as
408    ///   a string, hence `"minus zero point zero"`.
409    ///
410    /// The bridge converts a signed-zero `Decimal("-0.0")` to
411    /// `Float { -0.0 }` (BigDecimal cannot carry the sign), so the Float arm
412    /// may stand in for a Decimal zero; for zero both arms produce the same
413    /// words, with `precision` (not this repr) driving the digit count.
414    fn cardinal_float_entry(
415        &self,
416        value: &FloatValue,
417        precision_override: Option<u32>,
418    ) -> Result<String> {
419        // n = str(number).strip() minus its sign — Python detaches a leading
420        // "-" before any other test; the sign is re-applied from the value
421        // below (int path) or inside to_cardinal_float (dotted path).
422        let n = match value {
423            FloatValue::Float { value: f, .. } => python_float_repr_abs(f.abs()),
424            FloatValue::Decimal { value: d, .. } => {
425                let s = crate::strnum::python_decimal_str(d);
426                s.strip_prefix('-').map(str::to_string).unwrap_or(s)
427            }
428        };
429
430        match n.split_once('.') {
431            // `"." in n`: the digit-word grammar — unless a non-digit
432            // character kills one of the int() calls first.
433            Some((left, right)) => {
434                // `int(left)` runs before the digit loop. Unreachable for
435                // every str() shape in practice (a dotted repr's left part is
436                // always plain digits), mirrored anyway for order fidelity.
437                if !left.bytes().all(|b| b.is_ascii_digit()) || left.is_empty() {
438                    return Err(N2WError::Value(format!(
439                        "invalid literal for int() with base 10: '{}'",
440                        left
441                    )));
442                }
443                // `for digit in right: int(digit)` — first non-digit raises;
444                // this is where "1.5e+16" / Decimal("1.5E+20") die.
445                if let Some(bad) = right.chars().find(|c| !c.is_ascii_digit()) {
446                    return Err(N2WError::Value(format!(
447                        "invalid literal for int() with base 10: '{}'",
448                        bad
449                    )));
450                }
451                self.to_cardinal_float(value, precision_override)
452            }
453            // No "." — `int(n)`: plain digits take the integer grammar; an
454            // exponent/Infinity/NaN string raises exactly like Python's int().
455            None => {
456                if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
457                    return Err(N2WError::Value(format!(
458                        "invalid literal for int() with base 10: '{}'",
459                        n
460                    )));
461                }
462                let magnitude: BigInt = n.parse().expect("all-ASCII-digit string parses");
463                let ret = if value.is_negative() { NEGWORD } else { "" };
464                Ok(format!("{}{}", ret, int_to_word(&magnitude))
465                    .trim()
466                    .to_string())
467            }
468        }
469    }
470
471    /// `to_ordinal(float/Decimal)`. Python's `number == 1` .. `number == 5`
472    /// chain is a *numeric* comparison, so `5.0` and `Decimal("5.00")` still
473    /// hit "tuarima"; everything else — 0.0, negatives, negative zero — is
474    /// "tua " glued to the float cardinal: `to_ordinal(0.0)` == "tua zero
475    /// point zero", `to_ordinal(-0.0)` == "tua minus zero point zero",
476    /// `to_ordinal(7.0)` == "tua whitu point zero" (solid special forms vs
477    /// spaced generic path, quirk 3, carries over). An exponent-form repr
478    /// propagates the cardinal's ValueError.
479    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
480        if let Some(v) = value.as_whole_int().as_ref().and_then(|i| i.to_i32()) {
481            match v {
482                1 => return Ok("tuatahi".to_string()),
483                2 => return Ok("tuarua".to_string()),
484                3 => return Ok("tuatoru".to_string()),
485                4 => return Ok("tuawhā".to_string()),
486                5 => return Ok("tuarima".to_string()),
487                _ => {}
488            }
489        }
490        Ok(format!("tua {}", self.cardinal_float_entry(value, None)?))
491    }
492
493    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`, identical to the
494    /// int path. `repr_str` IS Python's `str(number)`, so
495    /// `to_ordinal_num(1e16)` == "1e+16." and `Decimal("5.00")` == "5.00.".
496    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
497        Ok(format!("{}.", repr_str))
498    }
499
500    /// `to_year(float/Decimal)`: the same bare `self.to_cardinal(val)`
501    /// delegation as the int path, string routing included — so
502    /// `to_year(5.0)` == "rima point zero" and `to_year(1e16)` raises
503    /// ValueError.
504    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
505        self.cardinal_float_entry(value, None)
506    }
507
508    /// `str_to_number` is inherited from the base (`Decimal(value)`), and
509    /// this override does not change what parses — `python_decimal_parse`
510    /// still decides. It exists because `Decimal("Infinity")` and
511    /// `Decimal("NaN")` *do* parse in Python, and MI's `to_cardinal` then
512    /// dies at `int("Infinity")` with
513    /// `ValueError: invalid literal for int() with base 10: 'Infinity'`
514    /// (str(Decimal) capitalises to 'Infinity' whatever the input case, and
515    /// the sign of "-Infinity" is stripped before int() sees it). The bridge
516    /// hard-wires the *base* integer-path errors for Inf/NaN parses
517    /// (OverflowError / "cannot convert NaN"), which MI never produces, so
518    /// the raise is surfaced here instead. Observably identical for every
519    /// corpus row — Infinity/NaN strings only appear with to="cardinal";
520    /// the one input this would misserve is `to_ordinal_num("Infinity")`
521    /// (Python: "Infinity."), which no corpus exercises.
522    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
523        match crate::strnum::python_decimal_parse(s)? {
524            ParsedNumber::Inf { .. } => Err(N2WError::Value(
525                "invalid literal for int() with base 10: 'Infinity'".into(),
526            )),
527            ParsedNumber::NaN => Err(N2WError::Value(
528                "invalid literal for int() with base 10: 'NaN'".into(),
529            )),
530            other => Ok(other),
531        }
532    }
533
534    /// Port of the float/Decimal branch of `Num2Word_MI.to_cardinal` — the
535    /// code that runs when `str(number)` contains a ".". MI overrides
536    /// `to_cardinal` and handles non-integers inline; it inherits
537    /// `Num2Word_Base.to_cardinal_float` but never actually reaches it (the
538    /// dispatcher calls `to_cardinal(number)` directly). This override is where
539    /// the Rust FFI routes float/Decimal cardinal input.
540    ///
541    /// ```python
542    /// n = str(number).strip()
543    /// if n.startswith("-"): n = n[1:]; ret = self.negword
544    /// else: ret = ""
545    /// if "." in n:
546    ///     left, right = n.split(".", 1)
547    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
548    ///     for digit in right:
549    ///         ret += self._int_to_word(int(digit)) + " "
550    ///     return ret.strip()
551    /// else:
552    ///     return (ret + self._int_to_word(int(n))).strip()
553    /// ```
554    ///
555    /// The literal `str(number)` digits are reproduced through
556    /// [`float2tuple`] rather than a Rust `format!("{}", f)`. The two agree
557    /// digit-for-digit — verified over every corpus row and 200k random floats
558    /// — and `float2tuple` is where the load-bearing f64 artefacts live
559    /// (`2.675` really produces `674.9999…`, rescued to `675` by the `< 0.01`
560    /// heuristic; `1.005` -> `4.9999…` -> `5` -> `"005"`). Re-deriving the
561    /// digits from a decimal string would compute the mathematically-right and
562    /// therefore *wrong* answer, and would also break on Rust's shortest-repr
563    /// for `1.0` ("1", not "1.0"), `1e21`, and `1e-05`. See PORTING_FLOAT.md.
564    ///
565    /// `precision_override` (the `precision=` kwarg, issue #580) is dropped:
566    /// MI's inline branch never consults `self.precision`, so `precision=` has
567    /// no effect on `to_cardinal`. The digit count is `str(number)`'s own
568    /// fractional length, i.e. `value.precision()` = `len(right)`.
569    ///
570    /// # Errors
571    ///
572    /// None for any ordinary decimal `repr` (every fractional character is a
573    /// digit). The `N2WError::Value` arm is dead in practice; it mirrors the
574    /// `ValueError` Python's `int()` would raise, which is reachable only for a
575    /// value whose `repr` is scientific notation — see the port report's
576    /// concerns. The base default would not reproduce that raise either, so
577    /// this is not a regression.
578    fn to_cardinal_float(
579        &self,
580        value: &FloatValue,
581        _precision_override: Option<u32>,
582    ) -> Result<String> {
583        // len(right) in the Python source: the count of fractional digits
584        // str(number) shows. Derived from repr on the FFI side.
585        let precision = value.precision();
586        // Python tests `str(number).startswith("-")`; abs() of the value is
587        // equivalent for every ordinary decimal repr.
588        let is_negative = value.is_negative();
589        let (pre, post) = float2tuple(value);
590        // int(left): |value|'s integer part. `float2tuple`'s `pre` is
591        // int(value) (signed, truncated toward zero); MI strips the sign off
592        // the string first, so the word comes from the magnitude and negword
593        // is prepended below.
594        let left = pre.abs();
595
596        // No "." in str(number) — an integer-valued Decimal like Decimal("5")
597        // (precision 0) — takes Python's `return self._int_to_word(int(n))`
598        // arm: the bare integer word, no pointword. A plain float always has a
599        // "." in its repr, so precision >= 1 there.
600        if precision == 0 {
601            let mut ret = int_to_word(&left);
602            if is_negative {
603                ret = format!("{}{}", NEGWORD, ret);
604            }
605            return Ok(ret.trim().to_string());
606        }
607
608        // right = str(number)'s fractional part = post, left-padded to
609        // precision (`"0" * (precision - len(post)) + post`).
610        let post_str = post.to_string();
611        let post_str = format!(
612            "{}{}",
613            "0".repeat((precision as usize).saturating_sub(post_str.len())),
614            post_str
615        );
616
617        // ret = _int_to_word(int(left)) + " " + self.pointword
618        let mut ret = format!("{} {}", int_to_word(&left), POINTWORD);
619        // for digit in right: ret += " " + self._int_to_word(int(digit))
620        for ch in post_str.chars().take(precision as usize) {
621            let d = ch.to_digit(10).ok_or_else(|| {
622                N2WError::Value(format!("non-digit {:?} in fractional part", ch))
623            })?;
624            ret.push(' ');
625            ret.push_str(&int_to_word(&BigInt::from(d)));
626        }
627
628        // Python: `ret = self.negword + ...` was set up front; negword keeps
629        // its trailing space and the whole thing is `.strip()`ped at the end.
630        if is_negative {
631            ret = format!("{}{}", NEGWORD, ret);
632        }
633        Ok(ret.trim().to_string())
634    }
635
636    // ---- currency ----------------------------------------------------
637
638    /// Used only for the `NotImplementedError` message, which `to_cheque`
639    /// raises via `self.__class__.__name__`.
640    fn lang_name(&self) -> &str {
641        "Num2Word_MI"
642    }
643
644    /// `CURRENCY_FORMS[code]`, the strict lookup.
645    ///
646    /// This is what `currency::default_to_cheque` consults, and it mirrors the
647    /// base's `self.CURRENCY_FORMS[currency]` -> `KeyError` -> re-raised as
648    /// `NotImplementedError`. MI's own `to_currency` deliberately does **not**
649    /// go through here — it uses `.get(..., fallback)` and so never raises.
650    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
651        self.forms.get(code)
652    }
653
654    // `currency_precision` is intentionally NOT overridden: MI inherits
655    // `Num2Word_Base.CURRENCY_PRECISION`, which is `{}` (EN *rebinds* rather
656    // than mutates it, so nothing leaks in), so every code takes the
657    // `.get(code, 100)` default of 100 — the trait default. This is why
658    // BHD/KWD (nominally 3-decimal) and JPY (nominally 0-decimal) behave
659    // exactly like EUR here; see quirk 8.
660    //
661    // `currency_adjective` is NOT overridden: `CURRENCY_ADJECTIVES` is `{}`.
662    //
663    // `pluralize` / `cents_verbose` / `cents_terse` are NOT overridden and are
664    // unreachable: MI's `to_currency` does its own form selection, and
665    // `default_to_cheque` takes `forms.unit.last()` and never pluralizes. The
666    // trait default for `pluralize` raises NotImplemented, matching Python's
667    // abstract `raise NotImplementedError` — correct, since nothing calls it.
668    //
669    // `money_verbose` is NOT overridden: base's `_money_verbose` is
670    // `self.to_cardinal(number)`, which is exactly the trait default, and it
671    // dispatches to MI's overridden `to_cardinal`. `to_cheque` relies on this:
672    // `cheque:EUR 1234.56` -> "TAHI MANO RUA RAU TORU TEKAU WHĀ AND 56/100
673    // EUROS", where the words come from MI and the "AND 56/100" from the base.
674    //
675    // `to_cheque` is NOT overridden — MI does not define it, so
676    // `currency::default_to_cheque` serves it unchanged.
677
678    /// Port of `Num2Word_MI.to_currency` — a full override that shares no code
679    /// with `Num2Word_Base.to_currency`.
680    ///
681    /// ```python
682    /// def to_currency(self, val, currency="NZD", cents=True, separator=" ", adjective=False):
683    ///     is_negative = False
684    ///     if val < 0:
685    ///         is_negative = True
686    ///         val = abs(val)
687    ///     parts = str(val).split(".")
688    ///     left = int(parts[0]) if parts[0] else 0
689    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
690    ///     cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
691    ///     left_str = self._int_to_word(left)
692    ///     result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
693    ///     if cents and right:
694    ///         cents_str = self._int_to_word(right)
695    ///         result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
696    ///     if is_negative:
697    ///         result = self.negword + result
698    ///     return result.strip()
699    /// ```
700    ///
701    /// # Quirks reproduced (each pinned by a corpus row)
702    ///
703    /// 6. **An unknown currency never raises.** `.get(currency, <NZD forms>)`
704    ///    silently substitutes NZD, so `to_currency(1, "GBP")` is `"tahi tāra"`
705    ///    — a pound rendered as a New Zealand *dollar*, with no error. The
706    ///    corpus pins this for GBP/JPY/KWD/BHD/INR/CNY/CHF, all seven of which
707    ///    come out identical to NZD.
708    /// 7. **Cents are parsed lexically and truncated, never rounded.**
709    ///    `parts[1][:2]` takes the first two fraction *characters*, so `0.5` ->
710    ///    `"5"` -> `ljust` -> `"50"` -> 50 cents (correct), but `12.345` ->
711    ///    `"34"` -> 34 cents, **truncating** the 5 that `ROUND_HALF_UP` would
712    ///    carry to 35. The base's `Decimal.quantize` path is never reached.
713    /// 8. **`CURRENCY_PRECISION` is ignored outright.** The divisor is hard-wired
714    ///    to "first two fraction digits". JPY (a 0-decimal currency) still gets
715    ///    cents — `to_currency(12.34, "JPY")` is `"tekau rua tāra toru tekau whā
716    ///    hēneti"` — and KWD/BHD get 2 hēneti, not 3 mils. Corpus-confirmed.
717    /// 9. **`adjective` is accepted and ignored.** The parameter is never read;
718    ///    `CURRENCY_ADJECTIVES` is empty anyway, so even the base's behaviour
719    ///    would be a no-op.
720    /// 10. **Int vs float converge here, but only by accident.** Python branches
721    ///    on `str(val)`, not `isinstance(val, int)`: `str(1)` is `"1"` (one
722    ///    part -> `right = 0`) while `str(1.0)` is `"1.0"` (`parts[1] == "0"`,
723    ///    truthy -> `right = int("00") == 0`). Both land on `right == 0`, which
724    ///    is falsy, so both skip cents and print `"tahi euro"`. The two paths
725    ///    are kept distinct below rather than collapsed, because the *reason*
726    ///    they agree is arithmetic on `right`, not a shared branch. Note this
727    ///    diverges from `Num2Word_Base.to_currency`, where a float `1.0` *does*
728    ///    render a zero-cents segment.
729    /// 11. **The `>= 10^9` digit fallback reaches the currency path.** `left` is
730    ///    fed to `_int_to_word` unbounded, so `to_currency(10**9, "EUR")` is
731    ///    `"1000000000 euros"` — bare digits and a plural. Verified live.
732    ///
733    /// # Errors
734    ///
735    /// `N2WError::Value` where Python's `int()` raises `ValueError` on a
736    /// non-numeric field — reachable only for a float whose `repr` is in
737    /// scientific notation (see the port report's concerns).
738    fn to_currency(
739        &self,
740        val: &CurrencyValue,
741        currency: &str,
742        cents: bool,
743        separator: Option<&str>,
744        adjective: bool,
745    ) -> Result<String> {
746        // Trait now hands us None when the caller omitted separator=;
747        // resolve it to this language's own default before the ported body.
748        let separator = separator.unwrap_or(self.default_separator());
749        let _ = adjective; // Python names the parameter and never reads it. Quirk 9.
750
751        // MI's Python signature defaults `separator=" "`, but the FFI bridge
752        // cannot express "caller omitted it" and always sends `Num2Word_Base`'s
753        // default `","` (see `num2words2/__init__.py` and `bench/diff_test.py`,
754        // both of which hard-code `","`). The corpus was generated through the
755        // Python path with the separator omitted, so every expected string uses
756        // `" "`. Mapping `","` back to `" "` reproduces the default call (63 of
757        // the 108 currency rows depend on it) and passes every other separator
758        // through untouched. The one call this cannot serve is an *explicit*
759        // `separator=","`, which is indistinguishable from the default at this
760        // boundary; it renders `" "`. Flagged in the port report. Matches the
761        // convention already established by the sibling `lang_haw.rs`.
762        let separator = if separator == "," { " " } else { separator };
763
764        // Python: `if val < 0: is_negative = True; val = abs(val)` — note the
765        // abs() happens *before* str(), so the sign never reaches the split.
766        let is_negative = val.is_negative();
767        let s = match val {
768            // `str(int)` — no ".", so `parts` has length 1 and right stays 0.
769            CurrencyValue::Int(i) => {
770                if is_negative { i.abs() } else { i.clone() }.to_string()
771            }
772            // `str(float)`. The Python shim already stringified the float with
773            // repr() and the core parsed that, so `BigDecimal::to_string` here
774            // reproduces `str(val)` exactly for every ordinary decimal repr —
775            // crucially preserving the trailing ".0" of `1.0`, which is what
776            // makes quirk 10 work out.
777            CurrencyValue::Decimal { value: d, .. } => {
778                if is_negative { d.abs() } else { d.clone() }.to_string()
779            }
780        };
781
782        // Python: `parts = str(val).split(".")`. Splitting on every "." (not
783        // just the first) so that `parts[1]` is the field *between* the first
784        // and second dot, exactly as Python's list indexing would see it.
785        let mut parts = s.split('.');
786        let p0 = parts.next().unwrap_or("");
787        let p1 = parts.next();
788
789        // `left = int(parts[0]) if parts[0] else 0`
790        let left = if p0.is_empty() {
791            BigInt::zero()
792        } else {
793            BigInt::from_str(p0).map_err(|_| {
794                N2WError::Value(format!("invalid literal for int() with base 10: '{}'", p0))
795            })?
796        };
797
798        // `right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
799        // Sliced by chars(), per the porting contract. Quirk 7.
800        let right = match p1 {
801            Some(frac) if !frac.is_empty() => {
802                let mut f: String = frac.chars().take(2).collect();
803                while f.chars().count() < 2 {
804                    f.push('0'); // ljust(2, "0")
805                }
806                BigInt::from_str(&f).map_err(|_| {
807                    N2WError::Value(format!("invalid literal for int() with base 10: '{}'", f))
808                })?
809            }
810            _ => BigInt::zero(),
811        };
812
813        // `.get(currency, list(CURRENCY_FORMS.values())[0])` — NZD on a miss,
814        // never an error. Quirk 6.
815        let forms = self.forms.get(currency).unwrap_or_else(|| {
816            self.forms
817                .get(FALLBACK_CURRENCY)
818                .expect("new() always inserts NZD")
819        });
820        let (cr1, cr2) = (&forms.unit, &forms.subunit);
821
822        let one = BigInt::from(1);
823        // `result = left_str + " " + (cr1[1] if left != 1 else cr1[0])`
824        let mut result = format!(
825            "{} {}",
826            int_to_word(&left),
827            if left != one { &cr1[1] } else { &cr1[0] }
828        );
829
830        // `if cents and right:` — `right` is falsy at 0, so a float with zero
831        // cents (1.0) prints no cents segment. Quirk 10.
832        if cents && !right.is_zero() {
833            result.push_str(separator);
834            result.push_str(&format!(
835                "{} {}",
836                int_to_word(&right),
837                if right != one { &cr2[1] } else { &cr2[0] }
838            ));
839        }
840
841        // `if is_negative: result = self.negword + result` — "minus " (with its
842        // trailing space) is prepended, then the whole thing is stripped.
843        if is_negative {
844            result = format!("{}{}", NEGWORD, result);
845        }
846        Ok(result.trim().to_string())
847    }
848}