num2words2_core/lang_or.rs
1//! Port of `lang_OR.py` (Odia / Oriya), the class registered under key `"or"`
2//! (`__init__.py:395` → `"or": lang_OR.Num2Word_OR()`).
3//!
4//! Shape: **self-contained**. `Num2Word_OR` subclasses `Num2Word_Base` but
5//! defines no `high_numwords`/`mid_numwords`/`low_numwords` and no
6//! `set_high_numwords`, so Python never builds `self.cards` and never sets
7//! `MAXVAL`. `to_cardinal` is overridden outright and drives a hand-written
8//! `_int_to_word` recursion. Consequently `cards`/`maxval`/`merge` stay at
9//! their trait defaults here, and **there is no overflow check at all** — see
10//! "bug 1" below for what happens instead of `OverflowError`.
11//!
12//! Nothing is inherited from `Num2Word_Base` in a load-bearing way: OR
13//! overrides all four in-scope entry points itself.
14//! * `to_cardinal` — overridden (below)
15//! * `to_ordinal` — overridden: suppletive for 1..=10, else
16//! `to_cardinal(n) + ମ` (see divergence 2 below)
17//! * `to_ordinal_num` — overridden: `str(n) + ମ` (note: NOT the base's
18//! bare `str(n)`, so the trait default would be wrong here)
19//! * `to_year(val, longval=True)` — overridden to ignore `longval` entirely
20//! and just delegate to `to_cardinal`. Same result as the base default,
21//! but it is an explicit override rather than inheritance.
22//!
23//! `setup()` also sets `pointword`/`exclude_title`, which only matter to the
24//! float and title paths — out of scope. `is_title` is never enabled by OR.
25//!
26//! # Deliberate divergences from upstream
27//!
28//! Three, all documented here: the **script** (Odia, not IAST), the
29//! **tens[7]/teens[7] collision**, and the **ordinals for 1..=10**.
30//! Everything else is ported verbatim, bug for bug.
31//!
32//! ## 1. Odia script
33//!
34//! `lang_OR.py` spells every numeral in **IAST-style Latin transliteration**
35//! ("eka", "duī", "calīśa", "hajāra"). Odia is written in the Odia script,
36//! and every sibling Indic module in this crate (`lang_bn.rs`, `lang_hi.rs`,
37//! `lang_ta.rs`, `lang_te.rs`, ...) already emits its own script, so the
38//! transliteration was an upstream accident rather than a choice. The tables
39//! here are the Odia spellings: "ଏକ", "ଦୁଇ", "ଚାଳିଶ", "ହଜାର".
40//!
41//! This is a lexicon change only — the composition rules, the multiplier
42//! guards, the 10^9 digit cliff and the currency fallback are all still
43//! ported verbatim, including the non-idiomatic tens-and-units composition
44//! (bug 7 below).
45//!
46//! ## 2. tens[7] no longer collides with teens[7]
47//!
48//! Both tables held "satara". 70 and 17 therefore produced the *same* string,
49//! and 77 came out as "satara o sāta" — "seventeen and seven". The module
50//! header already called this out as wrong ("Odia for 70 is sattari; satara
51//! is 17") and kept it only because the frozen corpus pinned it. Since the
52//! whole lexicon is being rewritten anyway, `TENS[7]` is now ସତୁରୀ (70) and
53//! `TEENS[7]` stays ସତର (17).
54//!
55//! This is a value fix, not a spelling fix: it changes which number the
56//! output denotes. It is the one change here that a caller could have been
57//! relying on, and it is the reason 17/70/77 have their own test cases.
58//!
59//! ## 3. Ordinals 1..=10 are suppletive
60//!
61//! Upstream builds every ordinal as `to_cardinal(n) + "ma"`, which gives the
62//! non-words *ଏକମ for 1st and *ଛଅମ for 6th. Odia 1-10 are Sanskrit-derived
63//! forms with their own stems: ପ୍ରଥମ, ଦ୍ୱିତୀୟ, ତୃତୀୟ, ଚତୁର୍ଥ, ପଞ୍ଚମ, ଷଷ୍ଠ,
64//! ସପ୍ତମ, ଅଷ୍ଟମ, ନବମ, ଦଶମ. See [`ORDINAL_IRREGULARS`].
65//!
66//! **From 11 up nothing changed but the script.** ମ is a genuine Odia ordinal
67//! marker — it is what ends ପଞ୍ଚମ, ସପ୍ତମ, ଅଷ୍ଟମ, ନବମ and ଦଶମ — so the suffix
68//! arm is kept as upstream had it rather than replaced with a rule this port
69//! cannot source. The formal Sanskrit series (ଏକାଦଶ for 11th, ...) and the
70//! productive -ତମ of the sibling `lang_bn.rs` ("বিশতম" for 20th) are both
71//! attested alternatives; picking between them needs a native speaker, so
72//! this PR does not. `to_ordinal(11)` is ଏଗାରମ, as before.
73//!
74//! Zero is likewise left alone: `to_ordinal(0)` is still *ଶୂନ୍ୟମ. Odia has no
75//! ordinal for zero, but upstream never called `verify_ordinal` and this
76//! module does not start.
77//!
78//! # Faithfully reproduced Python bugs
79//!
80//! This is a port, not a rewrite. Every item below looks wrong and is exactly
81//! what Python emits — each one is pinned by a row in the frozen corpus.
82//!
83//! 1. **No number word above 10^9.** `_int_to_word` ends with a bare
84//! `return str(number)`, so any `number >= 1_000_000_000` converts to its
85//! own *decimal digits* instead of words, with no error:
86//! `to_cardinal(10**9) == "1000000000"` and
87//! `to_ordinal(10**9) == "1000000000ମ"`. Corpus confirms this all the way
88//! up to 10^21. This is why the port keeps `BigInt` end-to-end and never
89//! casts: the fallback must stringify arbitrarily large values verbatim.
90//! 2. ~~`tens[7]` collides with `teens[7]`~~ — **fixed**, see divergence 2
91//! above. 70 is now ସତୁରୀ and 77 is "ସତୁରୀ ଓ ସାତ"; 17 stays ସତର.
92//! 3. **Tens and units are composed, not lexicalised.** Odia has a distinct
93//! word for each of 21..=99 (42 is ବୟାଳିଶ, not "forty and two"), but
94//! `_int_to_word` builds them as `tens[t] + " ଓ " + ones[o]`, so 42 is
95//! "ଚାଳିଶ ଓ ଦୁଇ". Kept verbatim — replacing it means an 80-entry table and
96//! a different algorithm, which is a separate change.
97//! 4. **`million` is the string "ଦଶ ଲକ୍ଷ"**, literally "ten lakh", used as
98//! the multiplier for 10^6. This makes the Indian-system word collide with
99//! the Western scale it is applied to: `to_cardinal(10**6)` ==
100//! "ଏକ ଦଶ ଲକ୍ଷ" ("one ten-lakh"), and `to_cardinal(10**7)` ==
101//! "ଦଶ ଦଶ ଲକ୍ଷ" ("ten ten-lakh"). The module otherwise uses a strict
102//! Western 10^3/10^6 grouping and never uses lakh/crore grouping at all.
103//! 5. **`tens[0]` and `tens[1]` are unreachable.** `tens[1]` is "ଦଶ", but
104//! the `number < 20` branch catches 10..=19 via `teens` first, and the
105//! `number < 100` branch only ever computes `t` in 2..=9. Preserved in the
106//! table for index alignment; never read.
107//! 6. **Above 10 the ordinal is still pure suffixation with no morphology**:
108//! `to_ordinal` appends ମ to the *whole* cardinal string, so the suffix
109//! lands on the final word of a phrase — `to_ordinal(999) ==
110//! "ନଅ ଶହ ଓ ନବେ ଓ ନଅମ"` and `to_ordinal(10**6) == "ଏକ ଦଶ ଲକ୍ଷମ"`.
111//! Negatives keep the sign word and now take the suppletive arm where it
112//! applies: `to_ordinal(-1) == "ପ୍ରଥମ"` is *not* what happens — the
113//! 1..=10 test is on the signed value, so a negative falls through to the
114//! suffix arm and `to_ordinal(-1) == "ଋଣ ଏକମ"`, as upstream.
115//!
116//! # Error variants
117//!
118//! For integer input this module cannot raise: there is no overflow check
119//! (bug 1 swallows it), every list index is provably in range (each branch
120//! bounds its divisor result before indexing), and the sign is stripped before
121//! any arithmetic. All 459 corpus rows for `"or"` in the four integer modes
122//! (`cardinal`/`ordinal`/`ordinal_num`/`year`) are `"ok": true`.
123//!
124//! `to_currency` cannot raise either — see currency quirk C1: the unknown-code
125//! fallback means the `NotImplementedError` path is unreachable. The only
126//! raising surface is the inherited `to_cheque`, which *does* raise
127//! `NotImplementedError` for any code outside `CURRENCY_FORMS` (6 of the 9
128//! `cheque:*` corpus rows). `fraction` (`TypeError`) remains out of scope.
129//!
130//! # Currency (`to_currency` / `to_cheque`)
131//!
132//! `Num2Word_OR` declares `CURRENCY_FORMS` for **INR, USD and EUR only** and
133//! overrides `to_currency` and `pluralize`. It does *not* define
134//! `CURRENCY_PRECISION` or `CURRENCY_ADJECTIVES`, and does not override
135//! `to_cheque`, `_money_verbose`, `_cents_verbose` or `_cents_terse` — those
136//! come from `Num2Word_Base`, whose `CURRENCY_PRECISION`/`CURRENCY_ADJECTIVES`
137//! are both `{}` (so the divisor is always the 100 default, and no adjective
138//! ever applies). The corresponding trait defaults are therefore already
139//! correct and are deliberately left alone.
140//!
141//! ## Faithfully reproduced Python bugs — currency
142//!
143//! C1. **An unknown currency code silently becomes INR.** The lookup is
144//! `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
145//! — a `dict.get` with a *default*, not a `[]` subscript. So `to_currency`
146//! never raises `NotImplementedError`; it falls back to the first entry of
147//! the dict literal, which is INR. Corpus pins this hard:
148//! `to_currency(0, "JPY") == "ଶୂନ୍ୟ ଟଙ୍କା"` (Odia rupees, for yen), and
149//! likewise for GBP/KWD/BHD/CNY/CHF. The *inherited* `to_cheque` uses a
150//! strict `self.CURRENCY_FORMS[currency]` subscript and does raise for the
151//! very same codes — hence `cheque:JPY` is a `NotImplementedError` row
152//! while `currency:JPY` happily prints rupees. The two lookups must stay
153//! distinct; see [`LangOr::forms_or_first`] vs [`Lang::currency_forms`].
154//! C2. **`CURRENCY_PRECISION` is ignored entirely.** OR's `to_currency` does
155//! its own string surgery and never consults the divisor, so the 3-decimal
156//! currencies (KWD/BHD, divisor 1000) and the 0-decimal ones (JPY,
157//! divisor 1) are all treated as 2-decimal — and, per C1, as INR anyway.
158//! `to_currency(12.34, "JPY") == "ବାର ଟଙ୍କା ତିରିଶ ଓ ଚାରି ପଇସା"`: yen
159//! has no subunit at all, yet 34 paise are printed.
160//! C3. **`adjective` is accepted and never read.** No `prefix_currency` call.
161//! C4. **`pluralize` is dead code.** OR defines it, but its own `to_currency`
162//! indexes the form tuple inline (`cr1[1] if left != 1 else cr1[0]`) and
163//! the inherited `to_cheque` takes `cr1[-1]` directly. Nothing in either
164//! path calls it. Implemented anyway, because Python defines it.
165//! C5. **Every form tuple has identical singular and plural** — `("ଟଙ୍କା",
166//! "ଟଙ୍କା")`, `("ୟୁରୋ", "ୟୁରୋ")`, `("ସେଣ୍ଟ", "ସେଣ୍ଟ")`. The `left != 1`
167//! and `right != 1` selections are therefore unobservable: both arms yield
168//! the same word. Both entries are kept because the arity is load-bearing
169//! (`cr1[1]` would `IndexError` on a 1-tuple) and because `to_cheque`'s
170//! `cr1[-1]` reads the last one.
171//! C6. **A float with zero cents prints no cents segment.** `if cents and
172//! right:` gates on `right` being *truthy*, and `1.0` parses to
173//! `right == 0`. So `to_currency(1.0)` and `to_currency(1)` both give
174//! "ଏକ ୟୁରୋ" — OR collapses the int/float distinction that
175//! `Num2Word_Base.to_currency` preserves (base would render
176//! "... ଶୂନ୍ୟ ସେଣ୍ଟ" for the float). Verified against the real module, not assumed:
177//! corpus row `currency:EUR arg "1.0"` → "ଏକ ୟୁରୋ". The `CurrencyValue`
178//! int/decimal split is still honoured on the way in — it is what
179//! [`split_currency_parts`] reads — it simply cannot change the output
180//! here.
181//!
182//! # Out of scope (present in Python, deliberately not ported)
183//!
184//! `to_cardinal`'s `"." in n` branch (spells the fractional part digit by
185//! digit against `pointword` = "ଦଶମିକ", mapping digit 0 to "ଶୂନ୍ୟ" via the
186//! `self.ones[0] or "ଶୂନ୍ୟ"` falsy-empty-string trick). The trait hands
187//! `to_cardinal` a `&BigInt`, so the decimal branch is unreachable by
188//! construction. `cardinal_from_decimal` is likewise left at its default:
189//! nothing in OR's currency surface can reach the fractional-cents path,
190//! because `to_currency` truncates to two digits itself and never delegates to
191//! `currency::default_to_currency`.
192
193use crate::base::{Lang, N2WError, Result};
194use crate::currency::{CurrencyForms, CurrencyValue};
195use crate::floatpath::FloatValue;
196use crate::strnum::{python_decimal_parse, ParsedNumber};
197use bigdecimal::BigDecimal;
198use num_bigint::BigInt;
199use num_integer::Integer;
200use num_traits::{One, Signed, ToPrimitive, Zero};
201use std::collections::HashMap;
202
203/// `setup()`: `self.negword = "ଋଣ "` — note the **trailing space**, which is
204/// what separates it from the number, and which `.strip()` then makes
205/// harmless at the ends.
206const NEGWORD: &str = "ଋଣ ";
207
208const ZERO_WORD: &str = "ଶୂନ୍ୟ";
209
210/// `self.ones`. Index 0 is `""` (used by the out-of-scope float path via
211/// `self.ones[int(digit)] or "ଶୂନ୍ୟ"`); `_int_to_word` guards 0 before
212/// reaching here, so only 1..=9 are ever read on the integer path.
213const ONES: [&str; 10] = [
214 "", "ଏକ", "ଦୁଇ", "ତିନି", "ଚାରି", "ପାଞ୍ଚ", "ଛଅ", "ସାତ", "ଆଠ", "ନଅ",
215];
216
217/// `self.teens`, covering 10..=19 (indexed as `number - 10`).
218const TEENS: [&str; 10] = [
219 "ଦଶ", "ଏଗାର", "ବାର", "ତେର", "ଚଉଦ", "ପନ୍ଦର", "ଷୋହଳ", "ସତର", "ଅଠର", "ଉଣେଇଶ",
220];
221
222/// `self.tens`. Only indices 2..=9 are reachable (bug 5). Index 7 is "ସତର",
223/// a verbatim duplicate of `TEENS[7]` (bug 2) — do not "correct" it.
224const TENS: [&str; 10] = [
225 "", "ଦଶ", "କୋଡ଼ିଏ", "ତିରିଶ", "ଚାଳିଶ", "ପଚାଶ", "ଷାଠିଏ", "ସତୁରୀ", "ଅଶୀ", "ନବେ",
226];
227
228/// Odia ordinals 1-10 are **suppletive**: Sanskrit-derived forms with their
229/// own stems, not the cardinal plus a suffix. ଏକ ("one") does not become
230/// *ଏକମ but ପ୍ରଥମ; ଛଅ ("six") becomes ଷଷ୍ଠ, not *ଛଅମ.
231///
232/// Index 0 is unused — Odia has no ordinal for zero, and upstream's blind
233/// suffix produced the non-word *ଶୂନ୍ୟମ for it.
234///
235/// From 11 up the suffix arm applies; see [`ORDINAL_SUFFIX`].
236const ORDINAL_IRREGULARS: [&str; 11] = [
237 "",
238 "ପ୍ରଥମ",
239 "ଦ୍ୱିତୀୟ",
240 "ତୃତୀୟ",
241 "ଚତୁର୍ଥ",
242 "ପଞ୍ଚମ",
243 "ଷଷ୍ଠ",
244 "ସପ୍ତମ",
245 "ଅଷ୍ଟମ",
246 "ନବମ",
247 "ଦଶମ",
248];
249
250/// The ordinal suffix for 11 and up, and for the digit form at every
251/// magnitude: ମ, upstream's "ma" written in Odia.
252///
253/// It is a real Odia ordinal marker — it is exactly what ends ପଞ୍ଚମ, ସପ୍ତମ,
254/// ଅଷ୍ଟମ, ନବମ and ଦଶମ above — which is why upstream's blind `+ "ma"` looked
255/// plausible across the whole range. It is wrong only where a suppletive
256/// form exists, i.e. 1..=10.
257const ORDINAL_SUFFIX: &str = "ମ";
258
259const HUNDRED: &str = "ଶହ";
260const THOUSAND: &str = "ହଜାର";
261/// `self.million` — the 10^6 multiplier word, literally "ten lakh" (bug 4).
262const MILLION: &str = "ଦଶ ଲକ୍ଷ";
263
264/// The key `list(self.CURRENCY_FORMS.values())[0]` resolves to.
265///
266/// Python evaluates that against a plain dict literal, and since 3.7 dicts
267/// iterate in insertion order — so this is "whichever code is written first in
268/// the `CURRENCY_FORMS` literal", which for `lang_OR.py` is INR. It is *not*
269/// "INR by name": reordering the Python literal would change this constant.
270/// Only [`LangOr::forms_or_first`] reads it (quirk C1).
271const FIRST_FORM_KEY: &str = "INR";
272
273/// Python's `str(abs(val)).split(".")` surgery, as `(left, right)`.
274///
275/// ```text
276/// parts = str(val).split(".")
277/// left = int(parts[0]) if parts[0] else 0
278/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
279/// ```
280///
281/// The original `str(val)` is gone by the time we get here — the boundary hands
282/// us `Decimal(str(value))` — so the digit string is rebuilt from the decimal's
283/// own unscaled value and exponent. That is deliberately *not* `Display`:
284/// `BigDecimal`'s `Display` switches to exponential notation past a leading- or
285/// trailing-zero threshold, and reconstructing the plain digits sidesteps that
286/// entirely. Equivalences that make this safe:
287///
288/// * `parts[0]` is the integer digits, so `left` is `trunc(|val|)`.
289/// * `parts[1][:2]` **truncates** to two digits and `.ljust(2, "0")` pads a
290/// 1-digit fraction on the *right* — so `0.5` is 50 paise, not 5, and
291/// `12.345` is 34 paise, never rounded up to 35.
292/// * A missing `"."` and a fraction of `"0"` both give `right == 0`, which is
293/// why `1.0` and `1` agree (quirk C6).
294///
295/// Scale `<= 0` (an integral `Decimal` such as `Decimal("12")` or `1E+2`) has
296/// no `"."` in `str()` either, so `right` is 0 there too.
297fn split_currency_parts(val: &CurrencyValue) -> (BigInt, BigInt) {
298 let d = match val {
299 // `str(int)` never contains a ".", so parts[1] does not exist.
300 CurrencyValue::Int(v) => return (v.abs(), BigInt::zero()),
301 CurrencyValue::Decimal { value: d, .. } => d.abs(),
302 };
303
304 // value == unscaled * 10^-scale; the abs() above makes unscaled >= 0.
305 let (unscaled, scale) = d.as_bigint_and_exponent();
306 let digits = unscaled.to_string();
307
308 if scale <= 0 {
309 // Integral: trailing zeros are implied by the negative scale, so
310 // append them to recover the integer digits.
311 let mut int_digits = digits;
312 int_digits.push_str(&"0".repeat((-scale) as usize));
313 return (parse_digits(&int_digits), BigInt::zero());
314 }
315
316 // Left-pad so there is at least one integer digit, exactly as str() writes
317 // "0.01" rather than ".01".
318 let scale = scale as usize;
319 let padded = if digits.len() <= scale {
320 format!("{}{}", "0".repeat(scale - digits.len() + 1), digits)
321 } else {
322 digits
323 };
324 let split_at = padded.len() - scale;
325 let (int_part, frac_part) = padded.split_at(split_at);
326
327 // `parts[1][:2].ljust(2, "0")` — truncate to two digits, then pad right.
328 let mut cents: String = frac_part.chars().take(2).collect();
329 while cents.len() < 2 {
330 cents.push('0');
331 }
332
333 (parse_digits(int_part), parse_digits(¢s))
334}
335
336/// `int(<ascii digit string>)`. Every caller feeds it digits it just built from
337/// a `BigInt`'s own decimal form, so this cannot fail.
338fn parse_digits(s: &str) -> BigInt {
339 s.parse::<BigInt>()
340 .expect("digit string built from a BigInt's own decimal form")
341}
342
343/// CPython's `repr(float)` (== `str(float)`): shortest round-trip digits,
344/// fixed notation iff `-4 < decpt <= 16`, `.0` appended when integral,
345/// two-digit-padded exponent otherwise. `Num2Word_OR.to_cardinal` is driven
346/// entirely by this string — `str(1e16) == "1e+16"` has no `"."`, so `int()`
347/// raises `ValueError` there. Mirrors `lang_sk.rs`'s `python_repr_f64`.
348fn python_repr_f64(f: f64) -> String {
349 if f.is_nan() {
350 return "nan".to_string();
351 }
352 if f.is_infinite() {
353 return if f < 0.0 { "-inf" } else { "inf" }.to_string();
354 }
355
356 // `is_sign_negative` captures -0.0, whose repr is '-0.0'.
357 let neg = f.is_sign_negative();
358 let s = format!("{:e}", f.abs()); // e.g. "1.234e1", "5e-1", "0e0"
359 let (mantissa, exp_s) = match s.split_once('e') {
360 Some(parts) => parts,
361 None => return s, // unreachable for finite f64
362 };
363 let exp: i32 = exp_s.parse().unwrap_or(0);
364 let digits: String = mantissa.chars().filter(|c| c.is_ascii_digit()).collect();
365 let ndigits = digits.len() as i32;
366 let decpt = exp + 1;
367
368 let body = if decpt <= -4 || decpt > 16 {
369 let mut m = String::new();
370 m.push_str(&digits[..1]);
371 if ndigits > 1 {
372 m.push('.');
373 m.push_str(&digits[1..]);
374 }
375 let e = decpt - 1;
376 let (esign, eabs) = if e < 0 { ('-', -e) } else { ('+', e) };
377 format!("{}e{}{:02}", m, esign, eabs)
378 } else if decpt <= 0 {
379 format!("0.{}{}", "0".repeat((-decpt) as usize), digits)
380 } else if decpt >= ndigits {
381 format!("{}{}.0", digits, "0".repeat((decpt - ndigits) as usize))
382 } else {
383 let dp = decpt as usize;
384 format!("{}.{}", &digits[..dp], &digits[dp..])
385 };
386
387 if neg {
388 format!("-{}", body)
389 } else {
390 body
391 }
392}
393
394/// CPython's `str(Decimal)`: trailing zeros preserved (`"1.10"` stays
395/// `"1.10"`), `E±n` notation exactly when `exp > 0` or the adjusted exponent
396/// `< -6` — so `Decimal("1E+2")` prints `"1E+2"` and feeds `int()` a
397/// `ValueError`, matching Python instead of the plain-notation
398/// reconstruction an earlier revision used. Mirrors `lang_sk.rs`'s
399/// `python_str_decimal`.
400fn python_str_decimal(bd: &BigDecimal) -> String {
401 let (coeff, scale) = bd.as_bigint_and_exponent();
402 let py_exp: i64 = -scale; // Decimal._exp
403 let neg = coeff.is_negative(); // BigInt drops the sign of a negative zero
404 let int_str = coeff.abs().to_string(); // Decimal._int
405 let ndigits = int_str.len() as i64;
406 let leftdigits = py_exp + ndigits;
407
408 // dotplace, non-engineering branch of _pydecimal.__str__.
409 let dotplace: i64 = if py_exp <= 0 && leftdigits > -6 {
410 leftdigits
411 } else {
412 1
413 };
414
415 let (intpart, fracpart) = if dotplace <= 0 {
416 (
417 "0".to_string(),
418 format!(".{}{}", "0".repeat((-dotplace) as usize), int_str),
419 )
420 } else if dotplace >= ndigits {
421 (
422 format!("{}{}", int_str, "0".repeat((dotplace - ndigits) as usize)),
423 String::new(),
424 )
425 } else {
426 let dp = dotplace as usize;
427 (int_str[..dp].to_string(), format!(".{}", &int_str[dp..]))
428 };
429
430 let exp = if leftdigits == dotplace {
431 String::new()
432 } else {
433 // "%+d" — a sign but no zero-padding, unlike float repr.
434 format!("E{:+}", leftdigits - dotplace)
435 };
436
437 let sign = if neg { "-" } else { "" };
438 format!("{}{}{}{}", sign, intpart, fracpart, exp)
439}
440
441pub struct LangOr {
442 /// `CURRENCY_FORMS`, built once here rather than per call.
443 ///
444 /// Python builds this dict once at class-definition time; the binding holds
445 /// one `LangOr` in a `OnceLock`, so this table is allocated exactly once per
446 /// process too.
447 forms: HashMap<&'static str, CurrencyForms>,
448}
449
450impl Default for LangOr {
451 fn default() -> Self {
452 Self::new()
453 }
454}
455
456impl LangOr {
457 pub fn new() -> Self {
458 // Insertion order is irrelevant to a HashMap, but the *first* literal
459 // entry is what `list(values())[0]` picks — see FIRST_FORM_KEY. The
460 // order below mirrors lang_OR.py's literal so the two read alike.
461 let mut forms = HashMap::new();
462 forms.insert(
463 "INR",
464 CurrencyForms::new(&["ଟଙ୍କା", "ଟଙ୍କା"], &["ପଇସା", "ପଇସା"]),
465 );
466 forms.insert(
467 "USD",
468 CurrencyForms::new(&["ଡଲାର", "ଡଲାର"], &["ସେଣ୍ଟ", "ସେଣ୍ଟ"]),
469 );
470 forms.insert(
471 "EUR",
472 CurrencyForms::new(&["ୟୁରୋ", "ୟୁରୋ"], &["ସେଣ୍ଟ", "ସେଣ୍ଟ"]),
473 );
474 LangOr { forms }
475 }
476
477 /// `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
478 /// — the lenient lookup, with the INR fallback of quirk C1.
479 ///
480 /// Private, and deliberately *not* [`Lang::currency_forms`]: the inherited
481 /// `to_cheque` needs the strict subscript that raises. Conflating the two
482 /// would make `cheque:JPY` print rupees instead of raising.
483 fn forms_or_first(&self, currency: &str) -> &CurrencyForms {
484 self.forms
485 .get(currency)
486 .or_else(|| self.forms.get(FIRST_FORM_KEY))
487 .expect("the first-entry fallback key is always present in the table")
488 }
489
490 /// Python's `_int_to_word`. Called only with `number >= 0`: `to_cardinal`
491 /// strips the sign textually before ever reaching arithmetic.
492 ///
493 /// Python uses `divmod`, which floors. Operands here are non-negative, so
494 /// `div_mod_floor` and truncating division agree; `div_mod_floor` is used
495 /// anyway to mirror `divmod` exactly rather than rely on that coincidence.
496 fn int_to_word(&self, number: &BigInt) -> String {
497 if number.is_zero() {
498 return ZERO_WORD.to_string();
499 }
500
501 let ten = BigInt::from(10);
502 let hundred = BigInt::from(100);
503 let thousand = BigInt::from(1_000);
504 let million = BigInt::from(1_000_000);
505 let billion = BigInt::from(1_000_000_000);
506
507 // `if number < 10: return self.ones[number]`
508 if *number < ten {
509 // 1..=9 — 0 was returned above.
510 return ONES[number.to_usize().unwrap()].to_string();
511 }
512
513 // `if number < 20: return self.teens[number - 10]`
514 if *number < BigInt::from(20) {
515 return TEENS[(number - &ten).to_usize().unwrap()].to_string();
516 }
517
518 // `if number < 100: t, o = divmod(number, 10)`
519 // `return self.tens[t] + (" ଓ " + self.ones[o] if o else "")`
520 if *number < hundred {
521 let (t, o) = number.div_mod_floor(&ten);
522 let t = t.to_usize().unwrap(); // 2..=9
523 let o = o.to_usize().unwrap(); // 0..=9
524 let mut out = TENS[t].to_string();
525 if o != 0 {
526 out.push_str(" ଓ ");
527 out.push_str(ONES[o]);
528 }
529 return out;
530 }
531
532 // `if number < 1000: h, r = divmod(number, 100)`
533 // `base = self.ones[h] + " " + self.hundred`
534 // `return base + (" ଓ " + self._int_to_word(r) if r else "")`
535 //
536 // Note the separator here is " ଓ " (as in the tens), unlike the
537 // thousand/million branches below which use a bare " ".
538 if *number < thousand {
539 let (h, r) = number.div_mod_floor(&hundred);
540 let h = h.to_usize().unwrap(); // 1..=9
541 let mut out = format!("{} {}", ONES[h], HUNDRED);
542 if !r.is_zero() {
543 out.push_str(" ଓ ");
544 out.push_str(&self.int_to_word(&r));
545 }
546 return out;
547 }
548
549 // `if number < 1000000: t, r = divmod(number, 1000)`
550 // `base = self._int_to_word(t) + " " + self.thousand`
551 // `return base + (" " + self._int_to_word(r) if r else "")`
552 if *number < million {
553 let (t, r) = number.div_mod_floor(&thousand);
554 let mut out = format!("{} {}", self.int_to_word(&t), THOUSAND);
555 if !r.is_zero() {
556 out.push(' ');
557 out.push_str(&self.int_to_word(&r));
558 }
559 return out;
560 }
561
562 // `if number < 1000000000: m, r = divmod(number, 1000000)`
563 // `base = self._int_to_word(m) + " " + self.million`
564 // `return base + (" " + self._int_to_word(r) if r else "")`
565 if *number < billion {
566 let (m, r) = number.div_mod_floor(&million);
567 let mut out = format!("{} {}", self.int_to_word(&m), MILLION);
568 if !r.is_zero() {
569 out.push(' ');
570 out.push_str(&self.int_to_word(&r));
571 }
572 return out;
573 }
574
575 // `return str(number)` — bug 1. No words, no error, any magnitude.
576 number.to_string()
577 }
578
579 /// Port of the *string* body of `Num2Word_OR.to_cardinal`, driven from the
580 /// value's `str()` form (`n`):
581 ///
582 /// ```text
583 /// n = str(number).strip()
584 /// if n.startswith("-"):
585 /// return (self.negword + self.to_cardinal(n[1:])).strip()
586 /// if "." in n:
587 /// left, right = n.split(".", 1)
588 /// ret = self._int_to_word(int(left)) + " " + self.pointword
589 /// for digit in right:
590 /// ret += " " + (self.ones[int(digit)] or "ଶୂନ୍ୟ")
591 /// return ret.strip()
592 /// return self._int_to_word(int(n))
593 /// ```
594 ///
595 /// The fractional part is spelled **digit by digit** off `self.ones`, with
596 /// `ones[0] == ""` falling back to "ଶୂନ୍ୟ" (the `or "ଶୂନ୍ୟ"` trick) — so a
597 /// trailing/leading zero becomes "ଶୂନ୍ୟ", not silence. The integer part,
598 /// by contrast, goes through the full `_int_to_word` recursion. The sign is
599 /// handled textually: the "-" is sliced off and the call recurses on the
600 /// remaining string, so `negword` (which ends in a space) is prepended and
601 /// the final `.strip()` tidies the ends.
602 ///
603 /// `int(left)` / `int(n)` can be arbitrarily large — the fractional case of
604 /// `98746251323029.99` runs the integer part through `_int_to_word`, which
605 /// stringifies it verbatim past 10^9 (bug 1). Kept in `BigInt`, never cast.
606 fn cardinal_from_repr(&self, n: &str) -> Result<String> {
607 // `str(number).strip()`. Python's str.strip() and Rust's trim() agree
608 // on every character these reconstructed numeric strings can contain.
609 let n = n.trim();
610
611 // `if n.startswith("-"): return (negword + to_cardinal(n[1:])).strip()`
612 if let Some(rest) = n.strip_prefix('-') {
613 let inner = self.cardinal_from_repr(rest)?;
614 return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
615 }
616
617 // `if "." in n:` — split once, integer part in words, fraction spelled
618 // digit by digit.
619 if let Some((left, right)) = n.split_once('.') {
620 let left_bi = left.parse::<BigInt>().map_err(|_| {
621 N2WError::Value(format!("invalid literal for int() with base 10: {:?}", left))
622 })?;
623 let mut ret = format!("{} {}", self.int_to_word(&left_bi), self.pointword());
624 for ch in right.chars() {
625 let d = ch.to_digit(10).ok_or_else(|| {
626 N2WError::Value(format!(
627 "invalid literal for int() with base 10: {:?}",
628 ch
629 ))
630 })? as usize;
631 // `self.ones[int(digit)] or "ଶୂନ୍ୟ"`: ONES[0] == "" is falsy.
632 let word = if ONES[d].is_empty() { ZERO_WORD } else { ONES[d] };
633 ret.push(' ');
634 ret.push_str(word);
635 }
636 return Ok(ret.trim().to_string());
637 }
638
639 // `return self._int_to_word(int(n))`
640 let bi = n.parse::<BigInt>().map_err(|_| {
641 N2WError::Value(format!("invalid literal for int() with base 10: {:?}", n))
642 })?;
643 Ok(self.int_to_word(&bi))
644 }
645}
646
647impl Lang for LangOr {
648 /// This language's own `to_currency(currency=...)` default,
649 /// read from the live Python signature. Only 44 of 156 use EUR.
650 fn default_currency(&self) -> &str {
651 "INR"
652 }
653
654 /// This language's own `to_currency(separator=...)` default,
655 /// read from the live Python signature. Base's is ",", but only
656 /// 36 of 149 languages actually use it — most default to " " or a
657 /// conjunction, so inheriting Base's comma silently corrupts them.
658 fn default_separator(&self) -> &str {
659 " "
660 }
661
662 fn negword(&self) -> &str {
663 NEGWORD
664 }
665
666 fn pointword(&self) -> &str {
667 "ଦଶମିକ"
668 }
669
670 /// Python:
671 /// ```text
672 /// n = str(number).strip()
673 /// if n.startswith("-"):
674 /// return (self.negword + self.to_cardinal(n[1:])).strip()
675 /// if "." in n: ... # unreachable for BigInt input
676 /// return self._int_to_word(int(n))
677 /// ```
678 ///
679 /// The sign is handled *textually*: Python slices the "-" off the string
680 /// and recurses. For an integer that is exactly `abs()`, and `str(BigInt)`
681 /// never yields "-0", so there is no negative-zero wrinkle.
682 ///
683 /// `.strip()` is a no-op for integer input — `NEGWORD`'s trailing space is
684 /// always followed by a non-empty word, and `int_to_word` never returns a
685 /// string with leading or trailing whitespace. It is applied anyway to
686 /// mirror the source. (Python's `str.strip()` and Rust's `str::trim()`
687 /// disagree on a few exotic control characters such as U+001C..U+001F;
688 /// none can occur in these strings, so the two coincide here.)
689 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
690 if *value < BigInt::zero() {
691 let inner = self.to_cardinal(&-value)?;
692 return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
693 }
694 Ok(self.int_to_word(value))
695 }
696
697 /// The suppletive form for 1..=10, otherwise the cardinal plus ମ.
698 ///
699 /// Upstream glued "ma" to *every* cardinal, which gives the non-words
700 /// *ଏକମ for 1st and *ଛଅମ for 6th where Odia has ପ୍ରଥମ and ଷଷ୍ଠ. Above 10
701 /// the suffix arm is kept exactly as upstream had it, so the inherited
702 /// quirks are unchanged: the suffix still lands on the final word of a
703 /// multi-word phrase, and still on a digit run from the 10^9 cliff
704 /// ("1000000000ମ").
705 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
706 if let Some(i) = value.to_usize() {
707 if (1..=10).contains(&i) {
708 return Ok(ORDINAL_IRREGULARS[i].to_string());
709 }
710 }
711 Ok(format!("{}{}", self.to_cardinal(value)?, ORDINAL_SUFFIX))
712 }
713
714 /// The digits plus ମ — "1ମ", "10ମ", "21ମ". Unchanged from upstream apart
715 /// from the script: the digit form takes the suffix at every magnitude,
716 /// 1..=10 included, because ୧ମ is how the abbreviation is written even
717 /// where the spelled ordinal is suppletive.
718 ///
719 /// The minus sign survives: `to_ordinal_num(-1) == "-1ମ"`. This overrides
720 /// the base's bare `str(number)`, so the trait default must not be used.
721 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
722 Ok(format!("{}{}", value, ORDINAL_SUFFIX))
723 }
724
725 /// `def to_year(self, val, longval=True): return self.to_cardinal(val)`
726 /// — `longval` is accepted and ignored; there is no century/pair logic, so
727 /// 1900 is "ଏକ ହଜାର ନଅ ଶହ", not "nineteen hundred".
728 fn to_year(&self, value: &BigInt) -> Result<String> {
729 self.to_cardinal(value)
730 }
731
732 /// Float/Decimal cardinal path.
733 ///
734 /// OR does **not** inherit `Num2Word_Base.to_cardinal_float`: it overrides
735 /// `to_cardinal` outright and lets a `float`/`Decimal` fall through it as
736 /// `str(number)` (§ "Out of scope" in the module header — now in scope).
737 /// So this override reproduces `to_cardinal`'s string branch rather than
738 /// delegating to `floatpath::default_to_cardinal_float`.
739 ///
740 /// `base.float2tuple` and its `< 0.01` f64-artefact heuristic are **never**
741 /// reached here — OR reads the value's `str()` digits directly. For a float
742 /// that string is `repr(value)`, reconstructed as `format!("{:.*}", prec,
743 /// value)`: Rust's fixed-precision formatting is correctly-rounded
744 /// ties-to-even, identical to Python's, and `prec` (the repr-derived
745 /// fractional-digit count carried on `FloatValue::Float`) makes it round to
746 /// exactly the digits `repr` chose — so `repr(2.675) == "2.675"` gives
747 /// fraction "675", not the `674999…` that float2tuple would surface.
748 ///
749 /// `precision_override` (the `precision=` kwarg) is **ignored**, matching
750 /// Python: OR's `to_cardinal` never reads `self.precision`, so setting it
751 /// has no effect on the output. The repr-derived precision is used instead.
752 fn to_cardinal_float(
753 &self,
754 value: &FloatValue,
755 _precision_override: Option<u32>,
756 ) -> Result<String> {
757 let n = match value {
758 // `str(float)` == repr(float), exponent form included — so 1e16
759 // reaches `int("1e+16")` and raises ValueError, as Python does.
760 FloatValue::Float { value, .. } => python_repr_f64(*value),
761 // `str(Decimal)` — scale preserved, `E+n` form for a positive
762 // exponent (`Decimal("1E+2")` raises too).
763 FloatValue::Decimal { value, .. } => python_str_decimal(value),
764 };
765 self.cardinal_from_repr(&n)
766 }
767
768 /// `to_cardinal(float/Decimal)` — the FULL entry. Python routes *every*
769 /// float/Decimal through the `str(number)` algorithm, so a whole value
770 /// keeps its visible point: `5.0` -> "ପାଞ୍ଚ ଦଶମିକ ଶୂନ୍ୟ", `-0.0` ->
771 /// "ଋଣ ଶୂନ୍ୟ ଦଶମିକ ଶୂନ୍ୟ", `Decimal("5.00")` -> "ପାଞ୍ଚ ଦଶମିକ
772 /// ଶୂନ୍ୟ ଶୂନ୍ୟ". The base default's whole-value shortcut must not fire.
773 fn cardinal_float_entry(
774 &self,
775 value: &FloatValue,
776 precision_override: Option<u32>,
777 ) -> Result<String> {
778 self.to_cardinal_float(value, precision_override)
779 }
780
781 /// `to_ordinal(float/Decimal)`: the float cardinal + ମ — the
782 /// cardinal being the string algorithm above, suffix glued on raw.
783 /// Exponent forms raise ValueError before the suffix is appended.
784 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
785 Ok(format!(
786 "{}{}",
787 self.to_cardinal_float(value, None)?,
788 ORDINAL_SUFFIX
789 ))
790 }
791
792 /// `to_ordinal_num(float/Decimal)`: `str(number) + ମ`. `repr_str` is
793 /// the binding's Python `str(value)`, so exponent forms echo verbatim:
794 /// `to_ordinal_num(1e16)` == "1e+16ମ".
795 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
796 Ok(format!("{}{}", repr_str, ORDINAL_SUFFIX))
797 }
798
799 /// `to_year(float/Decimal)`: OR's `to_year` forwards to `to_cardinal`,
800 /// which for a float/Decimal is the string algorithm above.
801 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
802 self.to_cardinal_float(value, None)
803 }
804
805 /// Base's `str_to_number` parses "Infinity" *successfully*; OR's
806 /// ValueError comes later, from `int("Infinity")` inside `to_cardinal`.
807 /// The Rust dispatcher hard-codes Base's OverflowError for
808 /// `ParsedNumber::Inf`, which OR never executes — so Inf parses punt to
809 /// the Python fallback, which reproduces every mode byte for byte. NaN
810 /// stays on the dispatcher path (its hard-coded ValueError matches).
811 /// Same shape as `lang_as.rs`.
812 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
813 let parsed = python_decimal_parse(s)?;
814 Ok(parsed)
815 }
816
817 /// `Decimal('Infinity')` / `-Infinity` reached OR. The parse succeeds
818 /// (Base's `Decimal(value)`), so the failure surfaces per mode inside OR's
819 /// own converters — **not** at Base's `int(Decimal('Infinity'))` (the trait
820 /// default `OverflowError`). OR's `to_cardinal` slices the sign textually
821 /// then does `int("Infinity")` → `ValueError`; `to_ordinal`
822 /// (`to_cardinal + ମ`) and `to_year` (`to_cardinal`) raise it first.
823 /// `to_ordinal_num` is the textual `str(number) + ମ`, so it echoes.
824 fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
825 match to {
826 "ordinal_num" => {
827 let token = if negative { "-Infinity" } else { "Infinity" };
828 Ok(format!("{}{}", token, ORDINAL_SUFFIX))
829 }
830 _ => Err(N2WError::Value(
831 "invalid literal for int() with base 10: 'Infinity'".to_string(),
832 )),
833 }
834 }
835
836 /// `Decimal('NaN')` reached OR. `int("NaN")` → `ValueError` on the
837 /// cardinal/ordinal/year paths; `to_ordinal_num` echoes "NaNମ".
838 fn nan_result(&self, to: &str) -> Result<String> {
839 match to {
840 "ordinal_num" => Ok(format!("NaN{}", ORDINAL_SUFFIX)),
841 _ => Err(N2WError::Value(
842 "invalid literal for int() with base 10: 'NaN'".to_string(),
843 )),
844 }
845 }
846
847 // ---- currency ----------------------------------------------------
848 //
849 // Not overridden, because Python does not override them either:
850 // * `currency_precision` — base's CURRENCY_PRECISION is `{}`, so
851 // `.get(code, 100)` is always 100. The trait default returns 100.
852 // * `currency_adjective` — base's CURRENCY_ADJECTIVES is `{}` -> None.
853 // * `money_verbose` / `cents_verbose` — base returns `to_cardinal(n)`;
854 // the trait default does the same and resolves to OR's `to_cardinal`.
855 // * `cents_terse` — base's `%0*d` width-from-divisor logic.
856 // * `to_cheque` — inherited wholesale from `Num2Word_Base`;
857 // `currency::default_to_cheque` is a faithful port of it, and needs
858 // only `lang_name` + `currency_forms` from here.
859
860 /// `self.__class__.__name__`, for `to_cheque`'s NotImplementedError:
861 /// `Currency code "JPY" not implemented for "Num2Word_OR"`.
862 fn lang_name(&self) -> &str {
863 "Num2Word_OR"
864 }
865
866 /// The strict `self.CURRENCY_FORMS[currency]` subscript.
867 ///
868 /// Only the inherited `to_cheque` reads this, and it must raise
869 /// `NotImplementedError` on a miss — so, unlike `to_currency`, there is
870 /// **no** INR fallback here. See quirk C1 and [`LangOr::forms_or_first`].
871 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
872 self.forms.get(code)
873 }
874
875 /// Port of `Num2Word_OR.pluralize`, which replaces base's abstract
876 /// `raise NotImplementedError`:
877 ///
878 /// ```text
879 /// if not forms:
880 /// return ""
881 /// return forms[0] if n == 1 else forms[-1]
882 /// ```
883 ///
884 /// Dead code in practice (quirk C4) — nothing in OR's currency path calls
885 /// it — but Python defines it, so it is ported rather than left to the
886 /// default that raises. Note `forms[-1]`, the *last* form, not `forms[1]`:
887 /// identical for OR's 2-tuples, but the two differ for a 3-form table and
888 /// this is the one Python wrote.
889 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
890 // `if not forms:` — an empty tuple is falsy.
891 let Some(last) = forms.last() else {
892 return Ok(String::new());
893 };
894 Ok(if n.is_one() {
895 forms[0].clone()
896 } else {
897 last.clone()
898 })
899 }
900
901 /// Port of `Num2Word_OR.to_currency`, which replaces the base version
902 /// entirely:
903 ///
904 /// ```text
905 /// is_negative = val < 0
906 /// val = abs(val)
907 /// parts = str(val).split(".")
908 /// left = int(parts[0]) if parts[0] else 0
909 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
910 /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
911 /// result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
912 /// if cents and right:
913 /// result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
914 /// if is_negative:
915 /// result = self.negword + result
916 /// return result.strip()
917 /// ```
918 ///
919 /// Everything the base version does that this one does not — `pluralize`,
920 /// `CURRENCY_PRECISION`, `adjective`, the `isinstance(val, int)` cents
921 /// branch — is covered by quirks C1..C6 in the module docs.
922 fn to_currency(
923 &self,
924 val: &CurrencyValue,
925 currency: &str,
926 cents: bool,
927 separator: Option<&str>,
928 // In the Python signature, never read — quirk C3.
929 _adjective: bool,
930 ) -> Result<String> {
931 // Trait now hands us None when the caller omitted separator=;
932 // resolve it to this language's own default before the ported body.
933 let separator = separator.unwrap_or(self.default_separator());
934 // `is_negative = val < 0` is evaluated *before* `val = abs(val)`.
935 let is_negative = val.is_negative();
936 let (left, right) = split_currency_parts(val);
937
938 // The lenient lookup: an unknown code yields INR, not an error (C1).
939 let forms = self.forms_or_first(currency);
940
941 // `cr1[1] if left != 1 else cr1[0]`. All three OR tables carry exactly
942 // two forms, so the fixed indices cannot go out of range — and both
943 // hold the same word anyway (C5).
944 let unit = if left.is_one() {
945 &forms.unit[0]
946 } else {
947 &forms.unit[1]
948 };
949 let mut result = format!("{} {}", self.int_to_word(&left), unit);
950
951 // `if cents and right:` — a zero `right` is falsy and skips the entire
952 // segment, however the value was written (C6).
953 if cents && !right.is_zero() {
954 let subunit = if right.is_one() {
955 &forms.subunit[0]
956 } else {
957 &forms.subunit[1]
958 };
959 result.push_str(separator);
960 result.push_str(&self.int_to_word(&right));
961 result.push(' ');
962 result.push_str(subunit);
963 }
964
965 if is_negative {
966 // `self.negword + result` — NEGWORD already ends in a space, so
967 // this is not the `"%s " % negword.strip()` form base uses.
968 result = format!("{}{}", NEGWORD, result);
969 }
970
971 // Python's trailing `.strip()`. A no-op in practice: `int_to_word`
972 // never returns "" and the string always ends in a currency word.
973 // Mirrored anyway.
974 Ok(result.trim().to_string())
975 }
976}
977
978#[cfg(test)]
979mod float_tests {
980 use super::*;
981 use crate::base::Lang;
982 use std::str::FromStr;
983
984 fn f(v: f64, p: u32) -> FloatValue {
985 FloatValue::Float { value: v, precision: p }
986 }
987 fn d(s: &str, p: u32) -> FloatValue {
988 FloatValue::Decimal { value: BigDecimal::from_str(s).unwrap(), precision: p }
989 }
990
991 #[test]
992 fn corpus_float() {
993 let l = LangOr::new();
994 let cases: &[(FloatValue, &str)] = &[
995 (f(0.0, 1), "ଶୂନ୍ୟ ଦଶମିକ ଶୂନ୍ୟ"),
996 (f(0.5, 1), "ଶୂନ୍ୟ ଦଶମିକ ପାଞ୍ଚ"),
997 (f(1.0, 1), "ଏକ ଦଶମିକ ଶୂନ୍ୟ"),
998 (f(1.5, 1), "ଏକ ଦଶମିକ ପାଞ୍ଚ"),
999 (f(2.25, 2), "ଦୁଇ ଦଶମିକ ଦୁଇ ପାଞ୍ଚ"),
1000 (f(3.14, 2), "ତିନି ଦଶମିକ ଏକ ଚାରି"),
1001 (f(0.01, 2), "ଶୂନ୍ୟ ଦଶମିକ ଶୂନ୍ୟ ଏକ"),
1002 (f(0.1, 1), "ଶୂନ୍ୟ ଦଶମିକ ଏକ"),
1003 (f(0.99, 2), "ଶୂନ୍ୟ ଦଶମିକ ନଅ ନଅ"),
1004 (f(1.01, 2), "ଏକ ଦଶମିକ ଶୂନ୍ୟ ଏକ"),
1005 (f(12.34, 2), "ବାର ଦଶମିକ ତିନି ଚାରି"),
1006 (f(99.99, 2), "ନବେ ଓ ନଅ ଦଶମିକ ନଅ ନଅ"),
1007 (f(100.5, 1), "ଏକ ଶହ ଦଶମିକ ପାଞ୍ଚ"),
1008 (f(1234.56, 2), "ଏକ ହଜାର ଦୁଇ ଶହ ଓ ତିରିଶ ଓ ଚାରି ଦଶମିକ ପାଞ୍ଚ ଛଅ"),
1009 (f(-0.5, 1), "ଋଣ ଶୂନ୍ୟ ଦଶମିକ ପାଞ୍ଚ"),
1010 (f(-1.5, 1), "ଋଣ ଏକ ଦଶମିକ ପାଞ୍ଚ"),
1011 (f(-12.34, 2), "ଋଣ ବାର ଦଶମିକ ତିନି ଚାରି"),
1012 (f(1.005, 3), "ଏକ ଦଶମିକ ଶୂନ୍ୟ ଶୂନ୍ୟ ପାଞ୍ଚ"),
1013 (f(2.675, 3), "ଦୁଇ ଦଶମିକ ଛଅ ସାତ ପାଞ୍ଚ"),
1014 ];
1015 for (v, want) in cases {
1016 let got = l.to_cardinal_float(v, None).unwrap();
1017 assert_eq!(&got, want, "float {:?}", v);
1018 }
1019 }
1020
1021 #[test]
1022 fn corpus_decimal() {
1023 let l = LangOr::new();
1024 let cases: &[(FloatValue, &str)] = &[
1025 (d("0.01", 2), "ଶୂନ୍ୟ ଦଶମିକ ଶୂନ୍ୟ ଏକ"),
1026 (d("1.10", 2), "ଏକ ଦଶମିକ ଏକ ଶୂନ୍ୟ"),
1027 (d("12.345", 3), "ବାର ଦଶମିକ ତିନି ଚାରି ପାଞ୍ଚ"),
1028 (d("98746251323029.99", 2), "98746251323029 ଦଶମିକ ନଅ ନଅ"),
1029 (d("0.001", 3), "ଶୂନ୍ୟ ଦଶମିକ ଶୂନ୍ୟ ଶୂନ୍ୟ ଏକ"),
1030 ];
1031 for (v, want) in cases {
1032 let got = l.to_cardinal_float(v, None).unwrap();
1033 assert_eq!(&got, want, "decimal {:?}", v);
1034 }
1035 }
1036}