Skip to main content

num2words2_core/
lang_ky.rs

1//! Port of `lang_KY.py` (Kyrgyz).
2//!
3//! # Deliberate divergences from upstream
4//!
5//! Two, both documented below: the **script** (Cyrillic, not Latin) and the
6//! **ordinal algorithm** (vowel-harmonic suffix, not a glued `-inchi`).
7//! Everything else is ported verbatim, bug for bug.
8//!
9//! ## 1. Cyrillic script
10//!
11//! `lang_KY.py` spells every numeral in **Latin transliteration** ("bir",
12//! "üch", "jıyırma", "jüz"). Kyrgyz is written in Cyrillic — the Latin
13//! alphabet was dropped in 1940 and has no official standing — so the tables
14//! here are the Cyrillic spellings: "бир", "үч", "жыйырма", "жүз".
15//!
16//! The transliteration was not even self-consistent, which is the clearest
17//! evidence it was an accident rather than a choice: `pointword` was the
18//! **mixed-script** string `"üтүр"`, one Latin `ü` (U+00FC) followed by three
19//! Cyrillic letters (U+0442 U+04AF U+0440). Whoever wrote it was typing
20//! Cyrillic and the first keystroke landed in the wrong layout. It is now
21//! plain "үтүр".
22//!
23//! This is a lexicon change only — the composition rules, the `> 1`
24//! multiplier guards, the 10^9 digit cliff and the currency fallback are all
25//! still ported verbatim.
26//!
27//! ## 2. Ordinal algorithm
28//!
29//! Upstream builds every ordinal as `to_cardinal(n) + "-inchi"`, which gives
30//! the non-word "үч-inchi" for 3rd. Kyrgyz forms ordinals with *-(V)нч(V)*,
31//! and **both** vowels agree with the last vowel of the stem — so the one
32//! written suffix is really four, and the linking vowel is dropped after a
33//! stem that already ends in a vowel:
34//!
35//! ```text
36//! бир   + инчи  -> биринчи        алты  + нчы  -> алтынчы
37//! сегиз + инчи  -> сегизинчи      жети  + нчи  -> жетинчи
38//! кырк  + ынчы  -> кыркынчы       элүү  + нчү  -> элүүнчү
39//! отуз  + унчу  -> отузунчу       жыйырма + нчы -> жыйырманчы
40//! жүз   + үнчү  -> жүзүнчү        үч    + үнчү -> үчүнчү
41//! ```
42//!
43//! Only the **last word** takes the ending: 123rd is "жүз жыйырма үчүнчү",
44//! not "жүзүнчү жыйырманчы үчүнчү". See [`ordinal_suffix`] and
45//! [`vowel_class`].
46//!
47//! The **digit** form follows the same harmony: "1-инчи", "3-үнчү",
48//! "10-унчу", "40-ынчы". The ending is read off the spelled cardinal, since
49//! a digit carries no vowel of its own. Kyrgyz orthography rule 88 also
50//! allows the hyphen to stand for the suffix outright when a noun follows
51//! ("16-ноябрь", "4-класс"); that form needs the noun, so it is not what a
52//! standalone `to_ordinal_num` can return. The spelled-out ending after the
53//! hyphen is what the sibling Turkic modules in this crate already do —
54//! `lang_ba.rs` emits "1-се", `lang_az.rs` "1-ci".
55//!
56//! `verify_ordinal` is **not** restored here: upstream lets zero and
57//! negatives through, and this module keeps that, so `to_ordinal(0)` is
58//! "нөлүнчү" and `to_ordinal(-1)` is "минус биринчи". Only the ending
59//! changed.
60//!
61//! Shape: **self-contained**. `Num2Word_KY` subclasses `Num2Word_Base` but
62//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the guard in
63//! `Num2Word_Base.__init__`
64//!
65//! ```text
66//! if any(hasattr(self, field) for field in
67//!        ["high_numwords", "mid_numwords", "low_numwords"]):
68//! ```
69//!
70//! is never taken: Python never builds `self.cards` and never sets `MAXVAL`.
71//! `cards`/`maxval`/`merge` therefore stay at their trait defaults here, and
72//! **there is no overflow check at all** — see the `str(number)` fallback
73//! below for what happens past the table's ceiling.
74//!
75//! All four in-scope methods are overridden by `Num2Word_KY`, so nothing from
76//! `Num2Word_Base` reaches the output path:
77//!   * `to_cardinal`    — own algorithm over `_int_to_word`.
78//!   * `to_ordinal`     — `to_cardinal(n)` + the harmonised ending.
79//!   * `to_ordinal_num` — `str(n)` + "-" + the harmonised ending.
80//!   * `to_year`        — `to_cardinal(val)`, ignoring its own `longval` arg.
81//!
82//! Two consequences of those overrides are load-bearing:
83//!
84//! 1. `Num2Word_Base.to_cardinal` pipes its result through `self.title()`;
85//!    KY's override does not, so titling never applies. (`is_title` is also
86//!    left `False` by `setup`, so it would be a no-op regardless.)
87//! 2. `Num2Word_Base.to_ordinal` calls `verify_ordinal`, which raises
88//!    `TypeError` for negative input. KY's override skips it, so
89//!    `to_ordinal(-1)` == "минус биринчи" rather than raising. Likewise
90//!    `to_ordinal(0)` == "нөлүнчү" — no crash.
91//!
92//! # Faithfully reproduced Python quirks
93//!
94//! This is a port, not a rewrite. All of the following are what Python emits
95//! and are verified against the frozen corpus:
96//!
97//! 1. **Numbers >= 10^9 are not spelled at all.** `_int_to_word` handles
98//!    units/tens/hundreds/thousands/millions and then falls off the end with
99//!    a bare `return str(number)`. There is no milliard/billion entry, and no
100//!    `OverflowError` — `to_cardinal(10**9)` == "1000000000" (the digits),
101//!    and `to_ordinal(10**9)` == "1000000000-инчи" — the digits have no vowel
102//!    to harmonise with, so the default class and the digit form's hyphen
103//!    apply. This holds for arbitrary
104//!    magnitudes, hence [`LangKy::int_to_word`] takes `&BigInt` and only
105//!    narrows to `u64` *after* proving `number < 10^9`.
106//! 2. **"бир миллион" but plain "миң"/"жүз".** The thousands and hundreds
107//!    branches suppress the leading "бир" via `if h > 1` / `if t > 1`, so
108//!    100 == "жүз" and 1000 == "миң". The millions branch has no such guard,
109//!    so 10^6 == "бир миллион", not "миллион".
110//! 3. **10^9 and up have no word.** There is no "миллиард" entry — the table
111//!    stops at "миллион", so a milliard falls off the end into the digit
112//!    fallback above. Pre-existing, and a separate change.
113//! 4. **100000 is "жүз миң", not a lakh-style form.** The thousands branch
114//!    recurses on the multiplier, so the hundreds word composes with the
115//!    thousands word exactly as Kyrgyz does. Nothing to fix; noted because
116//!    the sibling South Asian modules differ here.
117//!
118//! # Float/Decimal entry routing (the `"." in str(number)` rule)
119//!
120//! `to_cardinal` never asks whether the value is *whole* — it asks whether the
121//! *string* shows a point. That routing is reproduced by
122//! [`Lang::cardinal_float_entry`] here (and, through it, the ordinal/year
123//! entries), with these pinned consequences:
124//!
125//! * **Whole floats keep their ".0" tail.** `str(5.0)` == "5.0" has a point,
126//!   so `to_cardinal(5.0)` == "беш үтүр нөл", *not* "беш". Likewise
127//!   `Decimal("5.00")` == "беш үтүр нөл нөл" — every fractional character is
128//!   spelled, trailing zeros included.
129//! * **`-0.0` renders the negword.** The sign is read off the string
130//!   (`str(-0.0)` == "-0.0" starts with "-"), so `to_cardinal(-0.0)` ==
131//!   "минус нөл үтүр нөл". A `< 0` test would miss it.
132//! * **Scientific notation is a `ValueError`.** `str(1e16)` == "1e+16" has no
133//!   point, so Python runs `int("1e+16")` and dies: `invalid literal for
134//!   int() with base 10: '1e+16'`. Same for `1e+20`, for tiny floats
135//!   (`str(1e-05)` == "1e-05"), and for non-canonical Decimals
136//!   (`str(Decimal("1E+2"))` == "1E+2"). The sign is stripped *before* the
137//!   `int()` (Python recurses on `n[1:]`), so the message never shows a minus.
138//! * **Point-less integral Decimals take the integer grammar.**
139//!   `Decimal("100")` stringifies as "100" — no point — and `int("100")`
140//!   succeeds, so it renders "жүз" like the int would.
141//! * `to_ordinal(float)` is `to_cardinal(float)` + the harmonised ending
142//!   ("беш үтүр нөлүнчү"); `to_ordinal_num(float)` is `str(number) + "-инчи"`
143//!   ("5.0-инчи", "1e+16-инчи" — no ValueError here, nothing is parsed);
144//!   `to_year(float)` is `to_cardinal(float)` (the trait default already
145//!   routes through the override above).
146//! * **`"Infinity"`/`"NaN"` strings fall back to Python.** `Decimal("Infinity")`
147//!   parses fine and dies later in `int("Infinity")` (ValueError) — but only
148//!   on the modes that parse; `to_ordinal_num("Infinity")` happily returns
149//!   "Infinity-инчи". `ParsedNumber` cannot carry Inf/NaN into that split, so
150//!   `str_to_number` returns NotImplemented and the shim reruns the original
151//!   pure-Python path, reproducing every mode exactly.
152//!
153//! # Sign handling
154//!
155//! Python's `to_cardinal` works on the *string* form: `n = str(number).strip()`,
156//! then `if n.startswith("-"): return (self.negword + self.to_cardinal(n[1:])).strip()`.
157//! For integral input `str(BigInt)` never contains ".", so this reduces to
158//! "recurse он the absolute value and prefix `negword`" — reproduced directly
159//! on `BigInt` here. The trailing `.strip()` is a no-op in practice (`negword`
160//! is "минус " and `_int_to_word` never returns a blank for a non-negative
161//! input) but is kept for fidelity.
162
163use crate::base::{Lang, N2WError, Result};
164use crate::currency::{CurrencyForms, CurrencyValue};
165use crate::floatpath::FloatValue;
166use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
167use bigdecimal::BigDecimal;
168use num_bigint::BigInt;
169use num_traits::{One, Signed, ToPrimitive, Zero};
170use std::collections::HashMap;
171
172/// `setup`: `self.negword`. Note the trailing space — it is the word/number
173/// separator, and the concatenation is `.strip()`ed afterwards.
174const NEGWORD: &str = "минус ";
175
176/// `setup`: `self.pointword`. Mixed Latin/Cyrillic in the original; see the
177/// module docs. Unused by the four in-scope modes.
178const POINTWORD: &str = "үтүр";
179
180/// The literal `"нөл"` that `_int_to_word` returns for 0. Python has no
181/// `self.ones[0]` entry for it — index 0 of `ones` is the empty string, and
182/// zero is special-cased ahead of the table lookup.
183const ZERO_WORD: &str = "нөл";
184
185/// The ordinal suffix used when no Kyrgyz vowel is available to harmonise
186/// against — a bare digit string, `Infinity`, `NaN`. и-class is the default
187/// because it is the class "бир" itself takes.
188const DEFAULT_ORDINAL_SUFFIX: Suffix = Suffix {
189    after_consonant: "инчи",
190    after_vowel: "нчи",
191};
192
193/// The four harmony classes of the Kyrgyz ordinal suffix.
194///
195/// Kyrgyz ordinals are the cardinal plus *-(V)нч(V)*, and both vowels agree
196/// with the last vowel of the stem. The linking vowel is dropped when the
197/// stem already ends in one, which is why "алты" gives *алтынчы* and not
198/// *алтыынчы*.
199struct Suffix {
200    /// Stem ends in a consonant — the suffix brings its own linking vowel:
201    /// кырк + ынчы, сегиз + инчи, отуз + унчу, жүз + үнчү.
202    after_consonant: &'static str,
203    /// Stem ends in a vowel — no linking vowel: алты + нчы, жети + нчи.
204    after_vowel: &'static str,
205}
206
207const SUFFIX_BACK_UNROUNDED: Suffix = Suffix {
208    after_consonant: "ынчы",
209    after_vowel: "нчы",
210};
211const SUFFIX_FRONT_UNROUNDED: Suffix = Suffix {
212    after_consonant: "инчи",
213    after_vowel: "нчи",
214};
215const SUFFIX_BACK_ROUNDED: Suffix = Suffix {
216    after_consonant: "унчу",
217    after_vowel: "нчу",
218};
219const SUFFIX_FRONT_ROUNDED: Suffix = Suffix {
220    after_consonant: "үнчү",
221    after_vowel: "нчү",
222};
223
224/// The harmony class a vowel belongs to, or `None` if the character is not a
225/// Kyrgyz vowel.
226///
227/// The four classes are the standard Kyrgyz cross-classification by backness
228/// and rounding. The Russian-only letters (ё, ю, я, and е in loanwords) are
229/// folded onto the class of the vowel they end in, which is what the borrowed
230/// numerals in this module need — "миллион" harmonises off о, "миллиард" off а.
231fn vowel_class(c: char) -> Option<&'static Suffix> {
232    match c {
233        // back, unrounded
234        'а' | 'ы' | 'я' => Some(&SUFFIX_BACK_UNROUNDED),
235        // front, unrounded
236        'е' | 'и' | 'э' => Some(&SUFFIX_FRONT_UNROUNDED),
237        // back, rounded
238        'о' | 'у' | 'ё' | 'ю' => Some(&SUFFIX_BACK_ROUNDED),
239        // front, rounded
240        'ө' | 'ү' => Some(&SUFFIX_FRONT_ROUNDED),
241        _ => None,
242    }
243}
244
245/// Pick the ordinal ending for a spelled-out cardinal.
246///
247/// Only the **last word** governs: "жүз жыйырма үч" is *жүз жыйырма үчүнчү*,
248/// harmonising off "үч" and leaving the earlier words bare. A stem with no
249/// Kyrgyz vowel at all — the digit fallback above 10^9, `Infinity`, `NaN` —
250/// falls back to [`DEFAULT_ORDINAL_SUFFIX`].
251fn ordinal_suffix(cardinal: &str) -> &'static str {
252    harmonise(cardinal).0
253}
254
255/// [`ordinal_suffix`] plus whether a Kyrgyz vowel was actually found.
256///
257/// `false` means the stem was a digit string (the 10^9 fallback), `Infinity`
258/// or `NaN` — nothing a suffix can attach to as if it were a word, so the
259/// caller writes the hyphenated digit form instead of gluing.
260fn harmonise(cardinal: &str) -> (&'static str, bool) {
261    let stem = match cardinal.split_whitespace().next_back() {
262        Some(w) => w,
263        None => return (DEFAULT_ORDINAL_SUFFIX.after_consonant, false),
264    };
265
266    let mut class: Option<&'static Suffix> = None;
267    let mut last_is_vowel = false;
268    for c in stem.chars() {
269        match vowel_class(c) {
270            Some(k) => {
271                class = Some(k);
272                last_is_vowel = true;
273            }
274            None => last_is_vowel = false,
275        }
276    }
277
278    match class {
279        Some(k) if last_is_vowel => (k.after_vowel, true),
280        Some(k) => (k.after_consonant, true),
281        None => (DEFAULT_ORDINAL_SUFFIX.after_consonant, false),
282    }
283}
284
285/// Attach the ordinal ending to a spelled cardinal.
286///
287/// A real word takes it glued — "бир" -> "биринчи". The digit fallback above
288/// 10^9 is not a word, so it takes the hyphen the digit form uses:
289/// "1000000000-инчи" rather than the unreadable "1000000000инчи".
290fn join_ordinal(cardinal: &str) -> String {
291    let (suffix, is_word) = harmonise(cardinal);
292    if is_word {
293        format!("{}{}", cardinal, suffix)
294    } else {
295        format!("{}-{}", cardinal, suffix)
296    }
297}
298
299/// `setup`: `self.ones`. Index 0 is the empty string (Python relies on this
300/// only via the float path's `self.ones[int(digit)] or "нөл"` fallback).
301const ONES: [&str; 10] = [
302    "", "бир", "эки", "үч", "төрт", "беш", "алты", "жети", "сегиз", "тогуз",
303];
304
305/// `setup`: `self.tens`. Index 0 is the empty string and is unreachable —
306/// the `number < 100` branch only runs for `number >= 10`, so `t >= 1`.
307const TENS: [&str; 10] = [
308    "", "он", "жыйырма", "отуз", "кырк", "элүү", "алтымыш", "жетимиш", "сексен", "токсон",
309];
310
311/// `setup`: `self.hundred`.
312const HUNDRED: &str = "жүз";
313
314/// `setup`: `self.thousand`. Spelled "миң" in the source — see module docs.
315const THOUSAND: &str = "миң";
316
317/// `setup`: `self.million`.
318const MILLION: &str = "миллион";
319
320/// 10^9 — the ceiling past which `_int_to_word` gives up and returns digits.
321fn one_e9() -> BigInt {
322    BigInt::from(1_000_000_000u64)
323}
324
325/// The key `to_currency` falls back to for an unknown currency code.
326///
327/// Python writes `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
328/// — the *first* entry in the class dict, which since Python 3.7 means the
329/// first one in the literal. That is "KGS". A `HashMap` has no insertion
330/// order, so the identity of that first entry is pinned here rather than
331/// recovered from iteration. Verified live:
332/// `list(Num2Word_KY.CURRENCY_FORMS.values())[0] == (("сом","сом"),("тыйын","тыйын"))`.
333const FALLBACK_CURRENCY: &str = "KGS";
334
335/// `Num2Word_KY.CURRENCY_FORMS`, verbatim.
336///
337/// KY declares its **own** class-level dict, so — unlike the 16 classes that
338/// read `Num2Word_EUR`'s dict after `Num2Word_EN.__init__` has mutated it —
339/// none of EN's ~24 extra ISO codes (GBP, JPY, KWD, BHD, INR, CNY, CHF, ...)
340/// exist here. The corpus proves the isolation: `to_cheque(1234.56, "GBP")`
341/// raises NotImplementedError rather than rendering pounds.
342///
343/// Every entry carries exactly two forms, and both are identical — Kyrgyz does
344/// not inflect the counted noun. `to_currency` still indexes `[0]`/`[1]`
345/// literally, so the arity is kept even though the two are indistinguishable.
346fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
347    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
348    // Listed in the order of the Python dict literal. Only the first entry's
349    // identity matters (see FALLBACK_CURRENCY); the rest is a plain lookup.
350    m.insert("KGS", CurrencyForms::new(&["сом", "сом"], &["тыйын", "тыйын"]));
351    m.insert("USD", CurrencyForms::new(&["доллар", "доллар"], &["сент", "сент"]));
352    m.insert("EUR", CurrencyForms::new(&["евро", "евро"], &["сент", "сент"]));
353    m.insert("RUB", CurrencyForms::new(&["рубль", "рубль"], &["копейка", "копейка"]));
354    m
355}
356
357/// `parts = str(abs(val)).split(".")` → `(left, right)`, for the float/Decimal
358/// arm of `to_currency`.
359///
360/// Python reads the *decimal string*, not the number:
361///
362/// ```text
363/// parts = str(val).split(".")
364/// left  = int(parts[0]) if parts[0] else 0
365/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
366/// ```
367///
368/// `BigDecimal`'s `(int_val, scale)` pair *is* that string's structure —
369/// "12.34" parses to `(1234, 2)`, "0.5" to `(5, 1)`, "1.0" to `(10, 1)` — so
370/// the split is done on the digits rather than by re-formatting, which would
371/// depend on `Display` agreeing with Python's `str()`.
372///
373/// Two behaviours here are Python's, not conveniences:
374///
375/// * **`[:2]` truncates, never rounds.** `12.345` → 34 cents, not 35.
376/// * **`.ljust(2, "0")` pads on the *right*.** `0.5` → parts[1] == "5" → "50"
377///   → **50** cents, not 5. Dropping the pad turns "нөл евро элүү сент" into
378///   "нөл евро беш сент".
379///
380/// `abs()` runs before `str()` in Python, and negating changes no digit and no
381/// scale, so taking `abs` of `int_val` here is equivalent.
382fn split_currency_parts(value: &BigDecimal) -> Result<(BigInt, BigInt)> {
383    let (int_val, scale) = value.as_bigint_and_exponent();
384    let int_val = int_val.abs();
385
386    // scale < 0 means the literal carried a positive exponent, which is the
387    // only way `BigDecimal::from_str` produces one. Python's `str()` keeps
388    // that form for floats (`str(1e21) == "1e+21"`) and for Decimals
389    // (`str(Decimal("1E+2")) == "1E+2"`) alike, so `.split(".")` yields a
390    // one-element list and `int("1e+21")` raises ValueError. Confirmed live:
391    //   >>> Num2Word_KY().to_currency(1e21, "EUR")
392    //   ValueError: invalid literal for int() with base 10: '1e+21'
393    // Both possible source types agree, so this branch is unambiguous.
394    if scale < 0 {
395        return Err(N2WError::Value(format!(
396            "invalid literal for int() with base 10: '{}'",
397            value
398        )));
399    }
400
401    // No ".": `len(parts) > 1` is false, so `right` stays 0.
402    if scale == 0 {
403        return Ok((int_val, BigInt::zero()));
404    }
405
406    // The last `scale` digits are what `str()` prints after the "."; pad on
407    // the left so there is always at least one integer digit ("0.01" → "001",
408    // giving parts[0] == "0" and parts[1] == "01").
409    let digits = int_val.to_string();
410    let s = scale as usize;
411    let padded = if digits.len() <= s {
412        format!("{}{}", "0".repeat(s - digits.len() + 1), digits)
413    } else {
414        digits
415    };
416    let split = padded.len() - s;
417    let left: BigInt = padded[..split].parse().expect("digits only");
418
419    // parts[1][:2].ljust(2, "0")
420    let mut two: String = padded[split..].chars().take(2).collect();
421    while two.len() < 2 {
422        two.push('0');
423    }
424    let right: BigInt = two.parse().expect("digits only");
425    Ok((left, right))
426}
427
428/// Reconstruct Python's `str(number).split(".")` for the float/Decimal cardinal
429/// path, returning `(abs_left, right_digits, has_dot)`.
430///
431/// KY's `to_cardinal` reads the *decimal string*, not a rounded `(pre, post)`
432/// tuple: every character after the "." is spelled as its own digit, with no
433/// rounding, truncation, `< 0.01` heuristic, or `precision` consultation. So
434/// this reproduces that string rather than routing through
435/// `Num2Word_Base.float2tuple` (which KY's override never reaches).
436///
437/// * **Decimal** — the `(int_val, scale)` pair *is* the string's structure, so
438///   the split is exact: "1.10" → left 1, right "10" (the trailing zero
439///   survives because `BigDecimal` keeps the scale); "0.001" → left 0, right
440///   "001"; "98746251323029.99" → left 98746251323029, right "99".
441/// * **Float** — formatted to exactly `precision` fractional places. `precision`
442///   is Python's shortest-round-trip digit count (`abs(Decimal(str(value))
443///   .as_tuple().exponent)`, computed on the Python side), so `{:.precision}`
444///   reproduces `str(float)` across the whole reachable range. The f64 artefacts
445///   that force `base.float2tuple`'s heuristic never surface here: `2.675`
446///   formats straight to "2.675" and `1.005` to "1.005", because `str()`
447///   already collapses the binary noise to the clean shortest form.
448///
449/// `abs()` is taken first (on the f64, or on `int_val`), mirroring Python
450/// stripping the leading "-" off the string before re-parsing; negating changes
451/// no digit and no scale, so the absolute reconstruction is identical.
452fn decompose_float(v: &FloatValue) -> (BigInt, String, bool) {
453    match v {
454        FloatValue::Decimal { value, .. } => {
455            // Use the parsed scale, not the passed `precision`: they agree for
456            // every reachable Decimal (both are `abs(exponent)`), and the scale
457            // is what actually carries the digit count of `str()`.
458            let (int_val, scale) = value.as_bigint_and_exponent();
459            let abs = int_val.abs();
460            if scale <= 0 {
461                // Integral Decimal: `str()` has no ".", so Python's integer
462                // branch runs. Unreachable from the dispatcher (integral values
463                // take the int path), handled defensively as the integer form.
464                return (abs, String::new(), false);
465            }
466            let digits = abs.to_string();
467            let s = scale as usize;
468            // Pad on the left so there is always at least one integer digit
469            // ("0.01" → "001" → left "0", right "01").
470            let padded = if digits.len() <= s {
471                format!("{}{}", "0".repeat(s - digits.len() + 1), digits)
472            } else {
473                digits
474            };
475            let split = padded.len() - s;
476            let left: BigInt = padded[..split].parse().expect("digits only");
477            // Exactly `scale` characters — the full fractional part, no `[:2]`
478            // truncation (that is the currency path's quirk, not this one).
479            let right = padded[split..].to_string();
480            (left, right, true)
481        }
482        FloatValue::Float { value, precision } => {
483            // Fixed-notation, so large magnitudes never flip to Rust's `1e21`
484            // Display form; `precision` fractional places reproduce `str()`.
485            let s = format!("{:.*}", *precision as usize, value.abs());
486            match s.split_once('.') {
487                Some((l, r)) => (l.parse().expect("digits only"), r.to_string(), true),
488                None => (s.parse().expect("digits only"), String::new(), false),
489            }
490        }
491    }
492}
493
494/// Python's `int(s)` failure, message verbatim from CPython. The `s` is the
495/// point-less `str(number)` slice KY feeds it — sign already stripped, because
496/// `to_cardinal` peels the "-" off the string and recurses *before* `int()`.
497fn int_value_error(s: &str) -> N2WError {
498    N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
499}
500
501/// Python `repr(float)` for the scientific range, on an already-`abs()`ed
502/// finite f64 (`a >= 1e16`, or `0 < a < 1e-4`).
503///
504/// Rust's `{:e}` yields the shortest round-trip mantissa — the same digits
505/// CPython's repr picks — but lays the exponent out as `1e16` / `1e-5` where
506/// Python writes `1e+16` / `1e-05`: always a sign, zero-padded to two digits.
507/// Only the exponent dressing needs fixing up.
508fn py_float_sci_repr(a: f64) -> String {
509    let sci = format!("{:e}", a); // "1e16", "1.5e20", "1e-5"
510    let (mant, exp_str) = sci.split_once('e').expect("{:e} always emits an 'e'");
511    let exp: i64 = exp_str.parse().expect("{:e} exponent is a base-10 integer");
512    format!(
513        "{}e{}{:02}",
514        mant,
515        if exp < 0 { '-' } else { '+' },
516        exp.abs()
517    )
518}
519
520/// Where `str(number)` sends the value in `to_cardinal` (sign already peeled).
521enum StrRoute {
522    /// `"." in n` — the decimal grammar spells every fractional character.
523    Point,
524    /// No "." and `int(n)` succeeds — `_int_to_word` runs on this magnitude.
525    WholeDigits(BigInt),
526}
527
528/// Reproduce Python's `"." in str(number)` routing, including the `int(n)`
529/// `ValueError` for the point-less forms that are not integer literals.
530///
531/// * **Float** — repr is fixed-notation (always with a ".") for every finite
532///   value outside the scientific range, so the only no-point floats are the
533///   scientific ones (`|v| >= 1e16`, `0 < |v| < 1e-4`) and inf/nan — all of
534///   which make `int()` raise. `FloatValue::has_visible_point` is *not* used
535///   here: it reports `true` for tiny scientific floats (`1e-05`), whose repr
536///   has no point and must raise.
537/// * **Decimal** — `str(Decimal)` is reconstructed by `python_decimal_str`;
538///   a point routes to the decimal grammar, plain digits (canonical integral
539///   Decimals like "100") parse, and everything else ("1E+2", "1E+20") is the
540///   `int()` ValueError.
541fn route_by_str(v: &FloatValue) -> Result<StrRoute> {
542    match v {
543        FloatValue::Float { value, .. } => {
544            let a = value.abs();
545            if !a.is_finite() {
546                // str(float("inf")) == "inf", str(float("nan")) == "nan" —
547                // no ".", so int() raises. The "-" of "-inf" is stripped by
548                // the string-sign recursion before int() ever sees it.
549                return Err(int_value_error(if a.is_nan() { "nan" } else { "inf" }));
550            }
551            if a >= 1e16 || (a > 0.0 && a < 1e-4) {
552                // repr picked exponent form: no "." in n -> int(n) raises.
553                return Err(int_value_error(&py_float_sci_repr(a)));
554            }
555            // Every other finite float reprs with a point ("5.0", "0.0001").
556            Ok(StrRoute::Point)
557        }
558        FloatValue::Decimal { value, .. } => {
559            // abs() first: Python strips the string sign before this test.
560            let s = python_decimal_str(&value.abs());
561            if s.contains('.') {
562                Ok(StrRoute::Point)
563            } else if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) {
564                Ok(StrRoute::WholeDigits(s.parse().expect("digits only")))
565            } else {
566                // "1E+2", "1E+20", ... — int() raises.
567                Err(int_value_error(&s))
568            }
569        }
570    }
571}
572
573pub struct LangKy {
574    exclude_title: Vec<String>,
575    forms: HashMap<&'static str, CurrencyForms>,
576}
577
578impl Default for LangKy {
579    fn default() -> Self {
580        Self::new()
581    }
582}
583
584impl LangKy {
585    pub fn new() -> Self {
586        LangKy {
587            // `setup`: self.exclude_title = ["минус", "үтүр"] — note "минус"
588            // here has no trailing space, unlike `negword`.
589            exclude_title: vec![NEGWORD.trim().to_string(), POINTWORD.to_string()],
590            // Built once here, never per call. `to_currency` only ever reads
591            // this table, and rebuilding it per call is what made an earlier
592            // revision of this port slower than the Python it replaces.
593            forms: build_currency_forms(),
594        }
595    }
596
597    /// Port of `_int_to_word`, entry point on `BigInt`.
598    ///
599    /// Splits into the arbitrary-precision concerns (zero, the >= 10^9 digit
600    /// fallback, and the unreachable negative-index quirk) and then hands the
601    /// proven-bounded remainder to [`LangKy::int_to_word_small`].
602    fn int_to_word(&self, number: &BigInt) -> Result<String> {
603        // `if number == 0: return "нөл"`
604        if number.is_zero() {
605            return Ok(ZERO_WORD.to_string());
606        }
607
608        if number.is_negative() {
609            // Unreachable from the four in-scope modes: `to_cardinal` strips
610            // the "-" off the *string* before it ever calls `_int_to_word`.
611            // Modelled anyway so this cannot panic, and because Python's
612            // behaviour here is a genuine quirk rather than an error: a
613            // negative `number` satisfies `number < 10`, so Python evaluates
614            // `self.ones[number]` — a *negative list index*. For -10..=-1 that
615            // silently wraps to `ONES[number + 10]` (so `_int_to_word(-5)`
616            // == "беш"); anything <= -11 is out of range and raises
617            // IndexError.
618            let wrapped = number + BigInt::from(10);
619            return match wrapped.to_usize() {
620                Some(i) if i < ONES.len() => Ok(ONES[i].to_string()),
621                _ => Err(N2WError::Index("list index out of range".to_string())),
622            };
623        }
624
625        // Quirk 1: the final `return str(number)`. No OverflowError, no
626        // milliard word — just the decimal digits. Must be checked on the
627        // BigInt, since `number` is unbounded above.
628        if number >= &one_e9() {
629            return Ok(number.to_string());
630        }
631
632        // Proven: 0 < number < 10^9, so a u64 is lossless from here down.
633        let n = number
634            .to_u64()
635            .expect("0 < number < 10^9 was just proven, so u64 conversion cannot fail");
636        Ok(self.int_to_word_small(n))
637    }
638
639    /// Port of `_int_to_word`'s spelled-out branches, for `0 <= n < 10^9`.
640    ///
641    /// Each `+ (" " + ... if x else "")` in Python is a conditional suffix,
642    /// *not* an unconditional join — hence the explicit `!= 0` guards, which
643    /// are what keep 100 at "жүз" rather than "жүз нөл".
644    fn int_to_word_small(&self, n: u64) -> String {
645        // Mirrors Python's first line. Every call site below guards `r != 0`,
646        // so this is defensive rather than reachable.
647        if n == 0 {
648            return ZERO_WORD.to_string();
649        }
650
651        // `if number < 10: return self.ones[number]`
652        if n < 10 {
653            return ONES[n as usize].to_string();
654        }
655
656        // `if number < 100:`
657        //     `t, o = divmod(number, 10)`
658        //     `return self.tens[t] + (" " + self.ones[o] if o else "")`
659        if n < 100 {
660            let (t, o) = (n / 10, n % 10);
661            let mut s = TENS[t as usize].to_string();
662            if o != 0 {
663                s.push(' ');
664                s.push_str(ONES[o as usize]);
665            }
666            return s;
667        }
668
669        // `if number < 1000:`
670        //     `h, r = divmod(number, 100)`
671        //     `base = (self.ones[h] + " " if h > 1 else "") + self.hundred`
672        //     `return base + (" " + self._int_to_word(r) if r else "")`
673        // Quirk 2: `h > 1` suppresses "бир", so 100 == "жүз".
674        if n < 1_000 {
675            let (h, r) = (n / 100, n % 100);
676            let mut s = String::new();
677            if h > 1 {
678                s.push_str(ONES[h as usize]);
679                s.push(' ');
680            }
681            s.push_str(HUNDRED);
682            if r != 0 {
683                s.push(' ');
684                s.push_str(&self.int_to_word_small(r));
685            }
686            return s;
687        }
688
689        // `if number < 1000000:`
690        //     `t, r = divmod(number, 1000)`
691        //     `base = (self._int_to_word(t) + " " if t > 1 else "") + self.thousand`
692        //     `return base + (" " + self._int_to_word(r) if r else "")`
693        // Quirk 2 again: `t > 1` suppresses "бир", so 1000 == "миң".
694        if n < 1_000_000 {
695            let (t, r) = (n / 1_000, n % 1_000);
696            let mut s = String::new();
697            if t > 1 {
698                s.push_str(&self.int_to_word_small(t));
699                s.push(' ');
700            }
701            s.push_str(THOUSAND);
702            if r != 0 {
703                s.push(' ');
704                s.push_str(&self.int_to_word_small(r));
705            }
706            return s;
707        }
708
709        // `if number < 1000000000:`
710        //     `m, r = divmod(number, 1000000)`
711        //     `base = self._int_to_word(m) + " " + self.million`
712        //     `return base + (" " + self._int_to_word(r) if r else "")`
713        // Quirk 2, inverted: no `m > 1` guard here, so 10^6 == "бир миллион".
714        let (m, r) = (n / 1_000_000, n % 1_000_000);
715        let mut s = self.int_to_word_small(m);
716        s.push(' ');
717        s.push_str(MILLION);
718        if r != 0 {
719            s.push(' ');
720            s.push_str(&self.int_to_word_small(r));
721        }
722        s
723    }
724}
725
726impl Lang for LangKy {
727    /// This language's own `to_currency(currency=...)` default,
728    /// read from the live Python signature. Only 44 of 156 use EUR.
729    fn default_currency(&self) -> &str {
730        "KGS"
731    }
732
733    /// This language's own `to_currency(separator=...)` default,
734    /// read from the live Python signature. Base's is ",", but only
735    /// 36 of 149 languages actually use it — most default to " " or a
736    /// conjunction, so inheriting Base's comma silently corrupts them.
737    fn default_separator(&self) -> &str {
738        " "
739    }
740
741    fn negword(&self) -> &str {
742        NEGWORD
743    }
744
745    fn pointword(&self) -> &str {
746        "үтүр"
747    }
748
749    // `is_title` is left at Num2Word_Base's `False`; `setup` never touches it.
750    // KY's `to_cardinal` override never calls `title()` anyway.
751
752    fn exclude_title(&self) -> &[String] {
753        &self.exclude_title
754    }
755
756    /// Port of `to_cardinal`.
757    ///
758    /// The Python operates on `str(number)`; for integral input the "." branch
759    /// is dead and the "-" branch reduces to a recursion on the absolute
760    /// value. See the module docs.
761    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
762        // `if n.startswith("-"):`
763        //     `return (self.negword + self.to_cardinal(n[1:])).strip()`
764        if value.is_negative() {
765            let inner = self.to_cardinal(&-value)?;
766            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
767        }
768        // `return self._int_to_word(int(n))`
769        self.int_to_word(value)
770    }
771
772    /// The cardinal with the harmonised ordinal ending glued to its **last
773    /// word** — "бир" -> "биринчи", "үч" -> "үчүнчү", "жүз жыйырма үч" ->
774    /// "жүз жыйырма үчүнчү". See [`ordinal_suffix`].
775    ///
776    /// No `verify_ordinal`, matching upstream: negatives and zero still pass
777    /// straight through rather than raising, so `to_ordinal(-1)` is
778    /// "минус биринчи" and `to_ordinal(0)` is "нөлүнчү". Past 10^9 it
779    /// inherits the digit fallback and the suffix has no vowel to harmonise
780    /// with, so it takes the hyphenated digit form:
781    /// `to_ordinal(10**9)` == "1000000000-инчи".
782    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
783        let cardinal = self.to_cardinal(value)?;
784        Ok(join_ordinal(&cardinal))
785    }
786
787    /// The digits, a hyphen, and the same harmonised ending the spelled form
788    /// would take: "1-инчи", "3-үнчү", "10-унчу", "40-ынчы".
789    ///
790    /// The ending is read off the *spelled* cardinal, because that is the
791    /// word a reader supplies when reading the digits aloud — there is no
792    /// vowel in "3" to harmonise against. Above 10^9 `to_cardinal` returns
793    /// the digits themselves, which have no vowel either, so the default
794    /// class applies.
795    ///
796    /// The minus sign survives, as upstream: `to_ordinal_num(-1)` is
797    /// "-1-инчи".
798    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
799        let suffix = match self.to_cardinal(value) {
800            Ok(cardinal) => ordinal_suffix(&cardinal),
801            // Unreachable for an integer — `to_cardinal` has no failing
802            // branch on this path — but the digit form must not start
803            // raising where it used to succeed.
804            Err(_) => DEFAULT_ORDINAL_SUFFIX.after_consonant,
805        };
806        Ok(format!("{}-{}", value, suffix))
807    }
808
809    /// Port of `to_year`: `return self.to_cardinal(val)`.
810    ///
811    /// KY declares `longval=True` but ignores it, and does no century
812    /// pairing or BC/AD handling — years are plain cardinals, so
813    /// `to_year(1984)` == "миң тогуз жүз сексен төрт" and `to_year(-44)` ==
814    /// "минус кырк төрт".
815    fn to_year(&self, value: &BigInt) -> Result<String> {
816        self.to_cardinal(value)
817    }
818
819    /// Port of `to_cardinal`'s float/Decimal branch.
820    ///
821    /// `Num2Word_KY` overrides `to_cardinal` and handles non-integers *inline*
822    /// off `str(number)`; it never reaches `Num2Word_Base.to_cardinal_float`
823    /// / `float2tuple`. So this reproduces that string-based branch directly:
824    ///
825    /// ```text
826    /// n = str(number).strip()
827    /// if n.startswith("-"):
828    ///     return (self.negword + self.to_cardinal(n[1:])).strip()
829    /// if "." in n:
830    ///     left, right = n.split(".", 1)
831    ///     ret = self._int_to_word(int(left)) + " " + self.pointword
832    ///     for digit in right:
833    ///         ret += " " + (self.ones[int(digit)] or "нөл")
834    ///     return ret.strip()
835    /// ```
836    ///
837    /// Notes on fidelity:
838    ///
839    /// * **No rounding, truncation, or precision.** Each fractional character is
840    ///   spelled as its own digit; `2.675` → "эки үтүр алты жети беш" and
841    ///   `Decimal("1.10")` → "бир үтүр бир нөл" (the trailing zero is kept).
842    /// * **`precision_override` is ignored.** KY's `to_cardinal` never reads
843    ///   `self.precision`, so the `precision=` kwarg has no effect on the
844    ///   output — verified live: `num2words(2.675, lang="ky", precision=1)`
845    ///   still yields all three fractional digits.
846    /// * **The `>= 10^9` digit fallback reaches the integer part.** The `left`
847    ///   of `98746251323029.99` renders as its bare digits, since `_int_to_word`
848    ///   gives up past a billion.
849    /// * **The fractional digit uses `self.ones[d]`, not `_int_to_word`.**
850    ///   `ones[0]` is `""` (falsy) → "нөл"; every other index is its own word.
851    fn to_cardinal_float(
852        &self,
853        value: &FloatValue,
854        _precision_override: Option<u32>,
855    ) -> Result<String> {
856        // `if n.startswith("-"):` — the sign is read off the string, then the
857        // absolute value is rendered and prefixed with negword.
858        let is_negative = value.is_negative();
859        let (left, right, has_dot) = decompose_float(value);
860
861        let mut body = if has_dot {
862            // `ret = self._int_to_word(int(left)) + " " + self.pointword`
863            let mut s = format!("{} {}", self.int_to_word(&left)?, POINTWORD);
864            // `for digit in right: ret += " " + (self.ones[int(digit)] or "нөл")`
865            for ch in right.chars() {
866                let d = ch.to_digit(10).expect("fractional part is digits only") as usize;
867                s.push(' ');
868                // `self.ones[d] or "нөл"` — ONES[0] == "" is falsy → "нөл".
869                s.push_str(if ONES[d].is_empty() { ZERO_WORD } else { ONES[d] });
870            }
871            s
872        } else {
873            // No ".": Python falls through to `_int_to_word(int(n))`. Only
874            // reachable for the defensive integral-Decimal case above.
875            self.int_to_word(&left)?
876        };
877
878        // `(self.negword + ...).strip()` — negword ("минус ") carries its own
879        // trailing space, which becomes the separator.
880        if is_negative {
881            body = format!("{}{}", NEGWORD, body);
882        }
883        Ok(body.trim().to_string())
884    }
885
886    /// `to_cardinal(float/Decimal)` — the full entry, routing on
887    /// `"." in str(number)` rather than on whole-ness (see the module docs).
888    ///
889    /// A whole float therefore keeps its ".0" tail (`5.0` -> "беш үтүр нөл",
890    /// `-0.0` -> "минус нөл үтүр нөл"), a point-less integral Decimal takes
891    /// the integer grammar (`Decimal("100")` -> "жүз"), and a point-less
892    /// non-integer form is Python's `int()` ValueError (`1e+16`,
893    /// `Decimal("1E+2")`).
894    ///
895    /// `precision_override` is threaded through untouched; KY's grammar never
896    /// reads a precision, so it is inert either way (see `to_cardinal_float`).
897    fn cardinal_float_entry(
898        &self,
899        value: &FloatValue,
900        precision_override: Option<u32>,
901    ) -> Result<String> {
902        match route_by_str(value)? {
903            StrRoute::Point => self.to_cardinal_float(value, precision_override),
904            StrRoute::WholeDigits(abs_int) => {
905                // `return self._int_to_word(int(n))`, with the string sign
906                // already peeled and re-attached as negword by the recursion:
907                // `(self.negword + self.to_cardinal(n[1:])).strip()`.
908                let body = self.int_to_word(&abs_int)?;
909                if value.is_negative() {
910                    Ok(format!("{}{}", NEGWORD, body).trim().to_string())
911                } else {
912                    Ok(body)
913                }
914            }
915        }
916    }
917
918    /// `to_ordinal(float/Decimal)`: the float cardinal plus the harmonised
919    /// ending, so `5.0` == "беш үтүр нөлүнчү" (the last spoken word is "нөл",
920    /// which harmonises to ү-class). The ValueError of `1e+16` propagates
921    /// unchanged, as upstream.
922    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
923        let cardinal = self.cardinal_float_entry(value, None)?;
924        Ok(join_ordinal(&cardinal))
925    }
926
927    /// `to_ordinal_num(float/Decimal)`: `str(number)`, a hyphen and the
928    /// default ending. Purely textual — nothing is parsed, so the scientific
929    /// forms still succeed: `1e+16` == "1e+16-инчи",
930    /// `Decimal("1E+2")` == "1E+2-инчи". A decimal repr has no Kyrgyz vowel
931    /// to harmonise against, so the class cannot be recovered here.
932    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
933        Ok(format!(
934            "{}-{}",
935            repr_str, DEFAULT_ORDINAL_SUFFIX.after_consonant
936        ))
937    }
938
939    // `year_float_entry` is deliberately NOT overridden: KY's `to_year` is
940    // `self.to_cardinal(val)`, and the trait default routes through the
941    // overridden `cardinal_float_entry` above — so `to_year(5.0)` == "беш
942    // үтүр нөл" and `to_year(1e+16)` raises ValueError, as the corpus pins.
943
944    /// `converter.str_to_number` — Base's `Decimal(value)`, which KY does not
945    /// override. `ParsedNumber` cannot carry Inf/NaN into KY's per-mode split
946    /// (`to_cardinal` dies in `int("Infinity")` with ValueError, but
947    /// `to_ordinal_num` returns "Infinity-инчи" successfully), so both
948    /// return NotImplemented: the shim catches it and reruns the original
949    /// pure-Python string path, which produces exactly those outcomes for
950    /// every mode.
951    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
952        // Inf/NaN are carried through natively now (see `inf_result` /
953        // `nan_result`), so the per-mode split runs in Rust instead of the
954        // Python fallback.
955        python_decimal_parse(s)
956    }
957
958    /// `Decimal('Infinity')` / `Decimal('-Infinity')` per mode. KY overrides
959    /// every in-scope method and reads `str(number)`:
960    ///
961    /// * `to_cardinal` / `to_ordinal` (== cardinal + suffix) / `to_year`
962    ///   (== cardinal) do `int("Infinity")` after stripping the sign, so they
963    ///   raise `ValueError` with the sign-stripped token — never OverflowError.
964    /// * `to_ordinal_num` is purely textual; nothing is parsed, so it
965    ///   succeeds ("Infinity-инчи" / "-Infinity-инчи").
966    ///
967    /// The currency/fraction fallbacks also `int()` the token, so they land on
968    /// the same ValueError.
969    fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
970        match to {
971            "ordinal_num" => Ok(format!(
972                "{}-{}",
973                if negative { "-Infinity" } else { "Infinity" },
974                DEFAULT_ORDINAL_SUFFIX.after_consonant
975            )),
976            // Sign is peeled off the string before `int()`, so the message
977            // always quotes the bare "Infinity".
978            _ => Err(int_value_error("Infinity")),
979        }
980    }
981
982    /// `Decimal('NaN')` per mode — same split as `inf_result`: the parsing
983    /// modes `int("NaN")` → ValueError, while `to_ordinal_num` returns
984    /// "NaN-инчи" without parsing.
985    fn nan_result(&self, to: &str) -> Result<String> {
986        match to {
987            "ordinal_num" => Ok(format!(
988                "NaN-{}",
989                DEFAULT_ORDINAL_SUFFIX.after_consonant
990            )),
991            _ => Err(int_value_error("NaN")),
992        }
993    }
994
995    // ---- currency -------------------------------------------------------
996    //
997    // `Num2Word_KY` overrides `to_currency` and `pluralize`, and adds its own
998    // `CURRENCY_FORMS`. Everything else on the currency path — `to_cheque`,
999    // `_money_verbose`, `_cents_verbose`, `_cents_terse` — is inherited from
1000    // `Num2Word_Base` untouched, so the trait defaults already mirror it.
1001    //
1002    // It defines neither `CURRENCY_PRECISION` nor `CURRENCY_ADJECTIVES`, so
1003    // both stay at `Num2Word_Base`'s empty dicts: `currency_precision` is 100
1004    // for every code (the trait default) and `currency_adjective` is always
1005    // None. Not overriding them here *is* the port.
1006
1007    fn lang_name(&self) -> &str {
1008        "Num2Word_KY"
1009    }
1010
1011    /// `CURRENCY_FORMS[code]` — a **strict** lookup, missing code → None.
1012    ///
1013    /// This is the subscript in `Num2Word_Base.to_cheque`
1014    /// (`cr1, _cr2 = self.CURRENCY_FORMS[currency]`, whose `KeyError` is
1015    /// caught and re-raised as NotImplementedError). `to_currency` must *not*
1016    /// route through here — it uses `.get(currency, <first entry>)` and so
1017    /// never raises. That asymmetry is real and the corpus pins both halves:
1018    ///
1019    /// ```text
1020    /// currency:GBP 12.34 → "он эки сом отуз төрт тыйын"   (falls back to KGS)
1021    /// cheque:GBP   1234.56 → NotImplementedError
1022    /// ```
1023    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1024        self.forms.get(code)
1025    }
1026
1027    /// Port of `Num2Word_KY.pluralize`:
1028    ///
1029    /// ```text
1030    /// if not forms:
1031    ///     return ""
1032    /// return forms[0] if n == 1 else forms[-1]
1033    /// ```
1034    ///
1035    /// Dead code in practice: KY's `to_currency` indexes `cr1`/`cr2` directly
1036    /// instead of calling this, and `Num2Word_Base.to_cheque` never pluralizes
1037    /// (it takes `cr1[-1]` unconditionally). Ported because it is a real
1038    /// override, and it would come alive if `to_currency` were ever reverted
1039    /// to Base's. Note the empty-`forms` guard — unlike `Num2Word_EUR`'s
1040    /// version, this one cannot raise IndexError.
1041    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1042        if forms.is_empty() {
1043            return Ok(String::new());
1044        }
1045        Ok(if n.is_one() {
1046            forms[0].clone()
1047        } else {
1048            forms[forms.len() - 1].clone()
1049        })
1050    }
1051
1052    /// Port of `Num2Word_KY.to_currency`.
1053    ///
1054    /// ```text
1055    /// is_negative = val < 0
1056    /// val = abs(val)
1057    /// parts = str(val).split(".")
1058    /// left  = int(parts[0]) if parts[0] else 0
1059    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
1060    /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
1061    /// result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
1062    /// if cents and right:
1063    ///     result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
1064    /// if is_negative:
1065    ///     result = self.negword + result
1066    /// return result.strip()
1067    /// ```
1068    ///
1069    /// A wholesale replacement of `Num2Word_Base.to_currency` that shares
1070    /// almost nothing with it. Four differences carry the behaviour:
1071    ///
1072    /// 1. **An unknown currency never raises.** Base subscripts the dict and
1073    ///    turns the `KeyError` into NotImplementedError; KY `.get`s it with the
1074    ///    first entry (KGS) as the default, so `currency:JPY` renders soms.
1075    ///    Every non-KGS/USD/EUR/RUB row in the corpus is that fallback.
1076    /// 2. **`CURRENCY_PRECISION` is never consulted.** The divisor is hard-wired
1077    ///    to 100 by `parts[1][:2]`, so the 3-decimal (KWD/BHD) and 0-decimal
1078    ///    (JPY) branches of Base simply do not exist — `currency:JPY 12.34` is
1079    ///    "он эки сом отуз төрт тыйын", subunits and all, where Base would round
1080    ///    to a whole yen and drop them.
1081    /// 3. **`has_decimal` is irrelevant.** Base prints a zero-cents segment for
1082    ///    any float; KY gates on `right` being non-zero, so `1.0` → "бир евро"
1083    ///    and `Decimal("5.00")` → "беш евро" (both verified live).
1084    /// 4. **`adjective` is accepted and ignored** — there is no
1085    ///    `CURRENCY_ADJECTIVES` to consult and no call to `prefix_currency`.
1086    ///
1087    /// `pluralize` is bypassed too: the forms are indexed literally.
1088    fn to_currency(
1089        &self,
1090        val: &CurrencyValue,
1091        currency: &str,
1092        cents: bool,
1093        separator: Option<&str>,
1094        _adjective: bool,
1095    ) -> Result<String> {
1096        // None = the caller omitted separator=, so KY's own default (" ")
1097        // applies. Resolved here because overriding `to_currency` bypasses the
1098        // trait default's body, which is where that normally happens.
1099        let separator = separator.unwrap_or(self.default_separator());
1100
1101        // `is_negative = val < 0` — evaluated before `abs`, on the number.
1102        let is_negative = val.is_negative();
1103
1104        let (left, right) = match val {
1105            // `str(int)` never contains a ".", so `parts` is one element and
1106            // `right` stays 0. This is why `currency:EUR 1` shows no cents
1107            // while `currency:EUR 1.0` goes through the branch below (and then
1108            // shows none anyway, because right == 0).
1109            CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
1110            CurrencyValue::Decimal { value, .. } => split_currency_parts(value)?,
1111        };
1112
1113        // `.get(currency, list(values())[0])` — the default is always KGS.
1114        let forms = self
1115            .forms
1116            .get(currency)
1117            .or_else(|| self.forms.get(FALLBACK_CURRENCY))
1118            .expect("KGS is always present");
1119        let (cr1, cr2) = (&forms.unit, &forms.subunit);
1120
1121        // `cr1[1] if left != 1 else cr1[0]` — a literal tuple index, not
1122        // `pluralize`. Both KY forms are identical, so the branch is invisible
1123        // in the output; it is kept because it is what Python evaluates.
1124        let unit = index_form(cr1, &left)?;
1125        let mut result = format!("{} {}", self.int_to_word(&left)?, unit);
1126
1127        // `if cents and right:` — `right` is falsy at 0, which is what
1128        // suppresses the segment for "1.0" and "100.0".
1129        if cents && !right.is_zero() {
1130            let sub = index_form(cr2, &right)?;
1131            // Python concatenates `separator` with no space of its own: the
1132            // default separator " " *is* the gap. So separator="," yields
1133            // "он эки евро,отуз төрт сент" — verified live. Do not add a
1134            // space here to make it look like Base's "%s%s %s%s %s %s".
1135            result.push_str(separator);
1136            result.push_str(&self.int_to_word(&right)?);
1137            result.push(' ');
1138            result.push_str(sub);
1139        }
1140
1141        if is_negative {
1142            result = format!("{}{}", NEGWORD, result);
1143        }
1144        // `.strip()` — a no-op for every reachable input (`negword` is the only
1145        // thing that could add whitespace and it prepends), kept for fidelity.
1146        Ok(result.trim().to_string())
1147    }
1148}
1149
1150/// `forms[1] if n != 1 else forms[0]`.
1151///
1152/// Python indexes the tuple directly, so a single-form entry with `n != 1`
1153/// would raise IndexError. Every `Num2Word_KY` entry has exactly two forms, so
1154/// that is unreachable — but it is mapped to `Index` rather than allowed to
1155/// panic, so the exception *type* survives if the table ever changes.
1156fn index_form<'a>(forms: &'a [String], n: &BigInt) -> Result<&'a str> {
1157    let i = if n.is_one() { 0 } else { 1 };
1158    forms
1159        .get(i)
1160        .map(|s| s.as_str())
1161        .ok_or_else(|| N2WError::Index("tuple index out of range".to_string()))
1162}
1163
1164#[cfg(test)]
1165mod currency_tests {
1166    use super::*;
1167    use std::str::FromStr;
1168
1169    /// Drive a `currency:<CODE>` corpus row. The corpus was generated without
1170    /// the `separator=` kwarg, so `None` is passed — exactly as `diff_test.py`
1171    /// does — which must resolve through `default_separator()` to " ".
1172    fn cur(arg: &str, is_int: bool, code: &str) -> String {
1173        let v = CurrencyValue::parse(arg, is_int, !is_int, !is_int).unwrap();
1174        LangKy::new()
1175            .to_currency(&v, code, true, None, false)
1176            .unwrap()
1177    }
1178
1179    fn cheque(arg: &str, code: &str) -> Result<String> {
1180        LangKy::new().to_cheque(&BigDecimal::from_str(arg).unwrap(), code)
1181    }
1182
1183    /// Every expectation is copied verbatim from the frozen corpus
1184    /// (`grep '"lang": "ky", "to": "currency' bench/corpus.jsonl`).
1185    #[test]
1186    fn corpus_currency_known_codes() {
1187        assert_eq!(cur("0", true, "EUR"), "нөл евро");
1188        assert_eq!(cur("1", true, "EUR"), "бир евро");
1189        assert_eq!(cur("2", true, "EUR"), "эки евро");
1190        assert_eq!(cur("100", true, "EUR"), "жүз евро");
1191        assert_eq!(cur("1000000", true, "EUR"), "бир миллион евро");
1192        assert_eq!(cur("12.34", false, "EUR"), "он эки евро отуз төрт сент");
1193        assert_eq!(cur("0.01", false, "EUR"), "нөл евро бир сент");
1194        assert_eq!(cur("0.5", false, "EUR"), "нөл евро элүү сент");
1195        assert_eq!(
1196            cur("99.99", false, "EUR"),
1197            "токсон тогуз евро токсон тогуз сент"
1198        );
1199        assert_eq!(
1200            cur("1234.56", false, "EUR"),
1201            "миң эки жүз отуз төрт евро элүү алты сент"
1202        );
1203        assert_eq!(cur("-12.34", false, "EUR"), "минус он эки евро отуз төрт сент");
1204
1205        assert_eq!(cur("0", true, "USD"), "нөл доллар");
1206        assert_eq!(cur("1", true, "USD"), "бир доллар");
1207        assert_eq!(cur("2", true, "USD"), "эки доллар");
1208        assert_eq!(cur("100", true, "USD"), "жүз доллар");
1209        assert_eq!(cur("1000000", true, "USD"), "бир миллион доллар");
1210        assert_eq!(cur("12.34", false, "USD"), "он эки доллар отуз төрт сент");
1211        assert_eq!(cur("0.01", false, "USD"), "нөл доллар бир сент");
1212        assert_eq!(cur("0.5", false, "USD"), "нөл доллар элүү сент");
1213        assert_eq!(
1214            cur("99.99", false, "USD"),
1215            "токсон тогуз доллар токсон тогуз сент"
1216        );
1217        assert_eq!(
1218            cur("1234.56", false, "USD"),
1219            "миң эки жүз отуз төрт доллар элүү алты сент"
1220        );
1221        assert_eq!(
1222            cur("-12.34", false, "USD"),
1223            "минус он эки доллар отуз төрт сент"
1224        );
1225    }
1226
1227    /// `1.0` is a float, so Python reaches `parts[1] == "0"` → `right == 0` →
1228    /// falsy → no cents segment. Same text as the int `1`, by a different path.
1229    #[test]
1230    fn corpus_currency_float_with_zero_cents() {
1231        assert_eq!(cur("1.0", false, "EUR"), "бир евро");
1232        assert_eq!(cur("1.0", false, "USD"), "бир доллар");
1233        assert_eq!(cur("1.0", false, "GBP"), "бир сом");
1234        assert_eq!(cur("1", true, "EUR"), "бир евро");
1235    }
1236
1237    /// Unknown codes silently render as KGS — `.get(currency, values()[0])`,
1238    /// never NotImplementedError. Note JPY still shows subunits and KWD/BHD
1239    /// are plain 100ths: KY never reads CURRENCY_PRECISION.
1240    #[test]
1241    fn corpus_currency_unknown_code_falls_back_to_kgs() {
1242        for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1243            assert_eq!(cur("0", true, code), "нөл сом");
1244            assert_eq!(cur("1", true, code), "бир сом");
1245            assert_eq!(cur("2", true, code), "эки сом");
1246            assert_eq!(cur("100", true, code), "жүз сом");
1247            assert_eq!(cur("1000000", true, code), "бир миллион сом");
1248            assert_eq!(cur("12.34", false, code), "он эки сом отуз төрт тыйын");
1249            assert_eq!(cur("0.01", false, code), "нөл сом бир тыйын");
1250            assert_eq!(cur("1.0", false, code), "бир сом");
1251            assert_eq!(cur("0.5", false, code), "нөл сом элүү тыйын");
1252            assert_eq!(
1253                cur("99.99", false, code),
1254                "токсон тогуз сом токсон тогуз тыйын"
1255            );
1256            assert_eq!(
1257                cur("1234.56", false, code),
1258                "миң эки жүз отуз төрт сом элүү алты тыйын"
1259            );
1260            assert_eq!(
1261                cur("-12.34", false, code),
1262                "минус он эки сом отуз төрт тыйын"
1263            );
1264        }
1265    }
1266
1267    /// Not corpus rows — each verified directly against the live interpreter,
1268    /// since the corpus generator never varies these kwargs.
1269    #[test]
1270    fn currency_quirks_verified_against_python() {
1271        let ky = LangKy::new();
1272        let v = CurrencyValue::parse("12.34", false, true, true).unwrap();
1273
1274        // cents=False drops the segment entirely; it does NOT fall back to
1275        // `_cents_terse` the way Num2Word_Base would.
1276        assert_eq!(ky.to_currency(&v, "EUR", false, None, false).unwrap(), "он эки евро");
1277
1278        // adjective is accepted and ignored (CURRENCY_ADJECTIVES is empty and
1279        // KY never calls prefix_currency).
1280        assert_eq!(
1281            ky.to_currency(&v, "EUR", true, None, true).unwrap(),
1282            "он эки евро отуз төрт сент"
1283        );
1284
1285        // KY concatenates `separator` with no space of its own, so an explicit
1286        // separator="," closes the gap. Live: 'on eki evro,otuz tört sent'.
1287        assert_eq!(
1288            ky.to_currency(&v, "EUR", true, Some(","), false).unwrap(),
1289            "он эки евро,отуз төрт сент"
1290        );
1291
1292        // parts[1][:2] truncates, never rounds: 12.345 → 34 sent, not 35.
1293        let v = CurrencyValue::parse("12.345", false, true, true).unwrap();
1294        assert_eq!(
1295            ky.to_currency(&v, "EUR", true, None, false).unwrap(),
1296            "он эки евро отуз төрт сент"
1297        );
1298
1299        // Decimal("5.00") → parts[1] == "00" → right == 0 → no cents.
1300        let v = CurrencyValue::parse("5.00", false, true, true).unwrap();
1301        assert_eq!(ky.to_currency(&v, "EUR", true, None, false).unwrap(), "беш евро");
1302        // float 100.0 → parts[1] == "0" → right == 0 → no cents.
1303        let v = CurrencyValue::parse("100.0", false, true, true).unwrap();
1304        assert_eq!(ky.to_currency(&v, "EUR", true, None, false).unwrap(), "жүз евро");
1305        // 0.0001 → parts[1][:2] == "00" → right == 0.
1306        let v = CurrencyValue::parse("0.0001", false, true, true).unwrap();
1307        assert_eq!(ky.to_currency(&v, "EUR", true, None, false).unwrap(), "нөл евро");
1308
1309        // RUB is the one code the corpus never exercises.
1310        let v = CurrencyValue::parse("2.05", false, true, true).unwrap();
1311        assert_eq!(
1312            ky.to_currency(&v, "RUB", true, None, false).unwrap(),
1313            "эки рубль беш копейка"
1314        );
1315
1316        // The 10^9 digit fallback reaches the currency path too.
1317        let v = CurrencyValue::parse("1000000000", true, false, false).unwrap();
1318        assert_eq!(ky.to_currency(&v, "EUR", true, None, false).unwrap(), "1000000000 евро");
1319    }
1320
1321    /// `str(1e+21) == "1e+21"` → `int("1e+21")` → ValueError. The parsed
1322    /// BigDecimal keeps the negative scale, so the notation stays recoverable;
1323    /// this guards the assumption that from_str does not normalise it away.
1324    #[test]
1325    fn currency_exponential_repr_is_valueerror() {
1326        for arg in ["1e+21", "1e+16", "1E+2"] {
1327            let v = CurrencyValue::parse(arg, false, true, true).unwrap();
1328            match LangKy::new().to_currency(&v, "EUR", true, None, false) {
1329                Err(N2WError::Value(_)) => {}
1330                other => panic!("{} expected ValueError, got {:?}", arg, other),
1331            }
1332        }
1333        // Documented divergence: Python raises ValueError for the float 1e-05
1334        // ("1e-05" has no "."), but renders Decimal("0.00001") as "нөл евро".
1335        // Both parse to the same BigDecimal (int_val 1, scale 5), so the two
1336        // are indistinguishable here and we match the Decimal branch. See
1337        // `concerns` in the port report.
1338        assert_eq!(cur("1e-05", false, "EUR"), "нөл евро");
1339    }
1340
1341    /// Inherited `Num2Word_Base.to_cheque` — KY adds no override. The unit is
1342    /// `cr1[-1]` unconditionally, and KY's forms are identical singular/plural.
1343    /// Verbatim from `grep '"lang": "ky", "to": "cheque' bench/corpus.jsonl`.
1344    #[test]
1345    fn corpus_cheque() {
1346        assert_eq!(
1347            cheque("1234.56", "EUR").unwrap(),
1348            "МИҢ ЭКИ ЖҮЗ ОТУЗ ТӨРТ AND 56/100 ЕВРО"
1349        );
1350        assert_eq!(
1351            cheque("1234.56", "USD").unwrap(),
1352            "МИҢ ЭКИ ЖҮЗ ОТУЗ ТӨРТ AND 56/100 ДОЛЛАР"
1353        );
1354        // `self.CURRENCY_FORMS[currency]` → KeyError → NotImplementedError.
1355        // Unlike to_currency, to_cheque has no KGS fallback. This asymmetry is
1356        // the whole reason `currency_forms()` stays a strict lookup.
1357        for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1358            match cheque("1234.56", code) {
1359                Err(N2WError::NotImplemented(m)) => assert_eq!(
1360                    m,
1361                    format!(
1362                        "Currency code \"{}\" not implemented for \"Num2Word_KY\"",
1363                        code
1364                    )
1365                ),
1366                other => panic!("{} expected NotImplementedError, got {:?}", code, other),
1367            }
1368        }
1369    }
1370
1371    /// Not corpus rows — verified live against `Num2Word_KY().to_cheque(...)`.
1372    #[test]
1373    fn cheque_quirks_verified_against_python() {
1374        assert_eq!(
1375            cheque("1234.56", "KGS").unwrap(),
1376            "МИҢ ЭКИ ЖҮЗ ОТУЗ ТӨРТ AND 56/100 СОМ"
1377        );
1378        // Negative → "MINUS " prefix; the whole body is upper-cased.
1379        assert_eq!(cheque("-1.0", "USD").unwrap(), "MINUS БИР AND 00/100 ДОЛЛАР");
1380    }
1381
1382    /// KY's `pluralize` is dead code on every reachable path (`to_currency`
1383    /// indexes the forms literally, `to_cheque` takes `cr1[-1]`), but it is a
1384    /// real override, so its contract is pinned here.
1385    #[test]
1386    fn pluralize_matches_python() {
1387        let ky = LangKy::new();
1388        let forms: Vec<String> = vec!["сом".into(), "сом".into()];
1389        assert_eq!(ky.pluralize(&BigInt::from(1), &forms).unwrap(), "сом");
1390        assert_eq!(ky.pluralize(&BigInt::from(2), &forms).unwrap(), "сом");
1391        // forms[-1], not forms[1] — a three-form entry would take the last.
1392        let three: Vec<String> = vec!["a".into(), "b".into(), "c".into()];
1393        assert_eq!(ky.pluralize(&BigInt::from(2), &three).unwrap(), "c");
1394        // `if not forms: return ""` — cannot raise IndexError, unlike EUR's.
1395        assert_eq!(ky.pluralize(&BigInt::from(2), &[]).unwrap(), "");
1396    }
1397}
1398
1399#[cfg(test)]
1400mod float_tests {
1401    use super::*;
1402    use std::str::FromStr;
1403
1404    /// Drive a `to: "cardinal"` float corpus row. `precision` is the value the
1405    /// Python binding computes (`abs(Decimal(str(value)).as_tuple().exponent)`)
1406    /// and hands across — the number of fractional digits in Python's `repr`.
1407    fn f(value: f64, precision: u32) -> String {
1408        LangKy::new()
1409            .to_cardinal_float(&FloatValue::Float { value, precision }, None)
1410            .unwrap()
1411    }
1412
1413    /// Drive a `to: "cardinal_dec"` Decimal corpus row from its literal string.
1414    /// The output does not depend on the `precision` field (decompose reads the
1415    /// parsed scale), but it is set to the true scale for realism.
1416    fn d(s: &str) -> String {
1417        let value = BigDecimal::from_str(s).unwrap();
1418        let precision = value.as_bigint_and_exponent().1.max(0) as u32;
1419        LangKy::new()
1420            .to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
1421            .unwrap()
1422    }
1423
1424    /// Every expectation copied verbatim from the frozen corpus
1425    /// (`grep '"lang": "ky", "to": "cardinal", "arg": "[0-9-]*\.' ...`).
1426    #[test]
1427    fn corpus_cardinal_float() {
1428        assert_eq!(f(0.0, 1), "нөл үтүр нөл");
1429        assert_eq!(f(0.5, 1), "нөл үтүр беш");
1430        assert_eq!(f(1.0, 1), "бир үтүр нөл");
1431        assert_eq!(f(1.5, 1), "бир үтүр беш");
1432        assert_eq!(f(2.25, 2), "эки үтүр эки беш");
1433        assert_eq!(f(3.14, 2), "үч үтүр бир төрт");
1434        assert_eq!(f(0.01, 2), "нөл үтүр нөл бир");
1435        assert_eq!(f(0.1, 1), "нөл үтүр бир");
1436        assert_eq!(f(0.99, 2), "нөл үтүр тогуз тогуз");
1437        assert_eq!(f(1.01, 2), "бир үтүр нөл бир");
1438        assert_eq!(f(12.34, 2), "он эки үтүр үч төрт");
1439        assert_eq!(f(99.99, 2), "токсон тогуз үтүр тогуз тогуз");
1440        assert_eq!(f(100.5, 1), "жүз үтүр беш");
1441        assert_eq!(f(1234.56, 2), "миң эки жүз отуз төрт үтүр беш алты");
1442        assert_eq!(f(-0.5, 1), "минус нөл үтүр беш");
1443        assert_eq!(f(-1.5, 1), "минус бир үтүр беш");
1444        assert_eq!(f(-12.34, 2), "минус он эки үтүр үч төрт");
1445    }
1446
1447    /// The two f64-artefact cases. KY sidesteps `float2tuple`'s
1448    /// `674.9999999999998` heuristic entirely: `str(2.675) == "2.675"` and
1449    /// `str(1.005) == "1.005"`, and `{:.precision}` reproduces those clean
1450    /// shortest-repr strings, so no rounding rescue is ever needed.
1451    #[test]
1452    fn corpus_cardinal_float_artefacts() {
1453        assert_eq!(f(1.005, 3), "бир үтүр нөл нөл беш");
1454        assert_eq!(f(2.675, 3), "эки үтүр алты жети беш");
1455    }
1456
1457    /// `precision_override` is inert for KY (its `to_cardinal` never consults
1458    /// `self.precision`). Verified live: all three digits survive regardless.
1459    #[test]
1460    fn precision_override_is_ignored() {
1461        let ky = LangKy::new();
1462        let v = FloatValue::Float { value: 2.675, precision: 3 };
1463        assert_eq!(ky.to_cardinal_float(&v, Some(1)).unwrap(), "эки үтүр алты жети беш");
1464        assert_eq!(ky.to_cardinal_float(&v, Some(5)).unwrap(), "эки үтүр алты жети беш");
1465    }
1466
1467    /// Every expectation copied verbatim from the frozen corpus
1468    /// (`grep '"lang": "ky", "to": "cardinal_dec"' ...`). The Decimal arm keeps
1469    /// trailing zeros ("1.10" → "бир үтүр бир нөл"), renders the full fractional
1470    /// part with no `[:2]` truncation ("12.345" → three digits), and inherits
1471    /// the `>= 10^9` digit fallback on the integer part.
1472    #[test]
1473    fn corpus_cardinal_dec() {
1474        assert_eq!(d("0.01"), "нөл үтүр нөл бир");
1475        assert_eq!(d("1.10"), "бир үтүр бир нөл");
1476        assert_eq!(d("12.345"), "он эки үтүр үч төрт беш");
1477        assert_eq!(d("98746251323029.99"), "98746251323029 үтүр тогуз тогуз");
1478        assert_eq!(d("0.001"), "нөл үтүр нөл нөл бир");
1479    }
1480
1481    /// Not corpus rows — verified live against `num2words(Decimal(...), 'ky')`.
1482    /// A Decimal whose fractional part is all zeros still spells them out
1483    /// (unlike the currency path, where `right == 0` suppresses the segment),
1484    /// because KY reads the *string* form, not a numeric magnitude.
1485    #[test]
1486    fn cardinal_dec_quirks_verified_against_python() {
1487        assert_eq!(d("5.00"), "беш үтүр нөл нөл");
1488    }
1489}
1490
1491#[cfg(test)]
1492mod entry_routing_tests {
1493    use super::*;
1494    use std::str::FromStr;
1495
1496    fn fv(value: f64, precision: u32) -> FloatValue {
1497        FloatValue::Float { value, precision }
1498    }
1499
1500    fn dv(s: &str) -> FloatValue {
1501        let value = BigDecimal::from_str(s).unwrap();
1502        let precision = value.as_bigint_and_exponent().1.max(0) as u32;
1503        FloatValue::Decimal { value, precision }
1504    }
1505
1506    /// `"." in str(number)` routing — corpus_wholefloat rows, verbatim.
1507    #[test]
1508    fn corpus_cardinal_entry() {
1509        let ky = LangKy::new();
1510        // Whole floats keep their ".0" tail.
1511        assert_eq!(ky.cardinal_float_entry(&fv(5.0, 1), None).unwrap(), "беш үтүр нөл");
1512        assert_eq!(ky.cardinal_float_entry(&fv(0.0, 1), None).unwrap(), "нөл үтүр нөл");
1513        assert_eq!(
1514            ky.cardinal_float_entry(&fv(-1000000.0, 1), None).unwrap(),
1515            "минус бир миллион үтүр нөл"
1516        );
1517        // -0.0: the sign lives in the *string*, so the negword survives.
1518        assert_eq!(
1519            ky.cardinal_float_entry(&fv(-0.0, 1), None).unwrap(),
1520            "минус нөл үтүр нөл"
1521        );
1522        // The >= 10^9 digit fallback composes with the ".0" tail.
1523        assert_eq!(
1524            ky.cardinal_float_entry(&fv(1e9, 1), None).unwrap(),
1525            "1000000000 үтүр нөл"
1526        );
1527        // Decimals: trailing zeros of the literal are all spelled.
1528        assert_eq!(ky.cardinal_float_entry(&dv("5.00"), None).unwrap(), "беш үтүр нөл нөл");
1529        assert_eq!(
1530            ky.cardinal_float_entry(&dv("12345.000"), None).unwrap(),
1531            "он эки миң үч жүз кырк беш үтүр нөл нөл нөл"
1532        );
1533        // Point-less integral Decimals take the integer grammar.
1534        assert_eq!(ky.cardinal_float_entry(&dv("0"), None).unwrap(), "нөл");
1535        assert_eq!(ky.cardinal_float_entry(&dv("100"), None).unwrap(), "жүз");
1536    }
1537
1538    /// Scientific repr has no "." -> `int(n)` ValueError, message verbatim.
1539    #[test]
1540    fn corpus_cardinal_entry_scientific_is_valueerror() {
1541        let ky = LangKy::new();
1542        for (v, repr) in [(1e16, "1e+16"), (1e20, "1e+20"), (1e-5, "1e-05")] {
1543            match ky.cardinal_float_entry(&fv(v, 0), None) {
1544                Err(N2WError::Value(m)) => assert_eq!(
1545                    m,
1546                    format!("invalid literal for int() with base 10: '{}'", repr)
1547                ),
1548                other => panic!("{} expected ValueError, got {:?}", v, other),
1549            }
1550        }
1551        // Negative: the string sign is stripped before int(), so no minus.
1552        match ky.cardinal_float_entry(&fv(-1e16, 0), None) {
1553            Err(N2WError::Value(m)) => {
1554                assert_eq!(m, "invalid literal for int() with base 10: '1e+16'")
1555            }
1556            other => panic!("expected ValueError, got {:?}", other),
1557        }
1558        for (s, repr) in [("1E+2", "1E+2"), ("1E+20", "1E+20")] {
1559            match ky.cardinal_float_entry(&dv(s), None) {
1560                Err(N2WError::Value(m)) => assert_eq!(
1561                    m,
1562                    format!("invalid literal for int() with base 10: '{}'", repr)
1563                ),
1564                other => panic!("{} expected ValueError, got {:?}", s, other),
1565            }
1566        }
1567    }
1568
1569    /// `to_ordinal(float)` = cardinal + the harmonised ending; errors
1570    /// propagate unchanged.
1571    #[test]
1572    fn corpus_ordinal_entry() {
1573        let ky = LangKy::new();
1574        assert_eq!(
1575            ky.ordinal_float_entry(&fv(5.0, 1)).unwrap(),
1576            "беш үтүр нөлүнчү"
1577        );
1578        assert_eq!(
1579            ky.ordinal_float_entry(&fv(-0.0, 1)).unwrap(),
1580            "минус нөл үтүр нөлүнчү"
1581        );
1582        assert_eq!(
1583            ky.ordinal_float_entry(&fv(3.25, 2)).unwrap(),
1584            "үч үтүр эки бешинчи"
1585        );
1586        assert_eq!(ky.ordinal_float_entry(&dv("0")).unwrap(), "нөлүнчү");
1587        assert_eq!(
1588            ky.ordinal_float_entry(&dv("5.00")).unwrap(),
1589            "беш үтүр нөл нөлүнчү"
1590        );
1591        assert!(matches!(
1592            ky.ordinal_float_entry(&fv(1e16, 0)),
1593            Err(N2WError::Value(_))
1594        ));
1595    }
1596
1597    /// `to_ordinal_num(float)` = `str(number)` + "-инчи" — nothing parses,
1598    /// so the scientific forms succeed here.
1599    #[test]
1600    fn corpus_ordinal_num_entry() {
1601        let ky = LangKy::new();
1602        assert_eq!(
1603            ky.ordinal_num_float_entry(&fv(5.0, 1), "5.0").unwrap(),
1604            "5.0-инчи"
1605        );
1606        assert_eq!(
1607            ky.ordinal_num_float_entry(&fv(-0.0, 1), "-0.0").unwrap(),
1608            "-0.0-инчи"
1609        );
1610        assert_eq!(
1611            ky.ordinal_num_float_entry(&fv(1e16, 0), "1e+16").unwrap(),
1612            "1e+16-инчи"
1613        );
1614        assert_eq!(
1615            ky.ordinal_num_float_entry(&dv("1E+2"), "1E+2").unwrap(),
1616            "1E+2-инчи"
1617        );
1618        assert_eq!(
1619            ky.ordinal_num_float_entry(&dv("5.00"), "5.00").unwrap(),
1620            "5.00-инчи"
1621        );
1622    }
1623
1624    /// `to_year(float)` — the trait default routes through the overridden
1625    /// `cardinal_float_entry`, giving KY's plain-cardinal years.
1626    #[test]
1627    fn corpus_year_entry() {
1628        let ky = LangKy::new();
1629        assert_eq!(ky.year_float_entry(&fv(5.0, 1)).unwrap(), "беш үтүр нөл");
1630        assert_eq!(
1631            ky.year_float_entry(&fv(-0.0, 1)).unwrap(),
1632            "минус нөл үтүр нөл"
1633        );
1634        assert!(matches!(
1635            ky.year_float_entry(&fv(1e20, 0)),
1636            Err(N2WError::Value(_))
1637        ));
1638    }
1639
1640    /// "Infinity"/"NaN" strings fall back to Python (NotImplemented), which
1641    /// reproduces the per-mode split (`cardinal` ValueError vs `ordinal_num`
1642    /// "Infinity-инчи") that `ParsedNumber` cannot carry.
1643    #[test]
1644    fn str_to_number_inf_nan_falls_through() {
1645        let ky = LangKy::new();
1646        // Inf/NaN are now carried through natively (see `inf_result`/
1647        // `nan_result`), so str_to_number succeeds rather than declining.
1648        for s in ["Infinity", "-Infinity", "NaN"] {
1649            assert!(ky.str_to_number(s).is_ok());
1650        }
1651        // Everything else keeps Base's Decimal(value) semantics.
1652        assert!(matches!(ky.str_to_number("5"), Ok(ParsedNumber::Dec(_))));
1653        assert!(matches!(ky.str_to_number("1e3"), Ok(ParsedNumber::Dec(_))));
1654    }
1655
1656    /// String inputs land on the same entries via Decimal: "1e3" parses to
1657    /// Decimal("1E+3"), whose point-less non-integer str raises int()'s
1658    /// ValueError — the corpus_strings rows for "1e3"/"1E3".
1659    #[test]
1660    fn corpus_strings_scientific_decimal_is_valueerror() {
1661        let ky = LangKy::new();
1662        match ky.cardinal_float_entry(&dv("1E+3"), None) {
1663            Err(N2WError::Value(m)) => {
1664                assert_eq!(m, "invalid literal for int() with base 10: '1E+3'")
1665            }
1666            other => panic!("expected ValueError, got {:?}", other),
1667        }
1668    }
1669}