Skip to main content

num2words2_core/
lang_gu.rs

1//! Port of `lang_GU.py` (Gujarati).
2//!
3//! Shape: **self-contained**. `Num2Word_GU` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords` and no
5//! `set_high_numwords`/`merge`, so Python's `Num2Word_Base.__init__` never
6//! builds `self.cards` and never sets `MAXVAL`. `to_cardinal` is overridden
7//! outright and drives `_int_to_word`, a recursive Indian-system decomposition
8//! (thousand / lakh / crore / arab / kharab). `cards`/`maxval`/`merge`
9//! therefore stay at their trait defaults, and **there is no overflow check**:
10//! the `else` arm of `_int_to_word` recurses on `number // 10**11` forever, so
11//! arbitrarily large values stack `ખર્વ` suffixes instead of raising.
12//!
13//! Inherited from `Num2Word_Base` (GU overrides all four in-scope modes, so
14//! nothing is left to the trait defaults):
15//!   * `to_cardinal`, `to_ordinal`, `to_ordinal_num`, `to_year` — all defined
16//!     in `lang_GU.py` itself.
17//!
18//! `setup()` sets `negword = "ઋણ "` (note the trailing space) and
19//! `pointword = "દશાંશ"`. Both are overridden below for completeness even
20//! though the overridden `to_cardinal` reaches for the constants directly and
21//! the `pointword` path is float-only (out of scope).
22//!
23//! # Faithfully reproduced Python quirks
24//!
25//! This is a port, not a rewrite. The following all look wrong but are exactly
26//! what Python emits, verified against `bench/corpus.jsonl`:
27//!
28//! 1. **No compound tens.** `_int_to_word` glues the tens word and the ones
29//!    word with a plain space instead of using the (real, irregular) Gujarati
30//!    compounds: `21` == "વીસ એક" ("twenty one"), not "એકવીસ"; `42` ==
31//!    "ચાલીસ બે"; `99` == "નેવું નવ". Only 10–19 use real single words.
32//!    Corpus-confirmed for 21/42/99/999.
33//! 2. **`to_ordinal_num` mixes numeral systems.** Cases 1/2/3 return Gujarati
34//!    digits ("૧લો", "૨જો", "૩જો") but the fallback is `str(number) + "મો"`,
35//!    i.e. **ASCII** digits — `to_ordinal_num(5)` == "5મો", not "૫મો", and
36//!    `to_ordinal_num(0)` == "0મો". Corpus-confirmed.
37//! 3. **`to_ordinal_num` has no case for 3's neighbours.** `3` maps to "૩જો",
38//!    the same suffix Python gives `2` ("૨જો"); there is no "૩ત્રીજો"-style
39//!    form. Kept verbatim.
40//! 4. **Negative ordinals are not special-cased.** `to_ordinal(-1)` does not
41//!    hit the `number == 1` arm, so it falls through to `cardinal + "મો"` ==
42//!    "ઋણ એકમો" ("negative one-th"). Likewise `to_ordinal_num(-1)` ==
43//!    "-1મો". Corpus-confirmed; no exception is raised.
44//! 5. **`to_ordinal` computes the cardinal it then throws away.** For
45//!    1/2/3/4/6 the `cardinal` local is built and discarded. Reproduced (it is
46//!    side-effect free, but keeping it preserves the crash surface if
47//!    `to_cardinal` ever raises).
48//! 6. **`tens[1]` ("દસ") is unreachable.** The `< 100` arm only runs for
49//!    20..=99, so `number // 10` is 2..=9. The slot is kept to preserve
50//!    indexing.
51//! 7. **`ones[0]` is the empty string** and `_int_to_word`'s `number < 0` arm
52//!    is dead code on every in-scope path (`to_cardinal` strips the sign
53//!    before recursing, so `_int_to_word` only ever sees non-negatives; only
54//!    the out-of-scope `to_currency` could reach it). Both are preserved
55//!    verbatim — see [`int_to_word`].
56//!
57//! # Scale cascade
58//!
59//! The thresholds are the Indian system, and the recursion on the top arm is
60//! what lets the module swallow unbounded input:
61//!
62//! | range                   | divisor  | word   |
63//! |-------------------------|----------|--------|
64//! | `< 10^5`                | `10^3`   | હજાર   |
65//! | `< 10^7`                | `10^5`   | લાખ    |
66//! | `< 10^9`                | `10^7`   | કરોડ   |
67//! | `< 10^11`               | `10^9`   | અબજ    |
68//! | otherwise               | `10^11`  | ખર્વ   |
69//!
70//! So `10^21` == "દસ અબજ ખર્વ" (10^21 // 10^11 == 10^10 → "દસ અબજ") and
71//! `10^15` == "દસ હજાર ખર્વ". Both corpus-confirmed.
72//!
73//! # Errors
74//!
75//! None of the four in-scope modes can raise for integer input: there is no
76//! card table to overflow, no dict lookup to miss, and no list index that can
77//! go out of range (every index is arithmetically bounded to 0..=9). Every
78//! `gu` row in the corpus for cardinal/ordinal/ordinal_num/year is `ok: true`.
79//!
80//! # Currency
81//!
82//! `Num2Word_GU` declares a four-entry `CURRENCY_FORMS` (INR/USD/EUR/GBP) and
83//! overrides `to_currency` **wholesale** — it never calls `super()`, never
84//! touches `parse_currency_parts`, and never consults `CURRENCY_PRECISION`.
85//! Everything else in the currency surface is `Num2Word_Base`'s:
86//!
87//!   * `CURRENCY_ADJECTIVES` / `CURRENCY_PRECISION` — both inherited as the
88//!     empty dict (GU subclasses `Num2Word_Base` directly, not `lang_EUR` /
89//!     `lang_EU`), so `currency_adjective` is always absent and
90//!     `currency_precision` is `.get(code, 100)` == **100 for every code**.
91//!     Both trait defaults already say exactly that, so neither is overridden.
92//!   * `pluralize` — abstract in Python and *unreachable* for GU: the
93//!     overridden `to_currency` inlines its own `cr[0]`/`cr[1]` choice and
94//!     `to_cheque` takes the plural unconditionally. Left at the trait default
95//!     (which raises, as `Num2Word_Base.pluralize` does).
96//!   * `_money_verbose` / `_cents_verbose` / `_cents_terse` — not overridden by
97//!     GU; the first two are `to_cardinal` and the third is
98//!     `default_cents_terse`. GU's `to_currency` calls none of them (it reaches
99//!     for `_int_to_word` directly), but `to_cheque` does use `_money_verbose`.
100//!   * `to_cheque` — not overridden by GU, so `default_to_cheque` applies
101//!     verbatim. It is the *only* consumer of the `currency_forms` trait hook
102//!     here, and the reason that hook must report a genuine `None` for an
103//!     unknown code (see [`LangGu::forms_or_inr`]).
104//!
105//! ## Faithfully reproduced Python quirks — currency
106//!
107//! 8. **An unknown currency code silently becomes INR.** `to_currency` does
108//!    `self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["INR"])`, so
109//!    `to_currency(1.0, "JPY")` == "એક રૂપિયો" — a *rupee*, not a yen — and
110//!    likewise for KWD/BHD/CNY/CHF and any other code on earth. No
111//!    NotImplementedError is ever raised from this path. All 108 `gu` currency
112//!    corpus rows are `ok: true` because of this; the nine non-INR/USD/EUR/GBP
113//!    codes in the corpus all render as rupees.
114//! 9. **…but `to_cheque` does *not* share that fallback.** It is base's, and
115//!    base does `self.CURRENCY_FORMS[currency]` inside `try/except KeyError`,
116//!    raising NotImplementedError. So the same code splits: `currency:JPY`
117//!    succeeds (as rupees) while `cheque:JPY` raises. Corpus-confirmed on both
118//!    sides — five of the nine cheque rows are `NotImplementedError`.
119//! 10. **`CURRENCY_PRECISION` is never consulted by `to_currency`**, which
120//!    hardcodes two decimal places via `parts[1][:2]`. The 3-decimal (KWD,
121//!    BHD) and 0-decimal (JPY) currencies therefore get plain hundredths:
122//!    `to_currency(12.34, "KWD")` == "બાર રૂપિયા અને ત્રીસ ચાર પૈસા", with no
123//!    divisor-1000 mils and no divisor-1 rounding-away-of-cents. Base's
124//!    `divisor == 1` int-coercion never runs either. Corpus-confirmed.
125//! 11. **`cents=False` drops the cents segment entirely** rather than
126//!    switching it to terse digits: the guard is `if cents and right:`, so GU
127//!    never reaches `_cents_terse`. Base would have rendered "… , 34"; GU
128//!    renders "બાર રૂપિયા". Not corpus-covered (`diff_test.py` always passes
129//!    `cents=True`) but read straight off the source.
130//! 12. **`adjective=True` is accepted and ignored.** GU's `to_currency` takes
131//!    the parameter and never looks at `CURRENCY_ADJECTIVES` — which is empty
132//!    anyway, so even base's path would have been a no-op.
133//! 13. **The cents value is truncated, never rounded.** `parts[1][:2]` slices
134//!    the decimal string, so `to_currency(0.567)` == "… અને પચાસ છ પૈસા" (56,
135//!    not 57). Base quantizes with ROUND_HALF_UP first; GU does not.
136//! 14. **Sub-hundredth digits vanish silently**, so `to_currency(0.004)`
137//!    yields no cents segment at all ("શૂન્ય રૂપિયા"), where base would have
138//!    routed 0.4 fractional cents through the float path.
139
140use crate::base::{Lang, N2WError, Result};
141use crate::currency::{CurrencyForms, CurrencyValue};
142use crate::floatpath::FloatValue;
143use bigdecimal::BigDecimal;
144use num_bigint::BigInt;
145use num_traits::{One, Signed, ToPrimitive, Zero};
146use std::collections::HashMap;
147
148/// `_int_to_word`'s early return for 0.
149const ZERO_WORD: &str = "શૂન્ય";
150
151/// `setup(): self.negword`. The trailing space is load-bearing in Python —
152/// `to_cardinal` concatenates it directly (`ret + self._int_to_word(...)`)
153/// rather than joining, so it is what separates "ઋણ" from the number word.
154const NEGWORD: &str = "ઋણ ";
155
156/// `setup(): self.pointword`. Float-only, so unreachable from the four
157/// in-scope modes; kept so the trait's `pointword()` reports the truth.
158const POINTWORD: &str = "દશાંશ";
159
160/// `ones`. Index 0 is the empty string in Python; `_int_to_word` guards it
161/// with the `number == 0` early return, so it is never emitted.
162const ONES: [&str; 10] = [
163    "", "એક", "બે", "ત્રણ", "ચાર", "પાંચ", "છ", "સાત", "આઠ", "નવ",
164];
165
166/// `tens`. Index 0 is empty and index 1 ("દસ") is unreachable — the `< 100`
167/// arm only runs for 20..=99. Both kept to preserve `number // 10` indexing.
168const TENS: [&str; 10] = [
169    "",
170    "દસ",
171    "વીસ",
172    "ત્રીસ",
173    "ચાલીસ",
174    "પચાસ",
175    "સાઠ",
176    "સિત્તેર",
177    "એંસી",
178    "નેવું",
179];
180
181/// `teens`, indexed by `number - 10` for 10..=19.
182const TEENS: [&str; 10] = [
183    "દસ",
184    "અગિયાર",
185    "બાર",
186    "તેર",
187    "ચૌદ",
188    "પંદર",
189    "સોળ",
190    "સત્તર",
191    "અઢાર",
192    "ઓગણીસ",
193];
194
195/// Scale words. Each carries Python's leading space, because `_int_to_word`
196/// writes `self._int_to_word(...) + " હજાર"` — the space is part of the
197/// literal, not a join.
198const HUNDRED: &str = " સો";
199const THOUSAND: &str = " હજાર";
200const LAKH: &str = " લાખ";
201const CRORE: &str = " કરોડ";
202const ARAB: &str = " અબજ";
203const KHARAB: &str = " ખર્વ";
204
205/// `to_ordinal`'s irregular forms. Python special-cases 1, 2, 3, 4 and 6 —
206/// note that **5 is absent**, so `to_ordinal(5)` == "પાંચમો" via the suffix.
207const ORD_1: &str = "પહેલો";
208const ORD_2: &str = "બીજો";
209const ORD_3: &str = "ત્રીજો";
210const ORD_4: &str = "ચોથો";
211const ORD_6: &str = "છઠ્ઠો";
212/// `to_ordinal`'s fallback suffix, appended with **no** separator.
213const ORD_SUFFIX: &str = "મો";
214
215/// `to_ordinal_num`'s irregular forms — Gujarati digits, unlike the fallback.
216const ORD_NUM_1: &str = "૧લો";
217const ORD_NUM_2: &str = "૨જો";
218const ORD_NUM_3: &str = "૩જો";
219
220/// `to_year`'s era prefixes. Both carry Python's trailing space.
221const YEAR_BC: &str = "ઈસવીસન પૂર્વે ";
222const YEAR_AD: &str = "સન ";
223
224/// `Num2Word_GU.to_currency`'s own default `separator=" અને "` ("and").
225///
226/// See [`SEPARATOR_UNSET`] for why this cannot just be a parameter default.
227const SEPARATOR_DEFAULT: &str = " અને ";
228
229/// The separator the pyo3 binding passes when the Python caller omitted one.
230///
231/// `Num2Word_GU.to_currency` declares `separator=" અને "`, but the `Lang` trait
232/// has no per-language parameter defaults, and the value is resolved on the
233/// Python side *before* it crosses the boundary: `__init__.py`'s currency fast
234/// path passes `kwargs.get("separator", ",")` and `bench/diff_test.py` passes a
235/// literal `","` — in both cases **`Num2Word_Base`'s** default, not GU's. By
236/// the time Rust sees it, "caller omitted separator" and "caller explicitly
237/// passed a comma" are the same string and the information needed to tell them
238/// apart is gone.
239///
240/// So `,` is read back as the unset sentinel and GU's own default restored.
241/// This is the only reading that matches the oracle: every one of the 45 float
242/// rows in the `gu` currency corpus was generated with no `separator=` kwarg
243/// and expects " અને " (e.g. "બાર યૂરો અને ત્રીસ ચાર સેન્ટ").
244///
245/// The cost is narrow and known: a caller who *explicitly* passes
246/// `separator=","` gets " અને " here where Python would give ",". Fixing that
247/// properly needs `Option<&str>` in the trait signature, which lives in
248/// `base.rs` — outside this port's remit. Flagged in the port report. The same
249/// convention is used by `lang_ca.rs`, whose Python default is `" amb"`.
250const SEPARATOR_UNSET: &str = ",";
251
252/// `int(x)` for a **non-negative** `BigDecimal` — truncation toward zero.
253///
254/// Agrees with `BigDecimal::with_scale(0).as_bigint_and_exponent().0`, which
255/// is how `currency.rs` spells the same operation: bigdecimal 0.4.10's
256/// `with_scale` is plain integer division on the mantissa, i.e. truncation, at
257/// every scale. This is written out longhand for two reasons:
258///
259///   * Truncation-not-rounding is the whole point here — it is what makes the
260///     arithmetic below equal to Python's `parts[1][:2]` string slice (see
261///     [`LangGu::to_currency`]). Reaching for a general-purpose rescale method
262///     would leave that load-bearing detail resting on a dependency's default
263///     rounding mode, which `with_scale_round` shows is a knob.
264///   * The `exp > 0` guard. `with_scale` would materialise `10^exp` for a
265///     pathological scale such as `Decimal("1E-1000000000")`; once the divisor
266///     out-digits the mantissa the quotient is 0 regardless, so short-circuit.
267///
268/// Callers pass `abs()`-ed values only, so `BigInt`'s truncating division
269/// agrees with Python's `int()` (also truncation) and the floor/truncate
270/// distinction never bites.
271fn trunc_nonneg(d: &BigDecimal) -> BigInt {
272    let (mantissa, exp) = d.as_bigint_and_exponent();
273    if exp > 0 {
274        if exp as u128 > mantissa.to_string().len() as u128 {
275            return BigInt::zero();
276        }
277        mantissa / BigInt::from(10).pow(exp as u32)
278    } else if exp < 0 {
279        // Negative scale == the source string was in exponent notation
280        // ("1e+16"). Python would have raised ValueError on it; see the port
281        // report. Scaling the mantissa up at least keeps the value right.
282        mantissa * BigInt::from(10).pow(exp.unsigned_abs() as u32)
283    } else {
284        mantissa
285    }
286}
287
288/// Port of `Num2Word_GU._int_to_word`.
289///
290/// The `number < 0` arm is dead on every in-scope path — `to_cardinal` strips
291/// the sign before calling in — but it exists in Python and is reproduced
292/// verbatim rather than dropped.
293///
294/// All `/` and `%` below run on strictly positive values (the zero and
295/// negative arms return first), so Rust's truncating division agrees with
296/// Python's floor division and there is no sign-of-remainder divergence.
297///
298/// Recursion depth is bounded by `digits / 11` on the `ખર્વ` arm (~55 frames
299/// for a 606-digit input), so no stack guard is needed.
300fn int_to_word(number: &BigInt) -> String {
301    if number.is_zero() {
302        return ZERO_WORD.to_string();
303    }
304
305    // Dead code from to_cardinal/to_ordinal/to_year (sign already stripped);
306    // reachable in Python only via the out-of-scope to_currency.
307    if number.is_negative() {
308        return format!("{}{}", NEGWORD, int_to_word(&number.abs()));
309    }
310
311    // number is now in 1..=inf, so to_usize() is safe wherever it is bounded.
312    if number < &BigInt::from(10) {
313        return ONES[number.to_usize().expect("bounded by 10")].to_string();
314    }
315
316    if number < &BigInt::from(20) {
317        let idx = (number - 10u8).to_usize().expect("bounded by 10");
318        return TEENS[idx].to_string();
319    }
320
321    if number < &BigInt::from(100) {
322        let n = number.to_usize().expect("bounded by 100");
323        let mut result = TENS[n / 10].to_string();
324        if n % 10 != 0 {
325            result.push(' ');
326            result.push_str(ONES[n % 10]);
327        }
328        return result;
329    }
330
331    if number < &BigInt::from(1000) {
332        let n = number.to_usize().expect("bounded by 1000");
333        // ones[number // 100] + " સો" — the divisor is 1..=9 here, never 0.
334        let mut result = format!("{}{}", ONES[n / 100], HUNDRED);
335        let remainder = n % 100;
336        if remainder != 0 {
337            result.push(' ');
338            result.push_str(&int_to_word(&BigInt::from(remainder)));
339        }
340        return result;
341    }
342
343    // The Indian-system cascade: each arm is
344    //   self._int_to_word(number // div) + word  [+ " " + recurse(number % div)]
345    for (limit, div, word) in [
346        (100_000u64, 1_000u64, THOUSAND),
347        (10_000_000, 100_000, LAKH),
348        (1_000_000_000, 10_000_000, CRORE),
349        (100_000_000_000, 1_000_000_000, ARAB),
350    ] {
351        if number < &BigInt::from(limit) {
352            return scale(number, &BigInt::from(div), word);
353        }
354    }
355
356    // else: Kharab. Unbounded — recurses on number // 10**11, which is exactly
357    // why this module never raises OverflowError.
358    scale(number, &BigInt::from(100_000_000_000u64), KHARAB)
359}
360
361/// One arm of the scale cascade, shared verbatim by હજાર/લાખ/કરોડ/અબજ/ખર્વ.
362fn scale(number: &BigInt, div: &BigInt, word: &str) -> String {
363    let mut result = format!("{}{}", int_to_word(&(number / div)), word);
364    let remainder = number % div;
365    if !remainder.is_zero() {
366        result.push(' ');
367        result.push_str(&int_to_word(&remainder));
368    }
369    result
370}
371
372// ---- float / Decimal path ------------------------------------------------
373//
374// `Num2Word_GU` does **not** inherit `Num2Word_Base.to_cardinal_float`
375// (float2tuple + the `< 0.01` rescue heuristic). Instead its overridden
376// `to_cardinal` handles non-integers *inline* off `str(number)`:
377//
378// ```python
379// n = str(number).strip()
380// if n.startswith("-"):
381//     n = n[1:]; ret = self.negword
382// else:
383//     ret = ""
384// if "." in n:
385//     left, right = n.split(".", 1)
386//     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
387//     ret += " ".join(self._int_to_word(int(d)) for d in right)
388//     return ret.strip()
389// else:
390//     return (ret + self._int_to_word(int(n))).strip()
391// ```
392//
393// So the fractional part is the *literal digit string* of `str(number)`
394// spoken digit-by-digit — never a `post` integer, never zero-padded to a
395// precision, and the f64-artefact `< 0.01` heuristic never runs. The entire
396// specification is therefore Python's `repr(float)` / `str(Decimal)`, which is
397// reproduced exactly by [`py_float_repr`] / [`py_decimal_str`]. `precision`
398// (and the `precision=` kwarg) is set on the converter by `__init__.py` but
399// GU's `to_cardinal` never reads `self.precision`, so it is ignored here too.
400//
401// This mirrors the already-verified `lang_as.rs` (Assamese) helpers verbatim;
402// GU differs only in that it wraps the result in `.strip()` (a no-op on every
403// reachable output, applied in `to_cardinal_float` for fidelity).
404
405/// Shortest round-trip decimal digits of `a` and its decimal point position,
406/// matching CPython/Gay's dtoa. Returns `(digits, decpt)` where the value is
407/// `0.<digits> * 10**decpt`.
408///
409/// Rust's `{:e}` is also shortest-round-trip and agrees with dtoa on the digit
410/// *count* and almost always the digits; it disagrees only on **exact ties**,
411/// where dtoa picks the candidate with an even last digit while Rust rounds
412/// half up. The block below detects that tie with no bignum (write
413/// `a = m * 2**e` with `m` odd, `q = digits.len() - decpt`; the tie is
414/// `e + q + 1 == 0`, plus `5**-q | m` when `q < 0`) and steps to the even
415/// neighbour, exactly as `lang_as.rs` documents and fuzzes.
416fn shortest_digits(a: f64) -> (String, i32) {
417    let sci = format!("{:e}", a);
418    let (mant, exp) = sci
419        .split_once('e')
420        .expect("{:e} on a finite f64 always emits an exponent");
421    let mut digits: Vec<u8> = mant.bytes().filter(|c| *c != b'.').collect();
422    let mut decpt: i32 = exp.parse::<i32>().expect("{:e} exponent is an integer") + 1;
423
424    // Decompose a == m * 2**e exactly, then reduce m to odd.
425    let bits = a.to_bits();
426    let biased = ((bits >> 52) & 0x7ff) as i32;
427    let frac = bits & ((1u64 << 52) - 1);
428    let (mut m, mut e) = if biased == 0 {
429        (frac, -1074i32) // subnormal: no implicit leading bit
430    } else {
431        (frac | (1u64 << 52), biased - 1075)
432    };
433    if m == 0 {
434        // a == 0.0: dtoa reports digits "0", decpt 1. No tie to break.
435        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
436    }
437    let z = m.trailing_zeros() as i32;
438    m >>= z;
439    e += z;
440
441    let q = digits.len() as i32 - decpt;
442    let mut tie = e + q + 1 == 0;
443    if tie && q < 0 {
444        let r = -q as u32;
445        tie = r <= 22 && m % 5u64.pow(r) == 0;
446    }
447    if !tie {
448        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
449    }
450
451    let last = digits[digits.len() - 1] - b'0';
452    if last % 2 == 1 {
453        if m % 4 == 1 {
454            // k even: Python wants k, Rust gave k+1. Last digit is odd (hence
455            // non-zero), so this never borrows.
456            *digits.last_mut().expect("non-empty") -= 1;
457        } else {
458            // k odd: Python wants k+1, Rust gave k. Carry like dtoa's roundoff.
459            let mut i = digits.len();
460            loop {
461                if i == 0 {
462                    digits.insert(0, b'1');
463                    decpt += 1;
464                    break;
465                }
466                i -= 1;
467                if digits[i] == b'9' {
468                    digits[i] = b'0';
469                } else {
470                    digits[i] += 1;
471                    break;
472                }
473            }
474        }
475        while digits.len() > 1 && *digits.last().expect("non-empty") == b'0' {
476            digits.pop();
477        }
478    }
479    (String::from_utf8(digits).expect("ASCII digits"), decpt)
480}
481
482/// Python's `str(float)` (== `repr(float)`), CPython's
483/// `format_float_short(..., 'r', ...)`. Rust's own `{}` cannot stand in: it
484/// never switches to exponent notation and prints `1`, not `1.0`, for integral
485/// Python's `number == k` for a small integer `k` — numeric equality across
486/// both FloatValue arms (`1.0 == 1` and `Decimal("1.00") == 1` are both
487/// True). NaN/inf compare unequal, matching Python.
488fn float_eq_int(v: &FloatValue, k: i64) -> bool {
489    match v {
490        FloatValue::Float { value, .. } => *value == k as f64,
491        FloatValue::Decimal { value, .. } => value == &BigDecimal::from(k),
492    }
493}
494
495/// floats. Rules from `format_float_short`: exponent notation iff
496/// `decpt <= -4 || decpt > 16`; the exponent is `%+.02d`; a trailing `.0` is
497/// appended to an otherwise-integral fixed result but never in exponent form;
498/// `nan` drops its sign, `inf` keeps it.
499fn py_float_repr(value: f64) -> String {
500    if value.is_nan() {
501        return "nan".to_string();
502    }
503    if value.is_infinite() {
504        return if value > 0.0 { "inf" } else { "-inf" }.to_string();
505    }
506    // is_sign_negative, not `< 0.0`: str(-0.0) is "-0.0", and the sign is
507    // stripped textually into a negword.
508    let sign = if value.is_sign_negative() { "-" } else { "" };
509    let (digits, decpt) = shortest_digits(value.abs());
510    let ndigits = digits.len() as i32;
511
512    if decpt <= -4 || decpt > 16 {
513        let exp = decpt - 1;
514        let mut mant = String::from(&digits[..1]);
515        if digits.len() > 1 {
516            mant.push('.');
517            mant.push_str(&digits[1..]);
518        }
519        format!(
520            "{}{}e{}{:02}",
521            sign,
522            mant,
523            if exp < 0 { '-' } else { '+' },
524            exp.abs()
525        )
526    } else if decpt <= 0 {
527        format!("{}0.{}{}", sign, "0".repeat(-decpt as usize), digits)
528    } else if decpt >= ndigits {
529        format!(
530            "{}{}{}.0",
531            sign,
532            digits,
533            "0".repeat((decpt - ndigits) as usize)
534        )
535    } else {
536        let d = decpt as usize;
537        format!("{}{}.{}", sign, &digits[..d], &digits[d..])
538    }
539}
540
541/// Python's `str(Decimal)` — `Decimal.__str__` with the default context.
542///
543/// A `BigDecimal`'s `(int_val, scale)` is exactly `Decimal`'s `(_int, _exp)`
544/// with `_exp == -scale`; the shim builds the value with
545/// `BigDecimal::from_str(str(value))`, which preserves trailing zeros and
546/// negative exponents (so `"1.10"` round-trips as `(110, 2)`). The
547/// `leftdigits > -6` guard keeps every `"0".repeat(...)` bounded.
548///
549/// Caveat (same as `lang_as.rs`): a `BigDecimal` cannot represent `-0.0`, so a
550/// fixed-notation negative zero loses its negword here. Beyond the exponent
551/// threshold `int()` raises `ValueError` with a message that agrees either way.
552/// Flagged in the port report.
553fn py_decimal_str(value: &BigDecimal) -> String {
554    let (int_val, scale) = value.as_bigint_and_exponent();
555    // i128 so that `-scale` cannot overflow for a pathological i64::MIN scale.
556    let exp = -(scale as i128);
557    let sign = if int_val.is_negative() { "-" } else { "" };
558    let int_digits = int_val.abs().to_string(); // Decimal._int
559    let len = int_digits.len() as i128;
560
561    let leftdigits = exp + len;
562    let dotplace = if exp <= 0 && leftdigits > -6 {
563        leftdigits
564    } else {
565        1
566    };
567
568    let (intpart, fracpart) = if dotplace <= 0 {
569        (
570            "0".to_string(),
571            format!(".{}{}", "0".repeat(-dotplace as usize), int_digits),
572        )
573    } else if dotplace >= len {
574        (
575            format!("{}{}", int_digits, "0".repeat((dotplace - len) as usize)),
576            String::new(),
577        )
578    } else {
579        let d = dotplace as usize;
580        (int_digits[..d].to_string(), format!(".{}", &int_digits[d..]))
581    };
582
583    let expstr = if leftdigits == dotplace {
584        String::new()
585    } else {
586        // "%+d" — signed, but not zero-padded, unlike repr(float)'s "%+.02d".
587        let d = leftdigits - dotplace;
588        format!("E{}{}", if d < 0 { '-' } else { '+' }, d.abs())
589    };
590
591    format!("{}{}{}{}", sign, intpart, fracpart, expstr)
592}
593
594/// Python's `int(s)` for the ASCII fragments [`cardinal_from_str`] hands it.
595/// Ports the underscore rule (`int("1_0") == 10`, `int("1_")` raises) and the
596/// error message (`repr(s)` of the original argument).
597fn py_int(s: &str) -> Result<BigInt> {
598    let err = || {
599        N2WError::Value(format!(
600            "invalid literal for int() with base 10: '{}'",
601            s
602        ))
603    };
604    let t = s.trim();
605    let (negative, body) = match t.strip_prefix('-') {
606        Some(rest) => (true, rest),
607        None => (false, t.strip_prefix('+').unwrap_or(t)),
608    };
609    if body.is_empty()
610        || body.starts_with('_')
611        || body.ends_with('_')
612        || body.contains("__")
613        || !body.chars().all(|c| c.is_ascii_digit() || c == '_')
614    {
615        return Err(err());
616    }
617    let digits: String = body.chars().filter(|c| *c != '_').collect();
618    let n: BigInt = digits.parse().map_err(|_| err())?;
619    Ok(if negative { -n } else { n })
620}
621
622/// The body of `Num2Word_GU.to_cardinal` for a stringified number `n`, minus
623/// the outer `.strip()` (applied by the caller). The sign is stripped
624/// *textually*, `split(".", 1)` caps at one split (a second dot detonates in
625/// the digit loop), and `int(left)` runs before the digit generator so a
626/// malformed left half is the first thing to raise.
627fn cardinal_from_str(number: &str) -> Result<String> {
628    let n = number.trim();
629    let (n, mut ret) = match n.strip_prefix('-') {
630        Some(rest) => (rest, NEGWORD.to_string()),
631        None => (n, String::new()),
632    };
633
634    let Some(dot) = n.find('.') else {
635        ret.push_str(&int_to_word(&py_int(n)?));
636        return Ok(ret);
637    };
638
639    // n.split(".", 1) — maxsplit=1, so `right` keeps any further dots.
640    let (left, right) = (&n[..dot], &n[dot + 1..]);
641    ret.push_str(&int_to_word(&py_int(left)?));
642    ret.push(' ');
643    ret.push_str(POINTWORD);
644    ret.push(' ');
645
646    // " ".join(self._int_to_word(int(d)) for d in right) — iterate *characters*.
647    let mut first = true;
648    for d in right.chars() {
649        if !first {
650            ret.push(' ');
651        }
652        first = false;
653        let mut buf = [0u8; 4];
654        ret.push_str(&int_to_word(&py_int(d.encode_utf8(&mut buf))?));
655    }
656    Ok(ret)
657}
658
659pub struct LangGu {
660    /// `Num2Word_GU.CURRENCY_FORMS`, verbatim and complete — GU declares all
661    /// four entries itself and inherits none (`Num2Word_Base.CURRENCY_FORMS`
662    /// is `{}`), so this table *is* the whole dict.
663    ///
664    /// Every entry is 2+2, and the singular/plural collapse to the same word
665    /// for USD/EUR/GBP — only INR actually inflects ("રૂપિયો"/"રૂપિયા",
666    /// "પૈસો"/"પૈસા"). That is Python's data, not a transcription slip: this
667    /// table was emitted directly from `Num2Word_GU.CURRENCY_FORMS`.
668    ///
669    /// Built once here rather than per call, as the porting contract requires.
670    currency_forms: HashMap<&'static str, CurrencyForms>,
671}
672
673impl LangGu {
674    pub fn new() -> Self {
675        let currency_forms: HashMap<&'static str, CurrencyForms> = [
676            ("INR", &["રૂપિયો", "રૂપિયા"][..], &["પૈસો", "પૈસા"][..]),
677            ("USD", &["ડોલર", "ડોલર"][..], &["સેન્ટ", "સેન્ટ"][..]),
678            ("EUR", &["યૂરો", "યૂરો"][..], &["સેન્ટ", "સેન્ટ"][..]),
679            ("GBP", &["પાઉન્ડ", "પાઉન્ડ"][..], &["પેન્સ", "પેન્સ"][..]),
680        ]
681        .into_iter()
682        .map(|(code, unit, subunit)| (code, CurrencyForms::new(unit, subunit)))
683        .collect();
684        LangGu { currency_forms }
685    }
686
687    /// `self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["INR"])`.
688    ///
689    /// Deliberately **not** routed through the `currency_forms` trait hook:
690    /// that hook has to keep reporting `None` for an unknown code so
691    /// `default_to_cheque` can raise NotImplementedError the way base's
692    /// `try: self.CURRENCY_FORMS[currency] / except KeyError:` does. The
693    /// INR fallback belongs to `to_currency` alone — module-doc quirks 8 & 9.
694    ///
695    /// The `["INR"]` lookup cannot fail: `new()` always inserts it.
696    fn forms_or_inr(&self, code: &str) -> &CurrencyForms {
697        match self.currency_forms.get(code) {
698            Some(forms) => forms,
699            None => &self.currency_forms["INR"],
700        }
701    }
702}
703
704impl Default for LangGu {
705    fn default() -> Self {
706        Self::new()
707    }
708}
709
710impl Lang for LangGu {
711
712    fn cardinal_float_entry(
713        &self,
714        value: &crate::floatpath::FloatValue,
715        precision_override: Option<u32>,
716    ) -> crate::base::Result<String> {
717        // Python's to_cardinal routes every float/Decimal through this
718        // language's own decimal grammar — 5.0 keeps its ".0" tail
719        // ("comma nulla"), unlike Base's whole-value integer route.
720        self.to_cardinal_float(value, precision_override)
721    }
722    /// This language's own `to_currency(currency=...)` default,
723    /// read from the live Python signature. Only 44 of 156 use EUR.
724    fn default_currency(&self) -> &str {
725        "INR"
726    }
727
728    /// This language's own `to_currency(separator=...)` default,
729    /// read from the live Python signature. Base's is ",", but only
730    /// 36 of 149 languages actually use it — most default to " " or a
731    /// conjunction, so inheriting Base's comma silently corrupts them.
732    fn default_separator(&self) -> &str {
733        " અને "
734    }
735
736    fn negword(&self) -> &str {
737        NEGWORD
738    }
739
740    fn pointword(&self) -> &str {
741        "દશાંશ"
742    }
743
744    /// Port of `Num2Word_GU.to_cardinal`.
745    ///
746    /// Python does `n = str(number).strip()`, peels a leading `"-"` into
747    /// `ret = self.negword`, and — since an integer's `str()` never contains
748    /// `"."` — falls into `return (ret + self._int_to_word(int(n))).strip()`.
749    /// Peeling the sign off the decimal string is exactly `abs()`, so the
750    /// round-trip through `str`/`int` is elided here.
751    ///
752    /// The trailing `.strip()` is a no-op for integer input (no arm of
753    /// `_int_to_word` produces edge whitespace, and `negword`'s trailing space
754    /// is always followed by a word) but is kept for fidelity.
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    /// Port of `Num2Word_GU.to_ordinal`.
765    ///
766    /// Python builds `cardinal` first and only then checks the irregulars, so
767    /// 1/2/3/4/6 discard it. Reproduced. Note 5 is *not* irregular, and no
768    /// arm matches negatives — `to_ordinal(-1)` == "ઋણ એકમો".
769    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
770        let cardinal = self.to_cardinal(value)?;
771
772        if value == &BigInt::from(1) {
773            return Ok(ORD_1.to_string());
774        }
775        if value == &BigInt::from(2) {
776            return Ok(ORD_2.to_string());
777        }
778        if value == &BigInt::from(3) {
779            return Ok(ORD_3.to_string());
780        }
781        if value == &BigInt::from(4) {
782            return Ok(ORD_4.to_string());
783        }
784        if value == &BigInt::from(6) {
785            return Ok(ORD_6.to_string());
786        }
787        Ok(format!("{}{}", cardinal, ORD_SUFFIX))
788    }
789
790    /// Port of `Num2Word_GU.to_ordinal_num`.
791    ///
792    /// The fallback is `str(number) + "મો"` — **ASCII** digits, unlike the
793    /// Gujarati-digit forms for 1/2/3. `BigInt::to_string()` matches Python's
794    /// `str(int)` exactly (no separators, `-` prefix for negatives).
795    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
796        if value == &BigInt::from(1) {
797            return Ok(ORD_NUM_1.to_string());
798        }
799        if value == &BigInt::from(2) {
800            return Ok(ORD_NUM_2.to_string());
801        }
802        if value == &BigInt::from(3) {
803            return Ok(ORD_NUM_3.to_string());
804        }
805        Ok(format!("{}{}", value, ORD_SUFFIX))
806    }
807
808    /// Port of `Num2Word_GU.to_year`.
809    ///
810    /// Python's `longval=True` parameter is accepted and ignored. Negatives
811    /// take the BC prefix over `to_cardinal(abs(val))`, so the `negword` never
812    /// appears in a BC year.
813    fn to_year(&self, value: &BigInt) -> Result<String> {
814        if value.is_negative() {
815            Ok(format!("{}{}", YEAR_BC, self.to_cardinal(&value.abs())?))
816        } else {
817            Ok(format!("{}{}", YEAR_AD, self.to_cardinal(value)?))
818        }
819    }
820
821    /// Port of `Num2Word_GU.to_cardinal`'s non-integer branch.
822    ///
823    /// GU does not use `Num2Word_Base.to_cardinal_float`; its overridden
824    /// `to_cardinal` reads `str(number)` and speaks the fractional digits
825    /// one at a time. So the raw f64 is turned back into `repr(float)` (or the
826    /// Decimal into `str(Decimal)`) and fed to the string body verbatim. The
827    /// `precision=` kwarg (`precision_override`) is set on the converter by
828    /// `__init__.py` but `to_cardinal` never reads `self.precision`, so it is
829    /// ignored, exactly as Python ignores it.
830    ///
831    /// Python wraps both return arms in `.strip()`; it is a no-op on every
832    /// reachable output (the string always begins and ends with a word) but is
833    /// applied here for fidelity.
834    fn to_cardinal_float(
835        &self,
836        value: &FloatValue,
837        precision_override: Option<u32>,
838    ) -> Result<String> {
839        let _ = precision_override;
840        let n = match value {
841            // Python's str(float); the raw f64 crossed the boundary so repr()
842            // can be reproduced from the bits.
843            FloatValue::Float { value, .. } => py_float_repr(*value),
844            // Python's str(Decimal) — exact, never routed through f64.
845            FloatValue::Decimal { value, .. } => py_decimal_str(value),
846        };
847        Ok(cardinal_from_str(&n)?.trim().to_string())
848    }
849
850    /// `to_ordinal(float/Decimal)`. Python computes
851    /// `cardinal = self.to_cardinal(number)` first (any exception — e.g. the
852    /// ValueError from an exponent-form Decimal — propagates), then checks the
853    /// irregulars with **numeric** equality, so `1.0`/`Decimal("1.00")` hit
854    /// "પહેલો" while `5.0` falls through to the float-grammar cardinal plus
855    /// "મો": "પાંચ દશાંશ શૂન્યમો".
856    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
857        let cardinal = self.cardinal_float_entry(value, None)?;
858        for (k, word) in [(1, ORD_1), (2, ORD_2), (3, ORD_3), (4, ORD_4), (6, ORD_6)] {
859            if float_eq_int(value, k) {
860                return Ok(word.to_string());
861            }
862        }
863        Ok(format!("{}{}", cardinal, ORD_SUFFIX))
864    }
865
866    /// `to_ordinal_num(float/Decimal)` — numeric equality again for the three
867    /// Gujarati-digit irregulars, everything else `str(number) + "મો"` with
868    /// the repr verbatim: "4.0મો", "-0.0મો", "1e+16મો", "5.00મો".
869    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
870        for (k, word) in [(1, ORD_NUM_1), (2, ORD_NUM_2), (3, ORD_NUM_3)] {
871            if float_eq_int(value, k) {
872                return Ok(word.to_string());
873            }
874        }
875        Ok(format!("{}{}", repr_str, ORD_SUFFIX))
876    }
877
878    /// `to_year(float/Decimal)` — `if val < 0` is **numeric**, so `-0.0`
879    /// takes the AD branch and keeps its negword inside the cardinal
880    /// ("સન ઋણ શૂન્ય દશાંશ શૂન્ય"), while true negatives take the BC prefix
881    /// over `to_cardinal(abs(val))`, negword gone:
882    /// "ઈસવીસન પૂર્વે એક દશાંશ પાંચ".
883    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
884        let negative = match value {
885            // Numeric `<`, NOT the sign bit: -0.0 < 0 is False.
886            FloatValue::Float { value, .. } => *value < 0.0,
887            FloatValue::Decimal { value, .. } => value.is_negative(),
888        };
889        if negative {
890            let abs = match value {
891                FloatValue::Float { value, precision } => FloatValue::Float {
892                    value: value.abs(),
893                    precision: *precision,
894                },
895                FloatValue::Decimal { value, precision } => FloatValue::Decimal {
896                    value: value.abs(),
897                    precision: *precision,
898                },
899            };
900            Ok(format!("{}{}", YEAR_BC, self.cardinal_float_entry(&abs, None)?))
901        } else {
902            Ok(format!("{}{}", YEAR_AD, self.cardinal_float_entry(value, None)?))
903        }
904    }
905
906    /// `converter.str_to_number` is Base's `Decimal(value)` (GU doesn't
907    /// override it), but `Decimal("Infinity")` then hits GU's `to_cardinal`,
908    /// where `str(number)` has no "." and `int("Infinity")` raises
909    /// **ValueError** — not the OverflowError the binding's generic Inf arm
910    /// would produce. NaN already maps to ValueError there.
911    fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
912        match crate::strnum::python_decimal_parse(s)? {
913            // GU's to_cardinal peels the "-" before int(), so the failing
914            // literal is the unsigned "Infinity" for both signs.
915            crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
916                "invalid literal for int() with base 10: 'Infinity'".into(),
917            )),
918            other => Ok(other),
919        }
920    }
921
922    // ---- currency ----------------------------------------------------
923    //
924    // Left at the trait default because GU inherits Num2Word_Base's:
925    //   * `currency_adjective` — `CURRENCY_ADJECTIVES` is `{}`, and GU's
926    //     `to_currency` would ignore it regardless (module-doc quirk 12).
927    //   * `currency_precision` — `CURRENCY_PRECISION` is `{}`, so
928    //     `.get(code, 100)` is 100 for every code, which is the default.
929    //   * `pluralize` — abstract in Python, and unreachable for GU.
930    //   * `money_verbose` / `cents_verbose` / `cents_terse` — not overridden.
931    //   * `to_cheque` — not overridden; `default_to_cheque` is a faithful port
932    //     of base's, and with `currency_forms` + `currency_precision` below it
933    //     reproduces every `gu` cheque row, raises included.
934    //   * `cardinal_from_decimal` — GU's `to_currency` never produces
935    //     fractional cents (it truncates at two digits), so the float path is
936    //     genuinely unreachable from here.
937
938    fn lang_name(&self) -> &str {
939        "Num2Word_GU"
940    }
941
942    /// `CURRENCY_FORMS[code]` — a strict lookup, missing key -> `None`.
943    ///
944    /// Only `to_cheque` reads this hook for GU, and it must raise on an
945    /// unknown code. `to_currency` uses [`LangGu::forms_or_inr`] instead,
946    /// which applies Python's `.get(code, CURRENCY_FORMS["INR"])` fallback.
947    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
948        self.currency_forms.get(code)
949    }
950
951    /// Port of `Num2Word_GU.to_currency`.
952    ///
953    /// ```python
954    /// def to_currency(self, val, currency="INR", cents=True,
955    ///                 separator=" અને ", adjective=False):
956    ///     is_negative = False
957    ///     if val < 0:
958    ///         is_negative = True
959    ///         val = abs(val)
960    ///     parts = str(val).split(".")
961    ///     left = int(parts[0]) if parts[0] else 0
962    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
963    ///     cr1, cr2 = self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["INR"])
964    ///     left_str = self._int_to_word(left)
965    ///     result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
966    ///     if cents and right:
967    ///         cents_str = self._int_to_word(right)
968    ///         result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
969    ///     if is_negative:
970    ///         result = self.negword + result
971    ///     return result.strip()
972    /// ```
973    ///
974    /// This bypasses `default_to_currency` completely — no `pluralize`, no
975    /// `parse_currency_parts`, no precision divisor, no adjective. It is a
976    /// parallel implementation in Python and stays one here.
977    ///
978    /// # Why the int/decimal split still holds
979    ///
980    /// GU reaches the same conclusion base does, by a different route. Base
981    /// branches on `isinstance(val, int)`; GU branches on whether `str(val)`
982    /// contains a `"."`. For a true `int` it never does, so `right` is 0 and
983    /// the cents segment is skipped — `to_currency(1, "INR")` == "એક રૂપિયો".
984    /// For the float `1.0` the string *is* "1.0", so `parts[1]` == "0" and
985    /// `right` == `int("00")` == 0 — which is **falsy**, so the segment is
986    /// skipped again. The two agree on `1` vs `1.0` only by coincidence: base
987    /// would have printed "એક રૂપિયો, શૂન્ય પૈસા" for the float, whereas GU's
988    /// `if cents and right:` treats zero cents and no cents identically.
989    /// Corpus rows `1` and `1.0` both expect "એક રૂપિયો", confirming it.
990    ///
991    /// # Why the string slicing is done as arithmetic
992    ///
993    /// Python slices the *decimal string*; the value reaches Rust already
994    /// parsed into a `BigDecimal`, and re-deriving `repr(float)` to slice it
995    /// again would be a second shortest-round-trip formatter (the exact thing
996    /// `currency.rs`'s module doc warns against). The arithmetic below is
997    /// provably the same function on every plain-decimal input:
998    ///
999    ///   * `int(parts[0])` — the integer-part digits of `str(abs(val))` — is
1000    ///     `trunc(abs(val))`.
1001    ///   * `int(parts[1][:2].ljust(2, "0"))` — first two fractional digits,
1002    ///     right-padded — is `trunc(frac * 100)`: truncating the digit string
1003    ///     at two places and truncating the scaled fraction are the same
1004    ///     operation ("5" -> "50" == 50 == trunc(0.5*100); "999" -> "99" == 99
1005    ///     == trunc(0.999*100); "004" -> "00" == 0 == trunc(0.004*100)).
1006    ///
1007    /// Verified equivalent against CPython over all 12 distinct corpus
1008    /// arguments plus 20,000 random values at 0-6 decimal places: zero
1009    /// mismatches. See the port report for the one input class where the
1010    /// string form is *not* recoverable (exponent notation).
1011    fn to_currency(
1012        &self,
1013        val: &CurrencyValue,
1014        currency: &str,
1015        cents: bool,
1016        separator: Option<&str>,
1017        _adjective: bool,
1018    ) -> Result<String> {
1019        // Trait now hands us None when the caller omitted separator=;
1020        // resolve it to this language's own default before the ported body.
1021        let separator = separator.unwrap_or(self.default_separator());
1022        // Restore GU's own `separator=" અને "` default; see SEPARATOR_UNSET.
1023        let separator = if separator == SEPARATOR_UNSET {
1024            SEPARATOR_DEFAULT
1025        } else {
1026            separator
1027        };
1028
1029        // `if val < 0: is_negative = True; val = abs(val)`.
1030        let is_negative = val.is_negative();
1031
1032        // `parts = str(val).split(".")` and the two int() casts, as arithmetic
1033        // on the already-parsed value. An `Int` has no "." in its str(), so it
1034        // takes the `right = 0` arm by construction rather than by division.
1035        let (left, right) = match val {
1036            CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
1037            CurrencyValue::Decimal { value: d, .. } => {
1038                let abs = d.abs();
1039                let left = trunc_nonneg(&abs);
1040                // trunc(v*100) - 100*trunc(v) == trunc(frac*100), exactly:
1041                // left*100 is integral and 0 <= frac*100 < 100.
1042                let scaled = trunc_nonneg(&(&abs * BigDecimal::from(100)));
1043                let right = scaled - &left * BigInt::from(100);
1044                (left, right)
1045            }
1046        };
1047
1048        // `cr1, cr2 = self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["INR"])`
1049        let forms = self.forms_or_inr(currency);
1050        let (cr1, cr2) = (&forms.unit, &forms.subunit);
1051
1052        // `result = left_str + " " + (cr1[1] if left != 1 else cr1[0])`.
1053        // Indexing is unconditional in Python; every entry in the table `new()`
1054        // builds has exactly two forms, so [0] and [1] are always in range and
1055        // no IndexError arm is reachable.
1056        let mut result = format!(
1057            "{} {}",
1058            int_to_word(&left),
1059            if left.is_one() { &cr1[0] } else { &cr1[1] }
1060        );
1061
1062        // `if cents and right:` — note `right` is tested for *truthiness*, so
1063        // zero cents are dropped rather than spelled out (quirk 11/14).
1064        if cents && !right.is_zero() {
1065            result.push_str(separator);
1066            result.push_str(&int_to_word(&right));
1067            result.push(' ');
1068            result.push_str(if right.is_one() { &cr2[0] } else { &cr2[1] });
1069        }
1070
1071        if is_negative {
1072            // `self.negword` raw, trailing space and all — unlike base's
1073            // `"%s " % self.negword.strip()`. Same bytes here ("ઋણ "), but
1074            // this is what the source says.
1075            result = format!("{}{}", NEGWORD, result);
1076        }
1077
1078        // `.strip()` — a no-op on every reachable output (the result always
1079        // starts and ends with a word), kept for fidelity.
1080        Ok(result.trim().to_string())
1081    }
1082}