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}