Skip to main content

num2words2_core/
lang_rw.rs

1//! Port of `lang_RW.py` (Kinyarwanda).
2//!
3//! Shape: **self-contained**. `Num2Word_RW` 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` and never sets `self.MAXVAL`. `to_cardinal` is
7//! overridden outright and drives `_int_to_word`, a plain recursive descent
8//! over 0 / <10 / <100 / <1000 / <10^6 / <10^9 bands.
9//!
10//! Consequently `cards`/`maxval`/`merge` stay at their trait defaults here, and
11//! **nothing in the integer modes can raise**: there is no overflow check, no
12//! `verify_ordinal` call, and no table lookup that can miss. Every
13//! `to_cardinal`/`to_ordinal`/`to_ordinal_num`/`to_year` on an integer returns
14//! `Ok`. This matches the frozen corpus, in which all 324 such `rw` rows carry
15//! `"ok": true`. The only `ok: false` rows there are seven `cheque:*` codes
16//! (`NotImplementedError`, reproduced below) and `fraction` (out of scope).
17//! The float/Decimal corpora (`corpus_wholefloat.jsonl`, `corpus_strings.jsonl`)
18//! do add `ok: false` rows — `ValueError` on exponent-form reprs (`1e+16`,
19//! `Decimal("1E+2")`, the string `"1e3"`) and on `"Infinity"` — because RW's
20//! `to_cardinal` feeds `int()` whatever `str(number)` produced. All are
21//! reproduced by the float entry hooks below.
22//!
23//! Inherited from `Num2Word_Base` and left alone by RW:
24//!   * `is_title` stays `False` (`__init__` sets it; `setup()` never touches
25//!     it), so `title()` is an identity. RW's `to_cardinal` override does not
26//!     call `self.title()` at all, unlike the base implementation — the
27//!     `exclude_title` list RW sets up is therefore dead weight in every
28//!     in-scope path. It is reproduced below for fidelity regardless.
29//!
30//! # Faithfully reproduced Python quirks
31//!
32//! This is a port, not a rewrite. The following look wrong but are exactly what
33//! Python emits, verified against the frozen corpus:
34//!
35//! 1. **`_int_to_word` gives up at 10^9 and returns bare digits.** The final
36//!    `return str(number)` means `to_cardinal(10**9)` == `"1000000000"` and
37//!    `to_cardinal(10**12)` == `"1000000000000"` — decimal numerals, not words.
38//!    There is no billion word in the table and no `OverflowError`; the
39//!    converter silently degrades. Corpus rows confirm both. Modelled by
40//!    [`int_to_word`].
41//! 2. **`to_ordinal` never calls `verify_ordinal`,** so negatives sail through:
42//!    `to_ordinal(-1)` == `"wa munsi ya rimwe"` ("the -1st"), where every
43//!    `verify_ordinal`-calling language raises `TypeError`. Likewise
44//!    `to_ordinal(0)` == `"wa zeru"`. Both are corpus-confirmed.
45//! 3. **`to_ordinal` inherits the 10^9 fallback,** so `to_ordinal(10**9)` ==
46//!    `"wa 1000000000"` — identical to `to_ordinal_num(10**9)`. The two modes
47//!    are indistinguishable above the ceiling.
48//! 4. **`negword` carries a trailing space** (`"munsi ya "`), unlike the base
49//!    class's `"(-) "` convention where callers `.strip()` it. RW's
50//!    `to_cardinal` concatenates it raw and `.strip()`s the *whole* result, so
51//!    the space lands between "ya" and the number word. The trailing `.strip()`
52//!    is a no-op in every reachable case (`_int_to_word` never returns padded
53//!    text) but is reproduced anyway.
54//! 5. **The hundreds/thousands/millions multiplier is dropped when it is 1**
55//!    but *not* when it is 0-ish — `h > 1` guards `ones[h]`, so 100 is "ijana"
56//!    (bare) while 200 is "kabiri ijana". Same for "igihumbi" and "miliyoni".
57//! 6. **`tens[]` uses different stems from `ones[]`** for the same digit:
58//!    `ones[8]` is "umunani" but `tens[8]` is "mirongo inani"; `ones[9]` is
59//!    "icyenda" but `tens[9]` is "mirongo cyenda". This is correct Kinyarwanda
60//!    noun-class agreement, not a typo — flagged only so a later reviewer does
61//!    not "fix" it into consistency.
62//!
63//! # Float / Decimal routing (`str(number)` decides everything)
64//!
65//! RW's `to_cardinal` runs on `n = str(number).strip()`, so the *repr* — not
66//! the numeric value — picks the branch. Four corpus-pinned consequences:
67//!
68//! * **Whole-valued floats keep their ".0" tail.** `str(5.0)` is `"5.0"`, so
69//!   `to_cardinal(5.0)` == "gatanu akadomo zeru" — never the integer path's
70//!   bare "gatanu". Same for `Decimal("5.00")` → "gatanu akadomo zeru zeru"
71//!   (trailing zeros survive `str(Decimal)`), while `Decimal("5")` has no "."
72//!   and *does* take the integer words. [`Lang::cardinal_float_entry`] is
73//!   therefore overridden to send every float/Decimal through the string
74//!   algorithm ([`cardinal_from_str`]) instead of the base's whole-value
75//!   integer route.
76//! * **Exponent-form reprs raise `ValueError`.** `str(1e16)` is `"1e+16"` and
77//!   `str(Decimal("1E+2"))` is `"1E+2"` — no ".", so `int(n)` chokes:
78//!   `ValueError: invalid literal for int() with base 10: '1e+16'`. The repr
79//!   is rebuilt exactly by [`python_float_repr`] /
80//!   [`crate::strnum::python_decimal_str`], so the error falls out of the same
81//!   `int()` step as in Python.
82//! * **`to_ordinal(1.0)` is "mbere".** The guard is `if number == 1:` —
83//!   numeric equality, so `1.0` and `Decimal("1.00")` hit it too. Everything
84//!   else is `"wa " + to_cardinal(number)`, floats included:
85//!   `to_ordinal(2.0)` == "wa kabiri akadomo zeru", and `to_ordinal(-0.0)` ==
86//!   "wa munsi ya zeru akadomo zeru" (no verify_ordinal, quirk 2).
87//! * **`to_ordinal_num` is `"wa " + str(number)`** for floats too:
88//!   `to_ordinal_num(1e16)` == "wa 1e+16" — no error, unlike `to_ordinal`.
89//!   The binding supplies Python's own `str(number)` as `repr_str`.
90//!
91//! String input `"Infinity"`/`"-Infinity"` parses to `Decimal("Infinity")` in
92//! Python (base `str_to_number`), and then RW's `int("Infinity")` raises
93//! `ValueError`. The Rust dispatcher hard-codes `ParsedNumber::Inf` →
94//! `OverflowError` (the base-class behaviour) before any language hook runs,
95//! so [`Lang::str_to_number`] is overridden here to surface the `ValueError`
96//! at parse time instead. Known divergence, documented rather than fixable
97//! from this file: Python's `to_ordinal_num(Decimal("Infinity"))` returns
98//! `"wa Infinity"` *without* raising, but with the early raise this port
99//! reports `ValueError` for that mode as well. No corpus row exercises it —
100//! the Infinity rows are all `to="cardinal"` (ValueError in both worlds).
101//!
102//! # The currency surface
103//!
104//! RW overrides `to_currency` **wholesale** and shares almost nothing with
105//! `Num2Word_Base.to_currency`. It does not call `pluralize`, `_money_verbose`,
106//! `_cents_verbose`, `_cents_terse`, `parse_currency_parts` or
107//! `CURRENCY_PRECISION`; it re-derives everything from `str(val)` by hand. So
108//! [`Lang::to_currency`] is overridden here rather than delegating to
109//! `currency::default_to_currency`, and the divergences below are real:
110//!
111//! 7. **`to_currency` cannot raise `NotImplementedError`.** Python does
112//!    `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
113//!    — an unknown code silently renders as **RWF**, the class body's first
114//!    key. `to_cheque`, inherited from the base class, uses a `[]` lookup and
115//!    *does* raise. Hence the corpus pairing
116//!    `currency:JPY 12.34 -> "icumi na kabiri amafaranga …"` beside
117//!    `cheque:JPY -> NotImplementedError`, for the same code, in the same
118//!    language. Modelled by the `unwrap_or` in [`LangRw::to_currency`] versus
119//!    the plain `Option` returned from [`LangRw::currency_forms`].
120//! 8. **`CURRENCY_PRECISION` is never consulted**, so the 3-decimal (KWD/BHD,
121//!    divisor 1000) and 0-decimal (JPY, divisor 1) handling that
122//!    `base.to_currency` performs does not exist here. Those codes are not in
123//!    RW's table anyway, so they take the RWF fallback and get a plain
124//!    two-decimal split — corpus-confirmed for all four of JPY/KWD/BHD/CNY.
125//!    (`to_cheque` *does* read it, but RW defines none and `Num2Word_Base`'s is
126//!    `{}`, so the divisor is always the default 100.)
127//! 9. **Cents truncate, they do not round.** `int(parts[1][:2].ljust(2, "0"))`
128//!    slices the first two fractional digits off the *string*: `12.345` gives
129//!    34 cents, and `1.005` gives **0** — which then trips the `if cents and
130//!    right` guard, so `to_currency(1.005)` == `"rimwe idolari"` with no cents
131//!    segment at all. `base.to_currency`'s ROUND_HALF_UP would say one cent.
132//!    Modelled by [`split_str_parts`].
133//! 10. **`cents=False` drops the cents segment entirely.** The base class
134//!    switches to `_cents_terse` (digits, e.g. "34"); RW's `if cents and right`
135//!    just skips it, so `to_currency(12.34, cents=False)` ==
136//!    `"icumi na kabiri idolari"`. `_cents_terse`/`_cents_verbose` are
137//!    unreachable from RW's currency path.
138//! 11. **`adjective=True` is a silent no-op.** RW accepts the parameter and its
139//!    body never mentions it; `CURRENCY_ADJECTIVES` is empty. The base class
140//!    would prefix the unit.
141//! 12. **`separator` is concatenated raw**, with no space of its own:
142//!    `result += separator + word + " " + form`. The default `" "` supplies the
143//!    space, so a caller passing `separator=" na"` gets
144//!    `"… idolari namirongo itatu na kane sentime"` — the words run together.
145//!    Reproduced verbatim.
146//! 13. **Both plural forms are identical in every entry**
147//!    (`("amafaranga", "amafaranga")`, `("sentime", "sentime")`), so the
148//!    `cr1[1] if left != 1 else cr1[0]` choice is unobservable in the output.
149//!    The index is still computed, because a one-element tuple would make it an
150//!    IndexError rather than a silent fallback — see [`plural_form`].
151//!
152//! RW is also **not** a victim of the `lang_EUR.py` mutation trap described in
153//! `PORTING_CURRENCY.md`: it subclasses `Num2Word_Base` (whose `CURRENCY_FORMS`
154//! is `{}`) and shadows the attribute with its own class-body dict, so
155//! `Num2Word_EN.__init__`'s in-place rewrite of the shared `Num2Word_EUR` dict
156//! cannot reach it. Confirmed against the live interpreter: at runtime
157//! `CONVERTER_CLASSES["rw"].CURRENCY_FORMS` holds exactly RWF/USD/EUR, with
158//! EUR still `("iyero", "iyero")` and no EN-injected codes.
159//!
160//! # Known gap: `to_currency` on floats whose `repr` is scientific notation
161//!
162//! The cardinal/ordinal/year float modes reconstruct `str(number)` exactly
163//! ([`python_float_repr`]) and reproduce the `int()` ValueError on exponent
164//! reprs. `to_currency` cannot: its value arrives pre-parsed as a
165//! `CurrencyValue`, which is less than the text Python reads. Python does
166//! `int(parts[0])` on whatever `str(abs(val))` produced,
167//! so a float big or small enough for `repr` to go exponential dies on the
168//! `int()`:
169//!
170//! ```text
171//! num2words(1e16,  to="currency", lang="rw")  # ValueError: invalid literal
172//! num2words(1e21,  to="currency", lang="rw")  # ValueError: invalid literal
173//! num2words(1e-05, to="currency", lang="rw")  # ValueError: invalid literal
174//! num2words(0.0001, to="currency", lang="rw") # ok -> "zeru idolari"
175//! ```
176//!
177//! i.e. `abs(x) >= 1e16` or `0 < abs(x) < 1e-4`. This port returns a string for
178//! all three instead (`"10000000000000000 idolari"`, `"zeru idolari"`, …).
179//!
180//! It is **not** reproducible here, and not for want of an error variant —
181//! `N2WError::Value` fits fine. The boundary is the problem: the shim parses
182//! `str(val)` into a `BigDecimal` and hands over the number, so the exponent
183//! notation is gone by the time this code runs. The two inputs that decide the
184//! outcome are indistinguishable on arrival:
185//!
186//! | Python input | `str(val)` | Python | arrives as |
187//! |---|---|---|---|
188//! | `1e-05` (float) | `"1e-05"` | ValueError | `BigDecimal(0.00001)`, `has_decimal=true` |
189//! | `Decimal("0.00001")` | `"0.00001"` | `"zeru idolari"` | `BigDecimal(0.00001)`, `has_decimal=true` |
190//!
191//! Same value, same `has_decimal`, opposite behaviour. (`has_decimal` separates
192//! the `1e16` pair, but not this one, and `Decimal("1E+16")` raises too — so no
193//! flag available here decides all three.) Reproducing this needs the raw
194//! `str(val)` carried across the boundary, which is a `currency.rs` change and
195//! out of scope for a single language file. No corpus row reaches it: the `rw`
196//! block's largest float is `1234.56`, and `bench/diff_test.py` already screens
197//! `"e"` when it computes `is_int`.
198//!
199//! # Unreachable Python quirk (deliberately not modelled)
200//!
201//! `_int_to_word(-5)` would hit `number < 10` and evaluate `self.ones[-5]`,
202//! which in Python is *negative indexing* — it silently returns `ones[5]` ==
203//! "gatanu", i.e. the wrong sign with no error. This is unreachable through any
204//! in-scope entry point: `to_cardinal` detaches the minus from the *string*
205//! (`n[1:]`) before parsing, and `to_currency` does `val = abs(val)` before
206//! anything else, so `_int_to_word` only ever sees non-negatives.
207//! [`int_to_word`] is therefore only ever called with a non-negative value, and
208//! its `to_u64()` guard would send a negative down the `str(number)` fallback
209//! rather than reproducing the negative-index behaviour.
210
211use crate::base::{Lang, N2WError, Result};
212use crate::currency::{CurrencyForms, CurrencyValue};
213use crate::floatpath::FloatValue;
214use crate::strnum::{python_decimal_parse, ParsedNumber};
215use bigdecimal::BigDecimal;
216use num_bigint::BigInt;
217use num_traits::{One, Signed, ToPrimitive, Zero};
218
219/// `self.ones`. Index 0 is `""` and is never used: `_int_to_word` handles 0
220/// before the `< 10` branch, and every other use is guarded (`if o`, `h > 1`).
221const ONES: [&str; 10] = [
222    "",
223    "rimwe",
224    "kabiri",
225    "gatatu",
226    "kane",
227    "gatanu",
228    "gatandatu",
229    "karindwi",
230    "umunani",
231    "icyenda",
232];
233
234/// `self.tens`. Index 0 is `""` and unreachable (the `< 100` branch is only
235/// entered for `number >= 10`, so `t >= 1`).
236const TENS: [&str; 10] = [
237    "",
238    "icumi",
239    "makumyabiri",
240    "mirongo itatu",
241    "mirongo ine",
242    "mirongo itanu",
243    "mirongo itandatu",
244    "mirongo irindwi",
245    "mirongo inani",
246    "mirongo cyenda",
247];
248
249const HUNDRED: &str = "ijana";
250const THOUSAND: &str = "igihumbi";
251const MILLION: &str = "miliyoni";
252
253/// `self.negword`. The trailing space is in the Python source — see quirk 4.
254const NEGWORD: &str = "munsi ya ";
255/// `self.pointword`, the token RW's `to_cardinal` prints between the integer
256/// part and the spelled-out fractional digits on the float/Decimal path — see
257/// [`to_cardinal_float`](LangRw::to_cardinal_float).
258const POINTWORD: &str = "akadomo";
259/// `self.exclude_title`. Dead in every in-scope path (`is_title` is False and
260/// RW's `to_cardinal` never calls `title()`), kept for fidelity.
261const EXCLUDE_TITLE: [&str; 4] = ["na", "akadomo", "munsi", "ya"];
262
263const ZERU: &str = "zeru";
264
265/// The ceiling of `_int_to_word`'s word-producing bands: `1000000000`.
266const BILLION: u64 = 1_000_000_000;
267
268/// Python's `_int_to_word`, entry point.
269///
270/// Python tests `number < 1000000000` last and falls through to
271/// `return str(number)`. Anything at or above 10^9 is therefore rendered as
272/// bare decimal digits (quirk 1). Below that the value provably fits in a
273/// `u64`, so [`int_to_word_small`] does the recursive work on a fixed-width
274/// int — the BigInt is never truncated, only handed off once bounded.
275fn int_to_word(value: &BigInt) -> String {
276    match value.to_u64() {
277        Some(n) if n < BILLION => int_to_word_small(n),
278        // Covers both `>= 10^9` and (unreachably) negatives, matching Python's
279        // final `return str(number)` for the former.
280        _ => value.to_string(),
281    }
282}
283
284/// Python's `_int_to_word` for `0 <= n < 10^9`.
285///
286/// Every recursive call shrinks the value (`divmod` by a strictly larger
287/// power), so no inner call can re-enter the `str(number)` fallback.
288fn int_to_word_small(n: u64) -> String {
289    // if number == 0: return "zeru"
290    if n == 0 {
291        return ZERU.to_string();
292    }
293
294    // if number < 10: return self.ones[number]
295    if n < 10 {
296        return ONES[n as usize].to_string();
297    }
298
299    // if number < 100:
300    //     t, o = divmod(number, 10)
301    //     return self.tens[t] + (" na " + self.ones[o] if o else "")
302    if n < 100 {
303        let (t, o) = (n / 10, n % 10);
304        let mut out = TENS[t as usize].to_string();
305        if o != 0 {
306            out.push_str(" na ");
307            out.push_str(ONES[o as usize]);
308        }
309        return out;
310    }
311
312    // if number < 1000:
313    //     h, r = divmod(number, 100)
314    //     base = (self.ones[h] + " " if h > 1 else "") + self.hundred
315    //     return base + (" na " + self._int_to_word(r) if r else "")
316    if n < 1_000 {
317        let (h, r) = (n / 100, n % 100);
318        let mut out = String::new();
319        if h > 1 {
320            out.push_str(ONES[h as usize]);
321            out.push(' ');
322        }
323        out.push_str(HUNDRED);
324        if r != 0 {
325            out.push_str(" na ");
326            out.push_str(&int_to_word_small(r));
327        }
328        return out;
329    }
330
331    // if number < 1000000:
332    //     t, r = divmod(number, 1000)
333    //     base = (self._int_to_word(t) + " " if t > 1 else "") + self.thousand
334    //     return base + (" na " + self._int_to_word(r) if r else "")
335    if n < 1_000_000 {
336        let (t, r) = (n / 1_000, n % 1_000);
337        let mut out = String::new();
338        if t > 1 {
339            out.push_str(&int_to_word_small(t));
340            out.push(' ');
341        }
342        out.push_str(THOUSAND);
343        if r != 0 {
344            out.push_str(" na ");
345            out.push_str(&int_to_word_small(r));
346        }
347        return out;
348    }
349
350    // if number < 1000000000:
351    //     m, r = divmod(number, 1000000)
352    //     base = (self._int_to_word(m) + " " if m > 1 else "") + self.million
353    //     return base + (" na " + self._int_to_word(r) if r else "")
354    let (m, r) = (n / 1_000_000, n % 1_000_000);
355    let mut out = String::new();
356    if m > 1 {
357        out.push_str(&int_to_word_small(m));
358        out.push(' ');
359    }
360    out.push_str(MILLION);
361    if r != 0 {
362        out.push_str(" na ");
363        out.push_str(&int_to_word_small(r));
364    }
365    out
366}
367
368/// Python's `to_cardinal`, integer path.
369///
370/// Python works on `str(number).strip()` and detaches the sign by slicing
371/// (`n[1:]`), then recurses. For an integer that is exactly negation, so this
372/// recurses on `-value` instead of reparsing a string. The full string form —
373/// including the `"." in n` float/Decimal branch — lives in
374/// [`cardinal_from_str`], reached via
375/// [`to_cardinal_float`](LangRw::to_cardinal_float).
376fn cardinal(value: &BigInt) -> String {
377    // if n.startswith("-"):
378    //     return (self.negword + self.to_cardinal(n[1:])).strip()
379    if value.is_negative() {
380        let inner = cardinal(&value.abs());
381        return format!("{}{}", NEGWORD, inner).trim().to_string();
382    }
383    int_to_word(value)
384}
385
386/// CPython's `repr(float)` / `str(float)`, which is what RW's `to_cardinal`
387/// runs on: `n = str(number).strip()`. RW's float branch is a *string*
388/// algorithm — it splits on ".", feeds `int()` the pieces and spells the
389/// fraction digit by digit — so the exact repr, not the numeric value, drives
390/// the output (and the ValueErrors). **RW never uses `base.float2tuple`**, so
391/// the two `floatpath.rs` traps — banker's rounding of a binary `post`, and
392/// the `< 0.01` artefact heuristic — do not arise here; recomputing
393/// `abs(2.675 - 2) * 1000` in `f64` (which gives `674.999…`) would compute
394/// the *wrong* thing to reproduce.
395///
396/// # 1. The digits
397///
398/// `{:e}` is Rust's shortest-round-trip in `<d>[.<ddd>]e<exp>` form, so the
399/// significant digits and the decimal-point position fall straight out. A rare
400/// tie can leave `{:e}`'s final digit one off the value CPython's dtoa would
401/// pick; re-formatting with `{:.*}` at the known digit count repairs it. This
402/// exact function is differentially tested against CPython on 300k doubles in
403/// the sibling ports (`lang_bm`, `lang_ki`): 0 mismatches with the repair.
404///
405/// # 2. The placement
406///
407/// CPython switches to exponent notation iff `decpt <= -4 || decpt > 16`
408/// (`format_float_short`, format code `'r'`), pads the exponent to two digits,
409/// and appends `.0` to anything that would otherwise look like an integer.
410/// Rust's `{}` does none of this, so both `1e16` and `1.0` would come out
411/// wrong in opposite directions. Both matter to RW: `str(1.0)` is `"1.0"` →
412/// "rimwe akadomo zeru", and `str(1e16)` is `"1e+16"` → `int("1e+16")` raises
413/// `ValueError`.
414///
415/// The `precision` that `FloatValue::Float` carries is deliberately *not* used
416/// to shortcut this: for an exponent-form repr it is the *exponent*
417/// (`abs(Decimal(str(v)).as_tuple().exponent)`), not a digit count — `1e16`
418/// arrives with `precision == 16`.
419fn python_float_repr(v: f64) -> String {
420    // repr(nan) / repr(inf) / repr(-inf). RW feeds these straight to int(),
421    // which rejects them like any other bad literal.
422    if v.is_nan() {
423        return "nan".to_string();
424    }
425    if v.is_infinite() {
426        return (if v.is_sign_negative() { "-inf" } else { "inf" }).to_string();
427    }
428    // The sign bit, not `v < 0.0`: repr(-0.0) is "-0.0", and RW renders that
429    // "munsi ya zeru akadomo zeru".
430    let sign = if v.is_sign_negative() { "-" } else { "" };
431    let a = v.abs();
432
433    // `decpt` is CPython's: the value is `0.<digits> * 10**decpt`.
434    let s = format!("{:e}", a);
435    let (mant, exp) = s.split_once('e').expect("LowerExp always emits an 'e'");
436    let exp: i32 = exp.parse().expect("LowerExp emits an integer exponent");
437    let mut digits: String = mant.chars().filter(|c| *c != '.').collect();
438    let mut decpt = exp + 1;
439
440    // Tie repair — see the doc comment. Only reachable when the shortest form
441    // has fractional digits; `a == 0.0` is excluded because `{:e}` reports it
442    // as "0e0" and there is nothing to round.
443    let frac_digits = digits.chars().count() as i32 - decpt;
444    if frac_digits > 0 && a != 0.0 {
445        let t = format!("{:.*}", frac_digits as usize, a);
446        let (ip, fp) = t.split_once('.').expect("frac_digits > 0 forces a point");
447        let all = format!("{}{}", ip, fp);
448        let trimmed = all.trim_start_matches('0');
449        if !trimmed.is_empty() {
450            let lead = all.chars().count() - trimmed.chars().count();
451            digits = trimmed.to_string();
452            decpt = ip.chars().count() as i32 - lead as i32;
453        }
454    }
455
456    let n = digits.chars().count() as i32;
457
458    if decpt <= -4 || decpt > 16 {
459        // CPython: mantissa, then "e", then "%+.02d" of decpt-1.
460        let e = decpt - 1;
461        let mut out = String::from(sign);
462        let mut it = digits.chars();
463        out.push(it.next().expect("a finite double has at least one digit"));
464        if n > 1 {
465            out.push('.');
466            out.push_str(it.as_str());
467        }
468        out.push('e');
469        out.push(if e < 0 { '-' } else { '+' });
470        out.push_str(&format!("{:02}", (e as i64).abs()));
471        out
472    } else if decpt <= 0 {
473        format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
474    } else if decpt >= n {
475        // Py_DTSF_ADD_DOT_0: an integral value still reprs with a ".0".
476        format!("{}{}{}.0", sign, digits, "0".repeat((decpt - n) as usize))
477    } else {
478        let k = decpt as usize;
479        format!(
480            "{}{}.{}",
481            sign,
482            digits.chars().take(k).collect::<String>(),
483            digits.chars().skip(k).collect::<String>()
484        )
485    }
486}
487
488/// `str(number)` for whatever the Python dispatcher handed the converter. The
489/// `FloatValue` split is exactly Python's `isinstance(value, Decimal)`: the
490/// two arms stringify by different rules and must not be collapsed.
491/// `str(Decimal)` is the spec algorithm in [`crate::strnum::python_decimal_str`]
492/// — capital `E`, trailing zeros preserved (`Decimal("1.10")` → "1.10"),
493/// exponent form for a positive exponent (`Decimal("1E+2")` → "1E+2").
494fn python_str(v: &FloatValue) -> String {
495    match v {
496        FloatValue::Float { value, .. } => python_float_repr(*value),
497        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
498    }
499}
500
501/// Python's `int(s)`, for the strings `str()` can produce here. `BigInt`'s
502/// parser and `int()` agree on everything reachable — plain ASCII digit runs
503/// with an optional sign — and the message quotes the offending literal
504/// verbatim, as Python's does (`'1e+16'`, `'1E+2'`, `'inf'`, `'nan'`).
505fn python_int(s: &str) -> Result<BigInt> {
506    s.parse::<BigInt>().map_err(|_| {
507        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
508    })
509}
510
511/// Port of `Num2Word_RW.to_cardinal` driven by the *string*, i.e. the float /
512/// Decimal branch:
513///
514/// ```python
515/// n = str(number).strip()
516/// if n.startswith("-"):
517///     return (self.negword + self.to_cardinal(n[1:])).strip()
518/// if "." in n:
519///     left, right = n.split(".", 1)
520///     ret = self._int_to_word(int(left)) + " " + self.pointword
521///     for digit in right:
522///         ret += " " + (self.ones[int(digit)] or "zeru")
523///     return ret.strip()
524/// return self._int_to_word(int(n))
525/// ```
526///
527/// Details that look like slips and are not:
528///
529/// * The negative branch recurses with a **string** (`n[1:]`), whose first act
530///   is `str(number)` — a no-op on a `str` — so a negative float re-enters
531///   here and keeps the "." branch. The recursion here mirrors that shape.
532/// * `int(left)` runs before the digit loop, so a bad left (`"1e+16"`) raises
533///   first and quotes the whole literal; a malformed *fraction* field
534///   (`"5E+300"` from a scientific Decimal) quotes the single char (`'E'`),
535///   because `int(digit)` is per **character**.
536/// * The integer part goes through `_int_to_word` (not `to_cardinal`), so
537///   above 10^9 it degrades to bare digits exactly as quirk 1 describes:
538///   `Decimal("98746251323029.99")` → "98746251323029 akadomo icyenda icyenda".
539/// * `self.ones[int(digit)] or "zeru"`: `ones[0]` is `""` (falsy), so a 0
540///   fraction digit speaks "zeru"; every other digit is `ones[d]`.
541/// * Python's `.strip()`s are no-ops in every reachable case (nothing is
542///   padded), reproduced as `trim()` because Python does them.
543fn cardinal_from_str(n: &str) -> Result<String> {
544    // Python's str.strip(); str()'s output never has surrounding space.
545    let n = n.trim();
546
547    if let Some(rest) = n.strip_prefix('-') {
548        let inner = cardinal_from_str(rest)?;
549        return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
550    }
551
552    if let Some((left, right)) = n.split_once('.') {
553        // `int(left)` runs before the loop, so a bad left raises first.
554        let mut ret = int_to_word(&python_int(left)?);
555        ret.push(' ');
556        ret.push_str(POINTWORD);
557        for ch in right.chars() {
558            let d = ch.to_digit(10).ok_or_else(|| {
559                N2WError::Value(format!(
560                    "invalid literal for int() with base 10: '{}'",
561                    ch
562                ))
563            })?;
564            ret.push(' ');
565            ret.push_str(if d == 0 { ZERU } else { ONES[d as usize] });
566        }
567        return Ok(ret.trim().to_string());
568    }
569
570    Ok(int_to_word(&python_int(n)?))
571}
572
573/// `Num2Word_RW.CURRENCY_FORMS`.
574///
575/// A `Vec` rather than the usual `HashMap` because **insertion order is
576/// load-bearing** here: `to_currency`'s fallback for an unknown code is
577/// `list(self.CURRENCY_FORMS.values())[0]`, and Python dicts iterate in
578/// insertion order, so that is RWF — the class body's first key. Backing this
579/// with a `HashMap` would make every unknown-code row depend on hash order.
580/// Three entries, so the linear scan in [`LangRw::currency_forms`] is cheaper
581/// than hashing anyway.
582///
583/// Built once in [`LangRw::new`] and stored, never per call.
584fn build_currency_forms() -> Vec<(&'static str, CurrencyForms)> {
585    // ("sentime", "sentime") — shared by all three entries.
586    const SENTIME: [&str; 2] = ["sentime", "sentime"];
587
588    vec![
589        (
590            "RWF",
591            CurrencyForms::new(&["amafaranga", "amafaranga"], &SENTIME),
592        ),
593        ("USD", CurrencyForms::new(&["idolari", "idolari"], &SENTIME)),
594        ("EUR", CurrencyForms::new(&["iyero", "iyero"], &SENTIME)),
595    ]
596}
597
598/// Python's `cr1[1] if left != 1 else cr1[0]` — the inline plural choice
599/// `to_currency` makes *instead of* calling `self.pluralize`.
600///
601/// Both forms are identical in every RW entry (quirk 13), so the result is the
602/// same either way today. The index is still computed because Python computes
603/// it: a hypothetical one-element tuple would raise IndexError here, not fall
604/// back silently.
605fn plural_form(forms: &[String], n: &BigInt) -> Result<String> {
606    let idx = if n.is_one() { 0 } else { 1 };
607    forms
608        .get(idx)
609        .cloned()
610        .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
611}
612
613/// Python's `str(val).split(".")`, reduced to the two integers `to_currency`
614/// pulls out of it. `value` must be **non-negative** — Python does
615/// `val = abs(val)` before stringifying.
616///
617/// * `left = int(parts[0]) if parts[0] else 0` — the digits before the point,
618///   i.e. the truncated integer part (== floor, since `value >= 0`). `parts[0]`
619///   is only empty for a string like ".5", which no `str()` of a number
620///   produces, so the `else 0` arm is dead.
621/// * `right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1]
622///   else 0` — the first two fractional digits, zero-padded on the *right*.
623///   Slicing a two-digit prefix and right-padding it is exactly
624///   `floor(frac * 100)`: "3" -> "30" -> 30, "345" -> "34" -> 34, "0" -> "00"
625///   -> 0. So this is a truncation, not a rounding (quirk 9).
626///
627/// Collapsing both `right` guards into the arithmetic is safe: a value with no
628/// fractional digits has `frac == 0`, which the multiply-and-truncate already
629/// maps to 0.
630fn split_str_parts(value: &BigDecimal) -> (BigInt, BigInt) {
631    // int(parts[0]) — `with_scale(0)` truncates, matching int() on the digits
632    // left of the point for a non-negative value.
633    let integer = value.with_scale(0);
634    let left = integer.as_bigint_and_exponent().0;
635
636    // int(parts[1][:2].ljust(2, "0")) == floor(frac * 100).
637    let frac = value - &integer;
638    let right = (frac * BigDecimal::from(100))
639        .with_scale(0)
640        .as_bigint_and_exponent()
641        .0;
642
643    (left, right)
644}
645
646pub struct LangRw {
647    /// `self.exclude_title`, owned so `Lang::exclude_title` can hand out a
648    /// `&[String]`. Unreachable in scope — see the module docs.
649    exclude_title: Vec<String>,
650    /// `self.CURRENCY_FORMS`, in Python's insertion order. See
651    /// [`build_currency_forms`] for why order matters.
652    currency_forms: Vec<(&'static str, CurrencyForms)>,
653}
654
655impl Default for LangRw {
656    fn default() -> Self {
657        Self::new()
658    }
659}
660
661impl LangRw {
662    pub fn new() -> Self {
663        LangRw {
664            exclude_title: EXCLUDE_TITLE.iter().map(|s| s.to_string()).collect(),
665            // Built once here, never per call.
666            currency_forms: build_currency_forms(),
667        }
668    }
669}
670
671impl Lang for LangRw {
672    /// This language's own `to_currency(currency=...)` default,
673    /// read from the live Python signature. Only 44 of 156 use EUR.
674    fn default_currency(&self) -> &str {
675        "RWF"
676    }
677
678    /// This language's own `to_currency(separator=...)` default,
679    /// read from the live Python signature. Base's is ",", but only
680    /// 36 of 149 languages actually use it — most default to " " or a
681    /// conjunction, so inheriting Base's comma silently corrupts them.
682    fn default_separator(&self) -> &str {
683        " "
684    }
685
686    // `cards`/`maxval`/`merge` intentionally left at their trait defaults: RW
687    // never builds a card table (see module docs) and `to_cardinal` below
688    // bypasses the splitnum/clean engine entirely.
689
690    fn negword(&self) -> &str {
691        // Trailing space is intentional — verbatim from the Python source.
692        NEGWORD
693    }
694
695    fn pointword(&self) -> &str {
696        "akadomo"
697    }
698
699    fn exclude_title(&self) -> &[String] {
700        &self.exclude_title
701    }
702
703    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
704        // Note: unlike `Num2Word_Base.to_cardinal`, RW's override never calls
705        // `self.title()`. `is_title` is False anyway, so this is unobservable,
706        // but the omission is reproduced rather than papered over.
707        Ok(cardinal(value))
708    }
709
710    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
711        // if number == 1: return "mbere"
712        // return "wa " + self.to_cardinal(number)
713        //
714        // No `verify_ordinal` call: negatives and zero pass straight through
715        // (quirk 2).
716        if value.is_one() {
717            return Ok("mbere".to_string());
718        }
719        Ok(format!("wa {}", cardinal(value)))
720    }
721
722    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
723        // return "wa " + str(number)
724        // Overrides the base's bare `return value`; negatives keep their minus,
725        // e.g. -1 -> "wa -1".
726        Ok(format!("wa {}", value))
727    }
728
729    fn to_year(&self, value: &BigInt) -> Result<String> {
730        // def to_year(self, val, longval=True): return self.to_cardinal(val)
731        // `longval` is accepted and ignored. Identical to the base default, but
732        // spelled out because Python spells it out.
733        Ok(cardinal(value))
734    }
735
736    /// `to_cardinal(float/Decimal)` — the **full** routing, whole values
737    /// included. RW's `to_cardinal` reads `str(number)`, so a whole-valued
738    /// float keeps its ".0" tail ("gatanu akadomo zeru") and an exponent-form
739    /// repr raises ValueError; the base default's whole → integer-path route
740    /// would get both wrong. See the module docs' float-routing section.
741    fn cardinal_float_entry(
742        &self,
743        value: &FloatValue,
744        precision_override: Option<u32>,
745    ) -> Result<String> {
746        self.to_cardinal_float(value, precision_override)
747    }
748
749    /// Port of `Num2Word_RW.to_cardinal`'s float/Decimal handling.
750    ///
751    /// RW overrides `to_cardinal` and does the non-integer work **inline on
752    /// `str(number)`** — it never reaches `Num2Word_Base.to_cardinal_float` or
753    /// `float2tuple`. So the trait default (`default_to_cardinal_float`), which
754    /// *does* go through `float2tuple`, is the wrong algorithm here: it matches
755    /// every clean corpus row by luck but diverges on `-0.0` (no negword, since
756    /// its `is_negative` is `value < 0`), on exponent-form reprs (renders words
757    /// where Python raises ValueError) and on high-precision floats whose
758    /// binary `post` picks up an artefact `str` never shows. This override
759    /// reconstructs `str(number)` exactly ([`python_str`]) and runs RW's string
760    /// algorithm on it — see [`cardinal_from_str`].
761    ///
762    /// `precision_override` (the `precision=` kwarg) is accepted and dropped:
763    /// RW's `to_cardinal` reads `str(number)` and never consults
764    /// `self.precision`, so the kwarg has no effect — confirmed against the
765    /// live interpreter, where `num2words(2.675, lang="rw", precision=1)`
766    /// equals the un-kwarg'd result.
767    fn to_cardinal_float(
768        &self,
769        value: &FloatValue,
770        _precision_override: Option<u32>,
771    ) -> Result<String> {
772        cardinal_from_str(&python_str(value))
773    }
774
775    /// `to_ordinal(float/Decimal)`: Python's `to_ordinal` is
776    /// `"mbere" if number == 1 else "wa " + to_cardinal(number)` — the `== 1`
777    /// test is *numeric*, so `1.0` and `Decimal("1.00")` return "mbere" too
778    /// (corpus: `ordinal 1.0` → "mbere"). Everything else takes the cardinal's
779    /// string algorithm with the "wa " prefix, ValueErrors propagating
780    /// unchanged (`to_ordinal(1e16)` raises before "wa " is prepended).
781    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
782        // `as_whole_int` is None for fractional values and NaN/±inf, all of
783        // which compare != 1 in Python as well.
784        if value.as_whole_int().is_some_and(|i| i.is_one()) {
785            return Ok("mbere".to_string());
786        }
787        Ok(format!("wa {}", self.cardinal_float_entry(value, None)?))
788    }
789
790    /// `to_ordinal_num(float/Decimal)`: `"wa " + str(number)`, exactly as the
791    /// integer overload — floats are *not* an error here, so
792    /// `to_ordinal_num(1e16)` == "wa 1e+16" while `to_ordinal(1e16)` raises.
793    /// `repr_str` is Python's own `str(number)`, supplied by the binding.
794    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
795        Ok(format!("wa {}", repr_str))
796    }
797
798    /// `to_year(float/Decimal)`: `to_year` is a bare `to_cardinal` alias, so
799    /// floats route through the same string algorithm (and raise the same
800    /// ValueErrors). Identical to the trait default — which also calls
801    /// `cardinal_float_entry` — but spelled out because Python spells it out.
802    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
803        self.cardinal_float_entry(value, None)
804    }
805
806    /// `converter.str_to_number` — base's `Decimal(value)`, with one
807    /// deliberate deviation: `Decimal("Infinity")` parses fine in Python and
808    /// the ValueError only fires later, inside RW's `int("Infinity")`. The
809    /// Rust dispatcher hard-codes `ParsedNumber::Inf` → OverflowError (the
810    /// base-class `int(Decimal('Infinity'))` behaviour) before any language
811    /// hook runs, so the ValueError is surfaced here at parse time instead —
812    /// same observable type for every mode that reaches `to_cardinal`.
813    /// Known divergence (`to_ordinal_num("Infinity")`) documented in the
814    /// module docs. NaN is left alone: the dispatcher already reports
815    /// ValueError for it, matching Python's `int("NaN")`.
816    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
817        match python_decimal_parse(s)? {
818            ParsedNumber::Inf { .. } => Err(N2WError::Value(
819                // str(Decimal("Infinity")) is "Infinity"; the sign is sliced
820                // off (n[1:]) before int() sees it, so both signs quote the
821                // same literal.
822                "invalid literal for int() with base 10: 'Infinity'".into(),
823            )),
824            other => Ok(other),
825        }
826    }
827
828    // ---- currency -------------------------------------------------------
829    //
830    // RW overrides `to_currency` and `pluralize`, and defines `CURRENCY_FORMS`.
831    // Everything else on the currency path is inherited from `Num2Word_Base`
832    // unchanged and the trait defaults already mirror it:
833    //
834    //   * `to_cheque`      -> currency::default_to_cheque (see below).
835    //   * `_money_verbose` -> `self.to_cardinal(number)`; the trait default is
836    //                         exactly that, and dispatches to RW's override.
837    //   * `_cents_verbose` / `_cents_terse` -> unreachable (quirk 10).
838    //   * `CURRENCY_PRECISION` / `CURRENCY_ADJECTIVES` -> RW defines neither and
839    //     `Num2Word_Base`'s are `{}`, so `currency_precision` stays at the
840    //     trait's 100 and `currency_adjective` at `None`.
841    //   * `cardinal_from_decimal` -> left at its default. RW can never reach the
842    //     fractional-cents branch: `split_str_parts` truncates to whole cents,
843    //     and RW does not use `default_to_currency`'s float path at all.
844
845    fn lang_name(&self) -> &str {
846        "Num2Word_RW"
847    }
848
849    /// `CURRENCY_FORMS[code]`, as a *missing-is-None* lookup.
850    ///
851    /// This is the strict `[]` semantics `Num2Word_Base.to_cheque` needs — it
852    /// catches KeyError and re-raises NotImplementedError, which
853    /// `currency::default_to_cheque` reproduces from the `None`. RW's own
854    /// `to_currency` deliberately does *not* go through this failure mode; it
855    /// falls back to RWF instead (quirk 7).
856    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
857        self.currency_forms
858            .iter()
859            .find(|(c, _)| *c == code)
860            .map(|(_, f)| f)
861    }
862
863    /// `Num2Word_RW.pluralize`:
864    ///
865    /// ```python
866    /// if not forms:
867    ///     return ""
868    /// return forms[0] if n == 1 else forms[-1]
869    /// ```
870    ///
871    /// Dead code in practice — RW's `to_currency` inlines its own plural choice
872    /// (see [`plural_form`]) and `to_cheque` takes `cr1[-1]` unconditionally, so
873    /// nothing in the library calls this. Ported because Python defines it, and
874    /// because it is an observable public method.
875    ///
876    /// Note it differs from the inlined rule: `forms[-1]` (last) rather than
877    /// `forms[1]` (second), and an empty tuple yields `""` instead of raising.
878    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
879        if forms.is_empty() {
880            return Ok(String::new());
881        }
882        if n.is_one() {
883            Ok(forms[0].clone())
884        } else {
885            Ok(forms[forms.len() - 1].clone())
886        }
887    }
888
889    /// Port of `Num2Word_RW.to_currency`:
890    ///
891    /// ```python
892    /// def to_currency(self, val, currency="RWF", cents=True,
893    ///                 separator=" ", adjective=False):
894    ///     is_negative = val < 0
895    ///     val = abs(val)
896    ///     parts = str(val).split(".")
897    ///     left = int(parts[0]) if parts[0] else 0
898    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
899    ///     cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
900    ///     result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
901    ///     if cents and right:
902    ///         result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
903    ///     if is_negative:
904    ///         result = self.negword + result
905    ///     return result.strip()
906    /// ```
907    ///
908    /// Note what is *absent*: no `pluralize`, no `parse_currency_parts`, no
909    /// `CURRENCY_PRECISION`, no `has_decimal` guard, and no KeyError path. The
910    /// int-vs-float split `base.to_currency` makes with `isinstance(val, int)`
911    /// falls out of `str(val)` here instead — `str(5)` has no ".", so `right`
912    /// stays 0, while `str(5.0)` == "5.0" gives `parts[1] == "0"` and therefore
913    /// `right == 0` as well. Both suppress the cents segment, which is why
914    /// `1.0` renders "rimwe iyero" rather than base's "one euro, zero cents".
915    fn to_currency(
916        &self,
917        val: &CurrencyValue,
918        currency: &str,
919        cents: bool,
920        separator: Option<&str>,
921        _adjective: bool,
922    ) -> Result<String> {
923        // `adjective` is accepted and never read — quirk 11.
924        //
925        // `separator: None` means the caller omitted the kwarg; the trait
926        // resolves it through `default_separator`, which is RW's `" "`.
927        let separator = separator.unwrap_or(self.default_separator());
928
929        // is_negative = val < 0
930        //
931        // Signed zero matters: `-0.0 < 0` is False in Python, so `to_currency(-0.0)`
932        // is "zeru idolari" with no negword. `BigDecimal` has no signed zero, so
933        // `CurrencyValue::is_negative` agrees for free.
934        let is_negative = val.is_negative();
935
936        // val = abs(val); parts = str(val).split(".")
937        let (left, right) = match val {
938            // str() of an int never contains ".", so `len(parts) == 1` and
939            // `right` stays 0.
940            CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
941            CurrencyValue::Decimal { value, .. } => split_str_parts(&value.abs()),
942        };
943
944        // cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
945        //
946        // `.get(..., default)`, not `[...]`: an unknown code renders as RWF
947        // rather than raising (quirk 7). `currency_forms[0]` is `values()[0]`.
948        let forms = self
949            .currency_forms(currency)
950            .unwrap_or(&self.currency_forms[0].1);
951
952        // result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
953        //
954        // `_int_to_word`, not `to_cardinal`: no negword handling, and `left` is
955        // non-negative by construction. Above 10^9 it degrades to bare digits
956        // (quirk 1), so e.g. Decimal("10000000000000000") -> "10000000000000000
957        // idolari".
958        let mut result = format!(
959            "{} {}",
960            int_to_word(&left),
961            plural_form(&forms.unit, &left)?
962        );
963
964        // if cents and right:
965        //     result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
966        //
967        // Gated on `right` being non-zero, not on the input having a decimal
968        // point — and `separator` brings its own spacing or none (quirk 12).
969        if cents && !right.is_zero() {
970            result.push_str(separator);
971            result.push_str(&int_to_word(&right));
972            result.push(' ');
973            result.push_str(&plural_form(&forms.subunit, &right)?);
974        }
975
976        // if is_negative: result = self.negword + result
977        //
978        // Raw concatenation of the trailing-space negword (quirk 4), so
979        // "munsi ya " + "icumi …" lands correctly spaced.
980        if is_negative {
981            result = format!("{}{}", NEGWORD, result);
982        }
983
984        // return result.strip()
985        //
986        // A no-op in every reachable case: nothing in the assembled string is
987        // padded at either end. Reproduced because Python does it.
988        Ok(result.trim().to_string())
989    }
990}
991
992#[cfg(test)]
993mod tests {
994    use super::*;
995
996    /// Mirrors `bench/diff_test.py::_currency_call`: the corpus `arg` is
997    /// `repr(value)`, `is_int` is "no dot and no exponent", and the harness
998    /// passes `separator=None` so `default_separator` (" ") applies.
999    fn cur(arg: &str, code: &str) -> String {
1000        let is_int = !arg.contains('.') && !arg.to_lowercase().contains('e');
1001        let v = CurrencyValue::parse(arg, is_int, !is_int, !is_int).unwrap();
1002        LangRw::new()
1003            .to_currency(&v, code, true, None, false)
1004            .unwrap()
1005    }
1006
1007    fn cheque(arg: &str, code: &str) -> Result<String> {
1008        LangRw::new().to_cheque(&BigDecimal::from_str(arg).unwrap(), code)
1009    }
1010
1011    use bigdecimal::BigDecimal;
1012    use std::str::FromStr;
1013
1014    /// The full frozen-corpus `currency:` block for `rw`, byte for byte.
1015    #[test]
1016    fn corpus_currency_rows() {
1017        // Known codes: EUR / USD.
1018        assert_eq!(cur("0", "EUR"), "zeru iyero");
1019        assert_eq!(cur("1", "EUR"), "rimwe iyero");
1020        assert_eq!(cur("2", "EUR"), "kabiri iyero");
1021        assert_eq!(cur("100", "EUR"), "ijana iyero");
1022        assert_eq!(
1023            cur("12.34", "EUR"),
1024            "icumi na kabiri iyero mirongo itatu na kane sentime"
1025        );
1026        assert_eq!(cur("0.01", "EUR"), "zeru iyero rimwe sentime");
1027        // A float that is whole: no cents segment, because parts[1] == "0".
1028        assert_eq!(cur("1.0", "EUR"), "rimwe iyero");
1029        assert_eq!(
1030            cur("99.99", "EUR"),
1031            "mirongo cyenda na icyenda iyero mirongo cyenda na icyenda sentime"
1032        );
1033        assert_eq!(
1034            cur("1234.56", "EUR"),
1035            "igihumbi na kabiri ijana na mirongo itatu na kane iyero \
1036             mirongo itanu na gatandatu sentime"
1037        );
1038        assert_eq!(
1039            cur("-12.34", "EUR"),
1040            "munsi ya icumi na kabiri iyero mirongo itatu na kane sentime"
1041        );
1042        assert_eq!(cur("1000000", "EUR"), "miliyoni iyero");
1043        assert_eq!(cur("0.5", "EUR"), "zeru iyero mirongo itanu sentime");
1044
1045        assert_eq!(cur("0", "USD"), "zeru idolari");
1046        assert_eq!(cur("1", "USD"), "rimwe idolari");
1047        assert_eq!(
1048            cur("12.34", "USD"),
1049            "icumi na kabiri idolari mirongo itatu na kane sentime"
1050        );
1051        assert_eq!(cur("0.01", "USD"), "zeru idolari rimwe sentime");
1052        assert_eq!(cur("1.0", "USD"), "rimwe idolari");
1053        assert_eq!(cur("1000000", "USD"), "miliyoni idolari");
1054    }
1055
1056    /// Quirk 7/8: every code RW does not define renders as RWF, including the
1057    /// 3-decimal (KWD/BHD) and 0-decimal (JPY) ones, whose divisors RW never
1058    /// looks at. All corpus-confirmed.
1059    #[test]
1060    fn corpus_unknown_codes_fall_back_to_rwf() {
1061        for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1062            assert_eq!(cur("0", code), "zeru amafaranga");
1063            assert_eq!(cur("1", code), "rimwe amafaranga");
1064            assert_eq!(cur("2", code), "kabiri amafaranga");
1065            assert_eq!(cur("100", code), "ijana amafaranga");
1066            assert_eq!(
1067                cur("12.34", code),
1068                "icumi na kabiri amafaranga mirongo itatu na kane sentime"
1069            );
1070            assert_eq!(cur("0.01", code), "zeru amafaranga rimwe sentime");
1071            assert_eq!(cur("1.0", code), "rimwe amafaranga");
1072            assert_eq!(
1073                cur("99.99", code),
1074                "mirongo cyenda na icyenda amafaranga mirongo cyenda na icyenda sentime"
1075            );
1076            assert_eq!(
1077                cur("1234.56", code),
1078                "igihumbi na kabiri ijana na mirongo itatu na kane amafaranga \
1079                 mirongo itanu na gatandatu sentime"
1080            );
1081            assert_eq!(
1082                cur("-12.34", code),
1083                "munsi ya icumi na kabiri amafaranga mirongo itatu na kane sentime"
1084            );
1085            assert_eq!(cur("1000000", code), "miliyoni amafaranga");
1086            assert_eq!(cur("0.5", code), "zeru amafaranga mirongo itanu sentime");
1087        }
1088    }
1089
1090    /// The full frozen-corpus `cheque:` block for `rw`.
1091    #[test]
1092    fn corpus_cheque_rows() {
1093        assert_eq!(
1094            cheque("1234.56", "EUR").unwrap(),
1095            "IGIHUMBI NA KABIRI IJANA NA MIRONGO ITATU NA KANE AND 56/100 IYERO"
1096        );
1097        assert_eq!(
1098            cheque("1234.56", "USD").unwrap(),
1099            "IGIHUMBI NA KABIRI IJANA NA MIRONGO ITATU NA KANE AND 56/100 IDOLARI"
1100        );
1101        // Unlike to_currency, to_cheque uses the base class's `[]` lookup.
1102        for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1103            match cheque("1234.56", code) {
1104                Err(N2WError::NotImplemented(m)) => assert_eq!(
1105                    m,
1106                    format!(
1107                        "Currency code \"{}\" not implemented for \"Num2Word_RW\"",
1108                        code
1109                    )
1110                ),
1111                other => panic!("{code}: expected NotImplemented, got {other:?}"),
1112            }
1113        }
1114    }
1115
1116    /// Cross-checked against the live interpreter, not the corpus.
1117    #[test]
1118    fn python_quirks() {
1119        // Quirk 9: cents truncate. 12.345 -> "34", not "35"...
1120        assert_eq!(
1121            cur("12.345", "USD"),
1122            "icumi na kabiri idolari mirongo itatu na kane sentime"
1123        );
1124        // ...and 1.005 truncates to 0 cents, which then trips `if cents and
1125        // right` and drops the segment entirely. Base would say one cent.
1126        assert_eq!(cur("1.005", "USD"), "rimwe idolari");
1127        // One fractional digit is ljust-padded: "3" -> "30".
1128        assert_eq!(cur("12.3", "USD"), "icumi na kabiri idolari mirongo itatu sentime");
1129        assert_eq!(
1130            cur("2.999", "USD"),
1131            "kabiri idolari mirongo cyenda na icyenda sentime"
1132        );
1133        // `-0.0 < 0` is False in Python: no negword.
1134        assert_eq!(cur("-0.0", "USD"), "zeru idolari");
1135        // Quirk 4: negword's trailing space, on both an int and a float.
1136        assert_eq!(cur("-1", "RWF"), "munsi ya rimwe amafaranga");
1137        assert_eq!(cur("-1.0", "RWF"), "munsi ya rimwe amafaranga");
1138        // Quirk 1 reached through the currency path: _int_to_word degrades to
1139        // bare digits at 10^9, so a large unit count is printed as numerals.
1140        let v = CurrencyValue::parse("10000000000000000", false, false, false).unwrap();
1141        assert_eq!(
1142            LangRw::new()
1143                .to_currency(&v, "USD", true, None, false)
1144                .unwrap(),
1145            "10000000000000000 idolari"
1146        );
1147    }
1148
1149    /// Knobs the corpus never exercises (diff_test always passes cents=True,
1150    /// separator=None, adjective=False). Values from the live interpreter.
1151    #[test]
1152    fn untested_knobs_match_python() {
1153        let l = LangRw::new();
1154        let v = CurrencyValue::parse("12.34", false, true, true).unwrap();
1155        // Quirk 10: cents=False drops the segment; it does NOT use _cents_terse.
1156        assert_eq!(
1157            l.to_currency(&v, "USD", false, None, false).unwrap(),
1158            "icumi na kabiri idolari"
1159        );
1160        // Quirk 12: separator is concatenated raw — " na" runs into the word.
1161        assert_eq!(
1162            l.to_currency(&v, "USD", true, Some(" na"), false).unwrap(),
1163            "icumi na kabiri idolari namirongo itatu na kane sentime"
1164        );
1165        // Quirk 11: adjective=True changes nothing.
1166        assert_eq!(
1167            l.to_currency(&v, "USD", true, None, true).unwrap(),
1168            "icumi na kabiri idolari mirongo itatu na kane sentime"
1169        );
1170    }
1171
1172    /// `Num2Word_RW.pluralize` is unreachable from the library, but it is a
1173    /// public method and it does not follow the rule to_currency inlines.
1174    #[test]
1175    fn pluralize_is_last_not_second() {
1176        let l = LangRw::new();
1177        let forms: Vec<String> = ["a", "b", "c"].iter().map(|s| s.to_string()).collect();
1178        assert_eq!(l.pluralize(&BigInt::from(1), &forms).unwrap(), "a");
1179        // forms[-1], not forms[1].
1180        assert_eq!(l.pluralize(&BigInt::from(2), &forms).unwrap(), "c");
1181        // `if not forms: return ""` — no raise.
1182        assert_eq!(l.pluralize(&BigInt::from(2), &[]).unwrap(), "");
1183    }
1184
1185    /// Drive a float `cardinal` row the way the binding does: raw f64 plus the
1186    /// Python-side precision `abs(Decimal(repr(v)).as_tuple().exponent)`.
1187    fn cf(value: f64, precision: u32) -> String {
1188        LangRw::new()
1189            .to_cardinal_float(&FloatValue::Float { value, precision }, None)
1190            .unwrap()
1191    }
1192
1193    /// Drive a `cardinal_dec` row: BigDecimal parsed from str(Decimal), with the
1194    /// same precision the binding passes.
1195    fn cd(decimal_str: &str, precision: u32) -> String {
1196        LangRw::new()
1197            .to_cardinal_float(
1198                &FloatValue::Decimal {
1199                    value: BigDecimal::from_str(decimal_str).unwrap(),
1200                    precision,
1201                },
1202                None,
1203            )
1204            .unwrap()
1205    }
1206
1207    /// Every `cardinal` row with a float `arg` in the frozen corpus, byte for
1208    /// byte. `precision` is `abs(Decimal(repr(v)).as_tuple().exponent)`.
1209    #[test]
1210    fn corpus_cardinal_float_rows() {
1211        assert_eq!(cf(0.0, 1), "zeru akadomo zeru");
1212        assert_eq!(cf(0.5, 1), "zeru akadomo gatanu");
1213        assert_eq!(cf(1.0, 1), "rimwe akadomo zeru");
1214        assert_eq!(cf(1.5, 1), "rimwe akadomo gatanu");
1215        assert_eq!(cf(2.25, 2), "kabiri akadomo kabiri gatanu");
1216        assert_eq!(cf(3.14, 2), "gatatu akadomo rimwe kane");
1217        assert_eq!(cf(0.01, 2), "zeru akadomo zeru rimwe");
1218        assert_eq!(cf(0.1, 1), "zeru akadomo rimwe");
1219        assert_eq!(cf(0.99, 2), "zeru akadomo icyenda icyenda");
1220        assert_eq!(cf(1.01, 2), "rimwe akadomo zeru rimwe");
1221        assert_eq!(cf(12.34, 2), "icumi na kabiri akadomo gatatu kane");
1222        assert_eq!(
1223            cf(99.99, 2),
1224            "mirongo cyenda na icyenda akadomo icyenda icyenda"
1225        );
1226        assert_eq!(cf(100.5, 1), "ijana akadomo gatanu");
1227        assert_eq!(
1228            cf(1234.56, 2),
1229            "igihumbi na kabiri ijana na mirongo itatu na kane akadomo gatanu gatandatu"
1230        );
1231        assert_eq!(cf(-0.5, 1), "munsi ya zeru akadomo gatanu");
1232        assert_eq!(cf(-1.5, 1), "munsi ya rimwe akadomo gatanu");
1233        assert_eq!(
1234            cf(-12.34, 2),
1235            "munsi ya icumi na kabiri akadomo gatatu kane"
1236        );
1237        // The f64-artefact cases: RW spells the *repr* digits, never the binary
1238        // `post`. 1.005 (stored 1.00499…) still formats "005", 2.675 (stored
1239        // 2.67499…) still formats "675" — no float2tuple rounding involved.
1240        assert_eq!(cf(1.005, 3), "rimwe akadomo zeru zeru gatanu");
1241        assert_eq!(cf(2.675, 3), "kabiri akadomo gatandatu karindwi gatanu");
1242    }
1243
1244    /// Every `cardinal_dec` (Decimal input) row in the frozen corpus.
1245    #[test]
1246    fn corpus_cardinal_dec_rows() {
1247        assert_eq!(cd("0.01", 2), "zeru akadomo zeru rimwe");
1248        // Trailing zero preserved: str(Decimal("1.10")) == "1.10" -> "1","10".
1249        assert_eq!(cd("1.10", 2), "rimwe akadomo rimwe zeru");
1250        assert_eq!(
1251            cd("12.345", 3),
1252            "icumi na kabiri akadomo gatatu kane gatanu"
1253        );
1254        // Integer part above 10^9 degrades to bare digits (quirk 1).
1255        assert_eq!(
1256            cd("98746251323029.99", 2),
1257            "98746251323029 akadomo icyenda icyenda"
1258        );
1259        assert_eq!(cd("0.001", 3), "zeru akadomo zeru zeru rimwe");
1260    }
1261
1262    /// Cross-checked against the live interpreter, not the corpus.
1263    #[test]
1264    fn cardinal_float_quirks() {
1265        // str(-0.0) == "-0.0": the sign bit alone earns the negword, which the
1266        // trait default (whose is_negative is `value < 0`) would miss.
1267        assert_eq!(cf(-0.0, 1), "munsi ya zeru akadomo zeru");
1268        // 5.0 -> repr "5.0", precision 1: a whole-valued float still spells the
1269        // "0" fractional digit.
1270        assert_eq!(cf(5.0, 1), "gatanu akadomo zeru");
1271        // Decimal trailing zeros: str(Decimal("12.340")) == "12.340".
1272        assert_eq!(cd("12.340", 3), "icumi na kabiri akadomo gatatu kane zeru");
1273        // Large float whose integer part crosses 10^9 (repr "123456789.5").
1274        assert_eq!(
1275            cf(123456789.5, 1),
1276            "ijana na makumyabiri na gatatu miliyoni na kane ijana na mirongo \
1277             itanu na gatandatu igihumbi na karindwi ijana na mirongo inani na \
1278             icyenda akadomo gatanu"
1279        );
1280        // precision= is dropped: RW never reads self.precision.
1281        assert_eq!(
1282            LangRw::new()
1283                .to_cardinal_float(
1284                    &FloatValue::Float { value: 2.675, precision: 3 },
1285                    Some(1),
1286                )
1287                .unwrap(),
1288            "kabiri akadomo gatandatu karindwi gatanu"
1289        );
1290        // A precision-0 Decimal (str "5", no point) takes the bare-integer arm.
1291        assert_eq!(cd("5", 0), "gatanu");
1292    }
1293
1294    fn fv_f(value: f64, precision: u32) -> FloatValue {
1295        FloatValue::Float { value, precision }
1296    }
1297
1298    fn fv_d(s: &str, precision: u32) -> FloatValue {
1299        FloatValue::Decimal {
1300            value: BigDecimal::from_str(s).unwrap(),
1301            precision,
1302        }
1303    }
1304
1305    /// The wholefloat-corpus routing rows: every float/Decimal — whole values
1306    /// included — takes the string algorithm, so ".0" tails are spoken and
1307    /// exponent-form reprs raise ValueError from `int()`.
1308    #[test]
1309    fn corpus_float_entry_rows() {
1310        let l = LangRw::new();
1311        // Whole floats keep their ".0" (str(5.0) == "5.0").
1312        assert_eq!(
1313            l.cardinal_float_entry(&fv_f(5.0, 1), None).unwrap(),
1314            "gatanu akadomo zeru"
1315        );
1316        assert_eq!(
1317            l.cardinal_float_entry(&fv_f(-0.0, 1), None).unwrap(),
1318            "munsi ya zeru akadomo zeru"
1319        );
1320        assert_eq!(
1321            l.cardinal_float_entry(&fv_f(1234.0, 1), None).unwrap(),
1322            "igihumbi na kabiri ijana na mirongo itatu na kane akadomo zeru"
1323        );
1324        // Above 10^9 the integer field degrades to bare digits (quirk 1).
1325        assert_eq!(
1326            l.cardinal_float_entry(&fv_f(1e9, 1), None).unwrap(),
1327            "1000000000 akadomo zeru"
1328        );
1329        // Decimal without a visible point takes the integer words...
1330        assert_eq!(l.cardinal_float_entry(&fv_d("5", 0), None).unwrap(), "gatanu");
1331        // ...while trailing zeros survive str(Decimal).
1332        assert_eq!(
1333            l.cardinal_float_entry(&fv_d("5.00", 2), None).unwrap(),
1334            "gatanu akadomo zeru zeru"
1335        );
1336        assert_eq!(
1337            l.cardinal_float_entry(&fv_d("12345.000", 3), None).unwrap(),
1338            "icumi na kabiri igihumbi na gatatu ijana na mirongo ine na gatanu \
1339             akadomo zeru zeru zeru"
1340        );
1341        // Exponent-form reprs: str(1e16) == "1e+16", str(Decimal("1E+2")) ==
1342        // "1E+2" — no ".", so int() raises ValueError.
1343        for v in [
1344            l.cardinal_float_entry(&fv_f(1e16, 16), None),
1345            l.cardinal_float_entry(&fv_f(1e20, 20), None),
1346            l.cardinal_float_entry(&fv_d("1E+2", 0), None),
1347            l.cardinal_float_entry(&fv_d("1E+20", 0), None),
1348        ] {
1349            assert!(matches!(v, Err(N2WError::Value(_))), "{v:?}");
1350        }
1351    }
1352
1353    /// `to_ordinal` on floats: "mbere" for == 1, else "wa " + cardinal, with
1354    /// the cardinal's ValueErrors propagating.
1355    #[test]
1356    fn corpus_ordinal_entry_rows() {
1357        let l = LangRw::new();
1358        // number == 1 is numeric: 1.0 and Decimal("1.00") both hit "mbere".
1359        assert_eq!(l.ordinal_float_entry(&fv_f(1.0, 1)).unwrap(), "mbere");
1360        assert_eq!(l.ordinal_float_entry(&fv_d("1.00", 2)).unwrap(), "mbere");
1361        assert_eq!(
1362            l.ordinal_float_entry(&fv_f(2.0, 1)).unwrap(),
1363            "wa kabiri akadomo zeru"
1364        );
1365        assert_eq!(
1366            l.ordinal_float_entry(&fv_f(-0.0, 1)).unwrap(),
1367            "wa munsi ya zeru akadomo zeru"
1368        );
1369        assert_eq!(l.ordinal_float_entry(&fv_d("0", 0)).unwrap(), "wa zeru");
1370        assert_eq!(l.ordinal_float_entry(&fv_d("5", 0)).unwrap(), "wa gatanu");
1371        assert_eq!(
1372            l.ordinal_float_entry(&fv_f(0.5, 1)).unwrap(),
1373            "wa zeru akadomo gatanu"
1374        );
1375        assert!(matches!(
1376            l.ordinal_float_entry(&fv_f(1e16, 16)),
1377            Err(N2WError::Value(_))
1378        ));
1379        // to_ordinal_num floats are NOT an error: "wa " + str(number), where
1380        // str(number) is the binding-supplied repr.
1381        assert_eq!(
1382            l.ordinal_num_float_entry(&fv_f(1e16, 16), "1e+16").unwrap(),
1383            "wa 1e+16"
1384        );
1385        assert_eq!(
1386            l.ordinal_num_float_entry(&fv_d("5.00", 2), "5.00").unwrap(),
1387            "wa 5.00"
1388        );
1389        // to_year is a to_cardinal alias.
1390        assert_eq!(
1391            l.year_float_entry(&fv_f(5.0, 1)).unwrap(),
1392            "gatanu akadomo zeru"
1393        );
1394        assert!(matches!(
1395            l.year_float_entry(&fv_d("1E+2", 0)),
1396            Err(N2WError::Value(_))
1397        ));
1398    }
1399
1400    /// String inputs: "1e3" parses to Decimal('1E+3') whose str keeps the
1401    /// exponent, so int() raises; "Infinity" is intercepted in str_to_number
1402    /// (the dispatcher would otherwise report the base class's OverflowError).
1403    #[test]
1404    fn corpus_string_rows() {
1405        let l = LangRw::new();
1406        let parsed = l.str_to_number("1e3").unwrap();
1407        match parsed {
1408            ParsedNumber::Dec(d) => {
1409                let fv = FloatValue::Decimal { value: d, precision: 0 };
1410                assert!(matches!(
1411                    l.cardinal_float_entry(&fv, None),
1412                    Err(N2WError::Value(_))
1413                ));
1414            }
1415            other => panic!("expected Dec, got {other:?}"),
1416        }
1417        assert!(matches!(
1418            l.str_to_number("Infinity"),
1419            Err(N2WError::Value(_))
1420        ));
1421        assert!(matches!(
1422            l.str_to_number("-Infinity"),
1423            Err(N2WError::Value(_))
1424        ));
1425        // NaN stays a ParsedNumber::NaN — the dispatcher's ValueError already
1426        // matches Python's int("NaN") type.
1427        assert!(matches!(l.str_to_number("NaN"), Ok(ParsedNumber::NaN)));
1428    }
1429
1430    /// [`python_float_repr`] against CPython ground truth for the corpus range.
1431    #[test]
1432    fn float_repr_matches_cpython() {
1433        for (v, want) in [
1434            (0.0, "0.0"),
1435            (-0.0, "-0.0"),
1436            (1.0, "1.0"),
1437            (0.5, "0.5"),
1438            (1234.0, "1234.0"),
1439            (1000000000000000.0, "1000000000000000.0"),
1440            (1e16, "1e+16"),
1441            (1e20, "1e+20"),
1442            (2.675, "2.675"),
1443            (1.005, "1.005"),
1444            (123456789.5, "123456789.5"),
1445        ] {
1446            assert_eq!(python_float_repr(v), want);
1447        }
1448    }
1449
1450    /// The load-bearing assumption in `split_str_parts`: `with_scale(0)`
1451    /// truncates rather than rounding. currency.rs already relies on this, but
1452    /// RW's truncate-don't-round cents rule depends on it directly.
1453    #[test]
1454    fn with_scale_zero_truncates() {
1455        for (s, want) in [("0.9", 0), ("1.5", 1), ("2.5", 2), ("34.99", 34)] {
1456            assert_eq!(
1457                BigDecimal::from_str(s)
1458                    .unwrap()
1459                    .with_scale(0)
1460                    .as_bigint_and_exponent()
1461                    .0,
1462                BigInt::from(want),
1463                "with_scale(0) on {s}"
1464            );
1465        }
1466    }
1467}