Skip to main content

num2words2_core/
lang_ba.rs

1//! Port of `lang_BA.py` (Bashkir).
2//!
3//! Registry check: `CONVERTER_CLASSES["ba"] = lang_BA.Num2Word_BA()`
4//! (`__init__.py:332`), so `Num2Word_BA` is the class this key resolves to.
5//!
6//! Shape: **self-contained**. `Num2Word_BA` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`. `base.py`'s
8//! `__init__` only builds `self.cards` / sets `self.MAXVAL` when one of those
9//! three attributes exists, so for Bashkir **neither is ever created**:
10//! `cards`/`maxval`/`merge` stay at their trait defaults here and are never
11//! consulted. There is consequently **no overflow check at all** — `to_cardinal`
12//! is overridden outright and recurses over `_int_to_word`, which is total for
13//! every integer (see "unbounded recursion" below).
14//!
15//! `setup()` overrides two inherited attributes:
16//!   * `negword  = "минус "`  (note the **trailing space**, see below)
17//!   * `pointword = "өтөр"`
18//!
19//! All four in-scope modes are overridden by `Num2Word_BA`; nothing is
20//! inherited from `Num2Word_Base` except the attributes above. In particular
21//! `to_ordinal` does **not** call `verify_ordinal`, so negative ordinals are
22//! accepted rather than raising (`to_ordinal(-1)` == "минус бер-се").
23//!
24//! # No error paths
25//!
26//! For integer input this language cannot raise. Every list index is guarded by
27//! a range check that bounds it to 0..=9, and `_int_to_word` is never reached
28//! with 0 from a recursive call (each recursion site tests the remainder for
29//! truthiness first), so the unreachable `ones[0] == ""` slot never surfaces.
30//!
31//! The **float path** does add one: `Value` (Python `ValueError`), raised when
32//! `str(number)` comes out in exponent notation and Bashkir feeds that text to
33//! `int()`. See [`float_str`] and [`py_int`].
34//!
35//! # Faithfully reproduced Python quirks
36//!
37//! This is a port, not a rewrite. The following look wrong but are exactly what
38//! Python emits, verified against the frozen corpus:
39//!
40//! 1. **The scale table stops at "миллиард" (10^9) and then recurses.** The
41//!    final `else` arm divides by 10^9 and calls `_int_to_word` on the quotient
42//!    with no further scale words, so large values stack "миллиард" onto a
43//!    recursively-built multiplier instead of naming higher scales:
44//!      * 10^12 → "бер мең миллиард"          (lit. "one thousand milliard")
45//!      * 10^15 → "бер миллион миллиард"
46//!      * 10^18 → "бер миллиард миллиард"
47//!      * 10^21 → "бер мең миллиард миллиард"
48//!    All four are corpus rows. This makes `_int_to_word` **unbounded** — it
49//!    terminates for any BigInt (the quotient shrinks by 10^9 each step) rather
50//!    than raising `OverflowError`, so the value must stay a `BigInt` here.
51//! 2. **`negword` is used raw, not stripped.** Bashkir's `to_cardinal` does
52//!    `ret = self.negword` and concatenates directly, unlike
53//!    `Num2Word_Base.to_cardinal` which does `"%s " % self.negword.strip()`.
54//!    The two happen to agree because `negword` is exactly `"минус "`, but the
55//!    trailing space is load-bearing and is preserved verbatim in [`NEGWORD`].
56//! 3. **`to_ordinal` glues the suffix onto the bare cardinal**, giving the
57//!    invariant suffix "-се" regardless of the final word's phonology —
58//!    "нуль-се", "ун бер-се", "туғыҙ-се". No stem changes, no vowel harmony.
59//! 4. **`to_ordinal_num` never validates**, so it emits "0-се" and "-1-се".
60//! 5. **Hundreds are always explicit**: 100 → "бер йөҙ" ("one hundred"), never
61//!    a bare "йөҙ". Likewise 1000 → "бер мең". Same for the recursive scales.
62//!
63//! # The float/Decimal path: `str()`, not `float2tuple`
64//!
65//! `Num2Word_BA` overrides **`to_cardinal`**, not `to_cardinal_float`, and its
66//! non-integer branch never reaches `base.float2tuple`:
67//!
68//! ```python
69//! n = str(number).strip()
70//! if n.startswith("-"):
71//!     n = n[1:]; ret = self.negword
72//! else:
73//!     ret = ""
74//! if "." in n:
75//!     left, right = n.split(".", 1)
76//!     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
77//!     ret += " ".join(self._int_to_word(int(d)) for d in right)
78//!     return ret
79//! ```
80//!
81//! So the fractional digits are **the characters of `str(number)`**, not a
82//! `abs(value - pre) * 10**precision` product. This has three consequences that
83//! set Bashkir apart from the 26 languages inheriting `default_to_cardinal_float`:
84//!
85//! 1. **The f64 artefacts never arise.** `float2tuple`'s `674.9999999999998` /
86//!    `< 0.01` rescue dance has no analogue here: `str(2.675)` is `"2.675"` and
87//!    the digits are read straight off. The two routes happen to agree on 2.675
88//!    and 1.005 — because `repr` is shortest-round-trip and the heuristic exists
89//!    precisely to recover it — but they agree by coincidence, not by shared
90//!    code. Nothing in this file rounds, so `round_ties_even` has no place in it.
91//! 2. **`self.precision` is never read**, so the `precision=` kwarg (issue #580)
92//!    is a **silent no-op** for Bashkir. `num2words(2.675, lang="ba",
93//!    precision=1)` is still "ике өтөр алты ете биш", verified against the live
94//!    interpreter. [`LangBa::to_cardinal_float`] therefore ignores
95//!    `precision_override`, and `FloatValue::precision` with it.
96//! 3. **Exponent notation raises `ValueError`.** Bashkir hands `str()`'s output
97//!    to `int()` unfiltered, so any value `repr` spells with an `e` crashes:
98//!    `num2words(1e16, lang="ba")` is
99//!    `ValueError: invalid literal for int() with base 10: '1e+16'`, and
100//!    `num2words(1.5e-5, lang="ba")` is `... : 'e'` — the latter from the
101//!    *per-character* `int(d)` in the join, after `int("1")` has already
102//!    succeeded. Both messages are reproduced verbatim.
103//!
104//! ## Why `str()` is reconstructed rather than passed in
105//!
106//! The trait hands over a [`FloatValue`], not the text Python printed, so the
107//! text has to be rebuilt. That is not a re-implementation of float formatting:
108//! Rust's `{:e}` and Python's `repr` both emit the *shortest round-trip* digits,
109//! which is the hard part and is already solved on both sides. What is rebuilt
110//! is only the **layout** — where the point goes and whether the exponent form
111//! is used — which is a range check on one integer. See [`float_str`].
112//!
113//! `FloatValue`'s Float/Decimal split is load-bearing here for a reason beyond
114//! precision: **`float` and `Decimal` stringify by different rules**, and the
115//! outputs genuinely diverge.
116//!
117//! | value | `str()` | `to_cardinal("ba")` |
118//! |---|---|---|
119//! | `0.00001` (float) | `'1e-05'` | `ValueError` |
120//! | `Decimal("0.00001")` | `'0.00001'` | нуль өтөр нуль нуль нуль нуль бер |
121//!
122//! `repr(float)` goes exponential outside `-4 < decpt <= 16`;
123//! `Decimal.__str__` goes exponential (with a capital `E` and an *unpadded*
124//! exponent) when its own exponent is positive or `leftdigits <= -6`. Collapsing
125//! the two arms — or routing both through one spelling — changes real answers,
126//! so [`float_str`] and [`decimal_str`] are separate ports of the two separate
127//! CPython functions.
128//!
129//! # Currency
130//!
131//! `Num2Word_BA` overrides `to_currency` **wholesale** and shares almost nothing
132//! with `Num2Word_Base.to_currency`. It also defines its own three-entry
133//! `CURRENCY_FORMS` (RUB/USD/EUR) and no `CURRENCY_PRECISION` /
134//! `CURRENCY_ADJECTIVES`, so the divisor is 100 for every code.
135//!
136//! `to_cheque` is **not** overridden, so it comes straight from
137//! `Num2Word_Base` — which means the two entry points disagree about unknown
138//! currency codes, and the corpus records both halves of that split:
139//!
140//! * `to_currency` does `CURRENCY_FORMS.get(currency, CURRENCY_FORMS["RUB"])`
141//!   — an unknown code **silently falls back to roubles**. `currency:GBP` on
142//!   12.34 yields "ун ике **һум** һәм утыҙ дүрт **тин**", not pounds, and
143//!   never raises.
144//! * `to_cheque` does `CURRENCY_FORMS[currency]` — an unknown code raises
145//!   `NotImplementedError`. `cheque:GBP` is a corpus error row.
146//!
147//! So [`Lang::currency_forms`] here is the *strict* lookup (`None` for unknown,
148//! which is what `default_to_cheque` needs) and the RUB fallback lives inside
149//! [`LangBa::to_currency`], mirroring where Python puts it.
150//!
151//! Because `to_currency` is fully overridden, these inherited hooks are never
152//! reached and are deliberately left at their trait defaults:
153//! `pluralize` (Bashkir never calls it — `cr1[0]`/`cr2[0]` are indexed
154//! directly, so the abstract `Num2Word_Base.pluralize` never fires),
155//! `cents_verbose`, `cents_terse`, `currency_precision` and
156//! `currency_adjective`. `money_verbose` keeps the base default
157//! (`self.to_cardinal(number)`), which is what `to_cheque` calls.
158//!
159//! ## More reproduced Python quirks (currency)
160//!
161//! 6. **`self.parse_currency` does not exist.** `to_currency` opens with
162//!    `try: ... self.parse_currency(val) except AttributeError:`. No class in
163//!    the MRO defines `parse_currency` (`base.py` has `parse_minus` and imports
164//!    `parse_currency_parts` as a free function), so the attribute lookup
165//!    always raises and the `except` body is the *only* path ever taken. The
166//!    `try` block is dead code; this port implements the fallback directly.
167//! 7. **`adjective` and `cents=False` are near-no-ops.** `adjective` is
168//!    accepted and never read. `cents=False` does not switch to terse digits
169//!    the way `Num2Word_Base` does — it drops the subunit segment entirely.
170//! 8. **Cents are truncated to two digits, never rounded**:
171//!    `int(parts[1][:2].ljust(2, "0"))`. So `1.005` → 0 cents ("00"), `0.5` →
172//!    50 cents ("5" → "50"), `0.01` → 1 cent. See [`split_currency`].
173//! 9. **A float with zero cents prints no cents at all.** `1.0` → "бер евро",
174//!    because the guard is `if cents and right` and `right == 0` is falsy.
175//!    This is the one language where the int-vs-float split that
176//!    `base.to_currency` hinges on makes **no** observable difference: `1` and
177//!    `1.0` both render "бер евро". The distinction is still threaded through
178//!    faithfully rather than collapsed, because it is the *value* of `right`
179//!    that decides, not the type.
180//!
181//! # Python `//` vs Rust `/`
182//!
183//! `_int_to_word` is only ever entered with a non-negative value (`to_cardinal`
184//! strips the "-" from the *string* before `int()`), and every recursive call
185//! passes a quotient or remainder of a non-negative number. On non-negative
186//! operands Python's floor-`//`/`%` and Rust's truncating-`/`/`%` coincide, so
187//! plain `/` and `%` are used below.
188
189use crate::base::{Lang, N2WError, Result};
190use crate::currency::{CurrencyForms, CurrencyValue};
191use crate::floatpath::FloatValue;
192use crate::strnum::{python_decimal_parse, ParsedNumber};
193use bigdecimal::BigDecimal;
194use num_bigint::BigInt;
195use num_integer::Integer;
196use num_traits::{Signed, ToPrimitive, Zero};
197use std::collections::HashMap;
198use std::str::FromStr;
199
200/// `_int_to_word`'s early return for 0.
201const ZERO_WORD: &str = "нуль";
202
203/// `setup(): self.negword = "минус "` — trailing space intentional, see the
204/// module docs. Bashkir's `to_cardinal` concatenates this without stripping.
205const NEGWORD: &str = "минус ";
206
207/// `setup(): self.pointword = "өтөр"`. Read by the float branch of
208/// `to_cardinal` and by nothing else — Bashkir never reaches
209/// `Num2Word_Base.to_cardinal_float`, which is the only other consumer.
210const POINTWORD: &str = "өтөр";
211
212/// `to_currency(..., separator=" һәм ", ...)` — Bashkir's own default, which
213/// differs from `Num2Word_Base`'s `","`. See [`LangBa::to_currency`] for why
214/// this is applied here rather than taken from the caller.
215const SEPARATOR: &str = " һәм ";
216
217/// `Num2Word_Base.to_currency(..., separator=",", ...)`. The Python shim calls
218/// the core with `kwargs.get("separator", ",")`, so this value reaching us is
219/// indistinguishable from "the caller said nothing" — see
220/// [`LangBa::to_currency`].
221const BASE_SEPARATOR: &str = ",";
222
223/// `to_currency(..., currency="RUB", ...)`: the code an unrecognised currency
224/// falls back to, via `CURRENCY_FORMS.get(currency, CURRENCY_FORMS["RUB"])`.
225const FALLBACK_CURRENCY: &str = "RUB";
226
227/// `ones`. Index 0 is `""` in Python and is unreachable: `_int_to_word(0)`
228/// returns "нуль" before any lookup, and no recursive call passes 0.
229const ONES: [&str; 10] = [
230    "", "бер", "ике", "өс", "дүрт", "биш", "алты", "ете", "һигеҙ", "туғыҙ",
231];
232
233/// `tens`. Indices 0 and 1 are `""` in Python and are unreachable: this table
234/// is only indexed by `number // 10` for `20 <= number < 100`, i.e. 2..=9.
235const TENS: [&str; 10] = [
236    "",
237    "",
238    "егерме",
239    "утыҙ",
240    "ҡырҡ",
241    "илле",
242    "алтмыш",
243    "етмеш",
244    "һикһән",
245    "туҡһан",
246];
247
248/// `teens`, indexed by `number - 10` for `10 <= number < 20`.
249const TEENS: [&str; 10] = [
250    "ун",
251    "ун бер",
252    "ун ике",
253    "ун өс",
254    "ун дүрт",
255    "ун биш",
256    "ун алты",
257    "ун ете",
258    "ун һигеҙ",
259    "ун туғыҙ",
260];
261
262/// Narrow a value already proven `< 10` (or `< 100` before the `- 10`) to a
263/// table index. The guard in every caller bounds the value, so the `unwrap`
264/// cannot fire.
265fn idx(n: &BigInt) -> usize {
266    n.to_usize()
267        .expect("guarded by a range check to 0..=99 before this call")
268}
269
270/// `Num2Word_BA._int_to_word`. Requires `number >= 0`.
271fn int_to_word(number: &BigInt) -> String {
272    if number.is_zero() {
273        return ZERO_WORD.to_string();
274    }
275
276    // if number < 10: return ones[number]
277    if *number < BigInt::from(10u32) {
278        return ONES[idx(number)].to_string();
279    }
280
281    // elif number < 20: return teens[number - 10]
282    if *number < BigInt::from(20u32) {
283        return TEENS[idx(number) - 10].to_string();
284    }
285
286    // elif number < 100:
287    //     return tens[number // 10] + (" " + ones[number % 10] if number % 10 else "")
288    if *number < BigInt::from(100u32) {
289        let div = number / 10u32;
290        let rem = number % 10u32;
291        let mut out = TENS[idx(&div)].to_string();
292        if !rem.is_zero() {
293            out.push(' ');
294            out.push_str(ONES[idx(&rem)]);
295        }
296        return out;
297    }
298
299    // elif number < 1000:
300    //     return ones[number // 100] + " йөҙ"
301    //            + (" " + self._int_to_word(number % 100) if number % 100 else "")
302    if *number < BigInt::from(1000u32) {
303        let div = number / 100u32;
304        let rem = number % 100u32;
305        let mut out = ONES[idx(&div)].to_string();
306        out.push_str(" йөҙ");
307        if !rem.is_zero() {
308            out.push(' ');
309            out.push_str(&int_to_word(&rem));
310        }
311        return out;
312    }
313
314    // The three remaining arms are structurally identical: recurse on the
315    // quotient, append the scale word, recurse on a non-zero remainder.
316    // Bashkir has no scale word above миллиард, so the last arm's quotient
317    // recursion is what produces "бер мең миллиард" for 10^12 (see module docs).
318    let (scale, scale_word) = if *number < BigInt::from(1_000_000u32) {
319        (BigInt::from(1_000u32), " мең")
320    } else if *number < BigInt::from(1_000_000_000u32) {
321        (BigInt::from(1_000_000u32), " миллион")
322    } else {
323        (BigInt::from(1_000_000_000u32), " миллиард")
324    };
325
326    let div = number / &scale;
327    let rem = number % &scale;
328    let mut out = int_to_word(&div);
329    out.push_str(scale_word);
330    if !rem.is_zero() {
331        out.push(' ');
332        out.push_str(&int_to_word(&rem));
333    }
334    out
335}
336
337/// `Num2Word_BA.to_cardinal` restricted to integer input.
338///
339/// Python stringifies the value, peels a leading "-" off the *string*, and
340/// feeds the rest to `int()`. With an integral input there is no "." in the
341/// string, so the `pointword` branch is dead and this reduces to sign handling
342/// plus `_int_to_word` on the magnitude.
343fn cardinal(value: &BigInt) -> String {
344    if value.is_negative() {
345        // ret = self.negword  ->  "минус " (already ends in a space)
346        format!("{}{}", NEGWORD, int_to_word(&value.abs()))
347    } else {
348        int_to_word(value)
349    }
350}
351
352/// Python's `int(s)` on a fragment of `str(number)`, preserving the ValueError.
353///
354/// `int()` also tolerates surrounding whitespace, a leading `+`/`-` and `_`
355/// digit separators. None of those can occur in a fragment of `repr(float)` or
356/// `Decimal.__str__` — `to_cardinal` peels the sign off before splitting, and
357/// neither speller emits underscores — so `BigInt::from_str` is exact here.
358///
359/// The message is CPython's verbatim. It is not compared by the corpus (which
360/// records only `type(e).__name__`), but a caller may be matching on it, and
361/// both spellings this can produce are real: `'1e+16'` for a whole
362/// exponent-form string that had no `.`, and `'e'` for the per-character
363/// `int(d)` in the fractional join.
364fn py_int(s: &str) -> Result<BigInt> {
365    BigInt::from_str(s)
366        .map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
367}
368
369/// CPython's `repr(float)` — which is `str(float)` on Python 3.
370///
371/// # The digits: why `{:e}` alone is not enough
372///
373/// Rust's shortest-round-trip spelling (`{}`, `{:e}`) and CPython's `repr`
374/// agree on *how many* significant digits a double needs, and almost always on
375/// what they are — but **they break exact ties differently**, and that is the
376/// banker's-rounding trap wearing a different hat:
377///
378/// ```text
379/// f64::from_bits(4835708046038665529)   exact value 1962311374373454.25
380///     Rust  format!("{}", f)  ->  "1962311374373454.3"    ties away from zero
381///     CPython repr(f)         ->  "1962311374373454.2"    ties to even
382/// ```
383///
384/// Both spellings round-trip, so both are legitimate "shortest" answers; they
385/// are equidistant from the true value and the algorithms disagree on which to
386/// pick. David Gay's `dtoa` (mode 0), which CPython uses, resolves the tie to
387/// **even**. Differential fuzzing put this at ~50 in 210k random doubles — rare,
388/// but Bashkir spells every one of those digits out loud, so it is a wrong
389/// answer, not a rounding wobble.
390///
391/// The fix is to never use Rust's shortest *digits*, only its shortest
392/// *length*:
393///
394/// 1. `{:e}` says how many significant digits the shortest round-trip needs
395///    (`k`). Tie choice cannot change that count — both candidates in a tie are
396///    `k` digits long — so this number is trustworthy.
397/// 2. `{:.*e}` with `k-1` re-emits exactly `k` significant digits from the
398///    double's exact value. Rust's *fixed-precision* float formatting rounds
399///    half **to even** (`{:.0}` of 2.5 is "2", of 0.125 to 2dp is "0.12"), which
400///    is the rule `dtoa` mode 0 applies, so the digits now match CPython's.
401///
402/// Step 2 is what makes the nearest-`k`-digit decimal come out, ties to even —
403/// which is precisely `repr`'s definition once `k` is fixed.
404///
405/// # The layout
406///
407/// Given the digits, the rest is presentation. Rust's `{:e}` is
408/// unconditionally exponential (`1.234e-5`) while `repr` switches between fixed
409/// and exponential; reconstructing that switch is a range check on one integer.
410///
411/// `decpt` is the point's position relative to the digit string
412/// (`value == 0.<digits> * 10^decpt`, so `decpt == e10 + 1`). CPython's
413/// `format_float_short` uses fixed notation for `-4 < decpt <= 16` and
414/// exponential outside it, which is why `str(1e15)` is `"1000000000000000.0"`
415/// but `str(1e16)` is `"1e+16"`, and `str(1e-4)` is `"0.0001"` but `str(1e-5)`
416/// is `"1e-05"`. Both edges verified against the interpreter; the whole
417/// function was then differentially fuzzed against `str()` over ~210k doubles
418/// (random bit patterns plus the subnormal/overflow extremes) — it is what
419/// caught the tie bug above, and it now reports zero divergence.
420///
421/// Fixed notation appends `".0"` to an integral value (CPython's
422/// `Py_DTSF_ADD_DOT_0`), which is why `1.0` is "бер өтөр нуль" and not "бер" —
423/// the `"." in n` test in `to_cardinal` sees that dot.
424fn float_str(f: f64) -> String {
425    // str(nan)/str(inf)/str(-inf). Unreachable through num2words — Python
426    // computes `abs(Decimal(str(value)).as_tuple().exponent)` for `precision`
427    // before ever calling us, and `Decimal("nan").as_tuple().exponent` is the
428    // string "n", so `abs()` raises TypeError up there first. Spelled out
429    // anyway so this stays total rather than panicking in `split_once`.
430    if f.is_nan() {
431        return "nan".to_string();
432    }
433    if f.is_infinite() {
434        return if f < 0.0 { "-inf" } else { "inf" }.to_string();
435    }
436
437    // The sign is taken from the bit, not from `< 0.0`: repr(-0.0) is "-0.0",
438    // and Bashkir's to_cardinal peels that "-" into a negword. `-0.0 < 0.0` is
439    // false, so testing the value would silently drop it.
440    let sign = if f.is_sign_negative() { "-" } else { "" };
441    let a = f.abs();
442
443    // Step 1: the shortest round-trip *length*. Only the digit count is read
444    // from this string — its digits may be the away-from-zero side of a tie.
445    // `0.0` gives "0e0", so `k` is never 0.
446    let shortest = format!("{:e}", a);
447    let (mantissa, _) = shortest
448        .split_once('e')
449        .expect("{:e} on a finite f64 always emits an exponent");
450    let k = mantissa.chars().filter(char::is_ascii_digit).count();
451
452    // Step 2: re-emit exactly `k` significant digits from the exact value, with
453    // Rust's fixed-precision formatter — which rounds half to even, as dtoa
454    // mode 0 does. The exponent is re-read rather than reused: rounding at `k`
455    // digits is allowed to carry (9.99e2 -> 1.00e3), and only this string knows.
456    let exact = format!("{:.*e}", k - 1, a);
457    let (mantissa, e10) = exact
458        .split_once('e')
459        .expect("{:e} on a finite f64 always emits an exponent");
460    let e10: i64 = e10
461        .parse()
462        .expect("{:e}'s exponent is a plain decimal integer");
463    let ds: String = mantissa.chars().filter(|c| *c != '.').collect();
464    let decpt = e10 + 1;
465
466    if decpt > 16 || decpt <= -4 {
467        // Exponential: one digit, the rest, then a >=2-digit signed exponent
468        // ("1e+16", "1e-05", "1.5e-05").
469        let mantissa = if ds.len() > 1 {
470            format!("{}.{}", &ds[..1], &ds[1..])
471        } else {
472            ds
473        };
474        let sgn = if e10 < 0 { '-' } else { '+' };
475        return format!("{}{}e{}{:02}", sign, mantissa, sgn, e10.abs());
476    }
477
478    if decpt <= 0 {
479        // 0.5 -> decpt 0; 0.01 -> decpt -1.
480        format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), ds)
481    } else if decpt as usize >= ds.len() {
482        // Integral: Py_DTSF_ADD_DOT_0 appends ".0". 1.0, 100.0, 1e15, ...
483        format!(
484            "{}{}{}.0",
485            sign,
486            ds,
487            "0".repeat(decpt as usize - ds.len())
488        )
489    } else {
490        format!("{}{}.{}", sign, &ds[..decpt as usize], &ds[decpt as usize..])
491    }
492}
493
494/// CPython's `decimal.Decimal.__str__` (the `eng=False` half of it).
495///
496/// A `Decimal` spells itself by a **different rule than a float**, and Bashkir
497/// reads the spelling character by character, so the difference is observable:
498/// `Decimal("0.00001")` prints `"0.00001"` and converts fine, while the float
499/// `0.00001` prints `"1e-05"` and raises. Hence a separate port rather than
500/// reuse of [`float_str`].
501///
502/// ```python
503/// leftdigits = self._exp + len(self._int)
504/// if self._exp <= 0 and leftdigits > -6:
505///     dotplace = leftdigits          # fixed notation
506/// else:
507///     dotplace = 1                   # scientific notation
508/// if dotplace <= 0:
509///     intpart, fracpart = '0', '.' + '0'*(-dotplace) + self._int
510/// elif dotplace >= len(self._int):
511///     intpart, fracpart = self._int + '0'*(dotplace-len(self._int)), ''
512/// else:
513///     intpart, fracpart = self._int[:dotplace], '.' + self._int[dotplace:]
514/// exp = '' if leftdigits == dotplace else 'E%+d' % (leftdigits - dotplace)
515/// return sign + intpart + fracpart + exp
516/// ```
517///
518/// `bigdecimal`'s parser stores the shim's `str(value)` text as `(int_val,
519/// scale)` without normalising, and `Decimal` likewise keeps `_int`/`_exp` as
520/// written — so `Decimal._int` is `int_val.abs().to_string()` and `Decimal._exp`
521/// is `-scale`, digit for digit. `Decimal("1.10")` and `BigDecimal::from_str
522/// ("1.10")` both hold `(110, 2)`, which is why "1.10" keeps its trailing zero
523/// and renders "бер өтөр бер нуль" rather than "бер өтөр бер".
524///
525/// Note the two ways this differs from float spelling beyond the threshold:
526/// the exponent marker is a capital `E` and is **not** zero-padded (`"1E-7"`,
527/// not `"1e-07"`), and fixed notation never appends `".0"` — `str(Decimal("5"))`
528/// is `"5"`, which has no `.` and so takes `to_cardinal`'s *integer* branch.
529///
530/// # The one unrecoverable case
531///
532/// A negative-zero `Decimal`. `Decimal("-0.0")` keeps `_sign = 1` and prints
533/// `"-0.0"`, but `BigDecimal` carries the sign inside its `BigInt` mantissa,
534/// where zero has no sign — `(0, 1)` is all that survives, so this returns
535/// `"0.0"` and the negword is lost. Flagged in the port's `concerns`; no corpus
536/// row reaches it, and it is not fixable from inside this file.
537fn decimal_str(d: &BigDecimal) -> String {
538    let (mantissa, scale) = d.as_bigint_and_exponent();
539    let sign = if mantissa.is_negative() { "-" } else { "" };
540    // `_int`: the digits, unsigned. BigInt::to_string of a non-negative is
541    // ASCII digits only, so every byte slice below lands on a char boundary.
542    let ds = mantissa.abs().to_string();
543    // `_exp`: bigdecimal's `scale` is the negated Decimal exponent.
544    let exp = -scale;
545    let leftdigits = exp + ds.len() as i64;
546
547    let dotplace = if exp <= 0 && leftdigits > -6 {
548        leftdigits
549    } else {
550        1
551    };
552
553    let (intpart, fracpart) = if dotplace <= 0 {
554        // `leftdigits > -6` bounds this to at most 5 padding zeros.
555        (
556            "0".to_string(),
557            format!(".{}{}", "0".repeat((-dotplace) as usize), ds),
558        )
559    } else if dotplace as usize >= ds.len() {
560        (
561            format!("{}{}", ds, "0".repeat(dotplace as usize - ds.len())),
562            String::new(),
563        )
564    } else {
565        (
566            ds[..dotplace as usize].to_string(),
567            format!(".{}", &ds[dotplace as usize..]),
568        )
569    };
570
571    // `'E%+d'` — Rust's `{:+}` on an integer matches `%+d` exactly.
572    let e = if leftdigits == dotplace {
573        String::new()
574    } else {
575        format!("E{:+}", leftdigits - dotplace)
576    };
577
578    format!("{}{}{}{}", sign, intpart, fracpart, e)
579}
580
581/// `Num2Word_BA.to_cardinal`, the general (string-driven) body.
582///
583/// ```python
584/// n = str(number).strip()
585/// if n.startswith("-"):
586///     n = n[1:]; ret = self.negword
587/// else:
588///     ret = ""
589/// if "." in n:
590///     left, right = n.split(".", 1)
591///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
592///     ret += " ".join(self._int_to_word(int(d)) for d in right)
593///     return ret
594/// else:
595///     return ret + self._int_to_word(int(n))
596/// ```
597///
598/// The integer modes do not route through here: for a `BigInt` the string can
599/// never contain a `.`, so [`cardinal`] is this function with the dead branch
600/// removed, and it stays separate to keep the 67k verified integer rows off a
601/// string round trip.
602///
603/// Evaluation order is load-bearing for the error cases. Python builds the
604/// left-hand side *before* the `" ".join(...)` generator is consumed, so
605/// `int(left)` raises first; within the join, digits are converted left to
606/// right and the first bad one wins. `"1.5e-05"` therefore reports `'e'` — not
607/// `'5e-05'` and not `'1.5e-05'`.
608fn cardinal_from_str(n: &str) -> Result<String> {
609    // `.strip()`. A no-op on every string `str()` can produce, kept because
610    // Python does it.
611    let n = n.trim();
612    let (n, mut ret) = match n.strip_prefix('-') {
613        // `ret = self.negword` — used raw, trailing space and all (quirk 2).
614        Some(rest) => (rest, NEGWORD.to_string()),
615        None => (n, String::new()),
616    };
617
618    // `n.split(".", 1)` — the *first* dot, so "1.5e-05" splits to ("1", "5e-05").
619    match n.split_once('.') {
620        Some((left, right)) => {
621            ret.push_str(&int_to_word(&py_int(left)?));
622            ret.push(' ');
623            ret.push_str(POINTWORD);
624            ret.push(' ');
625            // `" ".join(self._int_to_word(int(d)) for d in right)`. Indexed by
626            // chars, not bytes; `right` is ASCII in practice but a stray
627            // multi-byte char must reach `int()` whole to be reported whole.
628            for (i, d) in right.chars().enumerate() {
629                if i > 0 {
630                    ret.push(' ');
631                }
632                // Python's `int(d)` sees a one-character string.
633                let mut buf = [0u8; 4];
634                ret.push_str(&int_to_word(&py_int(d.encode_utf8(&mut buf))?));
635            }
636            Ok(ret)
637        }
638        None => {
639            ret.push_str(&int_to_word(&py_int(n)?));
640            Ok(ret)
641        }
642    }
643}
644
645/// `Num2Word_BA._split_currency(abs(val))`, returning `(left, right)`.
646///
647/// ```python
648/// parts = str(n).split(".")
649/// left  = int(parts[0]) if parts[0] else 0
650/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
651/// ```
652///
653/// Python re-stringifies the number here and slices the digits. That string
654/// round-trip is reproduced *arithmetically* rather than literally, because the
655/// `BigDecimal` we hold was already parsed from `str(value)` on the Python side
656/// and does not retain the original spelling. The two agree exactly for every
657/// plain-decimal spelling — which is every float in `1e-4 <= |x| < 1e16` and
658/// every `Decimal` written without an exponent:
659///
660/// * `parts[1][:2]` right-padded to two digits and parsed is precisely
661///   `floor(frac * 100)` — truncation, no rounding. One digit ("0.5" → "50")
662///   pads up; three or more ("1.005" → "00") get cut. Both are floor.
663/// * `int(parts[0])` on a non-negative number is `floor`.
664///
665/// Trailing zeros in the source string cannot change the answer either
666/// (`"0.5"` → 5/10¹ and `"0.50"` → 50/10² both give 50), so it does not matter
667/// whether `BigDecimal::from_str` preserved the scale.
668///
669/// The known gap is exponent notation — see the `concerns` note on this port.
670/// `str(1e16)` is `"1e+16"`, and Python then feeds `"1e+16"` to `int()` and
671/// raises `ValueError`; `str(1.23e16)` is `"1.2345678901234568e+16"`, which
672/// *splits* on the "." and quietly yields 1 unit and 23 cents. Neither shape is
673/// recoverable from a parsed `BigDecimal`, and no corpus row exercises them.
674fn split_currency(val: &CurrencyValue) -> (BigInt, BigInt) {
675    match val {
676        // str(int) never contains "." -> parts has length 1 -> right = 0.
677        // This is the branch that gives an int no cents; note that a *float*
678        // with zero cents lands on the same output by a different route.
679        CurrencyValue::Int(i) => (i.abs(), BigInt::zero()),
680        CurrencyValue::Decimal { value: d, .. } => {
681            // `val = abs(val)` happened before `_split_currency` in Python.
682            let d = d.abs();
683            // d == mantissa / 10^exp, mantissa >= 0 after the abs.
684            let (mantissa, exp) = d.as_bigint_and_exponent();
685            if exp <= 0 {
686                // Integral value: no fractional digits to slice.
687                let scale = BigInt::from(10u32).pow((-exp) as u32);
688                (mantissa * scale, BigInt::zero())
689            } else {
690                let scale = BigInt::from(10u32).pow(exp as u32);
691                // Non-negative operands, so div_rem == Python's //, %.
692                let (left, rem) = mantissa.div_rem(&scale);
693                // floor(rem/10^exp * 100) == int(parts[1][:2].ljust(2, "0")).
694                let right = (rem * 100u32) / scale;
695                (left, right)
696            }
697        }
698    }
699}
700
701pub struct LangBa {
702    /// `CURRENCY_FORMS`, built once. Rebuilding it per call is what made an
703    /// earlier revision of this port slower than the Python it replaces.
704    currency_forms: HashMap<&'static str, CurrencyForms>,
705}
706
707impl LangBa {
708    pub fn new() -> Self {
709        // CURRENCY_FORMS = {
710        //     "RUB": (("һум", "һум"), ("тин", "тин")),
711        //     "USD": (("доллар", "доллар"), ("цент", "цент")),
712        //     "EUR": (("евро", "евро"), ("цент", "цент")),
713        // }
714        // Both forms of every pair are identical in Bashkir, and only index 0
715        // is ever read by `to_currency` — but the arity is kept at Python's
716        // two because `to_cheque` takes `cr1[-1]`.
717        let mut currency_forms = HashMap::new();
718        currency_forms.insert(
719            "RUB",
720            CurrencyForms::new(&["һум", "һум"], &["тин", "тин"]),
721        );
722        currency_forms.insert(
723            "USD",
724            CurrencyForms::new(&["доллар", "доллар"], &["цент", "цент"]),
725        );
726        currency_forms.insert(
727            "EUR",
728            CurrencyForms::new(&["евро", "евро"], &["цент", "цент"]),
729        );
730        LangBa { currency_forms }
731    }
732}
733
734impl Default for LangBa {
735    fn default() -> Self {
736        Self::new()
737    }
738}
739
740impl Lang for LangBa {
741
742    fn cardinal_float_entry(
743        &self,
744        value: &crate::floatpath::FloatValue,
745        precision_override: Option<u32>,
746    ) -> crate::base::Result<String> {
747        // Python's to_cardinal routes every float/Decimal through this
748        // language's own decimal grammar — 5.0 keeps its ".0" tail
749        // ("comma nulla"), unlike Base's whole-value integer route.
750        self.to_cardinal_float(value, precision_override)
751    }
752    /// This language's own `to_currency(currency=...)` default,
753    /// read from the live Python signature. Only 44 of 156 use EUR.
754    fn default_currency(&self) -> &str {
755        "RUB"
756    }
757
758    /// This language's own `to_currency(separator=...)` default,
759    /// read from the live Python signature. Base's is ",", but only
760    /// 36 of 149 languages actually use it — most default to " " or a
761    /// conjunction, so inheriting Base's comma silently corrupts them.
762    fn default_separator(&self) -> &str {
763        " һәм "
764    }
765
766    fn negword(&self) -> &str {
767        NEGWORD
768    }
769
770    fn pointword(&self) -> &str {
771        POINTWORD
772    }
773
774    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
775        Ok(cardinal(value))
776    }
777
778    /// `Num2Word_BA.to_cardinal` reached with a `float` or a `Decimal`.
779    ///
780    /// Bashkir does not override `to_cardinal_float` and never calls it — it
781    /// overrides `to_cardinal` and handles non-integers inline off `str(number)`
782    /// (see the module docs). So this hook is not
783    /// `default_to_cardinal_float`-shaped at all: no `float2tuple`, no
784    /// `pointword` gating on `precision > 0`, no `title()`, no rounding.
785    ///
786    /// `precision_override` (the `precision=` kwarg, issue #580) is **ignored**,
787    /// because Python ignores it. `__init__.py` does set `converter.precision`
788    /// for the call — `hasattr(converter, "precision")` is true, it is 2 by
789    /// default — but `Num2Word_BA.to_cardinal` never reads the attribute, so the
790    /// assignment has no effect. Verified: `num2words(2.675, lang="ba",
791    /// precision=1)` == `num2words(2.675, lang="ba")` == "ике өтөр алты ете биш".
792    /// `FloatValue::precision` is unread for the same reason.
793    fn to_cardinal_float(
794        &self,
795        value: &FloatValue,
796        _precision_override: Option<u32>,
797    ) -> Result<String> {
798        // The whole float path is `str(number)`, and float and Decimal spell
799        // themselves by different rules — keep the arms apart.
800        let n = match value {
801            FloatValue::Float { value, .. } => float_str(*value),
802            FloatValue::Decimal { value, .. } => decimal_str(value),
803        };
804        cardinal_from_str(&n)
805    }
806
807    /// `return self.to_cardinal(number) + "-се"`
808    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
809        Ok(format!("{}-се", cardinal(value)))
810    }
811
812    /// `return str(number) + "-се"` — no validation, so "-1-се" is expected.
813    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
814        Ok(format!("{}-се", value))
815    }
816
817    /// `return self.to_cardinal(val) + " йыл"` — the `longval` parameter is
818    /// accepted and then ignored by Python; years get no special casing.
819    fn to_year(&self, value: &BigInt) -> Result<String> {
820        Ok(format!("{} йыл", cardinal(value)))
821    }
822
823    // ---- float / Decimal entry routing --------------------------------
824    //
825    // `to_ordinal` / `to_ordinal_num` / `to_year` have no type guard in
826    // Python, so a float or Decimal flows through them exactly as an int
827    // does: the ordinal suffix / year word wraps the *full* decimal phrase
828    // (or, for `to_ordinal_num`, the raw `str(number)`).
829
830    /// `to_ordinal(float/Decimal)`: `self.to_cardinal(number) + "-се"` —
831    /// "биш өтөр нуль-се" for 5.0; the exponential-form ValueError from the
832    /// cardinal propagates unchanged.
833    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
834        Ok(format!("{}-се", self.cardinal_float_entry(value, None)?))
835    }
836
837    /// `to_ordinal_num(float/Decimal)`: `str(number) + "-се"` — no `int()`,
838    /// so it succeeds where the other modes raise ("1e+16-се") and "-0.0"
839    /// keeps its textual minus. `repr_str` is Python's `str(number)`.
840    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
841        Ok(format!("{}-се", repr_str))
842    }
843
844    /// `to_year(float/Decimal)`: `self.to_cardinal(val) + " йыл"` — the full
845    /// float cardinal plus the year word, ValueErrors included.
846    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
847        Ok(format!("{} йыл", self.cardinal_float_entry(value, None)?))
848    }
849
850    /// `converter.str_to_number` — Base's `Decimal(value)`, which BA does not
851    /// override. The `Inf` interception reproduces what happens *next* on the
852    /// pinned path: `to_cardinal(Decimal("Infinity"))` reads `str(number)` ==
853    /// "Infinity" (the "-Infinity" case strips its sign textually first),
854    /// finds no ".", and dies in `int("Infinity")` with ValueError; the
855    /// binding's shared Inf sentinel would otherwise raise OverflowError.
856    /// (NaN needs no interception: the binding's ValueError already matches
857    /// `int("NaN")`'s type.)
858    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
859        match python_decimal_parse(s)? {
860            ParsedNumber::Inf { .. } => Err(N2WError::Value(
861                "invalid literal for int() with base 10: 'Infinity'".into(),
862            )),
863            other => Ok(other),
864        }
865    }
866
867    // ---- currency ----------------------------------------------------
868
869    fn lang_name(&self) -> &str {
870        "Num2Word_BA"
871    }
872
873    /// The **strict** `CURRENCY_FORMS[code]` lookup, as `to_cheque` performs
874    /// it. `None` here is what makes `default_to_cheque` raise
875    /// `NotImplementedError` for GBP/JPY/KWD/BHD/INR/CNY/CHF, matching the
876    /// corpus error rows. `to_currency` deliberately does *not* route through
877    /// the strictness — it has its own RUB fallback, see below.
878    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
879        self.currency_forms.get(code)
880    }
881
882    /// `Num2Word_BA.to_currency`.
883    ///
884    /// ```python
885    /// def to_currency(self, val, currency="RUB", cents=True,
886    ///                 separator=" һәм ", adjective=False):
887    ///     try:
888    ///         left, right, is_negative = self.parse_currency(val)
889    ///     except AttributeError:                  # always taken, see quirk 6
890    ///         is_negative = False
891    ///         if val < 0:
892    ///             is_negative = True
893    ///             val = abs(val)
894    ///         left, right = self._split_currency(val)
895    ///
896    ///     cr1, cr2 = self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["RUB"])
897    ///
898    ///     left_str = self._int_to_word(int(left))
899    ///     cents_str = self._int_to_word(int(right)) if cents and right else ""
900    ///
901    ///     result = left_str + " " + cr1[0]
902    ///     if cents_str:
903    ///         result += separator + cents_str + " " + cr2[0]
904    ///     return self.negword + result if is_negative else result
905    /// ```
906    ///
907    /// # Why `separator` is not taken at face value
908    ///
909    /// Python resolves `separator` at the *call site* — omit the kwarg and the
910    /// binding fills in this class's own default, `" һәм "`. The Rust core
911    /// cannot see that: the shim in `__init__.py` calls it with
912    /// `kwargs.get("separator", ",")`, substituting `Num2Word_Base`'s default
913    /// for "unspecified", and `bench/diff_test.py` likewise hardcodes `","`.
914    /// So `","` arriving here carries no information — it is the sentinel for
915    /// an absent kwarg — and every corpus row for this language is a default
916    /// call expecting `" һәм "`.
917    ///
918    /// Treating `","` as "use my default" reproduces Python for the whole
919    /// reachable input space bar one case: an explicit `separator=","`, which
920    /// Python would honour and this maps back to `" һәм "`. That single
921    /// divergence is flagged in the port's `concerns`; it is not fixable from
922    /// inside this file, since the shim erases the distinction before we are
923    /// called.
924    fn to_currency(
925        &self,
926        val: &CurrencyValue,
927        currency: &str,
928        cents: bool,
929        separator: Option<&str>,
930        // Accepted and never read, exactly as in Python: Num2Word_BA has no
931        // CURRENCY_ADJECTIVES and its to_currency never consults them.
932        _adjective: bool,
933    ) -> Result<String> {
934        // Trait now hands us None when the caller omitted separator=;
935        // resolve it to this language's own default before the ported body.
936        let separator = separator.unwrap_or(self.default_separator());
937        let separator = if separator == BASE_SEPARATOR {
938            SEPARATOR
939        } else {
940            separator
941        };
942
943        // CURRENCY_FORMS.get(currency, CURRENCY_FORMS["RUB"]) — an unknown
944        // code silently becomes roubles rather than raising. Python evaluates
945        // the fallback eagerly as the `.get` default; "RUB" is always present.
946        let forms = self.currency_forms.get(currency).unwrap_or_else(|| {
947            self.currency_forms
948                .get(FALLBACK_CURRENCY)
949                .expect("CURRENCY_FORMS[\"RUB\"] is the eager .get default")
950        });
951
952        // if val < 0: is_negative = True; val = abs(val)
953        let is_negative = val.is_negative();
954        let (left, right) = split_currency(val);
955
956        let left_str = int_to_word(&left);
957        let mut result = format!("{} {}", left_str, forms.unit[0]);
958
959        // `if cents and right` — a zero `right` is falsy, so a float with no
960        // cents (1.0) drops the segment just like an int does. Note this is
961        // *not* the terse path: cents=False emits nothing at all.
962        if cents && !right.is_zero() {
963            result.push_str(separator);
964            result.push_str(&int_to_word(&right));
965            result.push(' ');
966            result.push_str(&forms.subunit[0]);
967        }
968
969        // `self.negword + result` — concatenated raw. negword is "минус "
970        // and already carries its trailing space (quirk 2); unlike
971        // Num2Word_Base this does not strip and re-space it.
972        Ok(if is_negative {
973            format!("{}{}", NEGWORD, result)
974        } else {
975            result
976        })
977    }
978}
979
980#[cfg(test)]
981mod tests {
982    use super::*;
983
984    fn cardinal_f(f: f64) -> Result<String> {
985        LangBa::new().to_cardinal_float(&FloatValue::Float { value: f, precision: 0 }, None)
986    }
987
988    fn cardinal_d(s: &str) -> Result<String> {
989        LangBa::new().to_cardinal_float(
990            &FloatValue::Decimal {
991                value: BigDecimal::from_str(s).unwrap(),
992                precision: 0,
993            },
994            None,
995        )
996    }
997
998    /// Every `"to": "cardinal"` row with a dot in `arg`, from bench/corpus.jsonl.
999    #[test]
1000    fn corpus_float_rows() {
1001        for (v, want) in [
1002            (0.0, "нуль өтөр нуль"),
1003            (0.5, "нуль өтөр биш"),
1004            (1.0, "бер өтөр нуль"),
1005            (1.5, "бер өтөр биш"),
1006            (2.25, "ике өтөр ике биш"),
1007            (3.14, "өс өтөр бер дүрт"),
1008            (0.01, "нуль өтөр нуль бер"),
1009            (0.1, "нуль өтөр бер"),
1010            (0.99, "нуль өтөр туғыҙ туғыҙ"),
1011            (1.01, "бер өтөр нуль бер"),
1012            (12.34, "ун ике өтөр өс дүрт"),
1013            (99.99, "туҡһан туғыҙ өтөр туғыҙ туғыҙ"),
1014            (100.5, "бер йөҙ өтөр биш"),
1015            (1234.56, "бер мең ике йөҙ утыҙ дүрт өтөр биш алты"),
1016            (-0.5, "минус нуль өтөр биш"),
1017            (-1.5, "минус бер өтөр биш"),
1018            (-12.34, "минус ун ике өтөр өс дүрт"),
1019            (1.005, "бер өтөр нуль нуль биш"),
1020            (2.675, "ике өтөр алты ете биш"),
1021        ] {
1022            assert_eq!(cardinal_f(v).unwrap(), want, "float {:?}", v);
1023        }
1024    }
1025
1026    /// Every `"to": "cardinal_dec"` row from bench/corpus.jsonl.
1027    #[test]
1028    fn corpus_decimal_rows() {
1029        for (s, want) in [
1030            ("0.01", "нуль өтөр нуль бер"),
1031            ("1.10", "бер өтөр бер нуль"),
1032            ("12.345", "ун ике өтөр өс дүрт биш"),
1033            (
1034                "98746251323029.99",
1035                "туҡһан һигеҙ мең ете йөҙ ҡырҡ алты миллиард ике йөҙ илле бер миллион \
1036                 өс йөҙ егерме өс мең егерме туғыҙ өтөр туғыҙ туғыҙ",
1037            ),
1038            ("0.001", "нуль өтөр нуль нуль бер"),
1039        ] {
1040            assert_eq!(cardinal_d(s).unwrap(), want, "Decimal({:?})", s);
1041        }
1042    }
1043
1044    /// `Decimal("1.10")` keeps its trailing zero and `Decimal("98746251323029.99")`
1045    /// keeps every digit — the two properties issue #603 is about. A `float()`
1046    /// cast would drop the first and corrupt the second at trillion scale.
1047    #[test]
1048    fn decimal_arm_is_exact_and_scale_preserving() {
1049        assert_eq!(cardinal_d("1.10").unwrap(), "бер өтөр бер нуль");
1050        assert_eq!(cardinal_d("1.1").unwrap(), "бер өтөр бер");
1051        assert_eq!(cardinal_d("5.00").unwrap(), "биш өтөр нуль нуль");
1052        // No ".0" is appended by Decimal.__str__, so this takes the *integer*
1053        // branch of to_cardinal — unlike the float 5.0, right below.
1054        assert_eq!(cardinal_d("5").unwrap(), "биш");
1055        assert_eq!(cardinal_f(5.0).unwrap(), "биш өтөр нуль");
1056    }
1057
1058    /// float and Decimal spell themselves by different rules, and Bashkir reads
1059    /// the spelling. `0.00001` is the case where that is load-bearing.
1060    #[test]
1061    fn float_and_decimal_spellings_diverge() {
1062        // repr(1e-05) == '1e-05'  ->  int('1e-05') explodes.
1063        assert!(matches!(cardinal_f(0.00001), Err(N2WError::Value(_))));
1064        // str(Decimal('0.00001')) == '0.00001'  ->  converts fine.
1065        assert_eq!(
1066            cardinal_d("0.00001").unwrap(),
1067            "нуль өтөр нуль нуль нуль нуль бер"
1068        );
1069        // Decimal goes exponential one decade later than float does.
1070        assert_eq!(
1071            cardinal_d("0.000001").unwrap(),
1072            "нуль өтөр нуль нуль нуль нуль нуль бер"
1073        );
1074        assert!(matches!(cardinal_d("0.0000001"), Err(N2WError::Value(_))));
1075    }
1076
1077    /// Python's `repr` switches to exponent notation at `decpt > 16` / `<= -4`,
1078    /// and Bashkir hands the result to `int()` unfiltered.
1079    #[test]
1080    fn exponential_repr_raises_value_error() {
1081        // No "." in '1e+16' -> the whole string reaches int().
1082        match cardinal_f(1e16) {
1083            Err(N2WError::Value(m)) => {
1084                assert_eq!(m, "invalid literal for int() with base 10: '1e+16'")
1085            }
1086            other => panic!("expected ValueError, got {:?}", other),
1087        }
1088        // '1.5e-05' has a "." -> int("1") succeeds, then the per-character
1089        // int(d) in the join trips on 'e'.
1090        match cardinal_f(1.5e-5) {
1091            Err(N2WError::Value(m)) => {
1092                assert_eq!(m, "invalid literal for int() with base 10: 'e'")
1093            }
1094            other => panic!("expected ValueError, got {:?}", other),
1095        }
1096        // The sign is peeled off the string before int() sees it.
1097        match cardinal_f(-1e16) {
1098            Err(N2WError::Value(m)) => {
1099                assert_eq!(m, "invalid literal for int() with base 10: '1e+16'")
1100            }
1101            other => panic!("expected ValueError, got {:?}", other),
1102        }
1103        // Decimal's own exponent form: capital E, unpadded exponent.
1104        match cardinal_d("1E+2") {
1105            Err(N2WError::Value(m)) => {
1106                assert_eq!(m, "invalid literal for int() with base 10: '1E+2'")
1107            }
1108            other => panic!("expected ValueError, got {:?}", other),
1109        }
1110    }
1111
1112    /// The values either side of both repr thresholds still convert.
1113    #[test]
1114    fn plain_reprs_at_the_exponential_edges_do_not_raise() {
1115        // decpt == 16: the last fixed-notation decade.
1116        assert_eq!(cardinal_f(1e15).unwrap(), "бер миллион миллиард өтөр нуль");
1117        // decpt == -3: the last fixed-notation decade going down.
1118        assert_eq!(
1119            cardinal_f(0.0001).unwrap(),
1120            "нуль өтөр нуль нуль нуль бер"
1121        );
1122        assert!(matches!(cardinal_f(1e-5), Err(N2WError::Value(_))));
1123        assert!(matches!(cardinal_f(1e16), Err(N2WError::Value(_))));
1124    }
1125
1126    /// `repr(float)` breaks exact decimal ties **to even**; Rust's shortest
1127    /// formatter breaks them away from zero. [`float_str`] must follow CPython.
1128    ///
1129    /// Each value below is exactly representable and sits exactly halfway
1130    /// between two 17-significant-digit decimals, so `format!("{}", f)` and
1131    /// `repr(f)` disagree on the final digit — which Bashkir says out loud.
1132    #[test]
1133    fn exact_decimal_ties_round_to_even_like_cpython() {
1134        // 1962311374373454.25 -> repr '...54.2', Rust Display '...54.3'.
1135        assert_eq!(float_str(1962311374373454.25), "1962311374373454.2");
1136        assert_eq!(float_str(2050093655521678.25), "2050093655521678.2");
1137        assert_eq!(float_str(-145360241606786.125), "-145360241606786.12");
1138        assert_eq!(float_str(106779538212252.625), "106779538212252.62");
1139        // The digit reaches the output: "ике", not "өс".
1140        assert!(cardinal_f(1962311374373454.25).unwrap().ends_with("ике"));
1141    }
1142
1143    /// [`float_str`] is CPython's `repr`, not Rust's `{}`.
1144    #[test]
1145    fn float_str_matches_cpython_repr() {
1146        for (f, want) in [
1147            (0.0, "0.0"),
1148            (-0.0, "-0.0"),
1149            (1.0, "1.0"),
1150            (0.5, "0.5"),
1151            (0.01, "0.01"),
1152            (100.5, "100.5"),
1153            (1.005, "1.005"),
1154            (2.675, "2.675"),
1155            (1234.56, "1234.56"),
1156            (1e15, "1000000000000000.0"),
1157            (1e16, "1e+16"),
1158            (1e21, "1e+21"),
1159            (1e100, "1e+100"),
1160            (0.0001, "0.0001"),
1161            (0.00001, "1e-05"),
1162            (1.5e-5, "1.5e-05"),
1163            (5e-324, "5e-324"),
1164            (1.7976931348623157e308, "1.7976931348623157e+308"),
1165            (123456789012345.6, "123456789012345.6"),
1166        ] {
1167            assert_eq!(float_str(f), want, "repr({:?})", f);
1168        }
1169    }
1170
1171    /// [`decimal_str`] is CPython's `Decimal.__str__`, which is a different
1172    /// function from `repr(float)` — capital E, unpadded exponent, no ".0".
1173    #[test]
1174    fn decimal_str_matches_cpython() {
1175        for (s, want) in [
1176            ("0.01", "0.01"),
1177            ("1.10", "1.10"),
1178            ("5", "5"),
1179            ("5.00", "5.00"),
1180            ("100", "100"),
1181            ("0", "0"),
1182            ("0.00", "0.00"),
1183            ("0.00001", "0.00001"),
1184            ("0.000001", "0.000001"),
1185            ("0.0000001", "1E-7"),
1186            ("1E+2", "1E+2"),
1187            ("1E+16", "1E+16"),
1188            ("-1.005", "-1.005"),
1189            ("98746251323029.99", "98746251323029.99"),
1190        ] {
1191            assert_eq!(decimal_str(&BigDecimal::from_str(s).unwrap()), want, "Decimal({:?})", s);
1192        }
1193    }
1194
1195    /// -0.5 keeps its sign even though `int(-0.5) == 0` carries none, because
1196    /// Bashkir peels the "-" off the *string* rather than testing the value.
1197    /// A float -0.0 keeps it too: the sign bit survives, and `is_sign_negative`
1198    /// is what reads it (`-0.0 < 0.0` is false).
1199    #[test]
1200    fn negative_zero_and_negative_fractions_keep_the_negword() {
1201        assert_eq!(cardinal_f(-0.5).unwrap(), "минус нуль өтөр биш");
1202        assert_eq!(cardinal_f(-0.0).unwrap(), "минус нуль өтөр нуль");
1203        assert_eq!(cardinal_f(-0.01).unwrap(), "минус нуль өтөр нуль бер");
1204        assert_eq!(cardinal_d("-0.5").unwrap(), "минус нуль өтөр биш");
1205    }
1206
1207    /// The `precision=` kwarg is a no-op for Bashkir: `to_cardinal` never reads
1208    /// `self.precision`. Neither `precision_override` nor `FloatValue::precision`
1209    /// may change the answer.
1210    #[test]
1211    fn precision_override_is_ignored() {
1212        let l = LangBa::new();
1213        let v = FloatValue::Float { value: 2.675, precision: 3 };
1214        let want = "ике өтөр алты ете биш";
1215        assert_eq!(l.to_cardinal_float(&v, None).unwrap(), want);
1216        assert_eq!(l.to_cardinal_float(&v, Some(1)).unwrap(), want);
1217        assert_eq!(l.to_cardinal_float(&v, Some(9)).unwrap(), want);
1218        // The carried precision is equally unread.
1219        let v = FloatValue::Float { value: 2.675, precision: 99 };
1220        assert_eq!(l.to_cardinal_float(&v, None).unwrap(), want);
1221    }
1222
1223    /// The float path must not disturb the integer modes, which route through
1224    /// `cardinal` and never touch a string.
1225    #[test]
1226    fn integer_modes_are_untouched() {
1227        let l = LangBa::new();
1228        assert_eq!(l.to_cardinal(&BigInt::from(0)).unwrap(), "нуль");
1229        assert_eq!(l.to_cardinal(&BigInt::from(-12)).unwrap(), "минус ун ике");
1230        assert_eq!(l.to_ordinal(&BigInt::from(11)).unwrap(), "ун бер-се");
1231    }
1232}