Skip to main content

num2words2_core/
lang_ce.rs

1//! Port of `lang_CE.py` (Chechen).
2//!
3//! Shape: **self-contained**. `Num2Word_CE` subclasses `Num2Word_EUR` (→
4//! `Num2Word_Base`), and `Num2Word_EUR.setup` *does* set `high_numwords`, so
5//! Python really does build `self.cards`/`MAXVAL` in `Num2Word_Base.__init__`
6//! — but every one of the four in-scope methods is overridden, so cards,
7//! `merge` and `MAXVAL` are never read. Two consequences:
8//!
9//!   * `cards`/`maxval`/`merge` stay at their trait defaults here.
10//!   * **There is no overflow check.** Chechen's ceiling is the literal
11//!     `return "NOT IMPLEMENTED"` at `number >= 10**34` — a plain string, not
12//!     an `OverflowError`. `to_cardinal(10**21)` == "цхьа триллиард" confirms
13//!     `MAXVAL` is inert.
14//!
15//! Python's `to_cardinal(number, clazz="д", case="abs")` carries two keyword
16//! arguments. The bare trait entry points always run at the defaults ("д" /
17//! "abs", or "ORD" via `to_ordinal`); the `*_kw` hooks accept the caller's
18//! kwargs bag and thread them through — `to_cardinal_kw` takes both `clazz`
19//! and `case`, `to_ordinal_kw` only `clazz`, `to_year_kw` and `to_currency_kw`
20//! only `case`, exactly the Python signatures. A bad `case` value surfaces as
21//! Python's `KeyError` from the suffix-table subscript ("bogus" is in no
22//! table); a non-string value (e.g. an explicit `None`) is NotImplemented so
23//! the dispatcher falls back to Python, which raises the original
24//! `TypeError`/`KeyError` itself. The internal helpers below keep both
25//! parameters so the recursion stays faithful — the language recurses with
26//! `case="attr"` on its own. See [`DEFAULT_CLAZZ`].
27//!
28//! No cross-call mutable state: `to_cardinal` never touches `self`.
29//!
30//! # Chechen grammar, in one paragraph
31//!
32//! Numerals inflect for case, and 4/14/40 (and their compounds) agree in noun
33//! class. The tables encode the class slot as the literal marker `"д*"`, which
34//! `.replace("д*", clazz)` rewrites — hence "д*иъ" → "диъ" under the default
35//! class "д". `makecase` picks a stored form when the entry has one, else
36//! glues a case suffix onto the `abs` form, choosing the vowel-stem or
37//! consonant-stem suffix table by the last character of `abs`.
38//!
39//! # Faithfully reproduced Python bugs and oddities
40//!
41//! Verified against the frozen corpus; all preserved verbatim.
42//!
43//! 1. **The tables mix two visually identical characters.** Chechen uses
44//!    palochka `Ӏ` (U+04C0), but some entries carry Ukrainian `І` (U+0406)
45//!    instead. Both survive into output and are observable: `to_cardinal(100)`
46//!    == "бӀе" (U+04C0) while `to_ordinal(100)` == "бІолгІа" (U+0406). Same
47//!    split for 40/60/80 `ORD`, 1000 `ORD` ("эзарлагІа") and every `ILLIONS`
48//!    `ORD`. These tables were extracted mechanically from the Python source
49//!    rather than retyped, precisely because the two are indistinguishable on
50//!    screen.
51//! 2. **Typos in the numword tables**, kept as-is: `CARDINALS[12]["ORD"]` is
52//!    "шийтталга" (no palochka, unlike every sibling "…лгӀа");
53//!    `CARDINALS[8]["ORD"]` is "борхӀалӀа" (missing "г");
54//!    `CARDINALS[18]["ORD"]` is "берхитталӀа" (lost both the stem palochka and
55//!    the "г"); `CARDINALS[19]["ORD"]` is "ткъаесналгӀа" though its `abs` is
56//!    "ткъайесна" (dropped "й"); `CARDINALS[5]["instr"]` is "нхеанца" (should
57//!    be "пхеанца" — "н" for "п"); `CARDINALS[1]["all"]` is "цхаьнга" (missing
58//!    "ь"). The first four are reachable from `to_ordinal`; the last two need
59//!    a `case=` kwarg the Rust path never receives.
60//! 3. **`to_ordinal` silently degrades to a cardinal at ≥ 10^6.** The illions
61//!    branch renders every group with `case="attr"` and only forwards `case`
62//!    to the sub-10^6 remainder, so `to_ordinal(10**6)` == "цхьа миллион" —
63//!    identical to `to_cardinal(10**6)`, with the ordinal suffix nowhere. The
64//!    `ORD` forms in `ILLIONS` are dead code, never read by any branch.
65//! 4. **`CARDINALS[1000000]` is unreachable.** The `number < 1000000` branch
66//!    excludes it and 10^6 falls into the illions branch, which reads
67//!    `ILLIONS[6]` instead. Kept in the table for fidelity; never consulted.
68//! 5. **Zero has no `ORD` entry**, so `to_ordinal(0)` falls through `makecase`
69//!    to the suffix path: "ноль" ends in "ь" (a consonant), giving
70//!    "ноль" + "алгӀа" == "нольалгӀа".
71//! 6. **`to_ordinal` accepts negatives** — it never calls `verify_ordinal`, so
72//!    `to_ordinal(-1)` == "минус цхьалгӀа". Only `to_ordinal_num` guards, and
73//!    it raises `TypeError` (see [`verify_ordinal`]).
74//! 7. `CARDINALS[1000][tcase]` in the thousands branch is a **direct dict
75//!    access, not a `makecase` call**, so it cannot synthesise a missing case
76//!    and would raise `KeyError` instead. Every case key happens to be present,
77//!    so this never fires — but the shape is preserved.
78//! 8. The module-level `MINUS` constant is shadowed by `setup`'s
79//!    `self.negword`. `DECIMALPOINT` is *not* shadowed: the float branch joins
80//!    on the module constant "а" and never reads `self.pointword`, so the
81//!    "запятая" that `setup` assigns is dead in CE. See [`DECIMALPOINT`].
82//! 9. **The float branch drops both keyword arguments.** It recurses as
83//!    `self.to_cardinal(int(abs_number))` and `self.to_cardinal(int(c))` — no
84//!    `clazz=`, no `case=` — so a float always renders at the defaults even
85//!    when the caller asked for another class or case. `to_ordinal(1.5)` is
86//!    therefore a plain cardinal, and `to_currency`'s fractional-cents branch
87//!    passes a `case=` that is silently discarded.
88//! 10. **`precision=` is inert.** `Num2Word_Base` carries `self.precision`, and
89//!    the dispatcher's `precision=` kwarg (issue #580) assigns it — but CE's
90//!    float branch slices `str(abs_number)` instead of calling
91//!    `float2tuple`, so it never reads the attribute. Verified live:
92//!    `num2words(2.675, lang="ce", precision=1)` == `num2words(2.675,
93//!    lang="ce")`. Hence [`LangCe::to_cardinal_float`] ignores its override.
94//!
95//! # Error variants
96//!
97//! `to_ordinal_num` on a negative raises `TypeError` → [`N2WError::Type`],
98//! carrying `Num2Word_Base.errmsg_negord` verbatim. The `KeyError` paths
99//! ([`N2WError::Key`]) model Python's dict lookups faithfully. Under the
100//! default `clazz`/`case` they are unreachable from *integer* input, but the
101//! Decimal arm of the float path reaches them constantly — every `cardinal_dec`
102//! row in the corpus is a `KeyError` (see [`LangCe::cardinal_decimal`]).
103
104use crate::base::{Kwargs, KwVal, Lang, N2WError, Result};
105use crate::currency::{parse_currency_parts, CurrencyForms, CurrencyValue};
106use crate::floatpath::FloatValue;
107use crate::strnum::{python_decimal_parse, ParsedNumber};
108use bigdecimal::BigDecimal;
109use num_bigint::BigInt;
110use num_integer::Integer;
111use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
112use std::collections::HashMap;
113
114/// The noun-class marker embedded in the tables; `.replace`d with `clazz`.
115const MASK: &str = "д*";
116
117/// Python's `to_cardinal(number, clazz="д", ...)` default: class "д".
118/// The dispatcher never passes `clazz=`, so this is always the value used.
119const DEFAULT_CLAZZ: &str = "д";
120
121/// `self.negword`, set in `Num2Word_CE.setup`. Note the override concatenates
122/// `self.negword + " "` directly rather than `.strip()`-ing it as
123/// `Num2Word_Base.to_cardinal` does; identical here since it has no padding.
124const NEGWORD: &str = "минус";
125
126/// `self.pointword`, set in `Num2Word_CE.setup` — and **dead**.
127///
128/// `Num2Word_Base.to_cardinal_float` would read it, but CE overrides
129/// `to_cardinal` and handles floats inline, joining on the module-level
130/// [`DECIMALPOINT`] instead. Nothing in CE ever reads `self.pointword`; it is
131/// surfaced through [`Lang::pointword`] because that hook reports what
132/// `setup()` assigns, not what the language happens to use.
133const POINTWORD: &str = "запятая";
134
135/// `lang_CE.py`'s module-level `DECIMALPOINT`, the word the float branch
136/// actually joins on.
137///
138/// The module defines it as a commented-out "запятая" overridden by `"а"`:
139///
140/// ```python
141/// # DECIMALPOINT = "запятая"  # check !
142/// DECIMALPOINT = "а"
143/// ```
144///
145/// so CE says "two *and* six seven five", not "two comma six seven five". This
146/// is why CE cannot inherit `floatpath::default_to_cardinal_float`, which emits
147/// `lang.title(lang.pointword())` — for CE that would print [`POINTWORD`].
148const DECIMALPOINT: &str = "а";
149
150// ---------------------------------------------------------------------------
151// Tables, extracted mechanically from lang_CE.py (see module docs, bug 1).
152// Case order within each entry mirrors the Python dict's insertion order.
153// ---------------------------------------------------------------------------
154
155const CARDINALS: &[(u64, &[(&str, &str)])] = &[
156    (0, &[("attr", "ноль"), ("abs", "ноль"), ("gen", "нолан"), ("dat", "нолана"), ("erg", "ноло"), ("instr", "ноланца"), ("mat", "ноланах"), ("comp", "ноланал"), ("all", "ноланга")]),
157    (1, &[("attr", "цхьа"), ("obl", "цхьана"), ("abs", "цхьаъ"), ("gen", "цхьаннан"), ("dat", "цхьанна"), ("erg", "цхьамма"), ("instr", "цхьаьнца"), ("mat", "цхьаннах"), ("comp", "цхьаннал"), ("all", "цхаьнга"), ("ORD", "цхьалгӀа")]),
158    (2, &[("attr", "ши"), ("obl", "шина"), ("abs", "шиъ"), ("gen", "шиннан"), ("dat", "шинна"), ("erg", "шимма"), ("instr", "шинца"), ("mat", "шиннах"), ("comp", "шиннал"), ("all", "шинга"), ("ORD", "шолгӀа")]),
159    (3, &[("attr", "кхо"), ("obl", "кхона"), ("abs", "кхоъ"), ("gen", "кхааннан"), ("dat", "кхаанна"), ("erg", "кхаамма"), ("instr", "кхаанца"), ("mat", "кхааннах"), ("comp", "кхааннал"), ("all", "кхаанга"), ("ORD", "кхоалгӀа")]),
160    (4, &[("attr", "д*и"), ("obl", "д*еа"), ("abs", "д*иъ"), ("gen", "д*еаннан"), ("dat", "д*еанна"), ("erg", "д*еамма"), ("instr", "д*еанца"), ("mat", "д*еаннах"), ("comp", "д*еаннал"), ("all", "д*еанга"), ("ORD", "д*оьалгӀа")]),
161    (5, &[("attr", "пхи"), ("obl", "пхеа"), ("abs", "пхиъ"), ("gen", "пхеаннан"), ("dat", "пхеанна"), ("erg", "пхеамма"), ("instr", "нхеанца"), ("mat", "пхеаннах"), ("comp", "пхеаннал"), ("all", "пхеанга"), ("ORD", "пхоьалгӀа")]),
162    (6, &[("abs", "ялх"), ("attr", "ялх"), ("ORD", "йолхалгӀа")]),
163    (7, &[("abs", "ворхӀ"), ("attr", "ворхӀ"), ("ORD", "ворхӀалгӀа")]),
164    (8, &[("abs", "бархӀ"), ("attr", "бархӀ"), ("ORD", "борхӀалӀа")]),
165    (9, &[("abs", "исс"), ("attr", "исс"), ("ORD", "уьссалгӀа")]),
166    (10, &[("attr", "итт"), ("abs", "итт"), ("gen", "иттаннан"), ("dat", "иттанна"), ("erg", "иттамма"), ("instr", "иттанца"), ("mat", "иттаннах"), ("comp", "иттаннал"), ("all", "иттанга"), ("ORD", "уьтталгӀа")]),
167    (11, &[("abs", "цхьайтта"), ("attr", "цхьайтта"), ("ORD", "цхьайтталгӀа")]),
168    (12, &[("abs", "шийтта"), ("attr", "шийтта"), ("ORD", "шийтталга")]),
169    (13, &[("abs", "кхойтта"), ("attr", "кхойтта"), ("ORD", "кхойтталгӀа")]),
170    (14, &[("abs", "д*ейтта"), ("attr", "д*ейтта"), ("ORD", "д*ейтталгӀа")]),
171    (15, &[("abs", "пхийтта"), ("attr", "пхийтта"), ("ORD", "пхийтталгӀа")]),
172    (16, &[("abs", "ялхитта"), ("attr", "ялхитта"), ("ORD", "ялхитталгӀа")]),
173    (17, &[("abs", "вуьрхӀитта"), ("attr", "вуьрхӀитта"), ("ORD", "вуьрхӀитталгӀа")]),
174    (18, &[("abs", "берхӀитта"), ("attr", "берхӀитта"), ("ORD", "берхитталӀа")]),
175    (19, &[("abs", "ткъайесна"), ("attr", "ткъайесна"), ("ORD", "ткъаесналгӀа")]),
176    (20, &[("abs", "ткъа"), ("gen", "ткъаннан"), ("dat", "ткъанна"), ("erg", "ткъамма"), ("instr", "ткъанца"), ("mat", "ткъаннах"), ("comp", "ткъаннал"), ("all", "ткъанга"), ("attr", "ткъе"), ("ORD", "ткъолгӀа")]),
177    (40, &[("abs", "шовзткъа"), ("attr", "шовзткъе"), ("ORD", "шовзткъалгІа")]),
178    (60, &[("abs", "кхузткъа"), ("attr", "кхузткъе"), ("ORD", "кхузткъалгІа")]),
179    (80, &[("abs", "дезткъа"), ("attr", "дезткъе"), ("ORD", "дезткъалгІа")]),
180    (100, &[("attr", "бӀе"), ("abs", "бӀе"), ("obl", "бӀен"), ("gen", "бӀеннан"), ("dat", "бӀенна"), ("erg", "бӀемма"), ("instr", "бӀенца"), ("mat", "бӀеннах"), ("comp", "бӀеннал"), ("all", "бӀенга"), ("ORD", "бІолгІа")]),
181    (1000, &[("attr", "эзар"), ("abs", "эзар"), ("obl", "эзаран"), ("gen", "эзарнан"), ("dat", "эзарна"), ("erg", "эзарно"), ("instr", "эзарнаца"), ("mat", "эзарнах"), ("comp", "эзарнал"), ("all", "эзаранга"), ("ORD", "эзарлагІа")]),
182    // Dead entry: unreachable (module docs, bug 4). Retained for fidelity.
183    (1000000, &[("attr", "миллион"), ("abs", "миллион"), ("ORD", "миллионалгІа")]),
184];
185
186/// `CARDINALS["casesuffix_cons"]` — glued onto `abs` forms ending in a consonant.
187const CASESUFFIX_CONS: &[(&str, &str)] = &[
188    ("gen", "аннан"),
189    ("dat", "анна"),
190    ("erg", "амма"),
191    ("instr", "анца"),
192    ("mat", "аннах"),
193    ("comp", "аннал"),
194    ("all", "анга"),
195    ("obl", "ан"),
196    ("ORD", "алгӀа"),
197];
198
199/// `CARDINALS["casesuffix_voc"]` — glued onto `abs` forms ending in a vowel.
200const CASESUFFIX_VOC: &[(&str, &str)] = &[
201    ("gen", "ннан"),
202    ("dat", "нна"),
203    ("erg", "мма"),
204    ("instr", "нца"),
205    ("mat", "ннах"),
206    ("comp", "ннал"),
207    ("all", "нга"),
208    ("obl", "н"),
209    ("ORD", "лгӀа"),
210];
211
212/// `ILLIONS`, `"attr"` slot only — the sole slot any branch reads (bug 3).
213/// Keys are the powers of ten the illions loop walks, ascending.
214const ILLIONS: &[(u32, &str)] = &[
215    (6, "миллион"),
216    (9, "миллиард"),
217    (12, "биллион"),
218    (15, "биллиард"),
219    (18, "триллион"),
220    (21, "триллиард"),
221    (24, "квадриллион"),
222    (27, "квадриллиард"),
223    (30, "квинтиллион"),
224    (33, "квинтиллиард"),
225];
226
227/// `reversed([6, 9, 12, 15, 18, 21, 24, 27, 30, 33])` — descending, as Python
228/// iterates it. Together with `number % 10**6` these cover every digit group
229/// of a value in `[10**6, 10**34)` with no gap.
230const POTS_DESC: [u32; 10] = [33, 30, 27, 24, 21, 18, 15, 12, 9, 6];
231
232// ---------------------------------------------------------------------------
233// Currency tables and constants.
234// ---------------------------------------------------------------------------
235
236/// The `mcase` `_money_verbose`/`_cents_verbose` resolve to.
237///
238/// Both read `mcase = "attr"; if case != "abs": mcase = "obl"`. The bare
239/// `to_currency` trait hook has no `case=` parameter, so it runs at the
240/// Python default `"abs"` and resolves to `"attr"`; `to_currency_kw` receives
241/// the caller's `case=` and any non-"abs" string — including "bogus", which
242/// Python happily maps to "obl" too — selects the oblique forms.
243fn money_case(case: &str) -> &'static str {
244    if case != "abs" {
245        "obl"
246    } else {
247        "attr"
248    }
249}
250
251/// Python 3.9's message for calling a 3-parameter method with 2 arguments.
252///
253/// 3.10+ prefixes the qualified name (`Num2Word_CE._money_verbose() ...`); the
254/// reference interpreter here is 3.9.6, which emits the bare name. The corpus
255/// records only the exception *type*, so the text is documentation rather than
256/// a matched assertion — but it is the real string, copied from the raise.
257const MONEY_VERBOSE_ARITY_ERR: &str =
258    "_money_verbose() missing 1 required positional argument: 'case'";
259
260/// `Num2Word_CE.CURRENCY_FORMS` — CE's **own** class attribute.
261///
262/// This is the one language where the `lang_EUR.py` trap does *not* apply.
263/// `Num2Word_EN.__init__` mutates `Num2Word_EUR.CURRENCY_FORMS` **in place**,
264/// and the 16 classes that never declare their own dict silently inherit the
265/// English rewrite. CE declares one, so attribute lookup stops at
266/// `Num2Word_CE` and the EUR dict is never consulted. Verified live rather
267/// than assumed:
268///
269/// ```text
270/// >>> CONVERTER_CLASSES['ce'].CURRENCY_FORMS is Num2Word_EUR.CURRENCY_FORMS
271/// False
272/// ```
273///
274/// Four codes, and only four. Everything EN grafts onto the shared dict — JPY,
275/// KWD, BHD, INR, CNY, CHF and ~19 others — is invisible from here, which is
276/// why every one of those corpus rows is a NotImplementedError. Note the
277/// consequence for the two currencies the contract calls out: CE reaches
278/// neither the 3-decimal (KWD/BHD, divisor 1000) nor the 0-decimal (JPY,
279/// divisor 1) path, because it cannot name those currencies at all.
280///
281/// Both forms of each pair are kept even though `to_currency` only ever reads
282/// `[0]` (see [`LangCe::to_currency`]): `to_cheque` takes `cr1[-1]`, and the
283/// tuple arity is ported data regardless.
284fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
285    [
286        ("EUR", CurrencyForms::new(&["Евро", "Евро"], &["Сент", "Сенташ"])),
287        ("RUB", CurrencyForms::new(&["Сом", "Сомаш"], &["Кепек", "Кепекаш"])),
288        ("USD", CurrencyForms::new(&["Доллар", "Доллараш"], &["Сент", "Сенташ"])),
289        ("GBP", CurrencyForms::new(&["Фунт", "Фунташ"], &["Пенни", "Пенни"])),
290    ]
291    .into_iter()
292    .collect()
293}
294
295/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged and **never read**.
296///
297/// CE declares no adjectives of its own, so it sees EUR's 16 English ones
298/// (confirmed live) — but [`LangCe::to_currency`] accepts `adjective` and
299/// never consults it, and `to_cheque` has no adjective step. So this table is
300/// unreachable through every path CE exposes:
301///
302/// ```text
303/// >>> c.to_currency(12.34, "USD", adjective=True)
304/// 'шийтта Доллар, ткъе дейтта Сент'   # not "US Доллар"
305/// ```
306///
307/// Ported anyway because it is data the class genuinely carries, and dropping
308/// it would misrepresent CE as having an empty table like `Num2Word_Base`.
309fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
310    [
311        ("AUD", "Australian"),
312        ("BYN", "Belarusian"),
313        ("CAD", "Canadian"),
314        ("EEK", "Estonian"),
315        ("USD", "US"),
316        ("RUB", "Russian"),
317        ("NOK", "Norwegian"),
318        ("MXN", "Mexican"),
319        ("RON", "Romanian"),
320        ("INR", "Indian"),
321        ("HUF", "Hungarian"),
322        ("ISK", "íslenskar"),
323        ("UZS", "Uzbekistan"),
324        ("SAR", "Saudi"),
325        ("JPY", "Japanese"),
326        ("KRW", "Korean"),
327    ]
328    .into_iter()
329    .collect()
330}
331
332// ---------------------------------------------------------------------------
333// Error constructors, mirroring the Python exception each site would raise.
334// ---------------------------------------------------------------------------
335
336/// Python `KeyError`: a missing dict key. Python renders the key with `repr`.
337fn key_error(key: impl std::fmt::Display) -> N2WError {
338    N2WError::Key(format!("'{}'", key))
339}
340
341/// The vowel a stem must end in to take the `casesuffix_voc` table.
342///
343/// Python writes `CARDINALS[number]["abs"][-1] in "а"`, a *substring* test
344/// against a one-character string — which for a single character is just
345/// equality with "а" (U+0430). Indexing is by character, never by byte.
346const VOWEL_A: char = 'а';
347
348fn cardinals_entry(n: u64) -> Result<&'static [(&'static str, &'static str)]> {
349    CARDINALS
350        .iter()
351        .find(|(k, _)| *k == n)
352        .map(|(_, v)| *v)
353        .ok_or_else(|| key_error(n))
354}
355
356fn case_of(entry: &[(&'static str, &'static str)], case: &str) -> Option<&'static str> {
357    entry.iter().find(|(c, _)| *c == case).map(|(_, w)| *w)
358}
359
360/// `CARDINALS[n][case]` — a direct dict access, raising `KeyError` when absent.
361fn cardinals_at(n: u64, case: &str) -> Result<&'static str> {
362    case_of(cardinals_entry(n)?, case).ok_or_else(|| key_error(case))
363}
364
365/// Python's `Num2Word_CE.makecase`.
366///
367/// Stored form if the entry has this case; otherwise `abs` + a case suffix,
368/// picking the suffix table by whether `abs` ends in "а".
369fn makecase(number: u64, case: &str, clazz: &str) -> Result<String> {
370    let entry = cardinals_entry(number)?;
371
372    if let Some(word) = case_of(entry, case) {
373        return Ok(word.replace(MASK, clazz));
374    }
375
376    // Python: CARDINALS[number]["abs"] — KeyError if the entry lacks "abs".
377    let abs_form = case_of(entry, "abs").ok_or_else(|| key_error("abs"))?;
378
379    // `abs_form[-1] in "а"`. Every table entry is a non-empty literal, so the
380    // `[-1]` cannot IndexError; `None` is unreachable and falls to the
381    // consonant table, which is what an empty stem would mean anyway.
382    let is_vowel_stem = abs_form.chars().next_back() == Some(VOWEL_A);
383    let table = if is_vowel_stem {
384        CASESUFFIX_VOC
385    } else {
386        CASESUFFIX_CONS
387    };
388
389    let suffix = table
390        .iter()
391        .find(|(c, _)| *c == case)
392        .map(|(_, w)| *w)
393        .ok_or_else(|| key_error(case))?;
394
395    Ok(abs_form.replace(MASK, clazz) + suffix)
396}
397
398/// The `number < 1000000` half of `to_cardinal`, for a non-negative value.
399///
400/// Split out from the BigInt entry point because every branch below 10^6 does
401/// small-integer arithmetic. The caller proves `number < 1_000_000` before
402/// calling, so `u64` is sound here (no fixed-width cast happens above that).
403/// Branch order matches Python exactly.
404fn to_cardinal_small(number: u64, clazz: &str, case: &str) -> Result<String> {
405    // elif number < 20
406    if number < 20 {
407        return makecase(number, case, clazz);
408    }
409
410    // elif number < 100 — Chechen counts in twenties (vigesimal).
411    if number < 100 {
412        let twens = number / 20;
413        let units = number % 20;
414        let base = twens * 20;
415        if units == 0 {
416            return makecase(number, case, clazz);
417        }
418        let twenties = makecase(base, "attr", clazz)?;
419        let rest = to_cardinal_small(units, clazz, case)?;
420        // The trailing `.replace(MASK, clazz)` is redundant — `makecase`
421        // already substituted the class — but it is what Python writes.
422        return Ok(twenties + " " + &rest.replace(MASK, clazz));
423    }
424
425    // elif number < 1000
426    if number < 1000 {
427        let hundreds = number / 100;
428        let tens = number % 100;
429        let hundert = if hundreds > 1 {
430            cardinals_at(hundreds, "attr")?.replace(MASK, clazz) + " "
431        } else {
432            String::new()
433        };
434        if tens != 0 {
435            let rest = to_cardinal_small(tens, clazz, case)?;
436            // Note: the hundred word is pinned to "abs" here regardless of
437            // `case`; only the remainder carries the case.
438            return Ok(hundert + cardinals_at(100, "abs")? + " " + &rest);
439        }
440        return Ok(hundert + &makecase(100, case, clazz)?);
441    }
442
443    // elif number < 1000000
444    let thousands = number / 1000;
445    let hundert = number % 1000;
446    let tcase = if hundert > 0 { "attr" } else { case };
447
448    let tausend = if thousands > 1 {
449        let head = to_cardinal_small(thousands, clazz, "attr")?;
450        // Direct dict access, not makecase (module docs, bug 7).
451        head + " " + cardinals_at(1000, tcase)?
452    } else {
453        makecase(1000, tcase, clazz)?
454    };
455
456    let rest = if hundert != 0 {
457        format!(" {}", to_cardinal_small(hundert, clazz, case)?)
458    } else {
459        String::new()
460    };
461
462    Ok(tausend + &rest)
463}
464
465fn pow10(n: u32) -> BigInt {
466    BigInt::from(10).pow(n)
467}
468
469// ---------------------------------------------------------------------------
470// The float path's one hard dependency: `repr(float)`.
471// ---------------------------------------------------------------------------
472
473/// CPython's `repr(float)` for a **non-negative, finite** `f64`.
474///
475/// # Why this exists at all
476///
477/// `floatpath.rs` deliberately refuses to reimplement `repr(float)` and takes
478/// `precision` from the Python side instead, because `base.float2tuple` only
479/// ever needs the *digit count*. CE is different in kind: its float branch is
480///
481/// ```python
482/// float_part = str(abs_number).split(".")[1]
483/// ```
484///
485/// — it slices the repr **string**. There is no digit count to be handed; the
486/// characters themselves are the input, and the two ways the slice can fail
487/// (no `"."` at all → `IndexError`; an `"e"` after the `"."` → `ValueError`)
488/// are reachable, observable behaviour. So the repr has to be reconstructed.
489///
490/// # How it is kept honest
491///
492/// Two independent facts are extracted, each from the tool that gets it right:
493///
494/// * **Shape** — `{:e}` gives the shortest round-trip digits and the decimal
495///   exponent. From those, `decpt` (the decimal-point position) and the
496///   significant-digit count, which drive CPython's fixed-vs-exponent choice in
497///   `pystrtod.c:format_float_short`, format code `'r'`:
498///
499///   ```c
500///   /* convert to exponential format at 1e16 */
501///   if (decpt <= -4 || decpt > 16)
502///       use_exp = 1;
503///   ```
504///
505/// * **Digits** — the fixed branch re-renders with `{:.prec$}`, *not* with the
506///   `{:e}` digits. This is not a stylistic choice. Rust's shortest-repr and
507///   CPython's `_Py_dg_dtoa` mode 0 disagree on **exact ties**: the double
508///   nearest `1658206780088562.25` sits equidistant between the 17-digit
509///   candidates `…562.2` and `…562.3`, and CPython emits `.2` where Rust's
510///   `{}`/`{:e}` emit `.3`. `{:.1}` is a different code path (`format_exact`)
511///   that rounds the *exact* binary value half-to-even, and agrees with CPython.
512///   Measured, not assumed: over ~300k doubles — random bit patterns,
513///   subnormals, `f64::MAX`, and the tie-prone `n/8 + 1e14` / `n/4 + 1e15`
514///   families that expose the disagreement — this function matches `repr(v)`
515///   byte for byte, while the naive `{:e}` reconstruction misses 65 of them.
516///
517/// A tie can only move the *last* digit, never `decpt` or the digit count, so
518/// taking the shape from `{:e}` is safe even though its digits are not.
519///
520/// `precision` is derived here rather than taken from `FloatValue`, matching
521/// Python: CE reads `str(abs_number)`, never `self.precision` (module docs,
522/// bug 10).
523fn python_repr(x: f64) -> String {
524    // CPython special-cases zero; `Py_DTSF_ADD_DOT_0` makes it "0.0".
525    if x == 0.0 {
526        return "0.0".to_string();
527    }
528
529    // "2.675e0", "1e21", "5e-1" — mantissa, then the decimal exponent.
530    let sci = format!("{:e}", x);
531    let (mant, exp_str) = match sci.split_once('e') {
532        Some(parts) => parts,
533        // Unreachable: `LowerExp` always emits `e<exp>` for finite input, and
534        // callers reject NaN/inf before getting here (`int()` raises on those
535        // first). Degrade to the raw form rather than panicking.
536        None => return sci.clone(),
537    };
538    let exp: i32 = exp_str.parse().unwrap_or(0);
539    let digits: String = mant.chars().filter(|c| c.is_ascii_digit()).collect();
540    let ndigits = digits.chars().count() as i32;
541
542    // `{:e}` normalises to one digit before the point, so its exponent is
543    // `decpt - 1`.
544    let decpt = exp + 1;
545
546    if decpt <= -4 || decpt > 16 {
547        // Exponential form: `d[.ddd]e±XX`. The dot appears only when there is
548        // more than one significant digit — which is exactly what decides
549        // IndexError vs ValueError upstream — and the exponent is signed and
550        // zero-padded to at least two places.
551        let e = decpt - 1;
552        let mut it = digits.chars();
553        let mut s = String::new();
554        if let Some(first) = it.next() {
555            s.push(first);
556        }
557        let rest: String = it.collect();
558        if !rest.is_empty() {
559            s.push('.');
560            s.push_str(&rest);
561        }
562        s.push('e');
563        s.push(if e < 0 { '-' } else { '+' });
564        s.push_str(&format!("{:02}", e.abs()));
565        return s;
566    }
567
568    // Fixed form. The count of fractional digits the shortest repr carries is
569    // `ndigits - decpt`, floored at 1 because CPython always appends ".0" to a
570    // whole float (`Py_DTSF_ADD_DOT_0`) — which is also why the fixed branch
571    // always contains a ".", and so never raises IndexError.
572    let prec = std::cmp::max(ndigits - decpt, 1) as usize;
573    format!("{:.*}", prec, x)
574}
575
576// ---------------------------------------------------------------------------
577// Decimal arithmetic, for the arm where `isinstance(number, float)` is False.
578// ---------------------------------------------------------------------------
579
580/// `10**n` for an exponent taken from a parsed decimal's scale.
581///
582/// The scale originates in the decimal literal the caller parsed, so it is
583/// bounded by that literal's length; a scale that does not fit `u32` would
584/// describe a number with more than four billion digits, which cannot have been
585/// allocated in the first place.
586fn pow10_scale(n: i64) -> BigInt {
587    pow10(u32::try_from(n).unwrap_or(0))
588}
589
590/// Python's `Decimal.__floordiv__` against a positive integer, for `a >= 0`.
591///
592/// `Decimal`'s `//` truncates toward zero; every caller has already taken
593/// `abs`, so that is also the floor.
594///
595/// Deliberately *not* `a / b` followed by a truncation: bigdecimal's `Div`
596/// rounds at a fixed 100-significant-digit ceiling, which would silently drift
597/// on a long input. `a` is exactly `coeff * 10**-scale`, so one exact `BigInt`
598/// division of `coeff` by `b * 10**scale` gives the quotient with no rounding
599/// step to get wrong.
600fn dec_floordiv(a: &BigDecimal, b: &BigInt) -> BigDecimal {
601    let (coeff, scale) = a.as_bigint_and_exponent();
602    let (num, den) = if scale >= 0 {
603        (coeff, b * pow10_scale(scale))
604    } else {
605        (coeff * pow10_scale(-scale), b.clone())
606    };
607    // Both operands are non-negative here, so BigInt's truncating division is
608    // the floor division Python performs.
609    BigDecimal::from(num / den)
610}
611
612/// Python's `Decimal.__mod__` against a positive integer, for `a >= 0`:
613/// `a - b * (a // b)`.
614///
615/// Python performs `%` as one operation and `_fix`es the result, so the
616/// remainder is rounded to 28 significant digits — not just returned exact.
617/// That rounding is load-bearing at the top of the illions range: the exact
618/// remainder of a 30-digit input can carry 29+ significant digits, and Python
619/// drops the last one before the recursion subscripts it, changing *which*
620/// `Decimal` the eventual `KeyError` names. Measured, not assumed:
621///
622/// ```text
623/// >>> Decimal("369019905581504987656260366567E-26") % 1000
624/// Decimal("690.1990558150498765626036657")   # 28 sig, exact would be 29
625/// ```
626fn dec_rem(a: &BigDecimal, b: &BigInt) -> BigDecimal {
627    let exact = a - BigDecimal::from(b.clone()) * dec_floordiv(a, b);
628    dec_fix(&exact)
629}
630
631/// `decimal.getcontext().prec` — the default context's significant-digit limit.
632const DECIMAL_CONTEXT_PREC: usize = 28;
633
634/// Round `n / 10**drop` half-to-even, for `n >= 0`. The rounding `_fix` applies
635/// under the default context, whose `rounding` is `ROUND_HALF_EVEN`.
636fn round_half_even(n: &BigInt, drop: u32) -> BigInt {
637    let pow = pow10(drop);
638    let (q, r) = n.div_rem(&pow);
639    let twice: BigInt = &r * 2;
640    match twice.cmp(&pow) {
641        std::cmp::Ordering::Less => q,
642        std::cmp::Ordering::Greater => q + 1,
643        // Exact half: keep the even quotient.
644        std::cmp::Ordering::Equal => {
645            if q.is_even() {
646                q
647            } else {
648                q + 1
649            }
650        }
651    }
652}
653
654/// The coefficient-rounding half of Python's `Decimal._fix` under the default
655/// context: round a non-negative value to at most 28 significant digits,
656/// half-to-even.
657///
658/// Every arithmetic operation in Python's `decimal` runs its result through
659/// `_fix`, so this is applied wherever CE does Decimal arithmetic that can grow
660/// past 28 digits — [`dec_rem`] and [`dec_abs`]. `_fix`'s other job, clamping
661/// the exponent to `Etiny`/`Etop`, cannot fire here: the default context's
662/// exponent range is ±999999 and CE only reaches this for `number < 10**34`.
663///
664/// The inputs are always non-negative (callers `abs` first), so the sign is not
665/// modelled.
666fn dec_fix(v: &BigDecimal) -> BigDecimal {
667    let (coeff, scale) = v.as_bigint_and_exponent();
668    // `Decimal("0")` has one significant digit by Python's count; a bare zero
669    // coefficient needs no rounding regardless.
670    let ndigits = coeff.to_string().chars().count();
671    if ndigits <= DECIMAL_CONTEXT_PREC {
672        return BigDecimal::new(coeff, scale);
673    }
674
675    let drop = (ndigits - DECIMAL_CONTEXT_PREC) as u32;
676    let rounded = round_half_even(&coeff, drop);
677    let new_scale = scale - drop as i64;
678
679    // `_fix`: a carry can widen the coefficient past prec (28 nines round up to
680    // 10**28). Python drops the now-trailing zero and bumps the exponent —
681    //     if len(self._int) > context.prec:
682    //         self_int = self_int[:-1]
683    //         exp += 1
684    // — which is exact, since the carry guarantees that last digit is a zero.
685    if rounded.to_string().chars().count() > DECIMAL_CONTEXT_PREC {
686        return BigDecimal::new(rounded / 10, new_scale - 1);
687    }
688    BigDecimal::new(rounded, new_scale)
689}
690
691/// Python's `abs(Decimal)` — **which is not a sign flip**.
692///
693/// `Decimal.__abs__` does not return `copy_abs()`; it routes through
694/// `__neg__`/`__pos__`, and both end in `_fix(context)`:
695///
696/// ```python
697/// def __abs__(self, round=True, context=None):
698///     if not round:
699///         return self.copy_abs()
700///     ...
701///     if self._sign:
702///         ans = self.__neg__(context=context)
703///     else:
704///         ans = self.__pos__(context=context)
705///     return ans
706/// ```
707///
708/// So `abs()` **rounds to the context's 28 significant digits**, and a Decimal
709/// wider than that silently loses its tail:
710///
711/// ```text
712/// >>> abs(Decimal("-8938465034294554016749591177719.10"))
713/// Decimal("8.938465034294554016749591178E+30")     # the ".10" is gone
714/// ```
715///
716/// `Num2Word_CE.to_cardinal` reaches this on its very first branch
717/// (`elif number < 0: ... self.to_cardinal(abs(number), ...)`), which makes the
718/// sign observable in the *output*, not just the minus word: a 33-digit
719/// negative renders as a clean integer sentence, while the identical positive
720/// keeps its fraction and dies on a `KeyError`. Reproduced rather than
721/// simplified — `copy_abs()` here would be the tidy answer and the wrong one.
722fn dec_abs(v: &BigDecimal) -> BigDecimal {
723    dec_fix(&v.abs())
724}
725
726/// `repr(Decimal)`, i.e. `"Decimal('%s')" % str(self)`.
727///
728/// Python puts the `Decimal` object itself in the `KeyError`, and `str(exc)` is
729/// `repr(args[0])` — so `CARDINALS[Decimal("9.99")]` surfaces as the message
730/// `Decimal('9.99')`, quotes and all.
731///
732/// `str(Decimal)` is the spec's to-scientific-string conversion, ported from
733/// `_pydecimal.Decimal.__str__`: fixed notation only when the exponent is
734/// non-positive *and* the value has not shrunk past 1e-6, else scientific with
735/// one digit ahead of the point. `context.capitals` defaults to 1, hence the
736/// upper-case `E`. Trailing zeros are significant and survive
737/// (`Decimal("1.10")` prints "1.10"), which `BigDecimal`'s scale preserves.
738fn decimal_repr(v: &BigDecimal) -> String {
739    let (coeff, scale) = v.as_bigint_and_exponent();
740    let sign = if coeff.is_negative() { "-" } else { "" };
741    // Decimal's `_int` (the coefficient digits) and `_exp`.
742    let int_digits = coeff.abs().to_string();
743    let ndigits = int_digits.chars().count() as i64;
744    let exp = -scale;
745    let leftdigits = exp + ndigits;
746
747    let dotplace = if exp <= 0 && leftdigits > -6 {
748        leftdigits
749    } else {
750        1
751    };
752
753    let (intpart, fracpart) = if dotplace <= 0 {
754        (
755            "0".to_string(),
756            format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
757        )
758    } else if dotplace >= ndigits {
759        (
760            format!(
761                "{}{}",
762                int_digits,
763                "0".repeat((dotplace - ndigits) as usize)
764            ),
765            String::new(),
766        )
767    } else {
768        let head: String = int_digits.chars().take(dotplace as usize).collect();
769        let tail: String = int_digits.chars().skip(dotplace as usize).collect();
770        (head, format!(".{}", tail))
771    };
772
773    let exp_part = if leftdigits == dotplace {
774        String::new()
775    } else {
776        let e = leftdigits - dotplace;
777        format!("E{}{}", if e < 0 { "-" } else { "+" }, e.abs())
778    };
779
780    format!("Decimal('{}{}{}{}')", sign, intpart, fracpart, exp_part)
781}
782
783/// `CARDINALS[number]` where `number` is a `Decimal` — resolved to the `int`
784/// key Python's dict lookup would land on.
785///
786/// The tables are keyed by `int`, but Python hashes the `Decimal`: for an
787/// integral value `hash(Decimal("80.00")) == hash(80)` and the two compare
788/// equal, so the lookup *succeeds*. A non-integral `Decimal` matches no key and
789/// raises `KeyError` naming its `repr` — which is how every `cardinal_dec` row
790/// in the corpus ends up.
791fn cardinals_key_dec(n: &BigDecimal) -> Result<u64> {
792    CARDINALS
793        .iter()
794        .map(|(k, _)| *k)
795        // BigDecimal's PartialEq aligns scales before comparing, so this is the
796        // numeric equality Python's dict relies on, not a representation test.
797        .find(|k| BigDecimal::from(*k) == *n)
798        .ok_or_else(|| N2WError::Key(decimal_repr(n)))
799}
800
801/// `self.makecase(number, case, clazz)` with a `Decimal` `number`.
802///
803/// Resolving the key first and delegating to the integer [`makecase`] is the
804/// same lookup Python performs, and keeps the suffix logic in one place.
805fn makecase_dec(number: &BigDecimal, case: &str, clazz: &str) -> Result<String> {
806    makecase(cardinals_key_dec(number)?, case, clazz)
807}
808
809/// Python's `Num2Word_Base.verify_ordinal`, integer half.
810///
811/// The float check (`errmsg_floatord`) cannot fire for `BigInt` input, so only
812/// the negative guard survives. `to_ordinal_num` is the *only* caller — note
813/// that `to_ordinal` skips it entirely (module docs, bug 6).
814fn verify_ordinal(value: &BigInt) -> Result<()> {
815    if value.is_negative() {
816        // errmsg_negord = "Cannot treat negative num %s as ordinal."
817        return Err(N2WError::Type(format!(
818            "Cannot treat negative num {} as ordinal.",
819            value
820        )));
821    }
822    Ok(())
823}
824
825/// A string-valued kwarg with a Python-signature default.
826///
827/// Absent -> the default, exactly as the signature would bind it. Present as
828/// a string -> that string, whatever it is — Python performs no validation up
829/// front; a bad `case` only explodes later, inside a dict subscript. Present
830/// as anything else (an explicit `None`, an int, ...) -> NotImplemented, so
831/// the dispatcher falls back to Python and the original raise (a `TypeError`
832/// from `str.replace(…, None)`, a `KeyError: None`, ...) happens there with
833/// its own message.
834fn kw_str<'a>(kw: &'a Kwargs, key: &str, default: &'a str) -> Result<&'a str> {
835    match kw.get(key) {
836        None => Ok(default),
837        Some(KwVal::Str(s)) => Ok(s.as_str()),
838        Some(_) => Err(N2WError::Fallback("kwargs".into())),
839    }
840}
841
842pub struct LangCe {
843    currency_forms: HashMap<&'static str, CurrencyForms>,
844    currency_adjectives: HashMap<&'static str, &'static str>,
845}
846
847impl Default for LangCe {
848    fn default() -> Self {
849        Self::new()
850    }
851}
852
853impl LangCe {
854    pub fn new() -> Self {
855        LangCe {
856            // Built once here, never per call. `to_currency` only reads these,
857            // and rebuilding a table on every call is what made an earlier
858            // revision of this port slower than the Python it replaces.
859            currency_forms: build_currency_forms(),
860            currency_adjectives: build_currency_adjectives(),
861        }
862    }
863
864    /// The `isinstance(number, float)` branch of `Num2Word_CE.to_cardinal`.
865    ///
866    /// ```python
867    /// if isinstance(number, float):
868    ///     negative = number < 0
869    ///     abs_number = abs(number)
870    ///     entires = self.to_cardinal(int(abs_number))
871    ///     float_part = str(abs_number).split(".")[1]
872    ///     postfix = " ".join(
873    ///         [self.to_cardinal(int(c)) for c in float_part]
874    ///     )
875    ///     result = entires + " " + DECIMALPOINT + " " + postfix
876    ///     if negative:
877    ///         result = self.negword + " " + result
878    ///     return result
879    /// ```
880    ///
881    /// Nothing here touches `float2tuple`: no `10**precision` scaling, no
882    /// round-vs-floor heuristic, no zero-padding to `precision`. CE reads the
883    /// repr's digits directly, so `2.675` yields "675" because that is what
884    /// `repr` says — where `base.float2tuple` would compute `674.9999999999998`
885    /// and lean on its `< 0.01` rescue to get back to the same place.
886    ///
887    /// Neither `clazz` nor `case` is threaded into the two recursive calls
888    /// (module docs, bug 9), so both parts always render at the defaults.
889    ///
890    /// The comment above `postfix` in the source — "Drops the trailing zero and
891    /// comma" — describes nothing the code does; every fractional digit is
892    /// emitted, trailing zeros included. `1.10` is not reachable as a float
893    /// (`repr(1.10)` is "1.1"), which is presumably how the claim survived.
894    fn cardinal_float(&self, number: f64) -> Result<String> {
895        // `negative = number < 0` — False for -0.0 and for NaN, both of which
896        // Python's `<` answers the same way.
897        let negative = number < 0.0;
898        let abs_number = number.abs();
899
900        // `entires = self.to_cardinal(int(abs_number))` runs *before* the
901        // split, so `int()`'s own failures win over the IndexError below.
902        if abs_number.is_nan() {
903            return Err(N2WError::Value("cannot convert float NaN to integer".into()));
904        }
905        if abs_number.is_infinite() {
906            return Err(N2WError::Overflow(
907                "cannot convert float infinity to integer".into(),
908            ));
909        }
910        // `BigInt::from_f64` truncates toward zero and is exact — the same
911        // contract as `int(float)`. It only returns None for non-finite input,
912        // which the two guards above have already rejected.
913        let ipart = BigInt::from_f64(abs_number).ok_or_else(|| {
914            N2WError::Value(format!("cannot convert float {} to integer", abs_number))
915        })?;
916        let entires = self.cardinal(&ipart, DEFAULT_CLAZZ, "abs")?;
917
918        // `float_part = str(abs_number).split(".")[1]`.
919        //
920        // A repr with no "." is an IndexError, and that is a live path, not a
921        // theoretical one: `repr(1e16)` is "1e+16", so `num2words(1e16,
922        // lang="ce")` raises. Fixed-form reprs always carry a ".", so this only
923        // fires in exponential form with a single significant digit.
924        let repr = python_repr(abs_number);
925        let float_part = repr
926            .split_once('.')
927            .map(|(_, frac)| frac)
928            .ok_or_else(|| N2WError::Index("list index out of range".into()))?;
929
930        // `postfix = " ".join([self.to_cardinal(int(c)) for c in float_part])`.
931        //
932        // The other exponential-form outcome lands here: `repr(1.5e21)` is
933        // "1.5e+21", so `float_part` is "5e+21" and `int("e")` raises
934        // ValueError on the second character — after "5" has already been
935        // converted, which the eager list comprehension discards.
936        let mut postfix: Vec<String> = Vec::new();
937        for c in float_part.chars() {
938            let digit = c.to_digit(10).ok_or_else(|| {
939                N2WError::Value(format!(
940                    "invalid literal for int() with base 10: '{}'",
941                    c
942                ))
943            })?;
944            postfix.push(self.cardinal(&BigInt::from(digit), DEFAULT_CLAZZ, "abs")?);
945        }
946
947        // `entires + " " + DECIMALPOINT + " " + postfix` — the module constant,
948        // not `self.pointword`.
949        let result = format!("{} {} {}", entires, DECIMALPOINT, postfix.join(" "));
950        if negative {
951            // `self.negword + " " + result`, with no `.strip()`.
952            return Ok(format!("{} {}", NEGWORD, result));
953        }
954        Ok(result)
955    }
956
957    /// `Num2Word_CE.to_cardinal` reached with a `Decimal`.
958    ///
959    /// There is no Decimal branch. `isinstance(number, float)` is False for a
960    /// `Decimal`, so it falls straight through into the *integer* branches and
961    /// they run on Decimal arithmetic — comparisons, `//`, `%` and all. The
962    /// walk only breaks when a non-integral value reaches a `CARDINALS[...]`
963    /// subscript, because an integral `Decimal` hashes and compares equal to
964    /// its `int` key and a fractional one matches nothing.
965    ///
966    /// So the failure is a `KeyError`, and *where* it lands depends on how far
967    /// the fraction is carried. Both corpus shapes are reproduced:
968    ///
969    /// ```text
970    /// Decimal("0.01")              -> KeyError: Decimal('0.01')
971    ///     `< 20` on the first hop, so the input itself is the key.
972    /// Decimal("98746251323029.99") -> KeyError: Decimal('9.99')
973    ///     illions -> `% 10**6` -> 323029.99 -> `% 1000` -> 29.99 ->
974    ///     `% 20` -> 9.99, which is finally `< 20`. The fraction rides the
975    ///     remainders all the way down and only the last hop subscripts it.
976    /// ```
977    ///
978    /// An *integral* Decimal has no such problem and converts normally —
979    /// `Decimal("5")` is "пхиъ" — which is why this cannot just raise.
980    ///
981    /// Python's Decimal context (28 significant digits) cannot bite inside the
982    /// reachable range: the widest intermediate is `number // 10**6`, and
983    /// `number < 10**34` bounds it at 28 digits exactly.
984    fn cardinal_decimal(&self, number: &BigDecimal, clazz: &str, case: &str) -> Result<String> {
985        // elif number < 0
986        if number.is_negative() {
987            // `abs(number)` — Python's, which rounds to 28 significant digits.
988            // See [`dec_abs`]; `number.abs()` here would be a silent divergence.
989            let inner = self.cardinal_decimal(&dec_abs(number), clazz, case)?;
990            return Ok(format!("{} {}", NEGWORD, inner));
991        }
992
993        // elif number < 20
994        if *number < BigDecimal::from(20u64) {
995            return makecase_dec(number, case, clazz);
996        }
997
998        // elif number < 100
999        if *number < BigDecimal::from(100u64) {
1000            let twenty = BigInt::from(20);
1001            let twens = dec_floordiv(number, &twenty);
1002            let units = dec_rem(number, &twenty);
1003            let base = twens * BigDecimal::from(20u64);
1004            if units.is_zero() {
1005                return makecase_dec(number, case, clazz);
1006            }
1007            let twenties = makecase_dec(&base, "attr", clazz)?;
1008            let rest = self.cardinal_decimal(&units, clazz, case)?;
1009            return Ok(twenties + " " + &rest.replace(MASK, clazz));
1010        }
1011
1012        // elif number < 1000
1013        if *number < BigDecimal::from(1000u64) {
1014            let hundreds = dec_floordiv(number, &BigInt::from(100));
1015            let tens = dec_rem(number, &BigInt::from(100));
1016            let hundert = if hundreds > BigDecimal::one() {
1017                // CARDINALS[hundreds]["attr"] — `hundreds` is a Decimal, but an
1018                // integral one, so it resolves to its int key.
1019                cardinals_at(cardinals_key_dec(&hundreds)?, "attr")?.replace(MASK, clazz) + " "
1020            } else {
1021                String::new()
1022            };
1023            if !tens.is_zero() {
1024                let rest = self.cardinal_decimal(&tens, clazz, case)?;
1025                return Ok(hundert + cardinals_at(100, "abs")? + " " + &rest);
1026            }
1027            return Ok(hundert + &makecase(100, case, clazz)?);
1028        }
1029
1030        // elif number < 1000000
1031        if *number < BigDecimal::from(1_000_000u64) {
1032            let thousands = dec_floordiv(number, &BigInt::from(1000));
1033            let hundert = dec_rem(number, &BigInt::from(1000));
1034            let tcase = if hundert > BigDecimal::zero() { "attr" } else { case };
1035
1036            let tausend = if thousands > BigDecimal::one() {
1037                let head = self.cardinal_decimal(&thousands, clazz, "attr")?;
1038                // Literal 1000, so this is the integer subscript either way.
1039                head + " " + cardinals_at(1000, tcase)?
1040            } else {
1041                makecase(1000, tcase, clazz)?
1042            };
1043
1044            let rest = if !hundert.is_zero() {
1045                format!(" {}", self.cardinal_decimal(&hundert, clazz, case)?)
1046            } else {
1047                String::new()
1048            };
1049            return Ok(tausend + &rest);
1050        }
1051
1052        // elif number < 10**34
1053        if *number < BigDecimal::from(pow10(34)) {
1054            let mut out: Vec<String> = Vec::new();
1055            for &pot in POTS_DESC.iter() {
1056                // step = number // 10**pot % 1000 — `//` binds first.
1057                let step = dec_rem(&dec_floordiv(number, &pow10(pot)), &BigInt::from(1000));
1058                if step > BigDecimal::zero() {
1059                    let words = self.cardinal_decimal(&step, clazz, "attr")?;
1060                    let illion = ILLIONS
1061                        .iter()
1062                        .find(|(k, _)| *k == pot)
1063                        .map(|(_, w)| *w)
1064                        .ok_or_else(|| key_error(pot))?;
1065                    out.push(format!("{} {}", words, illion));
1066                }
1067            }
1068            let rest = dec_rem(number, &pow10(6));
1069            // `if rest:` — Decimal truthiness, so Decimal("0.00") is falsy.
1070            if !rest.is_zero() {
1071                out.push(self.cardinal_decimal(&rest, clazz, case)?);
1072            }
1073            return Ok(out.join(" "));
1074        }
1075
1076        Ok("NOT IMPLEMENTED".to_string())
1077    }
1078
1079    /// Python's `Num2Word_CE.to_cardinal(number, clazz, case)`, integer path.
1080    ///
1081    /// The `isinstance(number, float)` branch lives in [`Self::cardinal_float`];
1082    /// everything from `elif number < 0` down is reproduced here in order.
1083    fn cardinal(&self, number: &BigInt, clazz: &str, case: &str) -> Result<String> {
1084        // elif number < 0
1085        if number.is_negative() {
1086            let inner = self.cardinal(&number.abs(), clazz, case)?;
1087            return Ok(format!("{} {}", NEGWORD, inner));
1088        }
1089
1090        // The four sub-10^6 branches. `number` is non-negative and bounded by
1091        // 10^6 here, so it provably fits u64.
1092        let million = pow10(6);
1093        if *number < million {
1094            let n = number
1095                .to_u64()
1096                .ok_or_else(|| N2WError::Value("value below 10^6 must fit u64".into()))?;
1097            return to_cardinal_small(n, clazz, case);
1098        }
1099
1100        // elif number < 10**34
1101        if *number < pow10(34) {
1102            let mut out: Vec<String> = Vec::new();
1103            for &pot in POTS_DESC.iter() {
1104                // step = number // 10**pot % 1000 — `number` is positive, so
1105                // BigInt's truncating division agrees with Python's floor.
1106                let step = (number / pow10(pot)) % 1000u32;
1107                if step > BigInt::zero() {
1108                    let s = step
1109                        .to_u64()
1110                        .ok_or_else(|| N2WError::Value("step below 1000 must fit u64".into()))?;
1111                    let words = to_cardinal_small(s, clazz, "attr")?;
1112                    let illion = ILLIONS
1113                        .iter()
1114                        .find(|(k, _)| *k == pot)
1115                        .map(|(_, w)| *w)
1116                        .ok_or_else(|| key_error(pot))?;
1117                    out.push(format!("{} {}", words, illion));
1118                }
1119            }
1120            // Only the sub-10^6 remainder ever sees `case` (module docs, bug 3).
1121            let rest = number % &million;
1122            if !rest.is_zero() {
1123                let r = rest
1124                    .to_u64()
1125                    .ok_or_else(|| N2WError::Value("remainder below 10^6 must fit u64".into()))?;
1126                out.push(to_cardinal_small(r, clazz, case)?);
1127            }
1128            return Ok(out.join(" "));
1129        }
1130
1131        // The literal fall-through for number >= 10**34. Not an OverflowError:
1132        // Python returns this as an ordinary string.
1133        Ok("NOT IMPLEMENTED".to_string())
1134    }
1135}
1136
1137impl Lang for LangCe {
1138
1139    fn python_maxval(&self) -> Option<num_bigint::BigInt> {
1140        // Python class attribute MAXVAL (self-contained converter).
1141        Some(num_bigint::BigInt::from(10u32).pow(606))
1142    }
1143    /// This language's own `to_currency(currency=...)` default,
1144    /// read from the live Python signature. Only 44 of 156 use EUR.
1145    fn default_currency(&self) -> &str {
1146        "RUB"
1147    }
1148
1149    /// This language's own `to_currency(separator=...)` default,
1150    /// read from the live Python signature. Base's is ",", but only
1151    /// 36 of 149 languages actually use it — most default to " " or a
1152    /// conjunction, so inheriting Base's comma silently corrupts them.
1153    fn default_separator(&self) -> &str {
1154        ","
1155    }
1156
1157    fn negword(&self) -> &str {
1158        NEGWORD
1159    }
1160
1161    /// What `setup()` assigns — reported for fidelity, never used. CE's float
1162    /// path joins on [`DECIMALPOINT`] instead (see [`POINTWORD`]).
1163    fn pointword(&self) -> &str {
1164        POINTWORD
1165    }
1166
1167    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
1168        self.cardinal(value, DEFAULT_CLAZZ, "abs")
1169    }
1170
1171    /// CE does not inherit `Num2Word_Base.to_cardinal_float` — it overrides
1172    /// `to_cardinal` and dispatches on `isinstance(number, float)` inline, so
1173    /// this hook routes to whichever arm Python's type test would pick.
1174    ///
1175    /// `precision_override` is discarded on purpose: the dispatcher's
1176    /// `precision=` kwarg assigns `self.precision`, and no CE code path reads it
1177    /// (module docs, bug 10). Honouring it here would invent behaviour Python
1178    /// does not have.
1179    fn to_cardinal_float(
1180        &self,
1181        value: &FloatValue,
1182        _precision_override: Option<u32>,
1183    ) -> Result<String> {
1184        match value {
1185            // `precision` goes unread for the same reason: CE derives its digits
1186            // from `str(abs_number)`, not from `float2tuple`.
1187            FloatValue::Float { value, .. } => self.cardinal_float(*value),
1188            // A Decimal fails `isinstance(number, float)` and falls through into
1189            // the integer branches, running on Decimal arithmetic.
1190            FloatValue::Decimal { value, .. } => self.cardinal_decimal(value, DEFAULT_CLAZZ, "abs"),
1191        }
1192    }
1193
1194    /// `to_cardinal(float/Decimal)`, full routing.
1195    ///
1196    /// The base default short-circuits a whole value to the integer path —
1197    /// which is exactly what CE does **not** do. Python's `to_cardinal` tests
1198    /// `isinstance(number, float)` before anything else, so `1.0` takes the
1199    /// float branch and renders its repr's fractional digit: "цхьаъ а ноль",
1200    /// never plain "цхьаъ". A whole `Decimal` conversely never sees the float
1201    /// branch and resolves through the integer branches' dict lookups. Both
1202    /// arms already live in [`Lang::to_cardinal_float`] above; this override
1203    /// only removes the whole-value shortcut in front of them.
1204    fn cardinal_float_entry(
1205        &self,
1206        value: &FloatValue,
1207        precision_override: Option<u32>,
1208    ) -> Result<String> {
1209        self.to_cardinal_float(value, precision_override)
1210    }
1211
1212    /// `to_ordinal(float/Decimal)` — `to_cardinal(number, clazz, case="ORD")`.
1213    ///
1214    /// For a float the `case` dies in the float branch (module docs, bug 9),
1215    /// so `to_ordinal(5.0)` is the plain cardinal "пхиъ а ноль". A Decimal
1216    /// carries "ORD" through the integer branches, so `to_ordinal(Decimal
1217    /// ("5.0"))` really is "пхоьалгӀа" — same call, opposite outcome, decided
1218    /// solely by `isinstance`. No `verify_ordinal` anywhere (bug 6), so
1219    /// negatives render with the minus word and `1e16` still IndexErrors out
1220    /// of the repr slice.
1221    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
1222        match value {
1223            FloatValue::Float { value, .. } => self.cardinal_float(*value),
1224            FloatValue::Decimal { value, .. } => self.cardinal_decimal(value, DEFAULT_CLAZZ, "ORD"),
1225        }
1226    }
1227
1228    /// `to_ordinal_num(float/Decimal)` — `verify_ordinal(number)`, then
1229    /// `str(number) + "-й"`.
1230    ///
1231    /// This is the one CE entry point that *does* call `verify_ordinal`, and
1232    /// Base's runs both checks on the original value:
1233    ///
1234    /// ```python
1235    /// if not value == int(value):
1236    ///     raise TypeError(self.errmsg_floatord % value)   # 0.5, Decimal("1.5")
1237    /// if not abs(value) == value:
1238    ///     raise TypeError(self.errmsg_negord % value)     # -3.0
1239    /// ```
1240    ///
1241    /// Both comparisons are numeric, so `-0.0` (and `Decimal("-0.0")`) pass —
1242    /// `abs(-0.0) == -0.0` is True — and come out as "-0.0-й", sign intact,
1243    /// because the suffix is glued onto `str(number)`, which is `repr_str`
1244    /// here. A whole huge float sails through too: `int(1e20)` is exact, so
1245    /// "1e+20-й" with the exponent form verbatim. `%s` renders the value the
1246    /// same way, hence `repr_str` in both messages.
1247    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
1248        let whole = value.as_whole_int().ok_or_else(|| {
1249            N2WError::Type(format!("Cannot treat float {} as ordinal.", repr_str))
1250        })?;
1251        if whole.is_negative() {
1252            return Err(N2WError::Type(format!(
1253                "Cannot treat negative num {} as ordinal.",
1254                repr_str
1255            )));
1256        }
1257        Ok(format!("{}-й", repr_str))
1258    }
1259
1260    /// `to_year(float/Decimal)` — `to_cardinal(year, case="abs")`, i.e. the
1261    /// exact cardinal routing: floats through the float branch (whole values
1262    /// included), Decimals through the integer branches.
1263    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
1264        self.cardinal_float_entry(value, None)
1265    }
1266
1267
1268    /// `converter.str_to_number` is Base's plain `Decimal(value)` — CE does
1269    /// not override it. The parse itself is therefore the default, but the
1270    /// *outcomes* for the two non-finite specials diverge from what the
1271    /// binding's generic Inf/NaN mapping assumes, because CE's `to_cardinal`
1272    /// never calls `int()`:
1273    ///
1274    /// * `Decimal("Infinity")` falls through every `<` comparison to the
1275    ///   literal `return "NOT IMPLEMENTED"` (and "-Infinity" to
1276    ///   "минус NOT IMPLEMENTED" via the negative branch) — a *string*, not
1277    ///   the OverflowError other languages raise.
1278    /// * `Decimal("NaN")` dies on the very first comparison, `number < 0`,
1279    ///   with `decimal.InvalidOperation` — not the ValueError from `int()`.
1280    ///
1281    /// Neither is expressible through `ParsedNumber` (a `BigDecimal` cannot
1282    /// hold Inf/NaN), so the parse itself is the plain default and the two
1283    /// specials are served natively by the [`Lang::inf_result`] /
1284    /// [`Lang::nan_result`] hooks below — no Python fallback.
1285    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
1286        python_decimal_parse(s)
1287    }
1288
1289    /// `Decimal("Infinity")` / `-Infinity`. CE's `to_cardinal` never calls
1290    /// `int()`: the value falls through every `<` comparison to the literal
1291    /// `return "NOT IMPLEMENTED"`, and the negative branch prepends
1292    /// `self.negword + " "` — a *string*, not the OverflowError the base path
1293    /// raises. Every mode routes through `to_cardinal` (`to_ordinal`/`to_year`
1294    /// forward to it, and the fall-through ignores `case`), so `to` is unread.
1295    fn inf_result(&self, negative: bool, _to: &str) -> Result<String> {
1296        if negative {
1297            Ok(format!("{} NOT IMPLEMENTED", NEGWORD))
1298        } else {
1299            Ok("NOT IMPLEMENTED".to_string())
1300        }
1301    }
1302
1303    /// `Decimal("NaN")`. CE's `to_cardinal` dies on its very first branch,
1304    /// `number < 0`, because `Decimal("NaN") < 0` raises
1305    /// `decimal.InvalidOperation` — not the ValueError of `int(NaN)`. No digit
1306    /// is present in "NaN", so the dispatcher propagates it natively.
1307    fn nan_result(&self, _to: &str) -> Result<String> {
1308        Err(N2WError::Custom {
1309            module: "decimal",
1310            class: "InvalidOperation",
1311            msg: "[<class 'decimal.InvalidOperation'>]".into(),
1312        })
1313    }
1314
1315    /// `Decimal("-0.0")`. `BigDecimal` cannot carry the sign, so the binding
1316    /// would demote it to `Float{-0.0}` and CE's float branch would render
1317    /// "ноль а ноль". But `Decimal("-0.0")` is *not* a float in Python: it
1318    /// falls through the integer branches, `number < 20` is true, and the
1319    /// `CARDINALS` lookup hashes it to the integer key `0` — so it renders as
1320    /// plain zero. cardinal/year run at `case="abs"` ("ноль"), ordinal at
1321    /// `case="ORD"` ("нольалгӀа"). `ordinal_num` is left to the demoted float
1322    /// path (returns `None`): it echoes `str(number)` == "-0.0" as "-0.0-й",
1323    /// which the repr-carrying float hook reproduces and this one cannot.
1324    fn neg_zero_decimal(&self, to: &str) -> Option<Result<String>> {
1325        let zero = BigInt::zero();
1326        match to {
1327            "cardinal" | "year" => Some(self.cardinal(&zero, DEFAULT_CLAZZ, "abs")),
1328            "ordinal" => Some(self.cardinal(&zero, DEFAULT_CLAZZ, "ORD")),
1329            _ => None,
1330        }
1331    }
1332
1333    // ---- grammatical kwargs ----------------------------------------------
1334    //
1335    // The Python signatures, verbatim:
1336    //
1337    //   to_cardinal(number, clazz="д", case="abs")
1338    //   to_ordinal(number, clazz="д")
1339    //   to_ordinal_num(number)            # no extras -> trait default
1340    //   to_year(year, case="abs")
1341    //   to_currency(val, currency="RUB", cents=True, separator=",",
1342    //               adjective=False, case="abs")   # see to_currency_kw above
1343    //
1344    // No value validation happens at the boundary: `clazz` is spliced into
1345    // the tables by `.replace("д*", clazz)` (so `clazz="x"` yields "xейтта"),
1346    // and `case` is a dict key that only fails inside `makecase`'s suffix
1347    // subscript — `case="bogus"` is the corpus's KeyError. An explicit
1348    // `None` (or any non-string) is NotImplemented via [`kw_str`] so Python
1349    // raises its own error.
1350
1351    /// `to_cardinal(number, clazz=..., case=...)`.
1352    fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1353        if !kw.only(&["clazz", "case"]) {
1354            return Err(N2WError::Fallback("kwargs".into()));
1355        }
1356        let clazz = kw_str(kw, "clazz", DEFAULT_CLAZZ)?;
1357        let case = kw_str(kw, "case", "abs")?;
1358        self.cardinal(value, clazz, case)
1359    }
1360
1361    /// `to_ordinal(number, clazz=...)` — no `case` in this signature; it is
1362    /// pinned to "ORD" by the forwarding call.
1363    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1364        if !kw.only(&["clazz"]) {
1365            return Err(N2WError::Fallback("kwargs".into()));
1366        }
1367        let clazz = kw_str(kw, "clazz", DEFAULT_CLAZZ)?;
1368        self.cardinal(value, clazz, "ORD")
1369    }
1370
1371    /// `to_year(year, case=...)` — no `clazz` in this signature; the
1372    /// forwarding call leaves it at the default.
1373    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1374        if !kw.only(&["case"]) {
1375            return Err(N2WError::Fallback("kwargs".into()));
1376        }
1377        let case = kw_str(kw, "case", "abs")?;
1378        self.cardinal(value, DEFAULT_CLAZZ, case)
1379    }
1380
1381    /// `to_cardinal(float/Decimal, clazz=..., case=...)`. The float branch
1382    /// drops both kwargs on the floor (module docs, bug 9) but *accepts*
1383    /// them; only a Decimal actually feels them, riding the integer branches.
1384    fn to_cardinal_float_kw(
1385        &self,
1386        value: &FloatValue,
1387        _precision_override: Option<u32>,
1388        kw: &Kwargs,
1389    ) -> Result<String> {
1390        if !kw.only(&["clazz", "case"]) {
1391            return Err(N2WError::Fallback("kwargs".into()));
1392        }
1393        let clazz = kw_str(kw, "clazz", DEFAULT_CLAZZ)?;
1394        let case = kw_str(kw, "case", "abs")?;
1395        match value {
1396            FloatValue::Float { value, .. } => self.cardinal_float(*value),
1397            FloatValue::Decimal { value, .. } => self.cardinal_decimal(value, clazz, case),
1398        }
1399    }
1400
1401    /// `to_ordinal(number, clazz="д")` → `to_cardinal(number, clazz, case="ORD")`.
1402    /// Deliberately *not* guarded by `verify_ordinal` — negatives are accepted
1403    /// and rendered with the minus word (module docs, bug 6).
1404    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
1405        self.cardinal(value, DEFAULT_CLAZZ, "ORD")
1406    }
1407
1408    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
1409        verify_ordinal(value)?;
1410        Ok(format!("{}-й", value))
1411    }
1412
1413    /// `to_year(year, case="abs")` → `to_cardinal(year, case=case)`. No
1414    /// era/century handling at all; identical to `to_cardinal` by default.
1415    fn to_year(&self, value: &BigInt) -> Result<String> {
1416        self.cardinal(value, DEFAULT_CLAZZ, "abs")
1417    }
1418
1419    // ---- currency -------------------------------------------------------
1420    //
1421    // CE overrides `to_currency` wholesale and diverges from
1422    // `Num2Word_Base.to_currency` in three ways that are all corpus-visible:
1423    //
1424    //   1. **Integers print cents.** Base early-returns on
1425    //      `isinstance(val, int)` and skips the cents segment; CE has no such
1426    //      branch and passes `is_int_with_cents=False` instead, so `right` is
1427    //      a plain `0` that still renders. `to_currency(1, "EUR")` is
1428    //      "цхьа Евро, ноль Сент", not "цхьа Евро".
1429    //   2. **No `has_decimal` guard.** Base gates the cents segment behind
1430    //      `has_decimal or right > 0`. CE has no gate at all, so the flag is
1431    //      never read and `Decimal("5")` renders cents just like `5.00`.
1432    //   3. **`pluralize` is never called.** CE binds `devise = cr1[0]` and
1433    //      `centime = cr2[0]` once, up front — always the singular. So
1434    //      "ши Евро" (2), "дезткъе ткъайесна Сент" (99), and even the
1435    //      fractional branch, where Base would force the *plural* `cr2[1]`.
1436    //
1437    // `_cents_terse` is inherited from Base unchanged and CE's
1438    // CURRENCY_PRECISION is Base's empty dict, so `currency_precision` stays
1439    // at the trait default of 100 for every code and `cents_terse` at its
1440    // default zero-pad-to-2. Both verified live:
1441    //   to_currency(0.05, "USD", cents=False) == "ноль Доллар, 05 Сент"
1442
1443    fn lang_name(&self) -> &str {
1444        "Num2Word_CE"
1445    }
1446
1447    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1448        self.currency_forms.get(code)
1449    }
1450
1451    /// Inherited from `Num2Word_EUR` and unreachable — see
1452    /// [`build_currency_adjectives`].
1453    fn currency_adjective(&self, code: &str) -> Option<&str> {
1454        self.currency_adjectives.get(code).copied()
1455    }
1456
1457    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
1458    ///
1459    /// Inherited, and non-default (Base's raises NotImplementedError), so it is
1460    /// ported — but no CE path reaches it. `to_currency` takes `cr1[0]`/`cr2[0]`
1461    /// directly and `to_cheque` takes `cr1[-1]`; only `default_to_currency`
1462    /// calls `pluralize`, and CE overrides `to_currency` wholesale.
1463    ///
1464    /// Python indexes the tuple, so a one-form entry with `n != 1` would raise
1465    /// IndexError. All four CE entries carry two forms, so that cannot fire —
1466    /// mapped to `Index` rather than a panic so the exception type survives if
1467    /// the table ever changes.
1468    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1469        let form = if n.is_one() { 0 } else { 1 };
1470        forms
1471            .get(form)
1472            .cloned()
1473            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
1474    }
1475
1476    /// `Num2Word_CE._money_verbose(self, number, currency, case)` — **three**
1477    /// positional parameters, where `Num2Word_Base`'s (and every other
1478    /// language's) takes two. CE is the only class in the library that widens
1479    /// this signature.
1480    ///
1481    /// This trait hook *is* the two-argument call, and CE has no two-argument
1482    /// `_money_verbose`, so reaching it is precisely Python's `TypeError`. The
1483    /// sole live call site is the one `to_cheque` inherits from Base:
1484    ///
1485    /// ```python
1486    /// words = self._money_verbose(whole, currency)   # boom, every time
1487    /// ```
1488    ///
1489    /// So `to_cheque` is **unconditionally broken** for CE on every currency it
1490    /// implements, and the corpus records exactly that split:
1491    ///
1492    /// ```text
1493    /// cheque:EUR/USD/GBP  -> TypeError            (arity, raised here)
1494    /// cheque:JPY/KWD/...  -> NotImplementedError  (code lookup, raised first)
1495    /// ```
1496    ///
1497    /// Leaving `to_cheque` at its default reproduces both *and* keeps the raise
1498    /// at the call site Python raises from: `default_to_cheque` looks the
1499    /// currency up before calling `money_verbose`, matching Base's order.
1500    ///
1501    /// `to_currency` never lands here — it calls `cardinal(.., MONEY_CASE)`
1502    /// directly, which is what `_money_verbose` resolves to under `case="abs"`.
1503    fn money_verbose(&self, _number: &BigInt, _currency: &str) -> Result<String> {
1504        Err(N2WError::Type(MONEY_VERBOSE_ARITY_ERR.into()))
1505    }
1506
1507    /// The fractional-cents hook: Python's `self.to_cardinal(float(right))`.
1508    ///
1509    /// The trait default routes to `floatpath::cardinal_from_bigdecimal`, which
1510    /// ends in `Num2Word_Base.to_cardinal_float` — the wrong method for CE,
1511    /// which overrides `to_cardinal` and never inherits that path. The default
1512    /// would print `lang.pointword()` ("запятая") where CE prints
1513    /// [`DECIMALPOINT`] ("а"), and would run `float2tuple`'s scaling and
1514    /// zero-padding where CE just slices the repr.
1515    ///
1516    /// The `float(right)` cast is reproduced rather than staying in arbitrary
1517    /// precision, because it is what Python does and it is lossy: `right` is a
1518    /// `Decimal` like 1.100, and the f64 it becomes is what `str()` then sees.
1519    fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
1520        let f = value
1521            .to_f64()
1522            .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", value)))?;
1523        self.cardinal_float(f)
1524    }
1525
1526    /// `Num2Word_CE._cents_verbose(self, number, currency, case)` — the same
1527    /// three-parameter widening as [`money_verbose`](Self::money_verbose), and
1528    /// the same `TypeError` on a two-argument call.
1529    ///
1530    /// Unreachable in practice: the only caller is `default_to_currency`, which
1531    /// CE's `to_currency` override displaces. Ported for symmetry, so that the
1532    /// pair cannot drift apart and a future refactor that routes CE back
1533    /// through the default path fails loudly instead of silently inventing a
1534    /// two-argument method Python does not have.
1535    fn cents_verbose(&self, _number: &BigInt, _currency: &str) -> Result<String> {
1536        Err(N2WError::Type(
1537            "_cents_verbose() missing 1 required positional argument: 'case'".into(),
1538        ))
1539    }
1540
1541    /// `Num2Word_CE.to_currency` at its `case="abs"` default. The full body,
1542    /// `case=` included, lives in [`LangCe::currency_with_case`];
1543    /// [`Lang::to_currency_kw`] below reaches it with the caller's `case`.
1544    fn to_currency(
1545        &self,
1546        val: &CurrencyValue,
1547        currency: &str,
1548        cents: bool,
1549        separator: Option<&str>,
1550        adjective: bool,
1551    ) -> Result<String> {
1552        self.currency_with_case(val, currency, cents, separator, adjective, "abs")
1553    }
1554
1555    /// `to_currency(..., case=...)` — the one extra kwarg CE's signature
1556    /// carries beyond the trait hook.
1557    fn to_currency_kw(
1558        &self,
1559        val: &CurrencyValue,
1560        currency: &str,
1561        cents: bool,
1562        separator: Option<&str>,
1563        adjective: bool,
1564        kw: &Kwargs,
1565    ) -> Result<String> {
1566        if !kw.only(&["case"]) {
1567            return Err(N2WError::Fallback("kwargs".into()));
1568        }
1569        let case = kw_str(kw, "case", "abs")?;
1570        self.currency_with_case(val, currency, cents, separator, adjective, case)
1571    }
1572}
1573
1574impl LangCe {
1575    /// `Num2Word_CE.to_currency`, `case=` and all.
1576    ///
1577    /// Divisor is hardcoded **100** throughout — both in `has_fractional_cents`
1578    /// and via `parse_currency_parts`'s default. CE never consults
1579    /// `CURRENCY_PRECISION`, which is moot anyway: its dict is Base's empty one
1580    /// and none of its four codes is a 3- or 0-decimal currency.
1581    ///
1582    /// `case` is only ever consumed through `_money_verbose`/`_cents_verbose`'s
1583    /// `mcase` reduction (see [`money_case`]) and the fractional-cents float
1584    /// call, which discards it (module docs, bug 9). `adjective` is accepted
1585    /// and never read, exactly as in Python (divergence 3 above).
1586    fn currency_with_case(
1587        &self,
1588        val: &CurrencyValue,
1589        currency: &str,
1590        cents: bool,
1591        separator: Option<&str>,
1592        _adjective: bool,
1593        case: &str,
1594    ) -> Result<String> {
1595        let mcase = money_case(case);
1596        // The trait hands us None when the caller omitted `separator=`;
1597        // resolve it through this language's own default (Base's ",").
1598        let separator = separator.unwrap_or(self.default_separator());
1599
1600        // `has_fractional_cents = (Decimal(str(val)) * 100) % 1 != 0`.
1601        //
1602        // An int can never satisfy this — `Decimal(str(5)) * 100` is exact —
1603        // so `keep_precision` is false for the whole Int arm. Python's Decimal
1604        // `%` truncates toward zero, as does `with_scale(0)`, so the two agree
1605        // on negatives: -12.34 yields `Decimal("-0.00")`, which is falsy,
1606        // exactly like the zero difference here.
1607        let has_fractional_cents = match val {
1608            CurrencyValue::Int(_) => false,
1609            CurrencyValue::Decimal { value, .. } => {
1610                let scaled = value * BigDecimal::from(100);
1611                &scaled - scaled.with_scale(0) != BigDecimal::zero()
1612            }
1613        };
1614
1615        // parse_currency_parts(val, is_int_with_cents=False, keep_precision=..)
1616        //
1617        // `is_int_with_cents=False` is divergence 1: an int keeps its full
1618        // magnitude as `left` and gets `right = 0`, rather than being split
1619        // into units and cents. `to_currency(100, "EUR")` is a hundred euro,
1620        // not one euro.
1621        let (left, right, is_negative) = parse_currency_parts(val, false, has_fractional_cents, 100);
1622
1623        // Python parses *before* the CURRENCY_FORMS lookup; the order is kept
1624        // even though parsing cannot fail.
1625        let forms = self.currency_forms.get(currency).ok_or_else(|| {
1626            N2WError::NotImplemented(format!(
1627                "Currency code \"{}\" not implemented for \"{}\"",
1628                currency,
1629                self.lang_name()
1630            ))
1631        })?;
1632
1633        // `devise = cr1[0]` / `centime = cr2[0]` — bound once, always the
1634        // singular, for every quantity including the fractional branch below.
1635        //
1636        // Python's `except KeyError` wraps these two subscripts as well, but a
1637        // tuple raises IndexError, not KeyError, so an empty form tuple would
1638        // escape the handler rather than become a NotImplementedError. All four
1639        // entries are non-empty, so neither can fire.
1640        let index_err = || N2WError::Index("tuple index out of range".into());
1641        let devise = forms.unit.first().ok_or_else(index_err)?;
1642        let centime = forms.subunit.first().ok_or_else(index_err)?;
1643
1644        // `"%s " % self.negword.strip()`.
1645        let minus_str = if is_negative {
1646            format!("{} ", NEGWORD.trim())
1647        } else {
1648            String::new()
1649        };
1650
1651        // `self._money_verbose(left, currency, case)`, reduced to `mcase`.
1652        // `left` is always >= 0 — parse_currency_parts abs()es it — so the
1653        // negative branch of `cardinal` is unreachable from here; the sign is
1654        // carried by `minus_str` alone.
1655        let money_str = self.cardinal(&left, DEFAULT_CLAZZ, mcase)?;
1656
1657        let cents_str = if has_fractional_cents {
1658            // Python's `isinstance(right, Decimal)` arm. `right` is a Decimal
1659            // exactly when keep_precision was true, i.e. iff
1660            // has_fractional_cents — the Int arm always yields a plain 0 and
1661            // the non-precision Decimal arm an `int(fraction * divisor)`.
1662            if cents {
1663                // `self.to_cardinal(float(right), case=case)` — CE's *own*
1664                // float branch, reached through the `cardinal_from_decimal`
1665                // override rather than Base's `to_cardinal_float`. That
1666                // override is what makes this arm say "а" and not "запятая":
1667                // CE joins on the module-level `DECIMALPOINT`, and
1668                // `self.pointword` is dead. The `case=` is accepted and
1669                // dropped, as it is for every float (module docs, bug 9).
1670                //
1671                // Verified live, because no corpus row reaches this arm — every
1672                // `ce` currency arg has <= 2 decimals, so has_fractional_cents
1673                // is always false:
1674                //
1675                //   to_currency(1.011, "USD") == "цхьа Доллар, цхьаъ а цхьаъ Сент"
1676                //   to_currency(2.675, "USD") == "ши Доллар, кхузткъе ворхӀ а пхиъ Сент"
1677                //   to_currency(0.005, "USD") == "ноль Доллар, ноль а пхиъ Сент"
1678                self.cardinal_from_decimal(&right)?
1679            } else {
1680                // `str(float(right))`. `right` is a cent count in [0, 100), so
1681                // Rust's shortest-round-trip `{}` and Python's `repr` agree and
1682                // neither reaches exponent form. (They part company below
1683                // ~1e-4, where Python switches to "1e-05" and Rust does not —
1684                // needs cents=False and a sub-micro-unit value to reach.)
1685                let f = right
1686                    .to_f64()
1687                    .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", right)))?;
1688                format!("{}", f)
1689            }
1690        } else {
1691            // `right` has scale 0 on this arm, so the unscaled BigInt *is* the
1692            // cent count. On the fractional arm above it would not be —
1693            // Decimal("0.500") unscales to 500 — hence the gating.
1694            let right_int = right.as_bigint_and_exponent().0;
1695            if cents {
1696                // `self._cents_verbose(right, currency, case)`, same `mcase`.
1697                self.cardinal(&right_int, DEFAULT_CLAZZ, mcase)?
1698            } else {
1699                // `self._cents_terse(right, currency)` — Base's, inherited
1700                // unchanged, and the trait default already mirrors it.
1701                self.cents_terse(&right_int, currency)?
1702            }
1703        };
1704
1705        Ok(format!(
1706            "{}{} {}{} {} {}",
1707            minus_str, money_str, devise, separator, cents_str, centime
1708        ))
1709    }
1710}
1711
1712// ---------------------------------------------------------------------------
1713// Tests
1714//
1715// Every expectation below was produced by the live Python interpreter
1716// (`num2words(v, lang="ce")`), not written by hand — floats are pinned by
1717// their exact bit pattern so no decimal literal can round differently on the
1718// way in.
1719// ---------------------------------------------------------------------------
1720
1721#[cfg(test)]
1722mod tests {
1723    use super::*;
1724    use std::str::FromStr;
1725
1726    /// Render a result the way the generator recorded it: the string, or
1727    /// `"<PythonExceptionName>: <message>"`.
1728    fn show(r: Result<String>) -> String {
1729        match r {
1730            Ok(s) => s,
1731            Err(N2WError::Key(m)) => format!("KeyError: {}", m),
1732            Err(N2WError::Index(m)) => format!("IndexError: {}", m),
1733            Err(N2WError::Value(m)) => format!("ValueError: {}", m),
1734            Err(N2WError::Overflow(m)) => format!("OverflowError: {}", m),
1735            Err(N2WError::Type(m)) => format!("TypeError: {}", m),
1736            Err(N2WError::NotImplemented(m)) => format!("NotImplementedError: {}", m),
1737            Err(e) => format!("{:?}", e),
1738        }
1739    }
1740
1741    /// What Python did: a sentence, or an exception rendered as
1742    /// `"<Name>: <message>"`. Deliberately not `Result`/`Ok`/`Err` — importing
1743    /// those variants here would shadow the crate's own `Result`.
1744    enum E {
1745        Words(&'static str),
1746        Raises(&'static str),
1747    }
1748    use E::{Raises, Words};
1749
1750    impl E {
1751        fn want(&self) -> &'static str {
1752            match self {
1753                Words(s) | Raises(s) => s,
1754            }
1755        }
1756    }
1757
1758    #[test]
1759    fn float_arm_matches_python() {
1760        // (bits of the f64, repr-derived precision, expected)
1761        let cases: &[(u64, u32, E)] = &[
1762        (0x0000000000000000_u64, 1, Words("ноль а ноль")), // 0.0
1763        (0x3fe0000000000000_u64, 1, Words("ноль а пхиъ")), // 0.5
1764        (0x3ff0000000000000_u64, 1, Words("цхьаъ а ноль")), // 1.0
1765        (0x3ff8000000000000_u64, 1, Words("цхьаъ а пхиъ")), // 1.5
1766        (0x4002000000000000_u64, 2, Words("шиъ а шиъ пхиъ")), // 2.25
1767        (0x40091eb851eb851f_u64, 2, Words("кхоъ а цхьаъ диъ")), // 3.14
1768        (0x3f847ae147ae147b_u64, 2, Words("ноль а ноль цхьаъ")), // 0.01
1769        (0x3fb999999999999a_u64, 1, Words("ноль а цхьаъ")), // 0.1
1770        (0x3fefae147ae147ae_u64, 2, Words("ноль а исс исс")), // 0.99
1771        (0x3ff028f5c28f5c29_u64, 2, Words("цхьаъ а ноль цхьаъ")), // 1.01
1772        (0x4028ae147ae147ae_u64, 2, Words("шийтта а кхоъ диъ")), // 12.34
1773        (0x4058ff5c28f5c28f_u64, 2, Words("дезткъе ткъайесна а исс исс")), // 99.99
1774        (0x4059200000000000_u64, 1, Words("бӀе а пхиъ")), // 100.5
1775        (0x40934a3d70a3d70a_u64, 2, Words("эзар ши бӀе ткъе дейтта а пхиъ ялх")), // 1234.56
1776        (0xbfe0000000000000_u64, 1, Words("минус ноль а пхиъ")), // -0.5
1777        (0xbff8000000000000_u64, 1, Words("минус цхьаъ а пхиъ")), // -1.5
1778        (0xc028ae147ae147ae_u64, 2, Words("минус шийтта а кхоъ диъ")), // -12.34
1779        (0x3ff0147ae147ae14_u64, 3, Words("цхьаъ а ноль ноль пхиъ")), // 1.005
1780        (0x4005666666666666_u64, 3, Words("шиъ а ялх ворхӀ пхиъ")), // 2.675
1781        (0x8000000000000000_u64, 1, Words("ноль а ноль")), // -0.0
1782        (0xbf847ae147ae147b_u64, 2, Words("минус ноль а ноль цхьаъ")), // -0.01
1783        (0x4132d687e3d70a3d_u64, 2, Words("цхьа миллион ши бӀе ткъе дейтта эзар пхи бӀе кхузткъе ворхӀ а бархӀ исс")), // 1234567.89
1784        (0x42dc12218377de66_u64, 1, Words("бӀе ткъе кхо биллион ди бӀе шовзткъе ялхитта миллиард ворхӀ бӀе дезткъе исс миллион шийтта эзар кхо бӀе шовзткъе пхиъ а ялх")), // 123456789012345.6
1785        (0x3f1a36e2eb1c432d_u64, 4, Words("ноль а ноль ноль ноль цхьаъ")), // 0.0001
1786        (0x430c6bf526340000_u64, 1, Words("цхьа биллиард а ноль")), // 1000000000000000.0
1787        (0x3fd3333333333334_u64, 17, Words("ноль а кхоъ ноль ноль ноль ноль ноль ноль ноль ноль ноль ноль ноль ноль ноль ноль ноль диъ")), // 0.30000000000000004
1788        (0x444b1ae4d6e2ef50_u64, 21, Raises("IndexError: list index out of range")), // 1e+21
1789        (0x4454542ba12a337c_u64, 20, Raises("ValueError: invalid literal for int() with base 10: 'e'")), // 1.5e+21
1790        (0x3ee4f8b588e368f1_u64, 5, Raises("IndexError: list index out of range")), // 1e-05
1791        (0x3eef75104d551d69_u64, 6, Raises("ValueError: invalid literal for int() with base 10: 'e'")), // 1.5e-05
1792        (0x4341c37937e08000_u64, 16, Raises("IndexError: list index out of range")), // 1e+16
1793        (0x0000000000000001_u64, 324, Raises("IndexError: list index out of range")), // 5e-324
1794        (0x46fed09bead87c03_u64, 34, Raises("IndexError: list index out of range")), // 1e+34
1795        (0x47071c74f0225d03_u64, 33, Raises("ValueError: invalid literal for int() with base 10: 'e'")), // 1.5e+34
1796        (0xc0934a3d70a3d70a_u64, 2, Words("минус эзар ши бӀе ткъе дейтта а пхиъ ялх")), // -1234.56
1797        (0x54b249ad2594c37d_u64, 100, Raises("IndexError: list index out of range")), // 1e+100
1798        (0x4034800000000000_u64, 1, Words("ткъа а пхиъ")), // 20.5
1799        (0x4044000000000000_u64, 1, Words("шовзткъа а ноль")), // 40.0
1800        (0x408f3ffdf3b645a2_u64, 3, Words("исс бӀе дезткъе ткъайесна а исс исс исс")), // 999.999
1801        (0x43179085685d83c9_u64, 1, Words("цхьа биллиард ялх бӀе шовзткъе берхӀитта биллион ши бӀе ялх миллиард ворхӀ бӀе дезткъе миллион дезткъе бархӀ эзар пхи бӀе кхузткъе шиъ а шиъ")), // 1658206780088562.2
1802        (0x42eee708f58b7e34_u64, 2, Words("ши бӀе кхузткъе цхьайтта биллион бархӀ бӀе ткъе цхьа миллиард дезткъе шийтта миллион ворхӀ бӀе ворхӀ эзар кхо бӀе кхойтта а ялх шиъ")), // 271821092707313.62
1803        ];
1804        let l = LangCe::new();
1805        for (bits, precision, want) in cases {
1806            let value = f64::from_bits(*bits);
1807            let v = FloatValue::Float {
1808                value,
1809                precision: *precision,
1810            };
1811            let got = show(l.to_cardinal_float(&v, None));
1812            assert_eq!(got, want.want(), "float {:?} (bits {:#x})", value, bits);
1813        }
1814    }
1815
1816    #[test]
1817    fn decimal_arm_matches_python() {
1818        let cases: &[(&str, E)] = &[
1819        ("0.01", Raises("KeyError: Decimal('0.01')")),
1820        ("1.10", Raises("KeyError: Decimal('1.10')")),
1821        ("12.345", Raises("KeyError: Decimal('12.345')")),
1822        ("98746251323029.99", Raises("KeyError: Decimal('9.99')")),
1823        ("0.001", Raises("KeyError: Decimal('0.001')")),
1824        ("5", Words("пхиъ")),
1825        ("-12.34", Raises("KeyError: Decimal('12.34')")),
1826        ("80.00", Words("дезткъа")),
1827        ("0.0000001", Raises("KeyError: Decimal('1E-7')")),
1828        ("5.5", Raises("KeyError: Decimal('5.5')")),
1829        ("20.5", Raises("KeyError: Decimal('0.5')")),
1830        ("1E+2", Words("бӀе")),
1831        ("323029.99", Raises("KeyError: Decimal('9.99')")),
1832        ("29.99", Raises("KeyError: Decimal('9.99')")),
1833        ("-0.00", Words("ноль")),
1834        ("1e34", Words("NOT IMPLEMENTED")),
1835        ("0", Words("ноль")),
1836        ("19.999", Raises("KeyError: Decimal('19.999')")),
1837        ("1000000.5", Raises("KeyError: Decimal('0.5')")),
1838        ("999.5", Raises("KeyError: Decimal('19.5')")),
1839        ];
1840        let l = LangCe::new();
1841        for (s, want) in cases {
1842            let value = BigDecimal::from_str(s).unwrap();
1843            // `precision` is `abs(exponent)` on the Python side and unread here,
1844            // exactly as in Python; the arm never consults it.
1845            let v = FloatValue::Decimal {
1846                value,
1847                precision: 0,
1848            };
1849            let got = show(l.to_cardinal_float(&v, None));
1850            assert_eq!(got, want.want(), "Decimal({:?})", s);
1851        }
1852    }
1853
1854    /// `precision=` assigns `self.precision`, which CE never reads — so every
1855    /// override must leave the output untouched (module docs, bug 10).
1856    #[test]
1857    fn precision_override_is_inert() {
1858        let l = LangCe::new();
1859        let v = FloatValue::Float {
1860            value: 2.675,
1861            precision: 3,
1862        };
1863        let base = show(l.to_cardinal_float(&v, None));
1864        assert_eq!(base, "шиъ а ялх ворхӀ пхиъ");
1865        for p in [0u32, 1, 3, 5, 17] {
1866            assert_eq!(show(l.to_cardinal_float(&v, Some(p))), base, "precision={}", p);
1867        }
1868    }
1869
1870    /// The fractional-cents arm of `to_currency`, which reaches the float path
1871    /// through `cardinal_from_decimal`. No corpus row covers it (every `ce`
1872    /// currency arg has <= 2 decimals), so these came from the interpreter with
1873    /// `num2words2._RUST = None` forced.
1874    ///
1875    /// That forcing is not paranoia. `num2words()` routes `to="currency"`
1876    /// through the compiled core whenever one is installed, so an interpreter
1877    /// with a stale `_rust.abi3.so` answers these queries *from the old Rust
1878    /// build* rather than from Python — and reports the very divergence this
1879    /// override fixes as though it were the spec. Ground truth for a port has
1880    /// to come from the pure-Python path, or the port is graded against itself.
1881    #[test]
1882    fn fractional_cents_matches_python() {
1883        let cases: &[(&str, &str, bool, E)] = &[
1884        ("1.011", "USD", true, Words("цхьа Доллар, цхьаъ а цхьаъ Сент")),
1885        ("2.675", "USD", true, Words("ши Доллар, кхузткъе ворхӀ а пхиъ Сент")),
1886        ("0.005", "USD", true, Words("ноль Доллар, ноль а пхиъ Сент")),
1887        ("1.011", "USD", false, Words("цхьа Доллар, 1.1 Сент")),
1888        ("12.34", "USD", true, Words("шийтта Доллар, ткъе дейтта Сент")),
1889        ("1.0055", "RUB", true, Words("цхьа Сом, ноль а пхиъ пхиъ Кепек")),
1890        ];
1891        let l = LangCe::new();
1892        for (val, code, cents, want) in cases {
1893            let v = CurrencyValue::parse(val, false, true, true).unwrap();
1894            let got = show(l.to_currency(&v, code, *cents, None, false));
1895            assert_eq!(got, want.want(), "to_currency({}, {})", val, code);
1896        }
1897    }
1898
1899    /// `python_repr` is the whole float arm's foundation: CE slices the repr
1900    /// string, so a wrong repr is a wrong sentence. Spot-checks of the shapes
1901    /// that distinguish it from a naive `{}` — CPython's fixed/exponent
1902    /// threshold, and the exact-tie doubles where Rust's own shortest-repr
1903    /// disagrees with `repr`.
1904    #[test]
1905    fn python_repr_matches_cpython() {
1906        let cases: &[(f64, &str)] = &[
1907            (0.0, "0.0"),
1908            (0.5, "0.5"),
1909            (2.675, "2.675"),
1910            (1.005, "1.005"),
1911            (100.5, "100.5"),
1912            (1.0, "1.0"),
1913            (0.0001, "0.0001"),          // decpt == -3, the last fixed one
1914            (1e-5, "1e-05"),             // decpt == -4, first exponential
1915            (1e15, "1000000000000000.0"),// decpt == 16, the last fixed one
1916            (1e16, "1e+16"),             // decpt == 17, first exponential
1917            (1e21, "1e+21"),
1918            (1.5e21, "1.5e+21"),
1919            (1e100, "1e+100"),
1920            (5e-324, "5e-324"),
1921            (0.30000000000000004, "0.30000000000000004"),
1922            (123456789012345.6, "123456789012345.6"),
1923            (1.7976931348623157e308, "1.7976931348623157e+308"),
1924            // Exact ties: the double is …562.25 / …313.625, equidistant between
1925            // two shortest candidates. CPython rounds half-to-even and emits
1926            // .2 / .62; Rust's own `{}` and `{:e}` emit .3 / .63.
1927            (1658206780088562.2, "1658206780088562.2"),
1928            (271821092707313.62, "271821092707313.62"),
1929        ];
1930        for (x, want) in cases {
1931            assert_eq!(&python_repr(*x), want, "python_repr({:?})", x);
1932        }
1933    }
1934
1935    /// `repr(Decimal)`, the text inside the KeyError. Trailing zeros are
1936    /// significant and the switch to scientific notation is exponent-driven.
1937    #[test]
1938    fn decimal_repr_matches_cpython() {
1939        let cases: &[(&str, &str)] = &[
1940            ("0.01", "Decimal('0.01')"),
1941            ("1.10", "Decimal('1.10')"),
1942            ("12.345", "Decimal('12.345')"),
1943            ("0.001", "Decimal('0.001')"),
1944            ("0.0000001", "Decimal('1E-7')"),   // adjusted < -6 -> scientific
1945            ("0.000001", "Decimal('0.000001')"),// adjusted == -6 -> fixed
1946            ("1E+2", "Decimal('1E+2')"),        // positive exponent -> scientific
1947            ("5", "Decimal('5')"),
1948            ("0", "Decimal('0')"),
1949            ("-12.34", "Decimal('-12.34')"),
1950            ("80.00", "Decimal('80.00')"),
1951        ];
1952        for (s, want) in cases {
1953            let v = BigDecimal::from_str(s).unwrap();
1954            assert_eq!(&decimal_repr(&v), want, "decimal_repr({:?})", s);
1955        }
1956    }
1957
1958    /// The integer modes must be untouched by this phase.
1959    #[test]
1960    fn integer_modes_unchanged() {
1961        let l = LangCe::new();
1962        assert_eq!(l.to_cardinal(&BigInt::from(0)).unwrap(), "ноль");
1963        assert_eq!(l.to_cardinal(&BigInt::from(99)).unwrap(), "дезткъе ткъайесна");
1964        assert_eq!(l.to_ordinal(&BigInt::from(100)).unwrap(), "бІолгІа");
1965        assert_eq!(l.to_cardinal(&BigInt::from(100)).unwrap(), "бӀе");
1966        assert_eq!(l.to_ordinal_num(&BigInt::from(7)).unwrap(), "7-й");
1967    }
1968
1969    fn fl(v: f64) -> FloatValue {
1970        FloatValue::Float {
1971            value: v,
1972            precision: 1,
1973        }
1974    }
1975
1976    fn dc(s: &str) -> FloatValue {
1977        FloatValue::Decimal {
1978            value: BigDecimal::from_str(s).unwrap(),
1979            precision: 0,
1980        }
1981    }
1982
1983    /// The entry hooks, against corpus_wholefloat rows: no whole-value
1984    /// shortcut for floats, integer branches for Decimals, `case="ORD"` only
1985    /// felt by the Decimal arm of to_ordinal.
1986    #[test]
1987    fn float_entry_hooks_match_corpus() {
1988        let l = LangCe::new();
1989        // cardinal: every float takes the float branch, X.0 included.
1990        assert_eq!(show(l.cardinal_float_entry(&fl(1.0), None)), "цхьаъ а ноль");
1991        assert_eq!(show(l.cardinal_float_entry(&fl(-0.0), None)), "ноль а ноль");
1992        assert_eq!(
1993            show(l.cardinal_float_entry(&fl(-1000000.0), None)),
1994            "минус цхьа миллион а ноль"
1995        );
1996        assert_eq!(
1997            show(l.cardinal_float_entry(&fl(1e16), None)),
1998            "IndexError: list index out of range"
1999        );
2000        // ordinal: float -> plain cardinal; Decimal -> real ordinal.
2001        assert_eq!(show(l.ordinal_float_entry(&fl(5.0))), "пхиъ а ноль");
2002        assert_eq!(show(l.ordinal_float_entry(&fl(-3.0))), "минус кхоъ а ноль");
2003        assert_eq!(show(l.ordinal_float_entry(&dc("5.00"))), "пхоьалгӀа");
2004        assert_eq!(show(l.ordinal_float_entry(&dc("0"))), "нольалгӀа");
2005        assert_eq!(show(l.ordinal_float_entry(&dc("-0.0"))), "нольалгӀа");
2006        assert_eq!(show(l.ordinal_float_entry(&dc("1E+2"))), "бІолгІа");
2007        assert_eq!(show(l.ordinal_float_entry(&dc("-3.0"))), "минус кхоалгӀа");
2008        assert_eq!(
2009            show(l.ordinal_float_entry(&dc("12345.000"))),
2010            "шийтта эзар кхо бӀе шовзткъе пхоьалгӀа"
2011        );
2012        assert_eq!(show(l.ordinal_float_entry(&dc("1E+20"))), "бӀе триллион");
2013        // year == cardinal routing.
2014        assert_eq!(show(l.year_float_entry(&fl(2.5))), "шиъ а пхиъ");
2015        assert_eq!(show(l.year_float_entry(&dc("-3.0"))), "минус кхоъ");
2016        // ordinal_num: verify_ordinal, then repr + "-й".
2017        assert_eq!(show(l.ordinal_num_float_entry(&fl(5.0), "5.0")), "5.0-й");
2018        assert_eq!(show(l.ordinal_num_float_entry(&fl(-0.0), "-0.0")), "-0.0-й");
2019        assert_eq!(show(l.ordinal_num_float_entry(&fl(1e16), "1e+16")), "1e+16-й");
2020        assert_eq!(
2021            show(l.ordinal_num_float_entry(&fl(0.5), "0.5")),
2022            "TypeError: Cannot treat float 0.5 as ordinal."
2023        );
2024        assert_eq!(
2025            show(l.ordinal_num_float_entry(&fl(-3.0), "-3.0")),
2026            "TypeError: Cannot treat negative num -3.0 as ordinal."
2027        );
2028        assert_eq!(show(l.ordinal_num_float_entry(&dc("5.00"), "5.00")), "5.00-й");
2029        assert_eq!(
2030            show(l.ordinal_num_float_entry(&dc("1.50"), "1.50")),
2031            "TypeError: Cannot treat float 1.50 as ordinal."
2032        );
2033    }
2034
2035    /// Inf/NaN parse as the base Decimal specials and are served natively by
2036    /// the `inf_result`/`nan_result` hooks; no Python fallback.
2037    #[test]
2038    fn str_to_number_native_inf_nan() {
2039        let l = LangCe::new();
2040        assert!(matches!(
2041            l.str_to_number("Infinity"),
2042            Ok(ParsedNumber::Inf { negative: false })
2043        ));
2044        assert!(matches!(
2045            l.str_to_number("-Infinity"),
2046            Ok(ParsedNumber::Inf { negative: true })
2047        ));
2048        assert!(matches!(l.str_to_number("NaN"), Ok(ParsedNumber::NaN)));
2049        assert!(matches!(l.str_to_number("5"), Ok(ParsedNumber::Dec(_))));
2050
2051        // Native Inf/NaN/-0.0 outcomes.
2052        assert_eq!(show(l.inf_result(false, "cardinal")), "NOT IMPLEMENTED");
2053        assert_eq!(
2054            show(l.inf_result(true, "cardinal")),
2055            "минус NOT IMPLEMENTED"
2056        );
2057        assert!(matches!(
2058            l.nan_result("cardinal"),
2059            Err(N2WError::Custom {
2060                module: "decimal",
2061                class: "InvalidOperation",
2062                ..
2063            })
2064        ));
2065        assert_eq!(show(l.neg_zero_decimal("cardinal").unwrap()), "ноль");
2066        assert_eq!(show(l.neg_zero_decimal("ordinal").unwrap()), "нольалгӀа");
2067        assert_eq!(show(l.neg_zero_decimal("year").unwrap()), "ноль");
2068        assert!(l.neg_zero_decimal("ordinal_num").is_none());
2069    }
2070
2071    /// Grammatical kwargs, against corpus_kwargs rows.
2072    #[test]
2073    fn kwargs_hooks_match_corpus() {
2074        let l = LangCe::new();
2075        let kw = |pairs: &[(&str, &str)]| {
2076            Kwargs(
2077                pairs
2078                    .iter()
2079                    .map(|(k, v)| (k.to_string(), KwVal::Str(v.to_string())))
2080                    .collect(),
2081            )
2082        };
2083        let n = BigInt::from(1234);
2084        // clazz agreement on 4/14/40 compounds.
2085        assert_eq!(
2086            show(l.to_cardinal_kw(&n, &kw(&[("clazz", "б")]))),
2087            "эзар ши бӀе ткъе бейтта"
2088        );
2089        assert_eq!(
2090            show(l.to_cardinal_kw(&n, &kw(&[("clazz", "x")]))),
2091            "эзар ши бӀе ткъе xейтта"
2092        );
2093        assert_eq!(
2094            show(l.to_ordinal_kw(&n, &kw(&[("clazz", "й")]))),
2095            "эзар ши бӀе ткъе йейтталгӀа"
2096        );
2097        // cases: attr, gen, ORD, and the KeyError on "bogus".
2098        assert_eq!(show(l.to_cardinal_kw(&BigInt::from(1), &kw(&[("case", "attr")]))), "цхьа");
2099        assert_eq!(
2100            show(l.to_cardinal_kw(&n, &kw(&[("case", "gen")]))),
2101            "эзар ши бӀе ткъе дейттаннан"
2102        );
2103        assert_eq!(
2104            show(l.to_year_kw(&BigInt::from(21), &kw(&[("case", "ORD")]))),
2105            "ткъе цхьалгӀа"
2106        );
2107        assert_eq!(
2108            show(l.to_cardinal_kw(&BigInt::from(5), &kw(&[("case", "bogus")]))),
2109            "KeyError: 'bogus'"
2110        );
2111        // to_ordinal has no case= parameter -> fall back to Python (Fallback).
2112        assert!(matches!(
2113            l.to_ordinal_kw(&n, &kw(&[("case", "gen")])),
2114            Err(N2WError::Fallback(_))
2115        ));
2116        // An explicit None on an unknown kwarg is Python's problem — decline.
2117        assert!(matches!(
2118            l.to_cardinal_kw(&n, &Kwargs(vec![("clazz".into(), KwVal::None)])),
2119            Err(N2WError::Fallback(_))
2120        ));
2121        // currency case=attr -> oblique money/cents forms.
2122        let v = CurrencyValue::Int(BigInt::from(1234));
2123        assert_eq!(
2124            show(l.to_currency_kw(&v, "RUB", true, None, false, &kw(&[("case", "attr")]))),
2125            "эзар ши бӀе ткъе дейттан Сом, нольан Кепек"
2126        );
2127        // and case absent keeps the "attr" money case (default path).
2128        assert_eq!(
2129            show(l.to_currency_kw(&v, "RUB", true, None, false, &Kwargs(vec![]))),
2130            "эзар ши бӀе ткъе дейтта Сом, ноль Кепек"
2131        );
2132        // Decimal + kwargs: float arm drops them, Decimal arm feels them.
2133        assert_eq!(
2134            show(l.to_cardinal_float_kw(&dc("21"), None, &kw(&[("case", "ORD")]))),
2135            "ткъе цхьалгӀа"
2136        );
2137        assert_eq!(
2138            show(l.to_cardinal_float_kw(&fl(1.5), None, &kw(&[("case", "ORD")]))),
2139            "цхьаъ а пхиъ"
2140        );
2141    }
2142}