Skip to main content

num2words2_core/
lang_mr.rs

1//! Port of `lang_MR.py` (Marathi).
2//!
3//! Shape: **self-contained**. `Num2Word_MR` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
5//! builds `self.cards` and never sets `MAXVAL`. Every in-scope mode is
6//! overridden outright and driven by `_int_to_word`, a plain recursive
7//! descent over the Indian scale system (हजार / लाख / कोटी / अब्ज / खर्व).
8//! Consequently `cards`/`maxval`/`merge` stay at their trait defaults here.
9//!
10//! **There is no overflow check and no ceiling.** Because `MAXVAL` is never
11//! set, the `value >= self.MAXVAL` guard in `Num2Word_Base.to_cardinal` is
12//! never reached (the override skips it), and the top `खर्व` branch recurses
13//! on `number // 10**11`, which re-enters the same branch for large enough
14//! inputs. So `10**22` is "एक खर्व खर्व" rather than an `OverflowError`, and
15//! arbitrarily large `BigInt`s terminate by repeated division. None of the
16//! four in-scope modes can raise: every list index is guarded by the range
17//! check that selects the branch, so there is no `IndexError`/`KeyError`
18//! path here.
19//!
20//! Inherited from `Num2Word_Base`:
21//!   * `setup()` sets only `negword = "ऋण "` (note the **trailing space**)
22//!     and `pointword = "दशांश"`. Nothing else is touched, so `is_title`
23//!     stays `False` — and MR's `to_cardinal` never calls `self.title`
24//!     anyway.
25//!
26//! # Faithfully reproduced Python oddities
27//!
28//! This is a port, not a rewrite. The following all look wrong but are
29//! exactly what Python emits, and every one is confirmed against the frozen
30//! corpus:
31//!
32//! 1. **Only the 20s compound.** `_int_to_word` glues the unit onto "वीस"
33//!    for 21..29 ("एकवीस", "पाचवीस", "चारवीस"), but every other decade is
34//!    emitted as two space-separated words in the wrong order for Marathi:
35//!    31 is "तीस एक" (lit. "thirty one"), 42 "चाळीस दोन", 99 the hardcoded
36//!    "नव्याण्णव". Real Marathi would be एकतीस / बेचाळीस. Preserved verbatim.
37//! 2. **The 20s compounds are themselves malformed.** 22 → `ones[2] + "वीस"`
38//!    = "दोनवीस" (Marathi: बावीस), 23 → "तीनवीस" (Marathi: तेवीस). Only 21
39//!    ("एकवीस") happens to come out right.
40//! 3. **The `number == 21` special case is dead code.** It produces
41//!    `"एक" + tens[2]`, but `ones[21 % 10]` is *already* "एक", so both arms
42//!    of the conditional agree. Reproduced anyway — see [`int_to_word`].
43//! 4. **`99` is special-cased but `98`/`97`/... are not**, so 99 is
44//!    "नव्याण्णव" while 98 is "नव्वद आठ".
45//! 5. **`to_ordinal` suffixes the raw cardinal**, so negatives and zero
46//!    produce nonsense rather than raising: `to_ordinal(0)` == "शून्यवा",
47//!    `to_ordinal(-21)` == "ऋण एकवीसवा", `to_ordinal(-1000)` ==
48//!    "ऋण एक हजारवा". `Num2Word_Base.verify_ordinal` (which would raise
49//!    `TypeError` on a negative) is never called.
50//! 6. **`to_ordinal_num` mixes numeral systems.** 1..4 return Devanagari
51//!    digits ("१ला", "२रा", "३रा", "४था"); everything else falls through to
52//!    `str(number) + "वा"`, which is ASCII — hence "5वा", "10वा", "0वा" and
53//!    even "-1वा". Also 2 and 3 share the suffix "रा".
54//! 7. **`to_year` ignores its `longval` parameter** and just prefixes the
55//!    cardinal, so there is no two-digit-pair year reading: 1905 is
56//!    "सन एक हजार नऊशे पाच", not "nineteen oh five". `to_year(0)` is
57//!    "सन शून्य" (0 is not `< 0`).
58//!
59//! 8. **`to_currency` falls back to rupees for every unknown code** rather
60//!    than raising, because it looks the code up with
61//!    `CURRENCY_FORMS.get(currency, CURRENCY_FORMS["INR"])`. The inherited
62//!    `to_cheque` uses a strict `CURRENCY_FORMS[currency]` subscript instead,
63//!    so the *same* code (JPY, CHF, ...) renders happily as रुपये through
64//!    `to_currency` but raises NotImplementedError through `to_cheque`. Both
65//!    halves of that contradiction are in the corpus.
66//!
67//! # Currency shape
68//!
69//! `to_currency` is overridden outright and shares nothing with
70//! `Num2Word_Base`'s currency machinery — see [`LangMr::to_currency`].
71//! `to_cheque` is *not* overridden, so it comes from `Num2Word_Base` and needs
72//! only `lang_name` + `currency_forms` + the default `money_verbose` (which
73//! routes to MR's `to_cardinal`) to reproduce
74//! "एक हजार दोनशे तीस चार AND 56/100 युरो". `.upper()` is a no-op on
75//! Devanagari — it is caseless — so only the literal "AND" looks upper-cased.
76//! `pluralize` is never reached from either path and correctly keeps the
77//! trait's raising default (`Num2Word_Base.pluralize` raises
78//! NotImplementedError too).
79//!
80//! Note also that the `number < 0` arm of `_int_to_word` (which would return
81//! `negword + _int_to_word(abs(n))` with **no** `.strip()`) is unreachable
82//! from every in-scope entry point: `to_cardinal` detaches the sign before
83//! calling in, and `to_ordinal`/`to_year` both route through `to_cardinal`.
84//! It is reproduced in [`int_to_word`] for fidelity regardless.
85
86use crate::base::{Lang, N2WError, Result};
87use crate::currency::{CurrencyForms, CurrencyValue};
88use crate::floatpath::FloatValue;
89use crate::strnum::{python_decimal_parse, ParsedNumber};
90use bigdecimal::BigDecimal;
91use num_bigint::BigInt;
92use num_integer::Integer;
93use num_traits::{Signed, ToPrimitive, Zero};
94use std::collections::HashMap;
95
96/// `self.negword`, set by `setup()`. The trailing space is load-bearing:
97/// `to_cardinal` concatenates it directly (`ret + word`) rather than joining
98/// with a separator, unlike `Num2Word_Base`, which does `negword.strip() + " "`.
99const NEGWORD: &str = "ऋण ";
100
101/// `self.pointword`, set by `setup()`. Reached only on the float/Decimal
102/// path, where `Num2Word_MR.to_cardinal` splices it between the integer and
103/// the digit-by-digit fraction (see [`cardinal_from_str`]). `is_title` is
104/// `False`, and MR uses `self.pointword` directly rather than `self.title(...)`,
105/// so it is emitted verbatim.
106const POINTWORD: &str = "दशांश";
107
108const ZERO_WORD: &str = "शून्य";
109
110/// `ones`. Index 0 is "" and is only ever selected when the caller has
111/// already established `number % 10 != 0` (or `number != 0`), so the empty
112/// string never reaches the output.
113const ONES: [&str; 10] = [
114    "", "एक", "दोन", "तीन", "चार", "पाच", "सहा", "सात", "आठ", "नऊ",
115];
116
117/// `tens`. Index 0 is unreachable (`number >= 20` in the only branch that
118/// reads this table) and index 1 is dead too — 10..19 are handled by
119/// [`TEENS`] before the `< 100` branch is ever entered.
120const TENS: [&str; 10] = [
121    "", "दहा", "वीस", "तीस", "चाळीस", "पन्नास", "साठ", "सत्तर", "ऐंशी", "नव्वद",
122];
123
124/// `teens`, indexed by `number - 10` for 10..=19.
125const TEENS: [&str; 10] = [
126    "दहा", "अकरा", "बारा", "तेरा", "चौदा", "पंधरा", "सोळा", "सतरा", "अठरा", "एकोणीस",
127];
128
129/// Hardcoded in the `number // 10 == 9 and number % 10 == 9` arm.
130const NINETY_NINE: &str = "नव्याण्णव";
131
132/// Suffix appended by the `< 1000` branch: `ones[number // 100] + "शे"`.
133const HUNDRED_SUFFIX: &str = "शे";
134
135/// Generic ordinal suffix appended to the cardinal for anything above 10.
136const ORDINAL_SUFFIX: &str = "वा";
137
138/// `to_ordinal`'s irregular forms for 1..=10. Python tests `number == k`
139/// with plain `==`, which is *numeric*, so these fire for equal
140/// floats/Decimals too: `to_ordinal(1.0)` is "पहिला" and
141/// `to_ordinal(Decimal("5.00"))` is "पाचवा" — the ".0" tail vanishes
142/// entirely. Shared by [`LangMr::to_ordinal`] (int path) and
143/// [`LangMr::ordinal_float_entry`] (float/Decimal path).
144const ORDINAL_SPECIALS: [(u64, &str); 10] = [
145    (1, "पहिला"),
146    (2, "दुसरा"),
147    (3, "तिसरा"),
148    (4, "चौथा"),
149    (5, "पाचवा"),
150    (6, "सहावा"),
151    (7, "सातवा"),
152    (8, "आठवा"),
153    (9, "नववा"),
154    (10, "दहावा"),
155];
156
157const YEAR_PREFIX: &str = "सन ";
158const YEAR_BC_PREFIX: &str = "इसवीसन पूर्व ";
159
160/// `Num2Word_MR.to_currency`'s own default `separator=" आणि "`.
161///
162/// See [`SEPARATOR_UNSET`] for why this cannot simply be a parameter default.
163const SEPARATOR_DEFAULT: &str = " आणि ";
164
165/// The separator the pyo3 binding passes when the Python caller omitted one.
166///
167/// `Num2Word_MR.to_currency` declares `separator=" आणि "`, but the `Lang` trait
168/// has no per-language defaults: `__init__.py`'s fast path (and
169/// `bench/diff_test.py`) substitute `kwargs.get("separator", ",")` —
170/// **`Num2Word_Base`'s** default — before the value ever reaches Rust. By then
171/// "caller omitted separator" and "caller explicitly passed a comma" are the
172/// same string, and the information needed to tell them apart no longer exists
173/// on this side of the boundary.
174///
175/// So `,` is read back as the unset sentinel and MR's own default restored.
176/// This is the only reading that matches the oracle: every float row of the
177/// `mr` currency corpus was generated by `num2words(v, lang="mr",
178/// to="currency", currency=c)` with no `separator=`, and each one expects
179/// " आणि " (e.g. `12.34` -> "बारा युरो आणि तीस चार सेंट्स").
180///
181/// The cost is narrow and known: a caller who *explicitly* passes
182/// `separator=","` gets " आणि " here where Python would give ",". Fixing that
183/// properly needs `Option<&str>` in the trait signature, which lives in
184/// `base.rs` — outside this port's remit. Flagged in the port report.
185/// `lang_ca.rs` and `lang_es.rs` resolve the identical conflict the same way.
186const SEPARATOR_UNSET: &str = ",";
187
188/// The fallback code for `CURRENCY_FORMS.get(currency, CURRENCY_FORMS["INR"])`.
189const CURRENCY_FALLBACK: &str = "INR";
190
191fn bi(n: u64) -> BigInt {
192    BigInt::from(n)
193}
194
195/// Narrow a `BigInt` the caller has already range-checked below 1000.
196///
197/// Every call site sits inside a `number < 100` or `number < 1000` guard and
198/// passes either the value itself or a quotient of it, so the conversion
199/// cannot fail.
200fn small(n: &BigInt) -> usize {
201    n.to_usize()
202        .expect("caller guarantees this value is range-checked below 1000")
203}
204
205/// One Indian-scale group: `_int_to_word(number // divisor) + " " + word`,
206/// then `" " + _int_to_word(remainder)` when the remainder is non-zero.
207///
208/// Shared by the हजार / लाख / कोटी / अब्ज / खर्व branches, which are
209/// character-for-character identical in Python apart from the two constants.
210fn scale(number: &BigInt, divisor: u64, word: &str) -> String {
211    let (div, rem) = number.div_mod_floor(&bi(divisor));
212    let mut result = format!("{} {}", int_to_word(&div), word);
213    if !rem.is_zero() {
214        result.push(' ');
215        result.push_str(&int_to_word(&rem));
216    }
217    result
218}
219
220/// Port of `Num2Word_MR._int_to_word`.
221///
222/// Python checks `number == 0` first, then the tables are (re)declared, then
223/// the `number < 0` arm runs — so every branch below the sign check operates
224/// on a non-negative value and Python's floor `//`/`%` coincide with
225/// truncating division. `div_mod_floor` matches Python exactly either way.
226fn int_to_word(number: &BigInt) -> String {
227    if number.is_zero() {
228        return ZERO_WORD.to_string();
229    }
230
231    // Unreachable from to_cardinal/to_ordinal/to_year (all detach the sign
232    // first), but reproduced as written — note there is no .strip() here.
233    if number.is_negative() {
234        return format!("{}{}", NEGWORD, int_to_word(&number.abs()));
235    }
236
237    if number < &bi(10) {
238        return ONES[small(number)].to_string();
239    }
240
241    if number < &bi(20) {
242        return TEENS[small(number) - 10].to_string();
243    }
244
245    if number < &bi(100) {
246        let n = small(number);
247        let tens_digit = n / 10;
248        let ones_digit = n % 10;
249        let mut result = TENS[tens_digit].to_string();
250        if ones_digit != 0 {
251            if tens_digit == 2 {
252                // Python: `"एक" + tens[2] if number == 21 else ones[number % 10] + tens[2]`
253                // — the conditional binds looser than `+`, so both arms
254                // concatenate onto tens[2]. The n == 21 arm is dead code:
255                // ONES[1] is already "एक". Kept for fidelity.
256                result = if n == 21 {
257                    format!("एक{}", TENS[2])
258                } else {
259                    format!("{}{}", ONES[ones_digit], TENS[2])
260                };
261            } else if tens_digit == 9 && ones_digit == 9 {
262                result = NINETY_NINE.to_string();
263            } else {
264                // Python: `result += " " + ones[number % 10] if number % 10 else ""`.
265                // The trailing conditional is redundant — we are already
266                // inside `if number % 10:` — so the else-branch never fires.
267                result.push(' ');
268                result.push_str(ONES[ones_digit]);
269            }
270        }
271        return result;
272    }
273
274    if number < &bi(1000) {
275        let (div, rem) = number.div_mod_floor(&bi(100));
276        let mut result = format!("{}{}", ONES[small(&div)], HUNDRED_SUFFIX);
277        if !rem.is_zero() {
278            result.push(' ');
279            result.push_str(&int_to_word(&rem));
280        }
281        return result;
282    }
283
284    if number < &bi(100_000) {
285        // Indian lakh system: thousands run 1_000..99_999.
286        return scale(number, 1_000, "हजार");
287    }
288
289    if number < &bi(10_000_000) {
290        return scale(number, 100_000, "लाख");
291    }
292
293    if number < &bi(1_000_000_000) {
294        // "Less than 100 crore" — the quotient here reaches 99.
295        return scale(number, 10_000_000, "कोटी");
296    }
297
298    if number < &bi(100_000_000_000) {
299        return scale(number, 1_000_000_000, "अब्ज");
300    }
301
302    // Kharab, and the only unbounded branch: 10**22 // 10**11 == 10**11
303    // lands right back here, yielding "एक खर्व खर्व".
304    scale(number, 100_000_000_000, "खर्व")
305}
306
307// ---- float / Decimal path -------------------------------------------------
308//
309// `Num2Word_MR.to_cardinal` does NOT use `Num2Word_Base.to_cardinal_float`.
310// It overrides `to_cardinal` and handles non-integers *inline* off
311// `str(number)`:
312//
313// ```python
314// n = str(number).strip()
315// if n.startswith("-"):
316//     n = n[1:]; ret = self.negword
317// else:
318//     ret = ""
319// if "." in n:
320//     left, right = n.split(".", 1)
321//     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
322//     ret += " ".join(self._int_to_word(int(d)) for d in right)
323//     return ret.strip()
324// else:
325//     return (ret + self._int_to_word(int(n))).strip()
326// ```
327//
328// So the fractional part is the *literal digit string* of `str(number)`
329// spoken one digit at a time — never a `post` integer, never zero-padded to a
330// precision, and the `base.float2tuple` `< 0.01` heuristic never runs. The
331// entire specification is therefore Python's `repr(float)` / `str(Decimal)`,
332// reproduced by [`py_float_repr`] / [`py_decimal_str`]. Two behaviours fall
333// straight out of that and are exactly what Python does, but which the
334// inherited base path gets wrong (which is why MR needs its own override):
335//
336//   * **negative zero keeps its sign.** `str(-0.0)` == "-0.0" and
337//     `str(Decimal("-0.00"))` == "-0.00" both start with "-", so MR prepends
338//     `negword` even though the value is not `< 0`. Base drops it (its guard
339//     is `value < 0 and pre == 0`).
340//   * **scientific notation raises `ValueError`.** For `abs(f) >= 1e16` or a
341//     nonzero `abs(f) < 1e-4`, `repr(float)` switches to exponent form; MR
342//     then feeds a token containing `e`/`E`/`+` to `int()` (either `int(n)`
343//     with no dot, or `int(d)` mid-fraction) and Python raises. Base renders
344//     it instead.
345//
346// `precision` (and the `precision=` kwarg) is set on the converter by
347// `__init__.py` but MR's `to_cardinal` never reads `self.precision`, so it is
348// ignored here too — `precision_override` is dropped in `to_cardinal_float`.
349//
350// These helpers mirror the already-verified `lang_as.rs` / `lang_gu.rs`
351// (Assamese / Gujarati) versions verbatim; those languages share MR's exact
352// `str(number)`-driven shape.
353
354/// Shortest round-trip decimal digits of `a` and its decimal point position,
355/// matching CPython/Gay's dtoa. Returns `(digits, decpt)` where the value is
356/// `0.<digits> * 10**decpt`.
357///
358/// Rust's `{:e}` is also shortest-round-trip and agrees with dtoa on the digit
359/// *count* and almost always the digits; it disagrees only on **exact ties**,
360/// where dtoa picks the candidate with an even last digit while Rust rounds
361/// half up. The block below detects that tie with no bignum (write
362/// `a = m * 2**e` with `m` odd, `q = digits.len() - decpt`; the tie is
363/// `e + q + 1 == 0`, plus `5**-q | m` when `q < 0`) and steps to the even
364/// neighbour, exactly as `lang_as.rs` documents and fuzzes.
365fn shortest_digits(a: f64) -> (String, i32) {
366    let sci = format!("{:e}", a);
367    let (mant, exp) = sci
368        .split_once('e')
369        .expect("{:e} on a finite f64 always emits an exponent");
370    let mut digits: Vec<u8> = mant.bytes().filter(|c| *c != b'.').collect();
371    let mut decpt: i32 = exp.parse::<i32>().expect("{:e} exponent is an integer") + 1;
372
373    // Decompose a == m * 2**e exactly, then reduce m to odd.
374    let bits = a.to_bits();
375    let biased = ((bits >> 52) & 0x7ff) as i32;
376    let frac = bits & ((1u64 << 52) - 1);
377    let (mut m, mut e) = if biased == 0 {
378        (frac, -1074i32) // subnormal: no implicit leading bit
379    } else {
380        (frac | (1u64 << 52), biased - 1075)
381    };
382    if m == 0 {
383        // a == 0.0: dtoa reports digits "0", decpt 1. No tie to break.
384        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
385    }
386    let z = m.trailing_zeros() as i32;
387    m >>= z;
388    e += z;
389
390    let q = digits.len() as i32 - decpt;
391    let mut tie = e + q + 1 == 0;
392    if tie && q < 0 {
393        let r = -q as u32;
394        tie = r <= 22 && m % 5u64.pow(r) == 0;
395    }
396    if !tie {
397        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
398    }
399
400    let last = digits[digits.len() - 1] - b'0';
401    if last % 2 == 1 {
402        if m % 4 == 1 {
403            // k even: Python wants k, Rust gave k+1. Last digit is odd (hence
404            // non-zero), so this never borrows.
405            *digits.last_mut().expect("non-empty") -= 1;
406        } else {
407            // k odd: Python wants k+1, Rust gave k. Carry like dtoa's roundoff.
408            let mut i = digits.len();
409            loop {
410                if i == 0 {
411                    digits.insert(0, b'1');
412                    decpt += 1;
413                    break;
414                }
415                i -= 1;
416                if digits[i] == b'9' {
417                    digits[i] = b'0';
418                } else {
419                    digits[i] += 1;
420                    break;
421                }
422            }
423        }
424        while digits.len() > 1 && *digits.last().expect("non-empty") == b'0' {
425            digits.pop();
426        }
427    }
428    (String::from_utf8(digits).expect("ASCII digits"), decpt)
429}
430
431/// Python's `str(float)` (== `repr(float)`), CPython's
432/// `format_float_short(..., 'r', ...)`. Rust's own `{}` cannot stand in: it
433/// never switches to exponent notation and prints `1`, not `1.0`, for integral
434/// floats. Rules from `format_float_short`: exponent notation iff
435/// `decpt <= -4 || decpt > 16`; the exponent is `%+.02d`; a trailing `.0` is
436/// appended to an otherwise-integral fixed result but never in exponent form;
437/// `nan` drops its sign, `inf` keeps it.
438fn py_float_repr(value: f64) -> String {
439    if value.is_nan() {
440        return "nan".to_string();
441    }
442    if value.is_infinite() {
443        return if value > 0.0 { "inf" } else { "-inf" }.to_string();
444    }
445    // is_sign_negative, not `< 0.0`: str(-0.0) is "-0.0", and the sign is
446    // stripped textually into a negword.
447    let sign = if value.is_sign_negative() { "-" } else { "" };
448    let (digits, decpt) = shortest_digits(value.abs());
449    let ndigits = digits.len() as i32;
450
451    if decpt <= -4 || decpt > 16 {
452        let exp = decpt - 1;
453        let mut mant = String::from(&digits[..1]);
454        if digits.len() > 1 {
455            mant.push('.');
456            mant.push_str(&digits[1..]);
457        }
458        format!(
459            "{}{}e{}{:02}",
460            sign,
461            mant,
462            if exp < 0 { '-' } else { '+' },
463            exp.abs()
464        )
465    } else if decpt <= 0 {
466        format!("{}0.{}{}", sign, "0".repeat(-decpt as usize), digits)
467    } else if decpt >= ndigits {
468        format!(
469            "{}{}{}.0",
470            sign,
471            digits,
472            "0".repeat((decpt - ndigits) as usize)
473        )
474    } else {
475        let d = decpt as usize;
476        format!("{}{}.{}", sign, &digits[..d], &digits[d..])
477    }
478}
479
480/// Python's `str(Decimal)` — `Decimal.__str__` with the default context.
481///
482/// A `BigDecimal`'s `(int_val, scale)` is exactly `Decimal`'s `(_int, _exp)`
483/// with `_exp == -scale`; the shim builds the value with
484/// `BigDecimal::from_str(str(value))`, which preserves trailing zeros and
485/// negative exponents (so `"1.10"` round-trips as `(110, 2)`). The
486/// `leftdigits > -6` guard keeps every `"0".repeat(...)` bounded.
487///
488/// Caveat (same as `lang_as.rs`): a `BigDecimal` cannot represent `-0.0`, so a
489/// fixed-notation negative zero loses its negword here. Beyond the exponent
490/// threshold `int()` raises `ValueError` with a message that agrees either way.
491/// Flagged in the port report.
492fn py_decimal_str(value: &BigDecimal) -> String {
493    let (int_val, scale) = value.as_bigint_and_exponent();
494    // i128 so that `-scale` cannot overflow for a pathological i64::MIN scale.
495    let exp = -(scale as i128);
496    let sign = if int_val.is_negative() { "-" } else { "" };
497    let int_digits = int_val.abs().to_string(); // Decimal._int
498    let len = int_digits.len() as i128;
499
500    let leftdigits = exp + len;
501    let dotplace = if exp <= 0 && leftdigits > -6 {
502        leftdigits
503    } else {
504        1
505    };
506
507    let (intpart, fracpart) = if dotplace <= 0 {
508        (
509            "0".to_string(),
510            format!(".{}{}", "0".repeat(-dotplace as usize), int_digits),
511        )
512    } else if dotplace >= len {
513        (
514            format!("{}{}", int_digits, "0".repeat((dotplace - len) as usize)),
515            String::new(),
516        )
517    } else {
518        let d = dotplace as usize;
519        (int_digits[..d].to_string(), format!(".{}", &int_digits[d..]))
520    };
521
522    let expstr = if leftdigits == dotplace {
523        String::new()
524    } else {
525        // "%+d" — signed, but not zero-padded, unlike repr(float)'s "%+.02d".
526        let d = leftdigits - dotplace;
527        format!("E{}{}", if d < 0 { '-' } else { '+' }, d.abs())
528    };
529
530    format!("{}{}{}{}", sign, intpart, fracpart, expstr)
531}
532
533/// Python's `int(s)` for the ASCII fragments [`cardinal_from_str`] hands it.
534/// Ports the underscore rule (`int("1_0") == 10`, `int("1_")` raises) and the
535/// error message (`repr(s)` of the original argument).
536fn py_int(s: &str) -> Result<BigInt> {
537    let err = || {
538        N2WError::Value(format!(
539            "invalid literal for int() with base 10: '{}'",
540            s
541        ))
542    };
543    let t = s.trim();
544    let (negative, body) = match t.strip_prefix('-') {
545        Some(rest) => (true, rest),
546        None => (false, t.strip_prefix('+').unwrap_or(t)),
547    };
548    if body.is_empty()
549        || body.starts_with('_')
550        || body.ends_with('_')
551        || body.contains("__")
552        || !body.chars().all(|c| c.is_ascii_digit() || c == '_')
553    {
554        return Err(err());
555    }
556    let digits: String = body.chars().filter(|c| *c != '_').collect();
557    let n: BigInt = digits.parse().map_err(|_| err())?;
558    Ok(if negative { -n } else { n })
559}
560
561/// The body of `Num2Word_MR.to_cardinal` for a stringified number `n`, minus
562/// the outer `.strip()` (applied by the caller). The sign is stripped
563/// *textually* (so "-0.0" keeps its `negword`), `split(".", 1)` caps at one
564/// split (a second dot detonates in the digit loop), and `int(left)` runs
565/// before the digit generator so a malformed left half is the first to raise.
566fn cardinal_from_str(number: &str) -> Result<String> {
567    let n = number.trim();
568    let (n, mut ret) = match n.strip_prefix('-') {
569        // ret = self.negword — "ऋण " already carries its own trailing space.
570        Some(rest) => (rest, NEGWORD.to_string()),
571        None => (n, String::new()),
572    };
573
574    let Some(dot) = n.find('.') else {
575        ret.push_str(&int_to_word(&py_int(n)?));
576        return Ok(ret);
577    };
578
579    // n.split(".", 1) — maxsplit=1, so `right` keeps any further dots.
580    let (left, right) = (&n[..dot], &n[dot + 1..]);
581    ret.push_str(&int_to_word(&py_int(left)?));
582    ret.push(' ');
583    ret.push_str(POINTWORD);
584    ret.push(' ');
585
586    // " ".join(self._int_to_word(int(d)) for d in right) — iterate *characters*.
587    let mut first = true;
588    for d in right.chars() {
589        if !first {
590            ret.push(' ');
591        }
592        first = false;
593        let mut buf = [0u8; 4];
594        ret.push_str(&int_to_word(&py_int(d.encode_utf8(&mut buf))?));
595    }
596    Ok(ret)
597}
598
599/// Marathi (`mr`).
600///
601/// Stateless apart from the immutable `CURRENCY_FORMS` table — Python's
602/// `Num2Word_MR` carries no cross-call flags (`setup` assigns two string
603/// constants and nothing mutates them afterwards, and `CURRENCY_FORMS` is a
604/// class attribute that is only ever read).
605pub struct LangMr {
606    /// `Num2Word_MR.CURRENCY_FORMS`, built once. `Num2Word_Base` declares an
607    /// empty dict and MR replaces it outright — there is no `__init__` merge
608    /// step in this class, and MR inherits neither `CURRENCY_ADJECTIVES` nor
609    /// `CURRENCY_PRECISION` overrides (both stay `{}`), so the trait defaults
610    /// for `currency_adjective` (None) and `currency_precision` (100) are
611    /// already correct and are deliberately not overridden.
612    currency_forms: HashMap<&'static str, CurrencyForms>,
613}
614
615impl LangMr {
616    pub fn new() -> Self {
617        // Exactly the four codes Num2Word_MR declares, each a 2-tuple of
618        // 2-tuples. Note USD/EUR/GBP repeat the singular as the plural
619        // ("डॉलर"/"डॉलर", "पेन्स"/"पेन्स"); only INR actually inflects
620        // (रुपया -> रुपये, पैसा -> पैसे). The arity is load-bearing:
621        // `to_currency` indexes `cr1[1]`/`cr2[1]` for the plural.
622        let mut currency_forms = HashMap::new();
623        currency_forms.insert(
624            "INR",
625            CurrencyForms::new(&["रुपया", "रुपये"], &["पैसा", "पैसे"]),
626        );
627        currency_forms.insert(
628            "USD",
629            CurrencyForms::new(&["डॉलर", "डॉलर"], &["सेंट", "सेंट्स"]),
630        );
631        currency_forms.insert(
632            "EUR",
633            CurrencyForms::new(&["युरो", "युरो"], &["सेंट", "सेंट्स"]),
634        );
635        currency_forms.insert(
636            "GBP",
637            CurrencyForms::new(&["पाउंड", "पाउंड"], &["पेन्स", "पेन्स"]),
638        );
639        LangMr { currency_forms }
640    }
641}
642
643impl Default for LangMr {
644    fn default() -> Self {
645        Self::new()
646    }
647}
648
649impl Lang for LangMr {
650
651    fn cardinal_float_entry(
652        &self,
653        value: &crate::floatpath::FloatValue,
654        precision_override: Option<u32>,
655    ) -> crate::base::Result<String> {
656        // Python's to_cardinal routes every float/Decimal through this
657        // language's own decimal grammar — 5.0 keeps its ".0" tail
658        // ("comma nulla"), unlike Base's whole-value integer route.
659        self.to_cardinal_float(value, precision_override)
660    }
661
662    /// `to_ordinal(float/Decimal)`. Python's `to_ordinal` opens with ten
663    /// `number == k` checks, and `==` is numeric, so whole floats/Decimals
664    /// take the irregular words: `to_ordinal(1.0)` == "पहिला",
665    /// `to_ordinal(Decimal("5.00"))` == "पाचवा" — the fractional tail
666    /// vanishes entirely. Everything else falls through to
667    /// `to_cardinal(number) + "वा"`, i.e. the `str(number)` grammar with the
668    /// suffix glued on: 11.0 → "अकरा दशांश शून्यवा", 0.5 →
669    /// "शून्य दशांश पाचवा". -0.0 equals no special (and 0 has none anyway)
670    /// and keeps its textual negword: "ऋण शून्य दशांश शून्यवा"; -3.0 is
671    /// likewise "ऋण तीन दशांश शून्यवा" (oddity 5: no negative guard).
672    /// Exponent-form inputs (1e16, `Decimal("1E+2")`) miss every `==` and
673    /// then raise `int()`'s ValueError from inside `to_cardinal`.
674    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
675        if let Some(i) = value.as_whole_int() {
676            for (k, word) in ORDINAL_SPECIALS {
677                if i == bi(k) {
678                    return Ok(word.to_string());
679                }
680            }
681        }
682        Ok(format!(
683            "{}{}",
684            self.to_cardinal_float(value, None)?,
685            ORDINAL_SUFFIX
686        ))
687    }
688
689    /// `to_ordinal_num(float/Decimal)`. Same numeric `==` on 1..=4, so 1.0 →
690    /// "१ला", 2.0 → "२रा", 3.0 → "३रा", `Decimal("4.00")` → "४था"; everything
691    /// else is `str(number) + "वा"` (oddity 6's numeral-system mix, now with
692    /// a decimal point in it). `repr_str` is the binding's Python
693    /// `str(value)`, exactly the string Python concatenates — `str()` never
694    /// raises, so 5.0 keeps its tail ("5.0वा") and exponent forms pass
695    /// through unharmed: "1e+16वा", "1E+2वा", "-0.0वा".
696    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
697        if let Some(i) = value.as_whole_int() {
698            if i == bi(1) {
699                return Ok("१ला".to_string());
700            }
701            if i == bi(2) {
702                return Ok("२रा".to_string());
703            }
704            if i == bi(3) {
705                return Ok("३रा".to_string());
706            }
707            if i == bi(4) {
708                return Ok("४था".to_string());
709            }
710        }
711        Ok(format!("{}{}", repr_str, ORDINAL_SUFFIX))
712    }
713
714    /// `to_year(float/Decimal)`. Python: `if val < 0:` — a *numeric* test,
715    /// not a sign-bit one, so -0.0 takes the "सन" arm and keeps the negword
716    /// its `to_cardinal` derives textually: "सन ऋण शून्य दशांश शून्य"
717    /// (`FloatValue::is_negative()` would lie here — it is sign-bit aware by
718    /// design). True negatives are `abs()`ed *before* `to_cardinal`, so the
719    /// BC arm never shows a negword: `to_year(-1.5)` ==
720    /// "इसवीसन पूर्व एक दशांश पाच". Exponent-form ValueErrors propagate from
721    /// `to_cardinal` in both arms.
722    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
723        let below_zero = match value {
724            // `-0.0 < 0` is False in Python.
725            FloatValue::Float { value, .. } => *value < 0.0,
726            // A BigInt mantissa carries no negative zero, so is_negative()
727            // is already the numeric test on this arm.
728            FloatValue::Decimal { value, .. } => value.is_negative(),
729        };
730        if below_zero {
731            let abs = match value {
732                FloatValue::Float { value, precision } => FloatValue::Float {
733                    value: value.abs(),
734                    precision: *precision,
735                },
736                FloatValue::Decimal { value, precision } => FloatValue::Decimal {
737                    value: value.abs(),
738                    precision: *precision,
739                },
740            };
741            Ok(format!(
742                "{}{}",
743                YEAR_BC_PREFIX,
744                self.to_cardinal_float(&abs, None)?
745            ))
746        } else {
747            Ok(format!(
748                "{}{}",
749                YEAR_PREFIX,
750                self.to_cardinal_float(value, None)?
751            ))
752        }
753    }
754
755    /// MR inherits Base's `str_to_number` (`Decimal(value)`), which parses
756    /// "Infinity"/"-Infinity" *successfully* — the ValueError Python shows
757    /// for `num2words("Infinity", lang="mr")` happens later, inside MR's
758    /// `to_cardinal` (`int("Infinity")` → `ValueError: invalid literal for
759    /// int() with base 10: 'Infinity'`; "-Infinity" strips its sign
760    /// textually first and reports 'Infinity' too). The Rust dispatcher
761    /// hard-codes Base's `int(Decimal("Infinity"))` semantics
762    /// (`OverflowError`) for `ParsedNumber::Inf`, which MR never executes,
763    /// so Inf parses punt to the Python fallback instead: it runs the
764    /// original converter and reproduces every mode byte for byte
765    /// (cardinal/ordinal/year raise the ValueError; `to_ordinal_num` happily
766    /// returns "Infinityवा"). NaN stays on the dispatcher path — its
767    /// hard-coded ValueError already matches the type MR raises from
768    /// `int("NaN")`. Same shape as `lang_be.rs` / `lang_as.rs`.
769    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
770        let parsed = python_decimal_parse(s)?;
771        Ok(parsed)
772    }
773
774    /// `Decimal('Infinity')` / `-Infinity` per mode. MR's `to_cardinal`
775    /// feeds the sign-stripped `str(number)` to `int()`, so `int("Infinity")`
776    /// raises `ValueError` for cardinal/ordinal/year ("-Infinity" strips its
777    /// sign textually first, so the message names 'Infinity' either way).
778    /// `to_ordinal_num` never parses — it is `str(number) + "वा"` — so it
779    /// succeeds: "Infinityवा" / "-Infinityवा". Replaces the base default
780    /// (`int(Decimal Inf)` → OverflowError), which MR never executes.
781    fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
782        match to {
783            "ordinal_num" => Ok(format!(
784                "{}Infinity{}",
785                if negative { "-" } else { "" },
786                ORDINAL_SUFFIX
787            )),
788            _ => Err(N2WError::Value(
789                "invalid literal for int() with base 10: 'Infinity'".to_string(),
790            )),
791        }
792    }
793
794    /// `Decimal('NaN')` per mode. cardinal/ordinal die in `int("NaN")` →
795    /// `ValueError`; `to_ordinal_num` returns "NaNवा" unparsed; but `to_year`
796    /// opens with `if val < 0:` — and `Decimal('NaN') < 0` raises
797    /// `decimal.InvalidOperation` *before* `to_cardinal` is ever reached.
798    fn nan_result(&self, to: &str) -> Result<String> {
799        match to {
800            "ordinal_num" => Ok(format!("NaN{}", ORDINAL_SUFFIX)),
801            "year" => Err(N2WError::Custom {
802                module: "decimal",
803                class: "InvalidOperation",
804                msg: "[<class 'decimal.InvalidOperation'>]".to_string(),
805            }),
806            _ => Err(N2WError::Value(
807                "invalid literal for int() with base 10: 'NaN'".to_string(),
808            )),
809        }
810    }
811
812    /// This language's own `to_currency(currency=...)` default,
813    /// read from the live Python signature. Only 44 of 156 use EUR.
814    fn default_currency(&self) -> &str {
815        "INR"
816    }
817
818    /// This language's own `to_currency(separator=...)` default,
819    /// read from the live Python signature. Base's is ",", but only
820    /// 36 of 149 languages actually use it — most default to " " or a
821    /// conjunction, so inheriting Base's comma silently corrupts them.
822    fn default_separator(&self) -> &str {
823        " आणि "
824    }
825
826    fn negword(&self) -> &str {
827        NEGWORD
828    }
829
830    fn pointword(&self) -> &str {
831        "दशांश"
832    }
833
834    /// Port of `Num2Word_MR.to_cardinal`, integer path only.
835    ///
836    /// Python does `n = str(number).strip()` and tests `n.startswith("-")`,
837    /// which for an integer is exactly a sign test. The `"." in n` branch
838    /// (float input) is out of scope: `str(int)` never contains a dot.
839    ///
840    /// The final `.strip()` is reproduced but cannot bite — `_int_to_word`
841    /// never returns an empty or space-padded string, so the only whitespace
842    /// in play is the single space inside `negword`.
843    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
844        let (ret, n) = if value.is_negative() {
845            (NEGWORD, value.abs())
846        } else {
847            ("", value.clone())
848        };
849        Ok(format!("{}{}", ret, int_to_word(&n)).trim().to_string())
850    }
851
852    /// Port of `Num2Word_MR.to_ordinal`.
853    ///
854    /// 1..=10 are irregular; everything else — including 0 and every
855    /// negative — is the cardinal with "वा" glued on. Never raises.
856    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
857        for (k, word) in ORDINAL_SPECIALS {
858            if value == &bi(k) {
859                return Ok(word.to_string());
860            }
861        }
862        Ok(format!("{}{}", self.to_cardinal(value)?, ORDINAL_SUFFIX))
863    }
864
865    /// Port of `Num2Word_MR.to_ordinal_num`.
866    ///
867    /// Devanagari digits for 1..=4, ASCII `str(number)` for everything else
868    /// (including negatives: `to_ordinal_num(-1)` == "-1वा"). See oddity 6.
869    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
870        if value == &bi(1) {
871            return Ok("१ला".to_string());
872        }
873        if value == &bi(2) {
874            return Ok("२रा".to_string());
875        }
876        if value == &bi(3) {
877            return Ok("३रा".to_string());
878        }
879        if value == &bi(4) {
880            return Ok("४था".to_string());
881        }
882        Ok(format!("{}{}", value, ORDINAL_SUFFIX))
883    }
884
885    /// Port of `Num2Word_MR.to_year`. The `longval=True` parameter is
886    /// accepted and then ignored by Python, so the trait's arity matches.
887    fn to_year(&self, value: &BigInt) -> Result<String> {
888        if value.is_negative() {
889            Ok(format!(
890                "{}{}",
891                YEAR_BC_PREFIX,
892                self.to_cardinal(&value.abs())?
893            ))
894        } else {
895            Ok(format!("{}{}", YEAR_PREFIX, self.to_cardinal(value)?))
896        }
897    }
898
899    /// Port of `Num2Word_MR.to_cardinal`'s non-integer branch.
900    ///
901    /// MR does not use `Num2Word_Base.to_cardinal_float`; its overridden
902    /// `to_cardinal` reads `str(number)` and speaks the fractional digits one
903    /// at a time. So the raw f64 is turned back into `repr(float)` (or the
904    /// Decimal into `str(Decimal)`) and fed to [`cardinal_from_str`] verbatim.
905    /// The `precision=` kwarg (`precision_override`) is set on the converter by
906    /// `__init__.py` but `to_cardinal` never reads `self.precision`, so it is
907    /// ignored, exactly as Python ignores it.
908    ///
909    /// Python wraps both return arms in `.strip()`; it is a no-op on every
910    /// reachable output (the string always begins and ends with a word) but is
911    /// applied here for fidelity.
912    fn to_cardinal_float(
913        &self,
914        value: &FloatValue,
915        precision_override: Option<u32>,
916    ) -> Result<String> {
917        let _ = precision_override;
918        let n = match value {
919            // Python's str(float); the raw f64 crossed the boundary so repr()
920            // can be reproduced from the bits.
921            FloatValue::Float { value, .. } => py_float_repr(*value),
922            // Python's str(Decimal) — exact, never routed through f64.
923            FloatValue::Decimal { value, .. } => py_decimal_str(value),
924        };
925        Ok(cardinal_from_str(&n)?.trim().to_string())
926    }
927
928    // ---- currency ----------------------------------------------------
929
930    /// `self.__class__.__name__`, for the NotImplementedError message that
931    /// the inherited `to_cheque` raises on an unknown code.
932    fn lang_name(&self) -> &str {
933        "Num2Word_MR"
934    }
935
936    /// `CURRENCY_FORMS[code]`.
937    ///
938    /// **Only the inherited `to_cheque` reaches this.** MR's own
939    /// `to_currency` overrides the lookup with a `.get(currency, ...INR)`
940    /// fallback and never consults this hook — see [`LangMr::to_currency`].
941    /// `to_cheque` keeps the strict `self.CURRENCY_FORMS[currency]` subscript,
942    /// so `None` here reproduces the `KeyError` that Python catches and
943    /// re-raises as NotImplementedError.
944    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
945        self.currency_forms.get(code)
946    }
947
948    /// Port of `Num2Word_MR.to_currency`.
949    ///
950    /// MR replaces `Num2Word_Base.to_currency` wholesale with a string-slicing
951    /// implementation, so **none** of the base currency machinery participates:
952    /// no `parse_currency_parts`, no `pluralize`, no `_money_verbose` /
953    /// `_cents_verbose` / `_cents_terse`, no `CURRENCY_PRECISION`, and no
954    /// `adjective` support. Four consequences, all confirmed against the
955    /// corpus:
956    ///
957    /// 1. **Unknown codes never raise.** `CURRENCY_FORMS.get(currency,
958    ///    CURRENCY_FORMS["INR"])` silently falls back to rupees, so
959    ///    `to_currency(2, "CHF")` is "दोन रुपये" and JPY/KWD/BHD/CNY/CHF all
960    ///    render in रुपये/पैसे. This is why the `mr` currency corpus has zero
961    ///    NotImplementedError rows while the cheque corpus has five.
962    /// 2. **Precision is hardcoded to two decimal places** by the `[:2]`
963    ///    slice, so the 3-decimal (KWD/BHD) and 0-decimal (JPY) currencies get
964    ///    no special handling: `12.34 KWD` is "बारा रुपये आणि तीस चार पैसे",
965    ///    not mils, and `12.34 JPY` still shows a cents segment.
966    /// 3. **`adjective` is accepted and then ignored** — MR declares the
967    ///    parameter but never reads it, and defines no `CURRENCY_ADJECTIVES`.
968    /// 4. **Cents are dropped whenever `right == 0`**, including for floats.
969    ///    `1.0` -> parts[1] is "0" -> `int("00")` -> 0 -> falsy -> "एक रुपया",
970    ///    *not* "एक रुपया आणि शून्य पैसे" as `Num2Word_Base` would give. So
971    ///    MR reaches the base's "int skips cents" outcome by a different
972    ///    route: the `Int`/`Decimal` split still matters lexically (an int has
973    ///    no "." at all, a float may carry non-zero cents), but for a
974    ///    whole-valued float the two coincide.
975    ///
976    /// Python computes `parts = str(val).split(".")` *after* `val = abs(val)`,
977    /// so the sign is detached before stringification. `CurrencyValue` carries
978    /// the value parsed from that same `str(value)`, and `BigDecimal`'s
979    /// `Display` round-trips it (verified: "1.0" stays "1.0", "0.5" stays
980    /// "0.5" — the scale is preserved, so `int("00")` vs `int("50")` comes out
981    /// right). See the port report for the one lexical form where this
982    /// round-trip diverges.
983    fn to_currency(
984        &self,
985        val: &CurrencyValue,
986        currency: &str,
987        cents: bool,
988        separator: Option<&str>,
989        adjective: bool,
990    ) -> Result<String> {
991        // Trait now hands us None when the caller omitted separator=;
992        // resolve it to this language's own default before the ported body.
993        let separator = separator.unwrap_or(self.default_separator());
994        // Restore MR's own `separator=" आणि "` default; see SEPARATOR_UNSET.
995        let separator = if separator == SEPARATOR_UNSET {
996            SEPARATOR_DEFAULT
997        } else {
998            separator
999        };
1000        // Declared by Python and never read. Named for the reader's benefit.
1001        let _ = adjective;
1002
1003        // `if val < 0: is_negative = True; val = abs(val)`. BigDecimal's
1004        // is_negative() is false for "-0.0" (the sign is not carried on a zero
1005        // int_val), matching Python's `-0.0 < 0` being False.
1006        let is_negative = val.is_negative();
1007
1008        // `str(val)` — the value stringified *after* the abs().
1009        let s = match val {
1010            CurrencyValue::Int(v) => {
1011                if is_negative {
1012                    v.abs().to_string()
1013                } else {
1014                    v.to_string()
1015                }
1016            }
1017            CurrencyValue::Decimal { value: d, .. } => {
1018                if is_negative {
1019                    d.abs().to_string()
1020                } else {
1021                    d.to_string()
1022                }
1023            }
1024        };
1025
1026        // `parts = str(val).split(".")` — split on every dot, as Python does.
1027        let parts: Vec<&str> = s.split('.').collect();
1028
1029        // `left = int(parts[0]) if parts[0] else 0`. A non-numeric parts[0]
1030        // is a ValueError out of int(); str() of a float big enough to render
1031        // in exponent form ("1e+16") lands here and Python raises ValueError
1032        // too, so the variant matches rather than approximates.
1033        let left = if parts[0].is_empty() {
1034            BigInt::zero()
1035        } else {
1036            parts[0]
1037                .parse::<BigInt>()
1038                .map_err(|e| N2WError::Value(e.to_string()))?
1039        };
1040
1041        // `right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
1042        // — a *truncating* two-character slice, not a rounding quantize, so
1043        // 12.349 gives 34 and 12.999 gives 99 (never 100). Sliced by chars().
1044        let right = if parts.len() > 1 && !parts[1].is_empty() {
1045            let mut frac: String = parts[1].chars().take(2).collect();
1046            while frac.chars().count() < 2 {
1047                frac.push('0');
1048            }
1049            frac.parse::<BigInt>()
1050                .map_err(|e| N2WError::Value(e.to_string()))?
1051        } else {
1052            BigInt::zero()
1053        };
1054
1055        // `cr1, cr2 = self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["INR"])`
1056        let forms = match self.currency_forms.get(currency) {
1057            Some(f) => f,
1058            None => self
1059                .currency_forms
1060                .get(CURRENCY_FALLBACK)
1061                .expect("INR is inserted by LangMr::new"),
1062        };
1063        let cr1 = &forms.unit;
1064        let cr2 = &forms.subunit;
1065
1066        let one = bi(1);
1067
1068        // `result = left_str + " " + (cr1[1] if left != 1 else cr1[0])`
1069        let left_str = int_to_word(&left);
1070        let mut result = format!(
1071            "{} {}",
1072            left_str,
1073            if left != one { &cr1[1] } else { &cr1[0] }
1074        );
1075
1076        // `if cents and right:` — `right` is falsy at 0, which is how a whole
1077        // float loses its cents segment.
1078        if cents && !right.is_zero() {
1079            let cents_str = int_to_word(&right);
1080            result.push_str(separator);
1081            result.push_str(&cents_str);
1082            result.push(' ');
1083            result.push_str(if right != one { &cr2[1] } else { &cr2[0] });
1084        }
1085
1086        // `result = self.negword + result` — negword already carries its own
1087        // trailing space ("ऋण "), so this is the interior separator too.
1088        if is_negative {
1089            result = format!("{}{}", NEGWORD, result);
1090        }
1091
1092        // Python's `.strip()`. Nothing here is ever space-padded (negword's
1093        // space lands mid-string), so this cannot bite — reproduced anyway.
1094        Ok(result.trim().to_string())
1095    }
1096}