Skip to main content

num2words2_core/
lang_br.rs

1//! Port of `lang_BR.py` (Breton).
2//!
3//! Registry check: `__init__.py` maps `"br"` → `lang_BR.Num2Word_BR()`, so this
4//! file ports `Num2Word_BR` — the class the key actually resolves to.
5//!
6//! Shape: **self-contained**. `Num2Word_BR` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the `__init__`
8//! guard
9//!
10//! ```python
11//! if any(hasattr(self, f) for f in ["high_numwords", "mid_numwords", "low_numwords"]):
12//! ```
13//!
14//! never fires: Python builds **no** `self.cards` and sets **no** `self.MAXVAL`
15//! (verified: `hasattr(b, "cards") is False`, `hasattr(b, "MAXVAL") is False`).
16//! `to_cardinal` is overridden outright and drives `_int_to_word`, so
17//! `cards`/`maxval`/`merge` stay at their trait defaults here and **no overflow
18//! check exists** — there is no input this module rejects.
19//!
20//! The Python inheritance chain is exactly `Num2Word_BR` → `Num2Word_Base` →
21//! `object`. Every in-scope method is overridden by BR, so nothing is inherited
22//! for the four supported modes:
23//!   * `to_cardinal`    — overridden (below)
24//!   * `to_ordinal`     — overridden: cardinal + `"-vet"`
25//!   * `to_ordinal_num` — overridden: `str(number) + "."`
26//!   * `to_year`        — overridden: delegates to `to_cardinal`, ignoring the
27//!     `longval` keyword entirely
28//!
29//! # The float / Decimal path
30//!
31//! BR does **not** override `to_cardinal_float`; it overrides `to_cardinal`,
32//! whose very first line is `n = str(number).strip()`. Every float and every
33//! `Decimal` therefore reaches the *same* body as an int and is dispatched on
34//! whether that string contains a `"."`. `Num2Word_Base.to_cardinal_float` and
35//! `Num2Word_Base.float2tuple` are inherited but **dead**: BR's `to_cardinal`
36//! never calls them, so [`crate::floatpath`] is deliberately not used here.
37//!
38//! That single fact drives everything below. The digits BR speaks are the
39//! digits `repr()` prints — not the ones `float2tuple` derives:
40//!
41//! ```python
42//! if "." in n:
43//!     left, right = n.split(".", 1)
44//!     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
45//!     for digit in right:
46//!         ret += self._int_to_word(int(digit)) + " "
47//!     return ret.strip()
48//! ```
49//!
50//! ## Further Python quirks reproduced (float / Decimal)
51//!
52//! 14. **`float2tuple`'s artefact heuristic never runs, and neither does its
53//!     rounding.** The `< 0.01` rescue in `base.float2tuple` — the thing that
54//!     turns `2.675`'s `674.9999999999998` back into `675` — sits on a code
55//!     path BR cannot reach; BR just reads `repr(2.675) == "2.675"` and spells
56//!     the characters. There is no `round()` anywhere in this file, so the
57//!     banker's-rounding trap does not apply to it either.
58//!
59//!     The two routes agree on the corpus's headline cases (`1.005` and `2.675`
60//!     both come out `"… pemp"`, base by rescuing its artefact and BR by never
61//!     creating one), but they are not interchangeable in general: over 30k
62//!     random floats they disagree on **8798**. The cheapest witness is a long
63//!     fraction, where `post = abs(value - pre) * 10**precision` is evaluated
64//!     in f64 and loses the last digit that `repr` kept —
65//!     `0.17108284528077355` has `precision == 17`, and that product is
66//!     `1.7108284528077356e16`, so `float2tuple` reports `…356` where `repr`
67//!     says `…355`. Base ends `"… pemp c'hwec'h"` (6); BR ends `"… pemp pemp"`
68//!     (5). `num2words(0.17108284528077355, lang="br")` returns BR's, which is
69//!     what makes the string route the observable one and not a detail.
70//! 15. **`self.precision` is never read, so `precision=` does nothing.**
71//!     `Num2Word_Base.__init__` sets `self.precision = 2` and the dispatcher
72//!     happily assigns to it (`hasattr(converter, "precision")` is True), but
73//!     BR's `to_cardinal` spells *every* character of `right` regardless.
74//!     Verified against the interpreter: `num2words(1.2345, lang="br",
75//!     precision=2)` == `num2words(1.2345, lang="br", precision=8)` ==
76//!     `"unan point daou tri pevar pemp"` — all four digits, both times. Hence
77//!     `precision_override` is accepted and ignored below.
78//! 16. **Scientific notation is a `ValueError`, and *which* one depends on the
79//!     digit count.** `repr` switches to exponent form at `decpt <= -4` or
80//!     `decpt > 16`, and neither `int()` call in the body tolerates it:
81//!       * one significant digit (`repr(1e16) == "1e+16"`) has no `"."`, so the
82//!         integer branch runs and `int("1e+16")` raises
83//!         `invalid literal for int() with base 10: '1e+16'`;
84//!       * more than one (`repr(1.5e16) == "1.5e+16"`) *does* have a `"."`, so
85//!         the float branch runs, `int("1")` succeeds, the digit loop eats
86//!         `"5"`, and then `int("e")` raises
87//!         `invalid literal for int() with base 10: 'e'`.
88//!     `inf` and `nan` die the same way as the first case. So BR converts no
89//!     float at or beyond `1e16`, and none below `1e-4`.
90//! 17. **A `Decimal` keeps its trailing zeros, because `str` does.**
91//!     `str(Decimal("1.10")) == "1.10"`, so 1.10 is `"unan point unan zero"` —
92//!     two fraction words for a value a float would render with one. Likewise
93//!     `Decimal("5.00")` is `"pemp point zero zero"` while `Decimal("5")`, whose
94//!     `str` has no `"."` at all, takes the *integer* branch and is plain
95//!     `"pemp"`. This is why the value cannot be normalised on the way in.
96//! 18. **`Decimal`'s exponent threshold is not `float`'s.** `Decimal.__str__`
97//!     goes scientific at `_exp > 0` or `adjusted exponent < -6`, and writes the
98//!     exponent unpadded with a capital `E`: `str(Decimal("1e-7")) == "1E-7"`
99//!     (versus `repr(1e-07) == "1e-07"`), and `str(Decimal("1e-5")) ==
100//!     "0.00001"` where the float renders `"1e-05"` and raises. So `Decimal`
101//!     spells fractions four orders of magnitude smaller than `float` can.
102//! 19. **`98746251323029.99` survives as a `Decimal` and would not as a float**
103//!     (issue #603). It renders `"98746251323029 point nav nav"` — the integer
104//!     part going through `_int_to_word`'s 10^9 digit fallback (quirk 1).
105//!
106//! # Faithfully reproduced Python quirks
107//!
108//! This is a port, not a rewrite. All of the following are odd-looking but are
109//! exactly what Python emits, verified against the interpreter:
110//!
111//! 1. **`_int_to_word` falls back to digits at 10^9.** The if/elif chain stops
112//!    at `number < 1000000000` and the final `else` is
113//!    `return str(number)  # Fallback for very large numbers`. So
114//!    `to_cardinal(10**9)` == `"1000000000"` — bare digits, not words — and
115//!    `to_ordinal(10**9)` == `"1000000000-vet"`. The negative sign is stripped
116//!    by `to_cardinal` before `_int_to_word` runs, so
117//!    `to_cardinal(-10**9)` == `"minus 1000000000"`. This is why the fallback
118//!    must use arbitrary-precision `to_string()`: the corpus reaches 10^21,
119//!    well past `u64`. Confirmed by corpus rows for 10^9, 10^12, 10^18, 10^21.
120//! 2. **Zero is spelled by an accident of falsiness.** `_int_to_word(0)` reads
121//!    `return self.ones[0] if self.ones[0] else "zero"`. `ones[0]` is the empty
122//!    string, which is falsy in Python, so the ternary *always* takes the else
123//!    branch and the first operand is dead. Result: `"zero"`.
124//! 3. **No `verify_ordinal` call.** `Num2Word_Base.verify_ordinal` would raise
125//!    `TypeError` for negative ordinals, but BR's `to_ordinal` never calls it.
126//!    So `to_ordinal(-1)` == `"minus unan-vet"` rather than raising — the
127//!    suffix lands on the end of the whole phrase, not on the number word.
128//!    Likewise `to_ordinal(-100)` == `"minus unan kant-vet"`.
129//! 4. **Hundreds are never bare.** The `number < 1000` branch always emits
130//!    `self.ones[hundreds_val] + " " + self.hundred`, so 100 is `"unan kant"`
131//!    ("one hundred"), never `"kant"`. Same for `mil` and `milion` via the
132//!    recursive calls: 1000 is `"unan mil"`, 10^6 is `"unan milion"`.
133//! 5. **Breton's vigesimal tens are stored whole**, spaces and hyphens
134//!    included: `tens[7]` is `"dek ha tri-ugent"` (70) and `tens[9]` is
135//!    `"dek ha pevar-ugent"` (90). Concatenation is therefore blind to word
136//!    count — 99 becomes `"dek ha pevar-ugent nav"`.
137//! 6. **`negword` keeps its trailing space.** `setup` sets `"minus "` (unlike
138//!    `Num2Word_Base.to_cardinal`, which does `self.negword.strip()`). BR's
139//!    `to_cardinal` prepends it raw and `.strip()`s the finished string, so the
140//!    single space survives between sign and number and nowhere else.
141//! 7. **`_int_to_word`'s own negative branch is dead code** on every in-scope
142//!    path: `to_cardinal` strips the `"-"` at the string level and feeds
143//!    `int(n)` a non-negative value. It is only reachable from `to_currency`
144//!    (out of scope). Reproduced anyway in [`int_to_word`] for fidelity.
145//!
146//! # Errors
147//!
148//! For **integer** input: none. BR raises nothing in the four supported modes:
149//! no `MAXVAL` means no `OverflowError`, no `verify_ordinal` means no
150//! `TypeError`, and every table index is bounded by an arithmetic guard.
151//! Accordingly every integer method here returns `Ok`, and the frozen corpus
152//! agrees — all 324 in-scope `br` rows carry `"ok": true`.
153//!
154//! For **float / Decimal** input the only error is `ValueError`, raised by
155//! `int()` on a token it cannot parse (quirk 16), and it is always
156//! [`N2WError::Value`]. There is still no `OverflowError` — `1e16` fails
157//! because `repr` wrote an `e`, not because BR checked a bound.
158//!
159//! # The currency surface
160//!
161//! `Num2Word_BR` defines `CURRENCY_FORMS` (EUR and USD only) and overrides
162//! `to_currency` outright. It defines **no** `CURRENCY_ADJECTIVES`, **no**
163//! `CURRENCY_PRECISION`, and does not override `pluralize`, `_money_verbose`,
164//! `_cents_verbose`, `_cents_terse` or `to_cheque` — all of those stay at
165//! `Num2Word_Base`, which is exactly what the trait defaults already model. So
166//! only [`Lang::lang_name`], [`Lang::currency_forms`] and [`Lang::to_currency`]
167//! are overridden here.
168//!
169//! `to_cheque` is therefore the inherited `Num2Word_Base.to_cheque`, driving
170//! `currency::default_to_cheque`. It looks the code up in `CURRENCY_FORMS`
171//! directly and raises `NotImplementedError` on a miss, which is why
172//! `cheque:EUR`/`cheque:USD` succeed while `cheque:GBP` and friends raise —
173//! matching the corpus exactly. Its `divisor` is
174//! `CURRENCY_PRECISION.get(currency, 100)`, and BR's `CURRENCY_PRECISION` is
175//! the empty inherited dict, so it is **always** 100: `cheque:KWD` would use
176//! `/100`, not `/1000`, if KWD were in the table at all.
177//!
178//! ## Further Python quirks reproduced (currency)
179//!
180//! 8. **An unknown currency code silently becomes euros.** `to_currency` reads
181//!    `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`.
182//!    The fallback is the *first inserted* entry — EUR — so `currency="GBP"`,
183//!    `"JPY"`, `"KWD"` … all render as euros instead of raising. Verified
184//!    against the interpreter: `to_currency(12.34, currency="JPY")` ==
185//!    `"dek daou euroioù tregont pevar sentimoù"`. This fallback lives *only*
186//!    in `to_currency`; [`Lang::currency_forms`] stays a faithful
187//!    `CURRENCY_FORMS[code]` (returning `None` on a miss) so the inherited
188//!    `to_cheque` still raises for the same codes.
189//! 9. **`CURRENCY_PRECISION` is never consulted by `to_currency`.** The cents
190//!    field is hard-coded to two digits via `parts[1][:2].ljust(2, "0")`, so
191//!    3-decimal currencies get no special handling and the `divisor`
192//!    machinery of `base.to_currency` is bypassed entirely. `0.5` is 50
193//!    subunits for every code.
194//! 10. **The int/float split is invisible here.** `base.to_currency` branches
195//!    on `isinstance(val, int)`; BR does not — it branches on whether
196//!    `str(val)` contains a `"."`. The two agree anyway, because a whole
197//!    float stringifies with a `"0"` fraction: `1.0` → `parts[1] == "0"` →
198//!    `int("00")` → `0`, which is falsy, so `if cents and right:` drops the
199//!    segment just as the int path would. Both `1` and `1.0` give
200//!    `"unan euro"`. The distinction is still honoured in the value passed in.
201//! 11. **`cents=False` drops the cents segment rather than going terse.**
202//!    `if cents and right:` is the only guard; there is no `_cents_terse`
203//!    branch, so `cents=False` loses the subunits entirely.
204//! 12. **A float too large to stringify plainly raises `ValueError`.** Python
205//!    takes `str(val)`, so `1e16` and above render in scientific notation and
206//!    `int("1e+16")` raises. Confirmed against the interpreter:
207//!    `to_currency(1e21)` → `ValueError`. `BigDecimal`'s `Display` preserves
208//!    the same `1e+21` form, so `BigInt::from_str` fails on exactly the same
209//!    inputs and [`N2WError::Value`] reproduces the type natively.
210//! 13. **`_int_to_word`'s digit fallback is reachable from currency.** Unlike
211//!    the four integer modes, `to_currency(1234567890.0)` yields
212//!    `"1234567890 euroioù"` — bare digits, per quirk 1.
213
214use crate::base::{Lang, N2WError, Result};
215use crate::currency::{CurrencyForms, CurrencyValue};
216use crate::floatpath::FloatValue;
217use crate::strnum::{python_decimal_parse, ParsedNumber};
218use bigdecimal::BigDecimal;
219use num_bigint::BigInt;
220use num_traits::{One, Signed, ToPrimitive, Zero};
221use std::collections::HashMap;
222use std::str::FromStr;
223
224/// `setup`: `self.negword`. Trailing space is intentional — see quirk 6.
225const NEGWORD: &str = "minus ";
226
227/// `setup`: `self.pointword`. Read by the float/Decimal branch of
228/// [`cardinal_from_py_str`], which splices it between the two halves.
229const POINTWORD: &str = "point";
230
231/// `setup`: `self.ones`. Index 0 is the empty string; see quirk 2.
232const ONES: [&str; 10] = [
233    "",
234    "unan",
235    "daou",
236    "tri",
237    "pevar",
238    "pemp",
239    "c'hwec'h",
240    "seizh",
241    "eizh",
242    "nav",
243];
244
245/// `setup`: `self.tens`, indexed by the tens digit. Breton counts in twenties,
246/// so these are multi-word forms stored verbatim; see quirk 5.
247const TENS: [&str; 10] = [
248    "",
249    "dek",
250    "ugent",
251    "tregont",
252    "daou-ugent",
253    "hanter-kant",
254    "tri-ugent",
255    "dek ha tri-ugent",
256    "pevar-ugent",
257    "dek ha pevar-ugent",
258];
259
260/// `Num2Word_BR.to_currency`'s own default `separator=" "`, confirmed against
261/// the interpreter: `Num2Word_BR.to_currency.__defaults__` is
262/// `("EUR", True, " ", False)`.
263const DEFAULT_SEPARATOR: &str = " ";
264
265/// `Num2Word_Base.to_currency`'s default `separator=","`.
266///
267/// The dispatcher (`__init__.py`) sends `kwargs.get("separator", ",")` down the
268/// Rust fast path — *base's* default, not BR's — so a caller who says nothing
269/// about the separator is indistinguishable from one who explicitly asked for
270/// `","`. `to_currency` treats this value as "caller said nothing" and
271/// substitutes [`DEFAULT_SEPARATOR`]; see the note on [`LangBr::to_currency`].
272const BASE_DEFAULT_SEPARATOR: &str = ",";
273
274/// `setup`: `self.hundred`.
275const HUNDRED: &str = "kant";
276/// `setup`: `self.thousand`.
277const THOUSAND: &str = "mil";
278/// `setup`: `self.million`.
279const MILLION: &str = "milion";
280
281/// Index a numword table with a `BigInt` the caller has already bounded to
282/// `0..=9` by an arithmetic guard. The `expect` is unreachable: every call site
283/// sits behind a `< 10`, `/ 10` (of a value `< 100`), or `/ 100` (of a value
284/// `< 1000`) test, so Python's own list indexing could not go out of range
285/// either.
286fn digit(n: &BigInt) -> usize {
287    n.to_usize().expect("guarded to 0..=9 by the caller")
288}
289
290/// Port of `Num2Word_BR._int_to_word`.
291///
292/// Mirrors the Python if/elif chain arm for arm, including the negative branch
293/// that `to_cardinal` can never reach (quirk 7) and the digits fallback above
294/// 10^9 (quirk 1). Stays on `BigInt` throughout: the fallback arm must render
295/// values the corpus takes to 10^21, and narrowing early would lose them.
296pub fn int_to_word(number: &BigInt) -> String {
297    // Python: `return self.ones[0] if self.ones[0] else "zero"` — ones[0] is
298    // "", which is falsy, so the else branch always wins. See quirk 2.
299    if number.is_zero() {
300        return "zero".to_string();
301    }
302
303    // Dead on every in-scope path (quirk 7), kept for fidelity.
304    if number.is_negative() {
305        return format!("{}{}", NEGWORD, int_to_word(&number.abs()));
306    }
307
308    // From here `number` is strictly positive, so BigInt's truncating `/` and
309    // `%` agree with Python's floor-division `//` and `%`.
310    if *number < BigInt::from(10u32) {
311        return ONES[digit(number)].to_string();
312    }
313
314    if *number < BigInt::from(100u32) {
315        let tens_val = number / 10u32;
316        let ones_val = number % 10u32;
317        if ones_val.is_zero() {
318            return TENS[digit(&tens_val)].to_string();
319        }
320        return format!("{} {}", TENS[digit(&tens_val)], ONES[digit(&ones_val)]);
321    }
322
323    if *number < BigInt::from(1_000u32) {
324        // Python reads self.ones[hundreds_val] directly here rather than
325        // recursing, so 100 is always "unan kant" — never bare "kant".
326        let hundreds_val = number / 100u32;
327        let remainder = number % 100u32;
328        let mut result = format!("{} {}", ONES[digit(&hundreds_val)], HUNDRED);
329        if !remainder.is_zero() {
330            result.push(' ');
331            result.push_str(&int_to_word(&remainder));
332        }
333        return result;
334    }
335
336    if *number < BigInt::from(1_000_000u32) {
337        let thousands_val = number / 1_000u32;
338        let remainder = number % 1_000u32;
339        let mut result = format!("{} {}", int_to_word(&thousands_val), THOUSAND);
340        if !remainder.is_zero() {
341            result.push(' ');
342            result.push_str(&int_to_word(&remainder));
343        }
344        return result;
345    }
346
347    if *number < BigInt::from(1_000_000_000u32) {
348        let millions_val = number / 1_000_000u32;
349        let remainder = number % 1_000_000u32;
350        let mut result = format!("{} {}", int_to_word(&millions_val), MILLION);
351        if !remainder.is_zero() {
352            result.push(' ');
353            result.push_str(&int_to_word(&remainder));
354        }
355        return result;
356    }
357
358    // Python: `return str(number)  # Fallback for very large numbers`.
359    // Quirk 1 — bare digits, no words, no error.
360    number.to_string()
361}
362
363/// Python's `int(<str>)`, for the tokens `str(float)`/`str(Decimal)` can hand
364/// it.
365///
366/// The message is reproduced verbatim because it is the *only* thing that
367/// distinguishes BR's two failure modes from each other (quirk 16), and because
368/// `BigInt::from_str`'s own message ("invalid digit found in string") would leak
369/// Rust into a `ValueError` a caller may well be printing.
370///
371/// `BigInt::from_str` is narrower than `int()` in ways this caller cannot
372/// reach: it rejects the surrounding whitespace, `_` separators and non-ASCII
373/// decimal digits that `int()` accepts. Neither `repr(float)` nor
374/// `Decimal.__str__` ever emits any of those, so the two agree on every string
375/// that actually arrives here.
376fn py_int(s: &str) -> Result<BigInt> {
377    BigInt::from_str(s)
378        .map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
379}
380
381/// Python's `repr(float)` — i.e. `PyOS_double_to_string(v, 'r', 0,
382/// Py_DTSF_ADD_DOT_0)`, which `float.__str__` is an alias of.
383///
384/// BR spells the characters of this string, so it is the whole specification of
385/// the float path and has to be exact, `".0"` suffix and `"e+16"` threshold
386/// included. It cannot be replaced by `format!("{}", v)`: Rust's `Display`
387/// prints `1.0` as `"1"`, which has no `"."` and would silently route 1.0
388/// through the *integer* branch to `"unan"` instead of `"unan point zero"`.
389///
390/// # Why the digits are taken in two steps
391///
392/// `_Py_dg_dtoa(mode=0)` is not merely "some string that round-trips": it is
393/// the *n*-significant-digit decimal **nearest** to `v`, ties to **even**, with
394/// *n* the shortest length that round-trips. The two halves of that come from
395/// two different Rust formatters, and using only the first is a real bug:
396///
397/// * `{:e}` is shortest-round-trip, so it settles *n* — and on `n` the two
398///   agree, both being minimal-length.
399/// * `{:e}` does **not** settle the digits. When `v` sits exactly halfway
400///   between two *n*-digit decimals both algorithms accept, Gay rounds the last
401///   digit to even and Rust's shortest rounds it up. `f64::from_bits(
402///   0x4313781b63796605)` is exactly `1370020896463233.25`: Python prints
403///   `"1370020896463233.2"`, `{:e}` yields `…3.3`, and BR would say `"… tri"`
404///   for a value Python calls `"… daou"`. A 200k-value fuzz against the live
405///   interpreter hit this 1462 times.
406/// * `{:.*e}` *is* exact and correctly rounded, and `flt2dec`'s `format_exact`
407///   documents the same tie rule Gay uses ("if the following digits are exactly
408///   half, round to even"). So the digits come from there, at the width `{:e}`
409///   established.
410///
411/// The rest is CPython's `format_float_short` layout logic transcribed.
412fn py_float_str(v: f64) -> String {
413    // CPython prints these from a fixed table, and `repr` drops the sign of a
414    // nan (`repr(-float("nan")) == "nan"`, sign bit or not) while keeping it on
415    // an inf.
416    if v.is_nan() {
417        return "nan".to_string();
418    }
419    if v.is_infinite() {
420        return if v < 0.0 { "-inf" } else { "inf" }.to_string();
421    }
422
423    // `is_sign_negative`, not `v < 0.0`: `_Py_dg_dtoa` reports the sign *bit*,
424    // so `repr(-0.0) == "-0.0"` and BR answers "minus zero point zero". A
425    // `< 0.0` test says false for -0.0 and would drop the minus.
426    let sign = if v.is_sign_negative() { "-" } else { "" };
427
428    // Step 1: the shortest round-trip form, "d[.ddd]e<exp>" — e.g. 0.5 ->
429    // "5e-1", 1.005 -> "1.005e0", 0.0 -> "0e0". Only its *digit count* is
430    // trusted; see the tie discussion above.
431    let shortest = format!("{:e}", v.abs());
432    let ndigits = shortest
433        .split_once('e')
434        .expect("{:e} always emits an exponent marker")
435        .0
436        .chars()
437        .filter(char::is_ascii_digit)
438        .count();
439
440    // Step 2: re-render at exactly that width. `{:.*e}` is the exact,
441    // correctly-rounded, ties-to-even formatter, which is what dtoa mode 0
442    // actually computes. The exponent is re-read from *this* call rather than
443    // carried over, so a hypothetical rounding carry stays self-consistent.
444    // saturating_sub only to keep an impossible ndigits == 0 from underflowing
445    // a usize; `{:e}` always emits at least one mantissa digit.
446    let exact = format!("{:.*e}", ndigits.saturating_sub(1), v.abs());
447    let (mant, exp) = exact
448        .split_once('e')
449        .expect("{:e} always emits an exponent marker");
450    let exp: i32 = exp.parse().expect("{:e} always emits a decimal exponent");
451    let digits: String = mant.chars().filter(|c| *c != '.').collect();
452    let digits_len = digits.chars().count() as i32;
453    // CPython's decpt: the decimal point's position within `digits`.
454    let decpt = exp + 1;
455
456    // format_float_short, `case 'r'`:
457    //
458    //     /* Convert to exponential format at 1e16. ... */
459    //     if (decpt <= -4 || decpt > 16)
460    //         use_exp = 1;
461    if decpt <= -4 || decpt > 16 {
462        // exp = decpt - 1; decpt = 1;
463        let e = decpt - 1;
464        let mut out = String::from(sign);
465        let mut it = digits.chars();
466        out.push(it.next().expect("dtoa never returns an empty digit string"));
467        let rest: String = it.collect();
468        // Py_DTSF_ADD_DOT_0 is not applied in exponent form, so a lone digit
469        // stays lone: repr(1e16) is "1e+16", never "1.0e+16".
470        if !rest.is_empty() {
471            out.push('.');
472            out.push_str(&rest);
473        }
474        // CPython: `sprintf(p, "%+.02d", exp)` — always signed, at least two
475        // digits, never truncated. 16 -> "+16", -5 -> "-05", -324 -> "-324".
476        out.push('e');
477        out.push(if e < 0 { '-' } else { '+' });
478        out.push_str(&format!("{:02}", (e as i64).abs()));
479        out
480    } else if decpt <= 0 {
481        // 0.0001 <= |v| < 1: "0." then -decpt padding zeros, then the digits.
482        format!(
483            "{}0.{}{}",
484            sign,
485            "0".repeat((-decpt) as usize),
486            digits
487        )
488    } else if decpt >= digits_len {
489        // Integral: pad right with zeros, then Py_DTSF_ADD_DOT_0 appends ".0".
490        // This is the arm that makes repr(1.0) == "1.0" and repr(0.0) == "0.0",
491        // and therefore the arm that puts every whole float on the *float*
492        // branch of to_cardinal.
493        format!(
494            "{}{}{}.0",
495            sign,
496            digits,
497            "0".repeat((decpt - digits_len) as usize)
498        )
499    } else {
500        // 0 < decpt < digits_len: split the digit string in place.
501        let head: String = digits.chars().take(decpt as usize).collect();
502        let tail: String = digits.chars().skip(decpt as usize).collect();
503        format!("{}{}.{}", sign, head, tail)
504    }
505}
506
507/// Python's `Decimal.__str__` (the spec's *to-scientific-string*), transcribed
508/// from `_pydecimal.Decimal.__str__` with `eng=False` and the default
509/// `context.capitals == 1`.
510///
511/// Not interchangeable with [`py_float_str`]: the thresholds differ (quirk 18),
512/// the exponent is unpadded and capital-`E`, and — the reason this exists at all
513/// — the coefficient is whatever the caller wrote, so `Decimal("1.10")`
514/// stringifies with its trailing zero intact (quirk 17).
515///
516/// `BigDecimal`'s own `Display` is close but not this: it is free to normalise
517/// and to choose its own exponent threshold, and either divergence changes how
518/// many words BR speaks.
519fn py_decimal_str(d: &BigDecimal) -> String {
520    // BigDecimal is coefficient * 10^-scale; Decimal is _int * 10^_exp.
521    let (coefficient, scale) = d.as_bigint_and_exponent();
522    let exp = -scale; // Decimal._exp
523    let sign = if coefficient.is_negative() { "-" } else { "" };
524    // Decimal._int — the coefficient's digits, unsigned and unnormalised.
525    let int_digits = coefficient.abs().to_string();
526    let len = int_digits.chars().count() as i64;
527
528    // leftdigits = self._exp + len(self._int)
529    let leftdigits = exp + len;
530
531    // if self._exp <= 0 and leftdigits > -6: dotplace = leftdigits
532    // elif not eng:                          dotplace = 1
533    let dotplace = if exp <= 0 && leftdigits > -6 {
534        leftdigits
535    } else {
536        1
537    };
538
539    let (intpart, fracpart) = if dotplace <= 0 {
540        (
541            "0".to_string(),
542            format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
543        )
544    } else if dotplace >= len {
545        (
546            format!("{}{}", int_digits, "0".repeat((dotplace - len) as usize)),
547            String::new(),
548        )
549    } else {
550        let head: String = int_digits.chars().take(dotplace as usize).collect();
551        let tail: String = int_digits.chars().skip(dotplace as usize).collect();
552        (head, format!(".{}", tail))
553    };
554
555    // exp = ['e', 'E'][context.capitals] + "%+d" % (leftdigits - dotplace)
556    //
557    // "%+d", not the float path's "%+.02d": no zero padding, which is why
558    // str(Decimal("1e-7")) is "1E-7" and repr(1e-07) is "1e-07".
559    let expstr = if leftdigits == dotplace {
560        String::new()
561    } else {
562        let e = leftdigits - dotplace;
563        format!("E{}{}", if e < 0 { "-" } else { "+" }, e.abs())
564    };
565
566    format!("{}{}{}{}", sign, intpart, fracpart, expstr)
567}
568
569/// Port of `Num2Word_BR.to_cardinal`'s body, driven by `str(number)` exactly as
570/// Python drives it.
571///
572/// Python's method takes the *number* and stringifies it on line one; the two
573/// callers here have already done that, because reproducing `str` is where all
574/// the difficulty lives ([`py_float_str`], [`py_decimal_str`]) and it differs
575/// per type. Everything after that line is type-blind, which is precisely why
576/// [`Lang::to_cardinal`] — the integer specialisation of this same body — can
577/// skip the round-trip through text: an integer's `str` has no `"."`, so it
578/// always takes the `else` branch and `int(str(v))` is just `v`.
579fn cardinal_from_py_str(number: &str) -> Result<String> {
580    // n = str(number).strip()
581    let n = number.trim();
582
583    // if n.startswith("-"): n = n[1:]; ret = self.negword
584    // else:                           ret = ""
585    let (ret, n) = match n.strip_prefix('-') {
586        Some(rest) => (NEGWORD, rest),
587        None => ("", n),
588    };
589
590    // if "." in n: left, right = n.split(".", 1)
591    match n.split_once('.') {
592        Some((left, right)) => {
593            // ret += self._int_to_word(int(left)) + " " + self.pointword + " "
594            let mut out = String::from(ret);
595            out.push_str(&int_to_word(&py_int(left)?));
596            out.push(' ');
597            out.push_str(POINTWORD);
598            out.push(' ');
599
600            // for digit in right: ret += self._int_to_word(int(digit)) + " "
601            //
602            // Iterating a str yields one character at a time, so this is
603            // int() on a single character — and any non-digit character (the
604            // "e" of an exponent form, quirk 16) raises ValueError right here,
605            // after the earlier digits have already been appended and thrown
606            // away with the exception.
607            for digit in right.chars() {
608                let d = py_int(digit.encode_utf8(&mut [0u8; 4]))?;
609                out.push_str(&int_to_word(&d));
610                out.push(' ');
611            }
612
613            // return ret.strip() — this is what removes the loop's last space.
614            Ok(out.trim().to_string())
615        }
616        // return (ret + self._int_to_word(int(n))).strip()
617        None => Ok(format!("{}{}", ret, int_to_word(&py_int(n)?))
618            .trim()
619            .to_string()),
620    }
621}
622
623pub struct LangBr {
624    /// `Num2Word_BR.CURRENCY_FORMS`. Built once in [`LangBr::new`] and only
625    /// read thereafter — the generated registry parks each language in a
626    /// `OnceLock` and calls `new` through `get_or_init`, so this table is
627    /// constructed a single time per process rather than per conversion.
628    currency_forms: HashMap<&'static str, CurrencyForms>,
629    /// `list(self.CURRENCY_FORMS.values())[0]` — the fallback `.get` uses for
630    /// an unknown code (quirk 8).
631    ///
632    /// Python evaluates that expression eagerly on every call and it resolves
633    /// to the *first inserted* entry, which under CPython's insertion-ordered
634    /// dicts is `EUR`. A `HashMap` has no first element, so the choice is
635    /// pinned here rather than left to iteration order.
636    fallback_forms: CurrencyForms,
637}
638
639impl LangBr {
640    pub fn new() -> Self {
641        let mut currency_forms = HashMap::new();
642        // Arity is load-bearing: `to_currency` indexes `cr1[0]`/`cr1[1]`, so
643        // both forms of both tuples must survive verbatim.
644        currency_forms.insert(
645            "EUR",
646            CurrencyForms::new(&["euro", "euroioù"], &["sentim", "sentimoù"]),
647        );
648        currency_forms.insert(
649            "USD",
650            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
651        );
652        let fallback_forms = currency_forms
653            .get("EUR")
654            .expect("CURRENCY_FORMS[\"EUR\"] is inserted directly above")
655            .clone();
656        LangBr {
657            currency_forms,
658            fallback_forms,
659        }
660    }
661}
662
663impl Default for LangBr {
664    fn default() -> Self {
665        LangBr::new()
666    }
667}
668
669impl Lang for LangBr {
670
671    fn cardinal_float_entry(
672        &self,
673        value: &crate::floatpath::FloatValue,
674        precision_override: Option<u32>,
675    ) -> crate::base::Result<String> {
676        // Python's to_cardinal routes every float/Decimal through this
677        // language's own decimal grammar — 5.0 keeps its ".0" tail
678        // ("comma nulla"), unlike Base's whole-value integer route.
679        self.to_cardinal_float(value, precision_override)
680    }
681
682    /// `to_ordinal(float/Decimal)`: Python's `to_ordinal` is
683    /// `self.to_cardinal(number) + "-vet"` with no verify_ordinal guard, so a
684    /// float keeps its spelled-out ".0" tail: `to_ordinal(5.0)` ==
685    /// "pemp point zero-vet", `to_ordinal(-1.5)` == "minus unan point
686    /// pemp-vet". Exponent-form reprs raise the cardinal path's ValueError.
687    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
688        Ok(format!("{}-vet", self.to_cardinal_float(value, None)?))
689    }
690
691    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` — the repr
692    /// verbatim, sign and trailing zeros included: `-0.0` → "-0.0.",
693    /// `Decimal("5.00")` → "5.00.", `1e16` → "1e+16.".
694    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
695        Ok(format!("{}.", repr_str))
696    }
697
698    /// `converter.str_to_number` — base `Decimal(value)` semantics, except
699    /// that an Infinity parse is surfaced as the ValueError BR's own
700    /// `to_cardinal` raises one step later: `str(Decimal("Infinity"))` has no
701    /// "." and `int("Infinity")` chokes on the literal. The dispatcher's
702    /// default maps `ParsedNumber::Inf` to base's OverflowError, which BR can
703    /// never raise. NaN keeps the default routing (ValueError either way).
704    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
705        match python_decimal_parse(s)? {
706            ParsedNumber::Inf { .. } => Err(N2WError::Value(
707                "invalid literal for int() with base 10: 'Infinity'".into(),
708            )),
709            other => Ok(other),
710        }
711    }
712
713    /// This language's own `to_currency(currency=...)` default,
714    /// read from the live Python signature. Only 44 of 156 use EUR.
715    fn default_currency(&self) -> &str {
716        "EUR"
717    }
718
719    /// This language's own `to_currency(separator=...)` default,
720    /// read from the live Python signature. Base's is ",", but only
721    /// 36 of 149 languages actually use it — most default to " " or a
722    /// conjunction, so inheriting Base's comma silently corrupts them.
723    fn default_separator(&self) -> &str {
724        " "
725    }
726
727    fn negword(&self) -> &str {
728        NEGWORD
729    }
730
731    fn pointword(&self) -> &str {
732        "point"
733    }
734
735    /// Port of `Num2Word_BR.to_cardinal` for integral input.
736    ///
737    /// Python works on the *string* form:
738    ///
739    /// ```python
740    /// n = str(number).strip()
741    /// if n.startswith("-"):
742    ///     n = n[1:]
743    ///     ret = self.negword
744    /// else:
745    ///     ret = ""
746    /// # no "." in an integer's repr, so the float branch is skipped
747    /// return (ret + self._int_to_word(int(n))).strip()
748    /// ```
749    ///
750    /// Stripping the leading `"-"` and re-parsing is exactly `abs()`, so the
751    /// value handed to `_int_to_word` is never negative — hence quirk 7. The
752    /// trailing `.strip()` is a no-op for every integer input (`negword`'s
753    /// space always sits between two non-empty tokens), but is kept so the
754    /// shape matches the original.
755    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
756        let (ret, n) = if value.is_negative() {
757            (NEGWORD, value.abs())
758        } else {
759            ("", value.clone())
760        };
761        Ok(format!("{}{}", ret, int_to_word(&n)).trim().to_string())
762    }
763
764    /// The float/Decimal arm of `Num2Word_BR.to_cardinal` — *not* an override
765    /// of `to_cardinal_float`, which BR inherits and never calls.
766    ///
767    /// `num2words(0.5, lang="br")` runs `Num2Word_BR.to_cardinal(0.5)`, which
768    /// stringifies the argument and reads the characters. So the work is all in
769    /// reproducing `str`, and the two arms of [`FloatValue`] need two different
770    /// `str`s — `repr(float)` and `Decimal.__str__` — which is exactly the split
771    /// [`FloatValue`] preserves. Collapsing them here would not merely lose
772    /// precision (issue #603); it would lose *digits BR speaks aloud*, since
773    /// `Decimal("1.10")` is two fraction words and `float(1.10)` is one.
774    ///
775    /// `precision_override` is discarded, because BR never reads
776    /// `self.precision` (quirk 15). The dispatcher does set the attribute — the
777    /// inherited `__init__` defines it — but nothing on this path looks at it.
778    fn to_cardinal_float(
779        &self,
780        value: &FloatValue,
781        _precision_override: Option<u32>,
782    ) -> Result<String> {
783        // Python: `n = str(number).strip()`, where `number` is whatever the
784        // caller passed num2words(). No float() cast, no float2tuple, no
785        // rounding — the sole difference between the arms is `str` itself.
786        let n = match value {
787            FloatValue::Float { value, .. } => py_float_str(*value),
788            FloatValue::Decimal { value, .. } => py_decimal_str(value),
789        };
790        cardinal_from_py_str(&n)
791    }
792
793    /// Port of `Num2Word_BR.to_ordinal`: cardinal + `"-vet"`, with no
794    /// `verify_ordinal` guard (quirk 3). The suffix attaches to the end of the
795    /// whole phrase, so `to_ordinal(-100)` == `"minus unan kant-vet"` and
796    /// `to_ordinal(10**9)` == `"1000000000-vet"`.
797    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
798        Ok(format!("{}-vet", self.to_cardinal(value)?))
799    }
800
801    /// Port of `Num2Word_BR.to_ordinal_num`: `str(number) + "."`. The number is
802    /// never spelled, so the sign survives verbatim: `-1` → `"-1."`.
803    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
804        Ok(format!("{}.", value))
805    }
806
807    /// Port of `Num2Word_BR.to_year`: `return self.to_cardinal(val)`. The
808    /// `longval=True` parameter is accepted and then ignored, so years get no
809    /// special pairing — 1999 is the plain cardinal
810    /// `"unan mil nav kant dek ha pevar-ugent nav"`.
811    fn to_year(&self, value: &BigInt) -> Result<String> {
812        self.to_cardinal(value)
813    }
814
815    // ---- currency ------------------------------------------------------
816    //
817    // Only the three hooks below are overridden. BR defines no
818    // CURRENCY_ADJECTIVES and no CURRENCY_PRECISION, and leaves `pluralize`,
819    // `_money_verbose`, `_cents_verbose`, `_cents_terse` and `to_cheque` at
820    // `Num2Word_Base` — whose behaviour the trait defaults already reproduce.
821
822    /// `self.__class__.__name__`, for the inherited `to_cheque`'s
823    /// `NotImplementedError` message.
824    fn lang_name(&self) -> &str {
825        "Num2Word_BR"
826    }
827
828    /// `CURRENCY_FORMS[code]` — a plain lookup that misses for anything but
829    /// EUR/USD.
830    ///
831    /// Deliberately **not** the `.get(code, <EUR>)` fallback of quirk 8: that
832    /// fallback is local to `to_currency`. The inherited `to_cheque` subscripts
833    /// `CURRENCY_FORMS[currency]` and catches `KeyError`, so it must still see
834    /// a miss here — that is what makes `cheque:GBP` raise
835    /// `NotImplementedError` while `currency:GBP` quietly prints euros.
836    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
837        self.currency_forms.get(code)
838    }
839
840    /// Port of `Num2Word_BR.to_currency`.
841    ///
842    /// BR ignores `base.to_currency` wholesale — no `parse_currency_parts`, no
843    /// `divisor`, no `pluralize`. It slices the decimal *string*:
844    ///
845    /// ```python
846    /// parts = str(val).split(".")
847    /// left = int(parts[0]) if parts[0] else 0
848    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
849    /// ```
850    ///
851    /// # The `separator` argument is not what Python's default would be
852    ///
853    /// BR's own signature defaults to `separator=" "`, but the trait can't
854    /// carry a per-language default argument: the dispatcher resolves it before
855    /// the call and uses `Num2Word_Base`'s `","`. Since the frozen corpus was
856    /// generated through Python — where BR's `" "` applies — `","` is read back
857    /// as the "unset" sentinel it is. This is exact for both a caller who omits
858    /// `separator` and one who passes anything other than `","`, and wrong only
859    /// for an explicit `separator=","`, which Python renders with a comma and
860    /// this renders with a space. See `concerns`; `lang_sa.rs` takes the same
861    /// approach for the same reason.
862    fn to_currency(
863        &self,
864        val: &CurrencyValue,
865        currency: &str,
866        cents: bool,
867        separator: Option<&str>,
868        adjective: bool,
869    ) -> Result<String> {
870        // Trait now hands us None when the caller omitted separator=;
871        // resolve it to this language's own default before the ported body.
872        let separator = separator.unwrap_or(self.default_separator());
873        // Python accepts `adjective` and never reads it: BR has no
874        // CURRENCY_ADJECTIVES and never calls prefix_currency, so
875        // `adjective=True` is byte-identical to the plain call.
876        let _ = adjective;
877
878        let separator = if separator == BASE_DEFAULT_SEPARATOR {
879            DEFAULT_SEPARATOR
880        } else {
881            separator
882        };
883
884        // `if val < 0: is_negative = True; val = abs(val)` happens *before*
885        // `str(val)`, so the string never carries a sign. The abs() is
886        // conditional in Python, so it stays conditional here.
887        let is_negative = val.is_negative();
888        let s = match val {
889            CurrencyValue::Int(v) => {
890                if is_negative {
891                    v.abs().to_string()
892                } else {
893                    v.to_string()
894                }
895            }
896            CurrencyValue::Decimal { value: d, .. } => {
897                if is_negative {
898                    d.abs().to_string()
899                } else {
900                    d.to_string()
901                }
902            }
903        };
904
905        // `str(val).split(".")` splits on every dot; Python then reads only
906        // parts[0] and parts[1], so trailing fragments are ignored either way.
907        let mut parts = s.split('.');
908        let part0 = parts.next().unwrap_or("");
909        let part1 = parts.next();
910
911        // `int(parts[0]) if parts[0] else 0`. Evaluated before `right`, so a
912        // scientific-notation string raises here first (quirk 12).
913        let left = if part0.is_empty() {
914            BigInt::zero()
915        } else {
916            BigInt::from_str(part0).map_err(|e| N2WError::Value(e.to_string()))?
917        };
918
919        // `int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
920        //
921        // `[:2]` truncates and `ljust` pads, so "5" -> "50" (0.5 is 50
922        // subunits) and "01" -> "01" (0.01 is 1). Sliced by chars, not bytes.
923        // A whole float gives "0" -> "00" -> 0, which is falsy (quirk 10).
924        let right = match part1 {
925            Some(f) if !f.is_empty() => {
926                let mut two: String = f.chars().take(2).collect();
927                while two.chars().count() < 2 {
928                    two.push('0');
929                }
930                BigInt::from_str(&two).map_err(|e| N2WError::Value(e.to_string()))?
931            }
932            _ => BigInt::zero(),
933        };
934
935        // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — quirk 8.
936        let forms = self
937            .currency_forms
938            .get(currency)
939            .unwrap_or(&self.fallback_forms);
940        let cr1 = &forms.unit;
941        let cr2 = &forms.subunit;
942
943        let one = BigInt::one();
944
945        // `left_str + " " + (cr1[1] if left != 1 else cr1[0])`. Note 0 takes
946        // the plural: "zero euroioù".
947        let mut result = format!(
948            "{} {}",
949            int_to_word(&left),
950            if left != one { &cr1[1] } else { &cr1[0] }
951        );
952
953        // `if cents and right:` — `right` is an int, so 0 is falsy and a float
954        // with zero cents drops the whole segment (quirk 10). `cents=False`
955        // drops it too, with no terse fallback (quirk 11).
956        if cents && !right.is_zero() {
957            result.push_str(separator);
958            result.push_str(&int_to_word(&right));
959            result.push(' ');
960            result.push_str(if right != one { &cr2[1] } else { &cr2[0] });
961        }
962
963        // `result = self.negword + result` — raw, keeping the trailing space of
964        // "minus " (quirk 6).
965        if is_negative {
966            result = format!("{}{}", NEGWORD, result);
967        }
968
969        // `result.strip()`. A no-op for every reachable input, but it is what
970        // Python writes.
971        Ok(result.trim().to_string())
972    }
973}
974
975#[cfg(test)]
976mod tests {
977    use super::*;
978
979    fn f(v: f64) -> Result<String> {
980        LangBr::new().to_cardinal_float(
981            &FloatValue::Float {
982                value: v,
983                // BR never reads precision (quirk 15); any value does.
984                precision: 0,
985            },
986            None,
987        )
988    }
989
990    fn d(s: &str) -> Result<String> {
991        LangBr::new().to_cardinal_float(
992            &FloatValue::Decimal {
993                value: BigDecimal::from_str(s).unwrap(),
994                precision: 0,
995            },
996            None,
997        )
998    }
999
1000    fn ok(r: Result<String>) -> String {
1001        r.expect("expected Ok")
1002    }
1003
1004    fn value_err(r: Result<String>) -> String {
1005        match r {
1006            Err(N2WError::Value(m)) => m,
1007            other => panic!("expected N2WError::Value, got {:?}", other),
1008        }
1009    }
1010
1011    /// Every `"lang": "br", "to": "cardinal"` corpus row whose arg has a dot.
1012    #[test]
1013    fn corpus_float_rows() {
1014        assert_eq!(ok(f(0.0)), "zero point zero");
1015        assert_eq!(ok(f(0.5)), "zero point pemp");
1016        assert_eq!(ok(f(1.0)), "unan point zero");
1017        assert_eq!(ok(f(1.5)), "unan point pemp");
1018        assert_eq!(ok(f(2.25)), "daou point daou pemp");
1019        assert_eq!(ok(f(3.14)), "tri point unan pevar");
1020        assert_eq!(ok(f(0.01)), "zero point zero unan");
1021        assert_eq!(ok(f(0.1)), "zero point unan");
1022        assert_eq!(ok(f(0.99)), "zero point nav nav");
1023        assert_eq!(ok(f(1.01)), "unan point zero unan");
1024        assert_eq!(ok(f(12.34)), "dek daou point tri pevar");
1025        assert_eq!(ok(f(99.99)), "dek ha pevar-ugent nav point nav nav");
1026        assert_eq!(ok(f(100.5)), "unan kant point pemp");
1027        assert_eq!(
1028            ok(f(1234.56)),
1029            "unan mil daou kant tregont pevar point pemp c'hwec'h"
1030        );
1031        assert_eq!(ok(f(-0.5)), "minus zero point pemp");
1032        assert_eq!(ok(f(-1.5)), "minus unan point pemp");
1033        assert_eq!(ok(f(-12.34)), "minus dek daou point tri pevar");
1034        assert_eq!(ok(f(1.005)), "unan point zero zero pemp");
1035        assert_eq!(ok(f(2.675)), "daou point c'hwec'h seizh pemp");
1036    }
1037
1038    /// Every `"lang": "br", "to": "cardinal_dec"` corpus row.
1039    #[test]
1040    fn corpus_decimal_rows() {
1041        assert_eq!(ok(d("0.01")), "zero point zero unan");
1042        assert_eq!(ok(d("1.10")), "unan point unan zero");
1043        assert_eq!(ok(d("12.345")), "dek daou point tri pevar pemp");
1044        assert_eq!(
1045            ok(d("98746251323029.99")),
1046            "98746251323029 point nav nav"
1047        );
1048        assert_eq!(ok(d("0.001")), "zero point zero zero unan");
1049    }
1050
1051    /// Quirk 14: BR reads `repr`, not `float2tuple`, and the two are not
1052    /// interchangeable.
1053    ///
1054    /// `float2tuple(0.17108284528077355)` evaluates `(v - 0) * 10**17` in f64,
1055    /// gets `1.7108284528077356e16` and reports a final digit of 6;
1056    /// `repr` kept the 5. Live check: `num2words(0.17108284528077355,
1057    /// lang="br")` ends "… pemp pemp", i.e. BR's answer, not base's
1058    /// "… pemp c'hwec'h".
1059    #[test]
1060    fn does_not_take_the_float2tuple_route() {
1061        assert_eq!(
1062            ok(f(0.17108284528077355)),
1063            "zero point unan seizh unan zero eizh daou eizh pevar pemp daou \
1064             eizh zero seizh seizh tri pemp pemp"
1065        );
1066        // The corpus's artefact cases, where the two routes happen to agree.
1067        assert_eq!(ok(f(1.005)), "unan point zero zero pemp");
1068        assert_eq!(ok(f(2.675)), "daou point c'hwec'h seizh pemp");
1069    }
1070
1071    /// Quirk 15: `precision=` is inert — BR never reads `self.precision`.
1072    /// Live check: num2words(1.2345, lang="br", precision=2) spells all four.
1073    #[test]
1074    fn precision_override_is_ignored() {
1075        let l = LangBr::new();
1076        let v = FloatValue::Float {
1077            value: 1.2345,
1078            precision: 4,
1079        };
1080        let expected = "unan point daou tri pevar pemp";
1081        assert_eq!(l.to_cardinal_float(&v, None).unwrap(), expected);
1082        assert_eq!(l.to_cardinal_float(&v, Some(2)).unwrap(), expected);
1083        assert_eq!(l.to_cardinal_float(&v, Some(8)).unwrap(), expected);
1084    }
1085
1086    /// `repr(-0.0)` is "-0.0": the sign *bit*, not `v < 0.0`, drives the minus.
1087    #[test]
1088    fn negative_zero_float_keeps_its_sign() {
1089        assert_eq!(ok(f(-0.0)), "minus zero point zero");
1090        assert_eq!(ok(f(0.0)), "zero point zero");
1091    }
1092
1093    /// Quirk 16: scientific notation dies in `int()`, and which `int()` call
1094    /// catches it depends on whether `repr` emitted a ".".
1095    #[test]
1096    fn scientific_notation_raises_value_error() {
1097        // One significant digit -> no "." -> the integer branch's int(n).
1098        assert_eq!(
1099            value_err(f(1e16)),
1100            "invalid literal for int() with base 10: '1e+16'"
1101        );
1102        assert_eq!(
1103            value_err(f(1e21)),
1104            "invalid literal for int() with base 10: '1e+21'"
1105        );
1106        assert_eq!(
1107            value_err(f(1e-5)),
1108            "invalid literal for int() with base 10: '1e-05'"
1109        );
1110        // More than one -> there *is* a "." -> the digit loop's int(digit),
1111        // which trips on the "e" after consuming the mantissa's digits.
1112        assert_eq!(
1113            value_err(f(1.5e16)),
1114            "invalid literal for int() with base 10: 'e'"
1115        );
1116        assert_eq!(
1117            value_err(f(f64::MAX)),
1118            "invalid literal for int() with base 10: 'e'"
1119        );
1120        // inf / nan take the integer branch too.
1121        assert_eq!(
1122            value_err(f(f64::INFINITY)),
1123            "invalid literal for int() with base 10: 'inf'"
1124        );
1125        assert_eq!(
1126            value_err(f(f64::NEG_INFINITY)),
1127            "invalid literal for int() with base 10: 'inf'"
1128        );
1129        assert_eq!(
1130            value_err(f(f64::NAN)),
1131            "invalid literal for int() with base 10: 'nan'"
1132        );
1133    }
1134
1135    /// The repr thresholds are exactly `decpt <= -4` / `decpt > 16`, so these
1136    /// neighbours land on opposite sides of "works" and "raises".
1137    #[test]
1138    fn repr_thresholds() {
1139        // 1e15 is positional, 1e16 is not.
1140        assert_eq!(ok(f(1e15)), "1000000000000000 point zero");
1141        assert!(f(1e16).is_err());
1142        // 1e-4 is positional, 1e-5 is not.
1143        assert_eq!(ok(f(1e-4)), "zero point zero zero zero unan");
1144        assert!(f(1e-5).is_err());
1145    }
1146
1147    /// The tie that `{:e}` alone gets wrong: this double is exactly
1148    /// 1370020896463233.25, equidistant from ...33.2 and ...33.3, and Python
1149    /// prints the even one. Rust's shortest formatter prints ...33.3.
1150    #[test]
1151    fn exact_decimal_ties_round_to_even_like_gay() {
1152        let v = f64::from_bits(0x4313781b63796605);
1153        assert_eq!(py_float_str(v), "1370020896463233.2");
1154        assert_eq!(py_float_str(20682518724725.062), "20682518724725.062");
1155        // And a plain (non-tie) rounding is unaffected.
1156        assert_eq!(py_float_str(0.30000000000000004), "0.30000000000000004");
1157    }
1158
1159    /// Quirk 17/18: `Decimal` keeps trailing zeros and has its own exponent
1160    /// thresholds, so it does not agree with `float` on either.
1161    #[test]
1162    fn decimal_str_is_not_float_repr() {
1163        // Trailing zeros are spoken.
1164        assert_eq!(ok(d("5.00")), "pemp point zero zero");
1165        // No "." in str(Decimal("5")) -> the *integer* branch.
1166        assert_eq!(ok(d("5")), "pemp");
1167        // Decimal goes positional four orders further down than float does.
1168        assert_eq!(ok(d("1e-5")), "zero point zero zero zero zero unan");
1169        assert!(f(1e-5).is_err());
1170        // ... and gives up one order earlier, with a capital, unpadded E.
1171        assert_eq!(
1172            value_err(d("1e-7")),
1173            "invalid literal for int() with base 10: '1E-7'"
1174        );
1175        assert_eq!(
1176            value_err(d("1E+21")),
1177            "invalid literal for int() with base 10: '1E+21'"
1178        );
1179    }
1180
1181    /// `py_decimal_str` is a port of `Decimal.__str__`, not `BigDecimal`'s
1182    /// `Display` — which normalises "0.0" to "0" and would drop a word.
1183    #[test]
1184    fn decimal_str_matches_python_not_bigdecimal_display() {
1185        assert_eq!(py_decimal_str(&BigDecimal::from_str("0.0").unwrap()), "0.0");
1186        assert_eq!(BigDecimal::from_str("0.0").unwrap().to_string(), "0");
1187        assert_eq!(ok(d("0.0")), "zero point zero");
1188        assert_eq!(py_decimal_str(&BigDecimal::from_str("1.10").unwrap()), "1.10");
1189        assert_eq!(py_decimal_str(&BigDecimal::from_str("1e-6").unwrap()), "0.000001");
1190        assert_eq!(py_decimal_str(&BigDecimal::from_str("-1.10").unwrap()), "-1.10");
1191    }
1192
1193    /// Quirk 19 / issue #603: the integer part goes through the 10^9 digit
1194    /// fallback (quirk 1), and the Decimal arm keeps the trillion-scale digits
1195    /// a float cast would have rounded away.
1196    #[test]
1197    fn large_values() {
1198        assert_eq!(ok(d("98746251323029.99")), "98746251323029 point nav nav");
1199        assert_eq!(ok(f(1234567890.5)), "1234567890 point pemp");
1200        // Just under the fallback: still spelled.
1201        assert_eq!(
1202            ok(f(123456789.5)),
1203            "unan kant ugent tri milion pevar kant hanter-kant c'hwec'h mil \
1204             seizh kant pevar-ugent nav point pemp"
1205        );
1206    }
1207}