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}