Skip to main content

num2words2_core/
lang_ka.rs

1//! Port of `lang_KA.py` (Georgian).
2//!
3//! Registry check: `CONVERTER_CLASSES["ka"] == lang_KA.Num2Word_KA()`
4//! (`__init__.py:343`), so this file ports `Num2Word_KA` — the only class the
5//! module defines.
6//!
7//! Shape: **self-contained**. `Num2Word_KA` subclasses `Num2Word_Base` but its
8//! `setup()` defines none of `high_numwords`/`mid_numwords`/`low_numwords`.
9//! `Num2Word_Base.__init__` guards the card-building block with
10//! `if any(hasattr(self, field) for field in [...])`, so Python never builds
11//! `self.cards` and **never sets `self.MAXVAL`**. All four in-scope methods are
12//! overridden by KA, so `cards`/`maxval`/`merge` stay at their trait defaults
13//! here and are never consulted. There is no overflow check at all — see bug 3.
14//!
15//! Nothing is inherited from `Num2Word_Base` in scope: KA overrides
16//! `to_cardinal`, `to_ordinal`, `to_ordinal_num` and `to_year`. In particular
17//! `verify_ordinal` is never called, which is why negatives and zero sail
18//! straight through `to_ordinal` instead of raising `TypeError` (bug 4).
19//!
20//! # Faithfully reproduced Python bugs
21//!
22//! This is a port, not a rewrite. Everything below is wrong-looking but is
23//! exactly what Python emits, verified against `bench/corpus.jsonl`:
24//!
25//! 1. **Composite tens are not composed.** Georgian is vigesimal and writes 21
26//!    as one word, "ოცდაერთი". `_int_to_word` instead concatenates the table
27//!    entries with a space: `to_cardinal(21)` == "ოცი ერთი" ("twenty one"),
28//!    `to_cardinal(31)` == "ოცდაათი ერთი", `to_cardinal(99)` ==
29//!    "ოთხმოცდაათი ცხრა". Note `tens[3]` == "ოცდაათი" already *means* thirty
30//!    (literally "twenty-and-ten"), so the composed forms read oddly.
31//! 2. **Hundreds carry a redundant "one".** The `< 1000` arm is unconditionally
32//!    `ones[n // 100] + " " + hundred`, with no `n // 100 == 1` special case, so
33//!    `to_cardinal(100)` == "ერთი ასი" ("one hundred") rather than plain "ასი".
34//!    The same shape appears at every scale: `to_cardinal(1000)` ==
35//!    "ერთი ათასი", `to_cardinal(10**6)` == "ერთი მილიონი".
36//! 3. **No words above 10^12 — and no `OverflowError`.** The final `else` of
37//!    `_int_to_word` is `return str(number)`, a bare digit fallback. So
38//!    `to_cardinal(10**12)` == "1000000000000" (a numeral, not words) and
39//!    `to_ordinal(10**21)` == "მე-1000000000000000000000". Because `MAXVAL` was
40//!    never set (see above), the base class's overflow guard is unreachable and
41//!    arbitrarily large input silently returns its own digits. This is why
42//!    [`int_to_word`] is infallible: there is no error path.
43//! 4. **`to_ordinal` prefixes the minus phrase.** It only special-cases 1..=10
44//!    and otherwise returns `"მე-" + to_cardinal(number)`, with no
45//!    `verify_ordinal` call. Hence `to_ordinal(0)` == "მე-ნული" ("th-zero") and
46//!    `to_ordinal(-1)` == "მე-მინუს ერთი" ("th-minus one") — the ordinal prefix
47//!    lands on the negation word. Both are corpus-confirmed, not speculation.
48//! 5. **`to_year` emits English.** `to_year(-44)` == "BC ორმოცი ოთხი` — the
49//!    literal ASCII "BC " is prepended in a Georgian module. The positive arm is
50//!    the no-op `return "" + self.to_cardinal(val)`.
51//! 6. `tens[1]` ("ათი") is dead: the `number < 20` arm catches 10 first and
52//!    returns `teens[0]`, which is the same word. Kept in the table for
53//!    index alignment.
54//! 7. `to_year`'s `longval=True` parameter is accepted and never read. The Rust
55//!    fast path in `num2words()` only fires when no kwargs are passed, so the
56//!    trait's single-argument `to_year` covers the dispatched surface exactly.
57//!
58//! ## Currency quirks (phase 2)
59//!
60//! 8. **An unknown currency code silently prints lari.** `to_currency` looks the
61//!    code up with `self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["GEL"])`,
62//!    so `currency:GBP`, `currency:JPY`, `currency:KWD` … all render Georgian
63//!    lari/tetri instead of raising. Only GEL/USD/EUR exist. This fallback is
64//!    **local to `to_currency`**: the inherited `to_cheque` subscripts
65//!    `CURRENCY_FORMS[currency]` and converts the `KeyError` into
66//!    `NotImplementedError`, which is why `cheque:GBP` raises while
67//!    `currency:GBP` quietly prints lari. Both halves are corpus-confirmed.
68//! 9. **`CURRENCY_PRECISION` is empty, so every code is 100 subunits.** KA never
69//!    defines it and `to_currency` never consults it — the fractional digits are
70//!    sliced out of the decimal *string*, hard-coded to two places. So JPY
71//!    (0-decimal) and KWD/BHD (3-decimal) get cents anyway: `currency:JPY` on
72//!    `12.34` is "თორმეტი ლარი ოცდაათი ოთხი თეთრი", not a rounded whole unit.
73//!    The trait's `currency_precision` default of 100 is exactly
74//!    `CURRENCY_PRECISION.get(code, 100)` against an empty dict, so it is left
75//!    alone — the inherited `to_cheque` reads it and wants 100.
76//! 10. **A float with zero cents drops the cents segment.** `right` is
77//!    `int(parts[1][:2].ljust(2, "0"))`, so `1.0` gives `int("00")` == 0, and the
78//!    guard is `if cents and right:` — an *int* truthiness test. So `1.0` prints
79//!    "ერთი euro", identical to the int `1`. This inverts `Num2Word_Base`, whose
80//!    whole point is that a float renders cents even when they are zero. KA does
81//!    not call `base.to_currency` at all, so the `isinstance(val, int)` branch
82//!    never runs and the int/float distinction collapses *for this language*.
83//! 11. **`[:2]` truncates rather than rounds.** `0.005` -> `parts[1][:2]` == "00"
84//!    -> 0 cents; `12.349` -> "34" -> 34 cents. No ROUND_HALF_UP anywhere.
85//!    Conversely `ljust` pads on the right, so `0.5` is *fifty* cents, not five.
86//! 12. **`cents=False` drops the cents entirely.** There is no `_cents_terse`
87//!    fallback — the guard is `if cents and right:`, so `cents=False` prints only
88//!    the unit. Base would have printed "12 euros, 34" instead.
89//! 13. `adjective` is accepted and never read; `CURRENCY_ADJECTIVES` is empty.
90//!
91//! # The float/Decimal path is a *string* algorithm
92//!
93//! `Num2Word_KA.to_cardinal` opens with `n = str(number).strip()` and branches
94//! on `"." in n`. It never calls `Num2Word_Base.to_cardinal_float`, never calls
95//! `float2tuple`, and never reads `self.precision`, so none of [`crate::floatpath`]
96//! applies. The fractional digits spoken are literally the characters after the
97//! `.` in `str(value)`, each one run back through `_int_to_word`. See the note
98//! on [`LangKa::to_cardinal_float`] and [`cardinal_from_str`]. The two classic
99//! f64-artefact cases (`1.005`, `2.675`) come out right, but via `repr`'s
100//! shortest round-trip rather than `float2tuple`'s `< 0.01` rescue.
101//!
102//! # Error variants
103//!
104//! For integer input every in-scope method is total: `str(number).strip()` of an
105//! int never contains "." (so the `pointword` split is unreachable), `int(n)` on
106//! the resulting digit string never raises, and the `_int_to_word` ladder
107//! terminates in the `str(number)` fallback rather than an exception.
108//!
109//! The float/Decimal path adds one, from the `int()` calls `to_cardinal` makes
110//! on the pieces of `str(number)`:
111//!
112//! * `Value` (`ValueError`) whenever `str(value)` is not a plain decimal
113//!   literal — exponent notation, `inf`, `nan`. `str(float)` switches to
114//!   exponent form at `|v| >= 1e16` and `0 < |v| < 1e-4`, so `1e16` raises
115//!   `invalid literal for int() with base 10: '1e+16'` (no `.`, the whole string
116//!   reaches `int()`) while `1.5e16` raises on the fragment `'e'` (the `.`
117//!   splits it, so the digit loop hits `int("e")` first). Same for `str(Decimal)`
118//!   in its uppercase-`E` exponent form.
119//!
120//! The currency surface adds two:
121//!
122//! * `NotImplemented` — from the **inherited** `to_cheque` only, for a code
123//!   outside GEL/USD/EUR. `to_currency` never raises it (quirk 8).
124//! * `Value` — `to_currency` runs `int()` over `str(val).split(".")[0]`, so a
125//!   float large enough for Python to `repr` in scientific notation feeds
126//!   `int("1e+16")` and raises `ValueError`. See the note on [`LangKa::to_currency`].
127
128use crate::base::{Lang, N2WError, Result};
129use crate::currency::{CurrencyForms, CurrencyValue};
130use crate::floatpath::FloatValue;
131use bigdecimal::BigDecimal;
132use num_bigint::BigInt;
133use num_integer::Integer;
134use num_traits::{One, Signed, ToPrimitive, Zero};
135use std::collections::HashMap;
136use std::str::FromStr;
137
138/// `self.negword`. Note the **trailing space** — it is part of the constant,
139/// not added at the call site.
140const NEGWORD: &str = "მინუს ";
141
142/// `self.pointword`. Reached on the float/Decimal path via
143/// [`cardinal_from_str`]. Unlike `Num2Word_Base.to_cardinal_float`, which
144/// pushes `self.title(self.pointword)` as its own list element, `Num2Word_KA`
145/// concatenates `" " + pointword + " "` by hand and never calls `title()`.
146/// Identical here (`is_title` is false), but reproduced as written.
147const POINTWORD: &str = "წერტილი";
148
149/// `self.zero`.
150const ZERO_WORD: &str = "ნული";
151
152/// `self.ones`; index 0 is "" and is only ever reached via `number == 0`,
153/// which returns `zero` first.
154const ONES: [&str; 10] = [
155    "",
156    "ერთი",
157    "ორი",
158    "სამი",
159    "ოთხი",
160    "ხუთი",
161    "ექვსი",
162    "შვიდი",
163    "რვა",
164    "ცხრა",
165];
166
167/// `self.tens`, indexed by the tens digit. Index 0 is unreachable (the
168/// `< 20` arm handles 10..19) and so is index 1 — see bug 6.
169const TENS: [&str; 10] = [
170    "",
171    "ათი",
172    "ოცი",
173    "ოცდაათი",
174    "ორმოცი",
175    "ორმოცდაათი",
176    "სამოცი",
177    "სამოცდაათი",
178    "ოთხმოცი",
179    "ოთხმოცდაათი",
180];
181
182/// `self.teens`, indexed by `number - 10` for 10..=19.
183const TEENS: [&str; 10] = [
184    "ათი",
185    "თერთმეტი",
186    "თორმეტი",
187    "ცამეტი",
188    "თოთხმეტი",
189    "თხუთმეტი",
190    "თექვსმეტი",
191    "ჩვიდმეტი",
192    "თვრამეტი",
193    "ცხრამეტი",
194];
195
196const HUNDRED: &str = "ასი";
197const THOUSAND: &str = "ათასი";
198const MILLION: &str = "მილიონი";
199const BILLION: &str = "მილიარდი";
200
201/// `Num2Word_KA.to_currency`'s own default `separator=" "`, confirmed against
202/// the interpreter: `Num2Word_KA.to_currency.__defaults__` is
203/// `("GEL", True, " ", False)`.
204const DEFAULT_SEPARATOR: &str = " ";
205
206/// `Num2Word_Base.to_currency`'s default `separator=","`.
207///
208/// The dispatcher (`__init__.py:546`) sends `kwargs.get("separator", ",")` down
209/// the Rust fast path — *base's* default, not KA's — so a caller who says
210/// nothing about the separator is indistinguishable from one who explicitly
211/// asked for `","`. `to_currency` reads this value as the "caller said nothing"
212/// sentinel it is and substitutes [`DEFAULT_SEPARATOR`]; see the note on
213/// [`LangKa::to_currency`]. `lang_br.rs` and `lang_sa.rs` take the same
214/// approach for the same reason.
215const BASE_DEFAULT_SEPARATOR: &str = ",";
216
217/// The code `to_currency` falls back to for an unknown currency (quirk 8).
218const FALLBACK_CURRENCY: &str = "GEL";
219
220/// The `to_ordinal` special cases for 1..=10, indexed by `number - 1`.
221/// Every other input (including 0 and negatives) takes the `მე-` + cardinal
222/// path — see bug 4.
223const ORDINALS: [&str; 10] = [
224    "პირველი", // 1
225    "მეორე",   // 2
226    "მესამე",  // 3
227    "მეოთხე",  // 4
228    "მეხუთე",  // 5
229    "მეექვსე", // 6
230    "მეშვიდე", // 7
231    "მერვე",   // 8
232    "მეცხრე",  // 9
233    "მეათე",   // 10
234];
235
236/// `to_ordinal`'s fallback prefix for everything outside 1..=10.
237const ORDINAL_PREFIX: &str = "მე-";
238
239/// Port of `Num2Word_KA._int_to_word`.
240///
241/// Infallible: the ladder's final `else` is `return str(number)`, so there is
242/// no input that escapes without a string (bug 3).
243///
244/// Python uses `//` and `%` (floor semantics). Every arm below `number < 0`
245/// only ever sees a positive value — the negative arm recurses on `abs(number)`
246/// first — but `div_mod_floor` is used anyway to keep Python's semantics rather
247/// than Rust's truncating `/`.
248fn int_to_word(number: &BigInt) -> String {
249    if number.is_zero() {
250        return ZERO_WORD.to_string();
251    }
252
253    // Dead code for the in-scope entry points: to_cardinal strips the sign
254    // before calling, to_year takes abs(), and to_ordinal delegates to
255    // to_cardinal. Ported for fidelity — and it agrees with the to_cardinal
256    // path anyway, since negword carries its own trailing space.
257    if number.is_negative() {
258        return format!("{}{}", NEGWORD, int_to_word(&number.abs()));
259    }
260
261    let ten = BigInt::from(10);
262    let hundred = BigInt::from(100);
263    let thousand = BigInt::from(1_000);
264    let million = BigInt::from(1_000_000);
265    let billion = BigInt::from(1_000_000_000);
266    let trillion = BigInt::from(1_000_000_000_000u64);
267
268    // `number < 10` -> ones[number]
269    if number < &ten {
270        // 1..=9, so the unwrap is total.
271        return ONES[number.to_usize().unwrap()].to_string();
272    }
273
274    // `number < 20` -> teens[number - 10]
275    if number < &BigInt::from(20) {
276        return TEENS[(number - &ten).to_usize().unwrap()].to_string();
277    }
278
279    // `number < 100` -> tens[number // 10] (+ " " + ones[number % 10])
280    if number < &hundred {
281        let (div, rem) = number.div_mod_floor(&ten);
282        // div is 2..=9.
283        let mut result = TENS[div.to_usize().unwrap()].to_string();
284        if !rem.is_zero() {
285            result.push(' ');
286            result.push_str(ONES[rem.to_usize().unwrap()]);
287        }
288        return result;
289    }
290
291    // `number < 1000` -> ones[number // 100] + " " + hundred (+ recurse)
292    // NB: no `div == 1` special case -> "ერთი ასი" for 100 (bug 2).
293    if number < &thousand {
294        let (div, rem) = number.div_mod_floor(&hundred);
295        // div is 1..=9.
296        let mut result = format!("{} {}", ONES[div.to_usize().unwrap()], HUNDRED);
297        if !rem.is_zero() {
298            result.push(' ');
299            result.push_str(&int_to_word(&rem));
300        }
301        return result;
302    }
303
304    // The three remaining scales share one shape: recurse on the quotient,
305    // append the scale word, then recurse on the remainder if non-zero.
306    // Written out rather than looped to keep the correspondence with the
307    // Python elif-ladder one-to-one.
308    if number < &million {
309        return scale(number, &thousand, THOUSAND);
310    }
311    if number < &billion {
312        return scale(number, &million, MILLION);
313    }
314    if number < &trillion {
315        return scale(number, &billion, BILLION);
316    }
317
318    // `else: return str(number)` -- the bare-numeral fallback (bug 3).
319    number.to_string()
320}
321
322/// The shared body of the thousand/million/billion arms:
323/// `_int_to_word(n // unit) + " " + word` then `+ " " + _int_to_word(n % unit)`
324/// when the remainder is non-zero.
325fn scale(number: &BigInt, unit: &BigInt, word: &str) -> String {
326    let (div, rem) = number.div_mod_floor(unit);
327    let mut result = format!("{} {}", int_to_word(&div), word);
328    if !rem.is_zero() {
329        result.push(' ');
330        result.push_str(&int_to_word(&rem));
331    }
332    result
333}
334
335// ---- float / Decimal path ----------------------------------------------
336//
337// `Num2Word_KA.to_cardinal` opens with `n = str(number).strip()` and branches
338// on `"." in n`. It never calls `Num2Word_Base.to_cardinal_float`, never calls
339// `float2tuple`, and never reads `self.precision`. So none of the machinery in
340// [`crate::floatpath`] applies: no `pre`/`post` split, no `10**precision`
341// scaling, no `abs(round(post) - post) < 0.01` heuristic, and no banker's
342// rounding. The fractional digits spoken are literally the characters after the
343// `.` in `str(value)`.
344//
345// So the whole float path reduces to reproducing Python's `str()`:
346// [`py_float_repr`] for `float`, [`py_decimal_str`] for `Decimal`, then feed the
347// result through [`cardinal_from_str`] — the exact body of `to_cardinal`. The
348// two classic f64-artefact cases (`1.005`, `2.675`) still come out right, but
349// for a different reason than the base path: `repr(2.675)` is the shortest
350// string that round-trips (`"2.675"`), so the `674.9999999999998` that
351// `float2tuple` exists to rescue is never computed here.
352//
353// These four helpers (`shortest_digits`/`py_float_repr`/`py_decimal_str`/
354// `py_int`) are the same language-agnostic reproductions used by `lang_as.rs`,
355// `lang_ba.rs`, `lang_eu.rs` and the other string-path modules.
356
357/// The shortest round-tripping decimal digits of `a` (finite, non-negative),
358/// plus `decpt`, the decimal-point position such that
359/// `a == 0.<digits> * 10**decpt`. This is CPython's `_Py_dg_dtoa(a, 0, 0, ...)`
360/// — David Gay's `dtoa` in mode 0.
361///
362/// Rust's `{:e}` is also shortest-round-trip and agrees with Gay on the digit
363/// *count* and almost always the digits. It disagrees on **exact ties**: when
364/// `a` sits precisely halfway between the two shortest candidates, both
365/// round-trip, and Gay's dtoa takes the one with an **even** last digit while
366/// Rust rounds half **up**. `repr(-78198386800398.125)` is
367/// `'-78198386800398.12'`; Rust's `{:e}` says `...13`. The block below detects
368/// that tie with no bignum and corrects it.
369///
370/// Write `a = m * 2**e` with `m` odd, `q = digits.len() - decpt`. The tie
371/// condition `a * 10**q == k + 1/2` reduces to `e + q + 1 == 0`, and when
372/// `q < 0` additionally `5**-q | m` (with `-q <= 22`, since `5**23 > 2**53`).
373/// Then `2k + 1 == m * 5**q` (or `m / 5**-q`), and because `5 ≡ 1 (mod 4)` that
374/// odd integer is `≡ m (mod 4)`, so `k` is even exactly when `m % 4 == 1`.
375fn shortest_digits(a: f64) -> (String, i32) {
376    // "d[.ddd]e<exp>", shortest round-trip.
377    let sci = format!("{:e}", a);
378    let (mant, exp) = sci
379        .split_once('e')
380        .expect("{:e} on a finite f64 always emits an exponent");
381    let mut digits: Vec<u8> = mant.bytes().filter(|c| *c != b'.').collect();
382    let mut decpt: i32 = exp.parse::<i32>().expect("{:e} exponent is an integer") + 1;
383
384    // Decompose a == m * 2**e exactly, then reduce m to odd.
385    let bits = a.to_bits();
386    let biased = ((bits >> 52) & 0x7ff) as i32;
387    let frac = bits & ((1u64 << 52) - 1);
388    let (mut m, mut e) = if biased == 0 {
389        (frac, -1074i32) // subnormal: no implicit leading bit
390    } else {
391        (frac | (1u64 << 52), biased - 1075)
392    };
393    if m == 0 {
394        // a == 0.0: dtoa reports digits "0", decpt 1. No tie to break.
395        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
396    }
397    let z = m.trailing_zeros() as i32;
398    m >>= z;
399    e += z;
400
401    let q = digits.len() as i32 - decpt;
402    let mut tie = e + q + 1 == 0;
403    if tie && q < 0 {
404        let r = -q as u32;
405        tie = r <= 22 && m % 5u64.pow(r) == 0;
406    }
407    if !tie {
408        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
409    }
410
411    let last = digits[digits.len() - 1] - b'0';
412    if last % 2 == 1 {
413        if m % 4 == 1 {
414            // k even: Python wants k, Rust gave k+1. Last digit odd => nonzero.
415            *digits.last_mut().expect("non-empty") -= 1;
416        } else {
417            // k odd: Python wants k+1, Rust gave k. Carry like dtoa's roundoff.
418            let mut i = digits.len();
419            loop {
420                if i == 0 {
421                    digits.insert(0, b'1');
422                    decpt += 1;
423                    break;
424                }
425                i -= 1;
426                if digits[i] == b'9' {
427                    digits[i] = b'0';
428                } else {
429                    digits[i] += 1;
430                    break;
431                }
432            }
433        }
434        // dtoa never emits trailing zeros; stripping them leaves decpt alone.
435        while digits.len() > 1 && *digits.last().expect("non-empty") == b'0' {
436            digits.pop();
437        }
438    }
439    (String::from_utf8(digits).expect("ASCII digits"), decpt)
440}
441
442/// Python's `str(float)` (== `repr(float)`), which `Num2Word_KA.to_cardinal`
443/// promotes from a formatting detail to *the entire specification* of the float
444/// path. This is CPython's `format_float_short(..., 'r', ...)` in `pystrtod.c`.
445///
446/// Rust's own `{}` cannot stand in: it never switches to exponent notation
447/// (`format!("{}", 1e16_f64)` is `"10000000000000000"`, where Python says
448/// `'1e+16'` — and that difference is why `to_cardinal(1e16)` raises
449/// `ValueError`) and it prints `1`, not `1.0`, for integral floats.
450///
451/// Rules from `format_float_short`:
452/// * exponent notation iff `decpt <= -4 || decpt > 16`;
453/// * the exponent is `%+.02d`: always signed, zero-padded to two digits;
454/// * `Py_DTSF_ADD_DOT_0` appends `.0` to an integral fixed-notation result, but
455///   never in exponent notation;
456/// * `nan` drops its sign, `inf` keeps it.
457fn py_float_repr(value: f64) -> String {
458    if value.is_nan() {
459        return "nan".to_string();
460    }
461    if value.is_infinite() {
462        return if value > 0.0 { "inf" } else { "-inf" }.to_string();
463    }
464    // is_sign_negative, not `< 0.0`: str(-0.0) is "-0.0", and to_cardinal
465    // strips that minus textually into a negword.
466    let sign = if value.is_sign_negative() { "-" } else { "" };
467    let (digits, decpt) = shortest_digits(value.abs());
468    let ndigits = digits.len() as i32;
469
470    if decpt <= -4 || decpt > 16 {
471        let exp = decpt - 1;
472        let mut mant = String::from(&digits[..1]);
473        if digits.len() > 1 {
474            mant.push('.');
475            mant.push_str(&digits[1..]);
476        }
477        format!(
478            "{}{}e{}{:02}",
479            sign,
480            mant,
481            if exp < 0 { '-' } else { '+' },
482            exp.abs()
483        )
484    } else if decpt <= 0 {
485        // 0.5 -> decpt 0 -> "0." + "" + "5"; 0.01 -> decpt -1 -> "0." + "0" + "1".
486        format!("{}0.{}{}", sign, "0".repeat(-decpt as usize), digits)
487    } else if decpt >= ndigits {
488        // Integral: pad right with zeros, then ADD_DOT_0. 1.0 -> "1" + ".0".
489        format!(
490            "{}{}{}.0",
491            sign,
492            digits,
493            "0".repeat((decpt - ndigits) as usize)
494        )
495    } else {
496        let d = decpt as usize;
497        format!("{}{}.{}", sign, &digits[..d], &digits[d..])
498    }
499}
500
501/// Python's `str(Decimal)` — `_pydecimal.Decimal.__str__` with `eng=False` and
502/// the default context (`capitals=1`, hence an uppercase `E`).
503///
504/// A `BigDecimal`'s `(int_val, scale)` is exactly `Decimal`'s `(_int, _exp)`
505/// with `_exp == -scale`: the shim builds this value with
506/// `BigDecimal::from_str(str(value))`, and that parse preserves trailing zeros
507/// and negative exponents rather than normalising, so `"1.10"` round-trips as
508/// `(110, 2)` and `"1E+16"` as `(1, -16)`.
509///
510/// # The negative-zero hole
511///
512/// `Decimal` carries `_sign` independently of `_int`, so `Decimal("-0.0")` is
513/// signed zero and `str()` gives `'-0.0'`; a `BigDecimal` cannot represent it
514/// (`int_val` is a `BigInt`, no negative zero), so `BigDecimal::from_str("-0.0")`
515/// has already discarded the sign. We emit `'0.0'` and drop the negword. The
516/// discriminator is the original string, which the `FloatValue::Decimal`
517/// boundary does not carry — flagged in the port report. (The `float` arm has
518/// no such hole: `-0.0_f64` keeps its sign bit, so `py_float_repr(-0.0)` is
519/// `"-0.0"` and matches Python.)
520fn py_decimal_str(value: &BigDecimal) -> String {
521    let (int_val, scale) = value.as_bigint_and_exponent();
522    // i128 so that `-scale` cannot overflow for a pathological i64::MIN scale.
523    let exp = -(scale as i128);
524    let sign = if int_val.is_negative() { "-" } else { "" };
525    let int_digits = int_val.abs().to_string(); // Decimal._int
526    let len = int_digits.len() as i128;
527
528    let leftdigits = exp + len;
529    let dotplace = if exp <= 0 && leftdigits > -6 {
530        leftdigits
531    } else {
532        1
533    };
534
535    let (intpart, fracpart) = if dotplace <= 0 {
536        (
537            "0".to_string(),
538            format!(".{}{}", "0".repeat(-dotplace as usize), int_digits),
539        )
540    } else if dotplace >= len {
541        (
542            format!("{}{}", int_digits, "0".repeat((dotplace - len) as usize)),
543            String::new(),
544        )
545    } else {
546        let d = dotplace as usize;
547        (int_digits[..d].to_string(), format!(".{}", &int_digits[d..]))
548    };
549
550    let expstr = if leftdigits == dotplace {
551        String::new()
552    } else {
553        // "%+d" — signed, but *not* zero-padded, unlike repr(float)'s "%+.02d".
554        let d = leftdigits - dotplace;
555        format!("E{}{}", if d < 0 { '-' } else { '+' }, d.abs())
556    };
557
558    format!("{}{}{}{}", sign, intpart, fracpart, expstr)
559}
560
561/// Python's `int(s)`, for the fragments [`cardinal_from_str`] hands it. Every
562/// string that reaches here is a slice of `str(float)` / `str(Decimal)`, i.e.
563/// plain ASCII, so the extra generality of the real builtin (non-ASCII digits,
564/// exotic whitespace) is deliberately not ported. What is ported is the
565/// underscore rule and the error message: Python formats the **original,
566/// unstripped** argument with `%.200R` (`repr(s)`), which for the ASCII literals
567/// here is exactly `'{}'`.
568fn py_int(s: &str) -> Result<BigInt> {
569    let err = || {
570        N2WError::Value(format!(
571            "invalid literal for int() with base 10: '{}'",
572            s
573        ))
574    };
575    let t = s.trim();
576    let (negative, body) = match t.strip_prefix('-') {
577        Some(rest) => (true, rest),
578        None => (false, t.strip_prefix('+').unwrap_or(t)),
579    };
580    // int() permits '_' as a digit separator, but not leading/trailing/doubled.
581    if body.is_empty()
582        || body.starts_with('_')
583        || body.ends_with('_')
584        || body.contains("__")
585        || !body.chars().all(|c| c.is_ascii_digit() || c == '_')
586    {
587        return Err(err());
588    }
589    let digits: String = body.chars().filter(|c| *c != '_').collect();
590    let n: BigInt = digits.parse().map_err(|_| err())?;
591    Ok(if negative { -n } else { n })
592}
593
594/// The full body of `Num2Word_KA.to_cardinal`, driven by `str(number)`:
595///
596/// ```text
597/// n = str(number).strip()
598/// if n.startswith("-"):
599///     n = n[1:]
600///     ret = self.negword          # "მინუს "
601/// else:
602///     ret = ""
603/// if "." in n:
604///     left, right = n.split(".", 1)
605///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
606///     ret += " ".join(self._int_to_word(int(d)) for d in right)
607///     return ret.strip()
608/// else:
609///     return (ret + self._int_to_word(int(n))).strip()
610/// ```
611///
612/// Three details that matter:
613///
614/// * The sign is stripped **textually**, so `-0.0` (whose `str` is `"-0.0"`)
615///   keeps its negword even though the value is not `< 0`: KA answers
616///   "მინუს ნული წერტილი ნული". The `float` arm reproduces this; the `Decimal`
617///   arm cannot (see [`py_decimal_str`]).
618/// * `split(".", 1)` caps at one split, so a second dot stays inside `right`
619///   and detonates in the digit loop rather than being ignored.
620/// * `int(left)` runs *before* the digit loop, so for `1.5e+16` the failing
621///   literal reported is `'e'`, not `'1.5e+16'`. Order is load-bearing for the
622///   error message; keep it.
623fn cardinal_from_str(number: &str) -> Result<String> {
624    // str(number).strip() — a no-op for real repr()s, applied for fidelity.
625    let n = number.trim();
626    let (n, mut ret) = match n.strip_prefix('-') {
627        Some(rest) => (rest, NEGWORD.to_string()),
628        None => (n, String::new()),
629    };
630
631    let Some(dot) = n.find('.') else {
632        // else: return (ret + _int_to_word(int(n))).strip()
633        ret.push_str(&int_to_word(&py_int(n)?));
634        return Ok(ret.trim().to_string());
635    };
636
637    // n.split(".", 1) — maxsplit=1, so `right` keeps any further dots. '.' is
638    // ASCII and `left` is ASCII digits, so byte slicing lands on char bounds.
639    let (left, right) = (&n[..dot], &n[dot + 1..]);
640    ret.push_str(&int_to_word(&py_int(left)?));
641    ret.push(' ');
642    ret.push_str(POINTWORD);
643    ret.push(' ');
644
645    // " ".join(self._int_to_word(int(d)) for d in right) — Python iterates
646    // *characters*, so index by chars(), never bytes.
647    let mut first = true;
648    for d in right.chars() {
649        if !first {
650            ret.push(' ');
651        }
652        first = false;
653        let mut buf = [0u8; 4];
654        ret.push_str(&int_to_word(&py_int(d.encode_utf8(&mut buf))?));
655    }
656    // return ret.strip()
657    Ok(ret.trim().to_string())
658}
659
660pub struct LangKa {
661    /// `Num2Word_KA.CURRENCY_FORMS`. Built once in [`LangKa::new`] and only read
662    /// thereafter — the generated registry parks each language in a `OnceLock`
663    /// and calls `new` through `get_or_init`, so this table is constructed a
664    /// single time per process rather than per conversion.
665    currency_forms: HashMap<&'static str, CurrencyForms>,
666    /// `self.CURRENCY_FORMS["GEL"]` — the default `to_currency`'s `.get` falls
667    /// back to for an unknown code (quirk 8).
668    ///
669    /// Python spells the fallback as an explicit `CURRENCY_FORMS["GEL"]`
670    /// subscript, so — unlike the sibling languages that write
671    /// `list(CURRENCY_FORMS.values())[0]` — there is no dict-ordering question
672    /// to answer here. Held as its own field so the lookup cannot panic; the
673    /// duplicate is two short string pairs.
674    ///
675    /// Note that Python evaluates this subscript *eagerly on every call*, as
676    /// `dict.get`'s second argument, even when `currency` is a hit. GEL is a
677    /// class constant and always present, so that has no observable effect.
678    fallback_forms: CurrencyForms,
679}
680
681impl Default for LangKa {
682    fn default() -> Self {
683        Self::new()
684    }
685}
686
687impl LangKa {
688    pub fn new() -> Self {
689        let mut currency_forms = HashMap::new();
690        // Arity is load-bearing: `to_currency` indexes `cr1[0]`/`cr1[1]` and
691        // `cr2[0]`/`cr2[1]`, and the inherited `to_cheque` takes `cr1[-1]`, so
692        // both forms of both tuples must survive verbatim.
693        //
694        // GEL's singular and plural are the *same word* — Georgian does not
695        // inflect the noun after a numeral. That is not a transcription slip:
696        // `("ლარი", "ლარი")` and `("თეთრი", "თეთრი")` are what the source says,
697        // so the `left != 1` test below is a no-op for GEL and a real choice
698        // only for USD/EUR.
699        currency_forms.insert(
700            "GEL",
701            CurrencyForms::new(&["ლარი", "ლარი"], &["თეთრი", "თეთრი"]),
702        );
703        currency_forms.insert(
704            "USD",
705            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
706        );
707        // KA declares CURRENCY_FORMS on its own class, so `Num2Word_EN.__init__`
708        // mutating `Num2Word_EUR`'s shared class dict never reaches it: EUR here
709        // is KA's own literal, and the ~24 codes EN adds to that shared dict are
710        // absent (which is why they fall back to GEL — quirk 8 — rather than
711        // resolving). Verified against the live interpreter, not the source.
712        currency_forms.insert(
713            "EUR",
714            CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
715        );
716        let fallback_forms = currency_forms
717            .get(FALLBACK_CURRENCY)
718            .expect("GEL is inserted above")
719            .clone();
720        LangKa {
721            currency_forms,
722            fallback_forms,
723        }
724    }
725}
726
727impl Lang for LangKa {
728
729    fn cardinal_float_entry(
730        &self,
731        value: &crate::floatpath::FloatValue,
732        precision_override: Option<u32>,
733    ) -> crate::base::Result<String> {
734        // Python's to_cardinal routes every float/Decimal through this
735        // language's own decimal grammar — 5.0 keeps its ".0" tail
736        // ("comma nulla"), unlike Base's whole-value integer route.
737        self.to_cardinal_float(value, precision_override)
738    }
739    /// This language's own `to_currency(currency=...)` default,
740    /// read from the live Python signature. Only 44 of 156 use EUR.
741    fn default_currency(&self) -> &str {
742        "GEL"
743    }
744
745    /// This language's own `to_currency(separator=...)` default,
746    /// read from the live Python signature. Base's is ",", but only
747    /// 36 of 149 languages actually use it — most default to " " or a
748    /// conjunction, so inheriting Base's comma silently corrupts them.
749    fn default_separator(&self) -> &str {
750        " "
751    }
752
753    fn negword(&self) -> &str {
754        NEGWORD
755    }
756
757    fn pointword(&self) -> &str {
758        "წერტილი"
759    }
760
761    /// Port of `Num2Word_KA.to_cardinal`, integer path only.
762    ///
763    /// Python does `n = str(number).strip()`, detaches a leading "-" into
764    /// `ret = self.negword`, then checks for "." — `str(int)` never contains
765    /// one, so integers always take the `else` branch:
766    /// `return (ret + self._int_to_word(int(n))).strip()`.
767    ///
768    /// The trailing `.strip()` is a no-op here (negword's trailing space is
769    /// interior once the word is appended, and `_int_to_word` never returns
770    /// padded output), but it is applied for fidelity.
771    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
772        let (ret, n) = if value.is_negative() {
773            (NEGWORD, value.abs())
774        } else {
775            ("", value.clone())
776        };
777        Ok(format!("{}{}", ret, int_to_word(&n)).trim().to_string())
778    }
779
780    /// Port of `Num2Word_KA.to_ordinal`.
781    ///
782    /// 1..=10 have suppletive forms; everything else — including 0 and every
783    /// negative — gets `"მე-" + to_cardinal(number)` (bug 4). No
784    /// `verify_ordinal`, so nothing raises.
785    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
786        // to_u32 rejects negatives and anything huge, which is exactly the set
787        // the Python `elif` chain falls through on.
788        if let Some(n) = value.to_u32() {
789            if (1..=10).contains(&n) {
790                return Ok(ORDINALS[(n - 1) as usize].to_string());
791            }
792        }
793        Ok(format!("{}{}", ORDINAL_PREFIX, self.to_cardinal(value)?))
794    }
795
796    /// Port of `Num2Word_KA.to_ordinal_num`: `str(number) + "."`.
797    /// Negatives keep their sign: `to_ordinal_num(-1)` == "-1.".
798    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
799        Ok(format!("{}.", value))
800    }
801
802    /// Port of `Num2Word_KA.to_year`.
803    ///
804    /// Negative years get the ASCII prefix "BC " and the *absolute* value's
805    /// cardinal, so no negword appears (bug 5). The positive arm is Python's
806    /// no-op `"" + self.to_cardinal(val)`.
807    fn to_year(&self, value: &BigInt) -> Result<String> {
808        if value.is_negative() {
809            Ok(format!("BC {}", self.to_cardinal(&value.abs())?))
810        } else {
811            self.to_cardinal(value)
812        }
813    }
814
815    /// `to_ordinal(float/Decimal)`: the `number == 1 ... == 10` chain matches
816    /// numerically, so whole values 1..=10 (5.0, Decimal("5.00")) take the
817    /// suppletive forms; everything else — negatives and fractions included —
818    /// is `"მე-" + to_cardinal(number)` with the value's own str() grammar
819    /// ("მე-ერთი ასი წერტილი ნული" for 100.0).
820    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
821        if let Some(i) = value.as_whole_int() {
822            if i.is_positive() && i <= BigInt::from(10) {
823                return self.to_ordinal(&i);
824            }
825        }
826        Ok(format!("მე-{}", self.cardinal_float_entry(value, None)?))
827    }
828
829    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` — Python's str,
830    /// handed in as `repr_str` ("5.0.", "-0.0.", "1E+2.").
831    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
832        Ok(format!("{}.", repr_str))
833    }
834
835    /// `to_year(float/Decimal)`: `if val < 0` (numeric — Decimal("-0.0") is
836    /// not < 0) → `"BC " + to_cardinal(abs(val))`, keeping the float grammar
837    /// ("BC ერთი წერტილი ხუთი" for -1.5).
838    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
839        let negative = match value {
840            FloatValue::Float { value, .. } => *value < 0.0,
841            FloatValue::Decimal { value, .. } => value.is_negative(),
842        };
843        if negative {
844            let abs = match value {
845                FloatValue::Float { value, precision } => FloatValue::Float {
846                    value: value.abs(),
847                    precision: *precision,
848                },
849                FloatValue::Decimal { value, precision } => FloatValue::Decimal {
850                    value: value.abs(),
851                    precision: *precision,
852                },
853            };
854            Ok(format!("BC {}", self.cardinal_float_entry(&abs, None)?))
855        } else {
856            self.cardinal_float_entry(value, None)
857        }
858    }
859
860    /// `str_to_number` is Base's `Decimal(value)`, but KA's `to_cardinal`
861    /// then runs `int()` over the dot-free `str()` form, so `"Infinity"`
862    /// raises **ValueError** (`int('Infinity')`) rather than the shared Inf
863    /// sentinel's OverflowError. No digit present → the dispatcher
864    /// propagates it, exactly like the original.
865    fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
866        match crate::strnum::python_decimal_parse(s)? {
867            crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
868                "invalid literal for int() with base 10: 'Infinity'".into(),
869            )),
870            other => Ok(other),
871        }
872    }
873
874    /// The `"." in n` branch of `Num2Word_KA.to_cardinal` — the same method as
875    /// [`LangKa::to_cardinal`]. Python has one `to_cardinal` that splits on
876    /// `str(number)`, so the trait's int/float boundary lands mid-method here
877    /// rather than at a class boundary.
878    ///
879    /// **`Num2Word_Base.to_cardinal_float` is never reached for Georgian**, and
880    /// neither is `float2tuple`. Consequences that invert the usual advice:
881    ///
882    /// * There is no `precision` anywhere. `num2words(..., precision=1)` sets
883    ///   `converter.precision`, but `Num2Word_KA.to_cardinal` never reads it —
884    ///   verified live, `num2words(1.23456, lang="ka", precision=1)` is
885    ///   unchanged at five fractional words. So `precision_override` is accepted
886    ///   and dropped, the same shape as `to_year`'s `longval` (bug 7) and
887    ///   `to_currency`'s `adjective` (quirk 13).
888    /// * `FloatValue::precision` is likewise unused: the digit count comes from
889    ///   `str()`, not from a precision field. Every digit after the point is
890    ///   spoken separately with no cap, so `0.000001` is six words and the
891    ///   Decimal `98746251323029.99` keeps full precision for free (issue
892    ///   #603's `float()` cast never happens — the Decimal arm stringifies).
893    ///
894    /// The `< 0.01` artefact heuristic is absent and *not* missing: `repr(2.675)`
895    /// is `"2.675"`, so the `674.9999999999998` it exists to repair is never
896    /// computed. `1.005` and `2.675` come out right by a different mechanism.
897    fn to_cardinal_float(
898        &self,
899        value: &FloatValue,
900        precision_override: Option<u32>,
901    ) -> Result<String> {
902        // precision= is set on the converter by __init__.py, then ignored by
903        // Num2Word_KA.to_cardinal. Reproduce the ignoring.
904        let _ = precision_override;
905        let n = match value {
906            // Python's str(float). The raw f64 crosses the boundary precisely so
907            // that repr() can be reproduced from the bits.
908            FloatValue::Float { value, .. } => py_float_repr(*value),
909            // Python's str(Decimal) — exact, and never routed through f64.
910            FloatValue::Decimal { value, .. } => py_decimal_str(value),
911        };
912        cardinal_from_str(&n)
913    }
914
915    // ---- currency ------------------------------------------------------
916    //
917    // Only the three hooks below are overridden. KA defines no
918    // CURRENCY_ADJECTIVES and no CURRENCY_PRECISION, and leaves `pluralize`,
919    // `_money_verbose`, `_cents_verbose`, `_cents_terse` and `to_cheque` at
920    // `Num2Word_Base` — whose behaviour the trait defaults already reproduce.
921    // In particular `pluralize` stays at the default that raises: KA's
922    // `to_currency` picks its forms with an inline `left != 1` test and never
923    // calls it, and the inherited `to_cheque` does not call it either.
924
925    /// `self.__class__.__name__`, for the inherited `to_cheque`'s
926    /// `NotImplementedError` message.
927    fn lang_name(&self) -> &str {
928        "Num2Word_KA"
929    }
930
931    /// `CURRENCY_FORMS[code]` — a plain lookup that misses for anything but
932    /// GEL/USD/EUR.
933    ///
934    /// Deliberately **not** the `.get(code, CURRENCY_FORMS["GEL"])` fallback of
935    /// quirk 8: that fallback is local to `to_currency`. The inherited
936    /// `to_cheque` subscripts `CURRENCY_FORMS[currency]` and catches `KeyError`,
937    /// so it must still see a miss here — that is what makes `cheque:GBP` raise
938    /// `NotImplementedError` while `currency:GBP` quietly prints lari.
939    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
940        self.currency_forms.get(code)
941    }
942
943    /// Port of `Num2Word_KA.to_currency`.
944    ///
945    /// KA ignores `base.to_currency` wholesale — no `parse_currency_parts`, no
946    /// `divisor`, no `pluralize`, no `isinstance(val, int)` branch. It slices the
947    /// decimal *string*:
948    ///
949    /// ```python
950    /// parts = str(val).split(".")
951    /// left = int(parts[0]) if parts[0] else 0
952    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
953    /// ```
954    ///
955    /// The int/float split still arrives intact and is still honoured — it is
956    /// simply carried by the string rather than by a type test. `str(1)` is "1"
957    /// (no dot, so `right` is 0) and `str(1.0)` is "1.0" (a dot, but `int("00")`
958    /// is also 0), so for *this* language both print "ერთი euro". Collapsing
959    /// `CurrencyValue` would nonetheless be wrong: it is what produces the "1.0"
960    /// spelling in the first place.
961    ///
962    /// # The `separator` argument is not what Python's default would be
963    ///
964    /// KA's own signature defaults to `separator=" "`, but the trait cannot carry
965    /// a per-language default argument: the dispatcher resolves it before the
966    /// call and uses `Num2Word_Base`'s `","`. Since the frozen corpus was
967    /// generated through Python — where KA's `" "` applies — `","` is read back
968    /// as the "unset" sentinel it is. This is exact both for a caller who omits
969    /// `separator` and for one who passes anything other than `","`, and wrong
970    /// only for an explicit `separator=","`, which Python renders with a comma
971    /// and this renders with a space. See `concerns`.
972    ///
973    /// # `Value` on scientific notation
974    ///
975    /// `int(parts[0])` inherits Python's rejection of `repr`-style exponents, so
976    /// `to_currency(1e16)` raises `ValueError`. `BigDecimal`'s `Display` keeps
977    /// the `1e+16` form for a large exponent, so `BigInt::from_str` rejects it
978    /// identically. It does *not* keep the `1e-05` form (it prints `0.00001`),
979    /// so a tiny float diverges — flagged in `concerns`; no corpus row covers it.
980    fn to_currency(
981        &self,
982        val: &CurrencyValue,
983        currency: &str,
984        cents: bool,
985        separator: Option<&str>,
986        adjective: bool,
987    ) -> Result<String> {
988        // Trait now hands us None when the caller omitted separator=;
989        // resolve it to this language's own default before the ported body.
990        let separator = separator.unwrap_or(self.default_separator());
991        // Python accepts `adjective` and never reads it: KA has no
992        // CURRENCY_ADJECTIVES and never calls prefix_currency, so
993        // `adjective=True` is byte-identical to the plain call (quirk 13).
994        let _ = adjective;
995
996        let separator = if separator == BASE_DEFAULT_SEPARATOR {
997            DEFAULT_SEPARATOR
998        } else {
999            separator
1000        };
1001
1002        // `if val < 0: is_negative = True; val = abs(val)` happens *before*
1003        // `str(val)`, so the string never carries a sign. The abs() is
1004        // conditional in Python, so it stays conditional here. Note `-0.0 < 0`
1005        // is False in Python and `BigDecimal` has no signed zero, so both sides
1006        // agree that -0.0 is not negative.
1007        let is_negative = val.is_negative();
1008        let s = match val {
1009            CurrencyValue::Int(v) => {
1010                if is_negative {
1011                    v.abs().to_string()
1012                } else {
1013                    v.to_string()
1014                }
1015            }
1016            CurrencyValue::Decimal { value: d, .. } => {
1017                if is_negative {
1018                    d.abs().to_string()
1019                } else {
1020                    d.to_string()
1021                }
1022            }
1023        };
1024
1025        // `str(val).split(".")` splits on every dot; Python then reads only
1026        // parts[0] and parts[1], so trailing fragments are ignored either way.
1027        let mut parts = s.split('.');
1028        let part0 = parts.next().unwrap_or("");
1029        let part1 = parts.next();
1030
1031        // `int(parts[0]) if parts[0] else 0`. Evaluated before `right`, so a
1032        // scientific-notation string raises here first.
1033        let left = if part0.is_empty() {
1034            BigInt::zero()
1035        } else {
1036            BigInt::from_str(part0).map_err(|e| N2WError::Value(e.to_string()))?
1037        };
1038
1039        // `int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
1040        //
1041        // `[:2]` truncates and `ljust` pads on the right, so "5" -> "50" (0.5 is
1042        // fifty subunits) and "01" -> "01" (0.01 is one). Sliced by chars, not
1043        // bytes. A whole float gives "0" -> "00" -> 0, which is falsy (quirk 10).
1044        let right = match part1 {
1045            Some(f) if !f.is_empty() => {
1046                let mut two: String = f.chars().take(2).collect();
1047                while two.chars().count() < 2 {
1048                    two.push('0');
1049                }
1050                BigInt::from_str(&two).map_err(|e| N2WError::Value(e.to_string()))?
1051            }
1052            _ => BigInt::zero(),
1053        };
1054
1055        // `.get(currency, self.CURRENCY_FORMS["GEL"])` — quirk 8.
1056        let forms = self
1057            .currency_forms
1058            .get(currency)
1059            .unwrap_or(&self.fallback_forms);
1060        let cr1 = &forms.unit;
1061        let cr2 = &forms.subunit;
1062
1063        let one = BigInt::one();
1064
1065        // `left_str + " " + (cr1[1] if left != 1 else cr1[0])`. Note 0 takes the
1066        // plural: "ნული euros". `_int_to_word` is called directly, not
1067        // `to_cardinal`, so a left part >= 10^12 falls back to bare digits
1068        // (bug 3) rather than raising.
1069        let mut result = format!(
1070            "{} {}",
1071            int_to_word(&left),
1072            if left != one { &cr1[1] } else { &cr1[0] }
1073        );
1074
1075        // `if cents and right:` — `right` is an int, so 0 is falsy and a float
1076        // with zero cents drops the whole segment (quirk 10). `cents=False`
1077        // drops it too, with no terse fallback (quirk 12).
1078        if cents && !right.is_zero() {
1079            result.push_str(separator);
1080            result.push_str(&int_to_word(&right));
1081            result.push(' ');
1082            result.push_str(if right != one { &cr2[1] } else { &cr2[0] });
1083        }
1084
1085        // `result = self.negword + result` — raw, keeping the trailing space of
1086        // "მინუს " rather than the `"%s " % negword.strip()` base would use.
1087        if is_negative {
1088            result = format!("{}{}", NEGWORD, result);
1089        }
1090
1091        // `result.strip()`. A no-op for every reachable input, but it is what
1092        // Python writes.
1093        Ok(result.trim().to_string())
1094    }
1095}
1096
1097#[cfg(test)]
1098mod float_tests {
1099    use super::*;
1100
1101    fn flt(v: f64) -> String {
1102        // Num2Word_KA ignores precision, so any value is equally correct; pass
1103        // the honest repr-derived one.
1104        let precision = py_float_repr(v)
1105            .split_once('.')
1106            .map(|(_, f)| f.len() as u32)
1107            .unwrap_or(0);
1108        LangKa::new()
1109            .to_cardinal_float(&FloatValue::Float { value: v, precision }, None)
1110            .unwrap()
1111    }
1112
1113    fn dec(s: &str) -> String {
1114        let value = BigDecimal::from_str(s).unwrap();
1115        let precision = value.as_bigint_and_exponent().1.unsigned_abs() as u32;
1116        LangKa::new()
1117            .to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
1118            .unwrap()
1119    }
1120
1121    /// Every `"lang": "ka", "to": "cardinal"` corpus row whose arg has a dot.
1122    #[test]
1123    fn corpus_float_rows() {
1124        assert_eq!(flt(0.0), "ნული წერტილი ნული");
1125        assert_eq!(flt(0.5), "ნული წერტილი ხუთი");
1126        assert_eq!(flt(1.0), "ერთი წერტილი ნული");
1127        assert_eq!(flt(1.5), "ერთი წერტილი ხუთი");
1128        assert_eq!(flt(2.25), "ორი წერტილი ორი ხუთი");
1129        assert_eq!(flt(3.14), "სამი წერტილი ერთი ოთხი");
1130        assert_eq!(flt(0.01), "ნული წერტილი ნული ერთი");
1131        assert_eq!(flt(0.1), "ნული წერტილი ერთი");
1132        assert_eq!(flt(0.99), "ნული წერტილი ცხრა ცხრა");
1133        assert_eq!(flt(1.01), "ერთი წერტილი ნული ერთი");
1134        assert_eq!(flt(12.34), "თორმეტი წერტილი სამი ოთხი");
1135        assert_eq!(flt(99.99), "ოთხმოცდაათი ცხრა წერტილი ცხრა ცხრა");
1136        assert_eq!(flt(100.5), "ერთი ასი წერტილი ხუთი");
1137        assert_eq!(
1138            flt(1234.56),
1139            "ერთი ათასი ორი ასი ოცდაათი ოთხი წერტილი ხუთი ექვსი"
1140        );
1141        assert_eq!(flt(-0.5), "მინუს ნული წერტილი ხუთი");
1142        assert_eq!(flt(-1.5), "მინუს ერთი წერტილი ხუთი");
1143        assert_eq!(flt(-12.34), "მინუს თორმეტი წერტილი სამი ოთხი");
1144        // The two f64-artefact cases: right answer via repr(), not float2tuple.
1145        assert_eq!(flt(1.005), "ერთი წერტილი ნული ნული ხუთი");
1146        assert_eq!(flt(2.675), "ორი წერტილი ექვსი შვიდი ხუთი");
1147    }
1148
1149    /// Every `"lang": "ka", "to": "cardinal_dec"` corpus row.
1150    #[test]
1151    fn corpus_decimal_rows() {
1152        assert_eq!(dec("0.01"), "ნული წერტილი ნული ერთი");
1153        // Trailing zero survives: str(Decimal("1.10")) == "1.10".
1154        assert_eq!(dec("1.10"), "ერთი წერტილი ერთი ნული");
1155        assert_eq!(dec("12.345"), "თორმეტი წერტილი სამი ოთხი ხუთი");
1156        // Issue #603's value: int part >= 10^12 => bare digits (bug 3), exact
1157        // fraction preserved (no float() cast on the Decimal arm).
1158        assert_eq!(
1159            dec("98746251323029.99"),
1160            "98746251323029 წერტილი ცხრა ცხრა"
1161        );
1162        assert_eq!(dec("0.001"), "ნული წერტილი ნული ნული ერთი");
1163    }
1164
1165    /// -0.0 is not `< 0`, but its float str() starts with '-', stripped
1166    /// textually into a negword. (The Decimal arm cannot represent -0.0.)
1167    #[test]
1168    fn negative_zero_float_keeps_negword() {
1169        assert_eq!(flt(-0.0), "მინუს ნული წერტილი ნული");
1170        assert_eq!(flt(0.0), "ნული წერტილი ნული");
1171    }
1172
1173    /// precision= is honoured by __init__.py and then ignored by the converter.
1174    #[test]
1175    fn precision_override_is_ignored() {
1176        let l = LangKa::new();
1177        let v = FloatValue::Float {
1178            value: 1.23456,
1179            precision: 5,
1180        };
1181        let want = "ერთი წერტილი ორი სამი ოთხი ხუთი ექვსი";
1182        assert_eq!(l.to_cardinal_float(&v, None).unwrap(), want);
1183        for p in [0u32, 1, 2, 5, 9] {
1184            assert_eq!(l.to_cardinal_float(&v, Some(p)).unwrap(), want);
1185        }
1186    }
1187
1188    /// str(float)/str(Decimal) go exponential outside their fixed ranges, and
1189    /// int() chokes. Which literal lands in the message depends on whether a
1190    /// '.' split the string first.
1191    #[test]
1192    fn exponent_notation_raises_value_error() {
1193        let l = LangKa::new();
1194        let f = |v: f64| {
1195            l.to_cardinal_float(&FloatValue::Float { value: v, precision: 0 }, None)
1196                .unwrap_err()
1197        };
1198        assert!(matches!(f(1e16), N2WError::Value(m)
1199            if m == "invalid literal for int() with base 10: '1e+16'"));
1200        assert!(matches!(f(1e-5), N2WError::Value(m)
1201            if m == "invalid literal for int() with base 10: '1e-05'"));
1202        // "1.5e+16" splits: int("1") is fine, then the digit loop hits 'e'.
1203        assert!(matches!(f(1.5e16), N2WError::Value(m)
1204            if m == "invalid literal for int() with base 10: 'e'"));
1205        let d = |s: &str| {
1206            l.to_cardinal_float(
1207                &FloatValue::Decimal {
1208                    value: BigDecimal::from_str(s).unwrap(),
1209                    precision: 0,
1210                },
1211                None,
1212            )
1213            .unwrap_err()
1214        };
1215        assert!(matches!(d("1E+16"), N2WError::Value(m)
1216            if m == "invalid literal for int() with base 10: '1E+16'"));
1217        assert!(matches!(d("1E-7"), N2WError::Value(m)
1218            if m == "invalid literal for int() with base 10: '1E-7'"));
1219    }
1220
1221    /// Integer-valued Decimals have no dot in str(), so they take the else
1222    /// branch and print just the integer — no pointword.
1223    #[test]
1224    fn integer_decimals_have_no_point() {
1225        assert_eq!(dec("5"), "ხუთი");
1226        assert_eq!(dec("1.00E+2"), "ერთი ასი"); // str -> "100"
1227    }
1228
1229    /// Boundaries: largest fixed-notation float, and a pure fraction.
1230    #[test]
1231    fn large_and_small_boundaries() {
1232        assert_eq!(flt(1e15), "1000000000000000 წერტილი ნული");
1233        assert_eq!(flt(0.0001), "ნული წერტილი ნული ნული ნული ერთი");
1234        assert_eq!(
1235            dec("0.000001"),
1236            "ნული წერტილი ნული ნული ნული ნული ნული ერთი"
1237        );
1238    }
1239
1240    /// repr() reproduction, including the dtoa tie cases.
1241    #[test]
1242    fn py_float_repr_matches_cpython() {
1243        for (v, want) in [
1244            (0.0, "0.0"),
1245            (-0.0, "-0.0"),
1246            (0.5, "0.5"),
1247            (1.0, "1.0"),
1248            (0.01, "0.01"),
1249            (1.005, "1.005"),
1250            (2.675, "2.675"),
1251            (100.5, "100.5"),
1252            (1e15, "1000000000000000.0"),
1253            (1e16, "1e+16"),
1254            (1.5e16, "1.5e+16"),
1255            (0.0001, "0.0001"),
1256            (1e-5, "1e-05"),
1257            (98746251323029.99, "98746251323029.98"),
1258            (-78198386800398.125, "-78198386800398.12"),
1259            (-1267860061485775.25, "-1267860061485775.2"),
1260        ] {
1261            assert_eq!(py_float_repr(v), want, "repr({:?})", v);
1262        }
1263    }
1264
1265    /// str(Decimal) reproduction — _pydecimal.Decimal.__str__.
1266    #[test]
1267    fn py_decimal_str_matches_cpython() {
1268        for (s, want) in [
1269            ("1.10", "1.10"),
1270            ("0.00", "0.00"),
1271            ("-0.5", "-0.5"),
1272            ("1E+16", "1E+16"),
1273            ("1E-7", "1E-7"),
1274            ("0.0000001", "1E-7"),
1275            ("0.000001", "0.000001"),
1276            ("1.00E+2", "100"),
1277            ("12.345", "12.345"),
1278            ("98746251323029.99", "98746251323029.99"),
1279            ("0.01", "0.01"),
1280        ] {
1281            assert_eq!(
1282                py_decimal_str(&BigDecimal::from_str(s).unwrap()),
1283                want,
1284                "{}",
1285                s
1286            );
1287        }
1288    }
1289}