num2words2_core/lang_ta.rs
1//! Port of `lang_TA.py` (Tamil).
2//!
3//! Shape: **self-contained**. `Num2Word_TA` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python's
5//! `Num2Word_Base.__init__` skips the `self.cards = OrderedDict()` branch
6//! entirely and never sets `MAXVAL`. All four in-scope methods
7//! (`to_cardinal`, `to_ordinal`, `to_ordinal_num`, `to_year`) are overridden
8//! outright, so `cards`/`maxval`/`merge` stay at their trait defaults here and
9//! **nothing overflows** — every integer, however large, renders.
10//!
11//! The algorithm is the Indian numbering system: crore (10^7), lakh (10^5),
12//! thousand, then a special hundreds table and a teens table, joined with
13//! single spaces by `" ".join(parts)`.
14//!
15//! Nothing is inherited from `Num2Word_Base` that matters: TA overrides every
16//! in-scope entry point, and `title()` is never reached (TA's `to_cardinal`
17//! does not call it), so `is_title` stays irrelevant.
18//!
19//! # Faithfully reproduced Python quirks
20//!
21//! This is a port, not a rewrite. All of the following look wrong but are
22//! exactly what Python emits, verified against the frozen corpus:
23//!
24//! 1. **`to_ordinal` glues its suffix on with no separator.** For any `n` not
25//! in the `ordinals` table (i.e. outside 1..=10), `_int_to_ordinal` returns
26//! `self._int_to_cardinal(n) + "ஆவது"` — no space. So `to_ordinal(11)` is
27//! "பதினொன்றுஆவது" and `to_ordinal(1000)` is "ஆயிரம்ஆவது". Preserved.
28//! 2. **`to_ordinal` accepts negatives and produces nonsense**, because it
29//! only special-cases `n == 0` and the 1..=10 table before falling through
30//! to the cardinal path: `to_ordinal(-42)` == "கழித்தல் நாற்பது இரண்டுஆவது"
31//! ("minus forty two-th"). Preserved.
32//! 3. **The thousands scale word takes no "ஒரு"**, unlike lakh and crore:
33//! 1000 renders as bare "ஆயிரம்" while 100000 is "ஒரு இலட்சம்" and 10000000
34//! is "ஒரு கோடி". Asymmetric in Python; preserved.
35//! 4. **Crore stacks on itself past 10^14** rather than promoting to a larger
36//! scale word, because `_int_to_word` recurses on the crore quotient:
37//! 10^15 == "பத்து கோடி கோடி", 10^21 == "ஒரு கோடி கோடி கோடி". Preserved.
38//! 5. **`self.scale` is dead.** The Python `__init__` builds
39//! `{1000: "ஆயிரம்", 100000: "இலட்சம்", 10000000: "கோடி",
40//! 1000000000000: "டிரில்லியன்"}` and never reads it — the scale words are
41//! hardcoded as string literals inside `_int_to_word`. In particular
42//! "டிரில்லியன்" (trillion) is **unreachable**: 10^12 renders via the crore
43//! path as "ஒரு இலட்சம் கோடி". Not ported (it has no observable effect).
44//! 6. **`_setup` is a latent `AttributeError`.** TA defines
45//! `def _setup(self): super()._setup()`, but `Num2Word_Base` has no
46//! `_setup` (it has `setup`, which base's `__init__` calls). Calling
47//! `_setup()` would raise; nothing ever does. Not ported.
48//! 7. **The `else` arm of the hundreds branch is dead code.** Python's
49//! `self.ones[n // 100] + " நூறு"` fallback can never fire: by the time
50//! control reaches it, `n < 1000` always holds (every larger scale has
51//! already been stripped with `n %= ...`), so `(n // 100) * 100` is always
52//! one of the nine keys in `hundreds_special`. Reproduced anyway in
53//! [`int_to_word`] for structural fidelity — see the comment there.
54//!
55//! # Currency
56//!
57//! TA overrides `to_currency` **outright** and shares almost nothing with
58//! `Num2Word_Base.to_currency`. It inherits `to_cheque` unchanged.
59//!
60//! `CURRENCY_FORMS` is declared in TA's own class body, so — unlike the 16
61//! classes that read `Num2Word_EUR.CURRENCY_FORMS` — it is untouched by the
62//! `Num2Word_EN.__init__` mutation that rewrites EUR/GBP and injects ~24 extra
63//! ISO codes into that shared dict. Confirmed against the live interpreter:
64//! `Num2Word_TA.CURRENCY_FORMS is Num2Word_EN.CURRENCY_FORMS` is `False`. The
65//! seven codes in [`build_currency_forms`] are the whole table, which is why
66//! JPY/KWD/BHD/CNY/CHF legitimately raise NotImplementedError.
67//!
68//! `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both `{}`, so the trait
69//! defaults (100, `None`) already match and are not overridden.
70//!
71//! ## Faithfully reproduced Python quirks (currency)
72//!
73//! 8. **The signature is `to_currency(n, currency="INR")`** — there is no
74//! `cents`, `separator` or `adjective` parameter at all, so all three are
75//! ignored here. (Passing any of them to the Python converter is a
76//! `TypeError`; see the crate-level note in the port report — 19 of 172
77//! converters have such a reduced signature and the dispatcher forwards the
78//! kwargs regardless.)
79//! 9. **There is no `has_decimal` guard.** The cents segment is gated on
80//! `right > 0` alone, so a whole float prints no subunit: `1.0` is
81//! "ஒன்று யூரோ" where Base would print a zero-cents segment. `Decimal("5.00")`
82//! likewise renders "ஐந்து ரூபாய்". `has_decimal` is therefore ignored.
83//! 10. **`pluralize` is never called.** TA takes `cr_major[0]` / `cr_minor[0]`
84//! directly, which is why it never trips Base's abstract `pluralize`. Both
85//! forms of every entry are identical anyway — Tamil does not inflect these
86//! nouns for number — but the arity of 2 is kept as the ported data.
87//! 11. **`CURRENCY_PRECISION` is never consulted by `to_currency`.** The
88//! divisor is hardcoded 100 in the `has_fractional_cents` test, and
89//! `parse_currency_parts` is called with no `divisor=` argument so it takes
90//! currency.py's default of 100. TA thus has no 3-decimal or 0-decimal
91//! path whatsoever. Moot in practice, since its `CURRENCY_PRECISION` is
92//! empty and every code resolves to 100 regardless.
93//! 12. **`keep_precision` skips the ROUND_HALF_UP quantize**, so a value with
94//! fractional cents *truncates* rather than rounds: `2.675` is
95//! "இரண்டு ரூபாய் அறுபது ஏழு பைசா" (67 paise, not 68).
96//! 13. **Past 10^26 TA stops speaking Tamil** and hands back the bare number —
97//! see [`LangTa::to_currency`] for the derivation. Reproduced for the int
98//! path only; the float path cannot be reproduced from this file.
99//!
100//! # Error behaviour
101//!
102//! For **integer** input none of the four in-scope methods can raise: there is
103//! no overflow check, no table lookup that can miss, and no `int()` parse.
104//! Every method returns `Ok`. The `try/except BaseException` wrappers around
105//! Python's `to_cardinal`/`to_ordinal`/`to_ordinal_num` are a float-truncation
106//! fallback (e.g. `to_cardinal(12.34)` raises `KeyError` on `teens[12.34]`,
107//! is caught, and retries as `int(12.34)` → "பன்னிரண்டு"); floats are out of
108//! scope, so on the integer path those handlers are unreachable. `to_year` has
109//! no such wrapper at all.
110//!
111//! # State
112//!
113//! None. Every method is a pure function of its argument — no flag is set in
114//! one call and consumed by another.
115
116use crate::base::{Lang, N2WError, Result};
117use crate::currency::{parse_currency_parts, CurrencyForms, CurrencyValue};
118use crate::floatpath::FloatValue;
119use bigdecimal::BigDecimal;
120use num_bigint::BigInt;
121use num_integer::Integer;
122use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
123use std::collections::HashMap;
124
125/// "பூஜ்ஜியம்" — the zero word, returned by both the cardinal and ordinal paths.
126const ZERO: &str = "பூஜ்ஜியம்";
127
128/// `self.negword`. Note the **trailing space**: Python concatenates it
129/// directly (`self.negword + self._int_to_word(-n)`) rather than joining, so
130/// the space is load-bearing. Do not trim it.
131const NEGWORD: &str = "கழித்தல் ";
132
133/// Suffix appended by `_int_to_ordinal` — **without** a separating space.
134const ORDINAL_SUFFIX: &str = "ஆவது";
135
136/// Suffix appended by `to_ordinal_num`, a different word from [`ORDINAL_SUFFIX`].
137const ORDINAL_NUM_SUFFIX: &str = "-வது";
138
139/// The year path's thousand word, distinct from the cardinal "ஆயிரம்".
140const YEAR_THOUSAND: &str = "ஆயிரத்து";
141
142/// `self.ones`; index 0 is the empty string and is never emitted (guarded by
143/// `if n > 0`).
144const ONES: [&str; 10] = [
145 "",
146 "ஒன்று",
147 "இரண்டு",
148 "மூன்று",
149 "நான்கு",
150 "ஐந்து",
151 "ஆறு",
152 "ஏழு",
153 "எட்டு",
154 "ஒன்பது",
155];
156
157/// `self.tens`; index 0 is the empty string and is never emitted (guarded by
158/// `if n >= 10`, which makes `n // 10 >= 1`).
159const TENS: [&str; 10] = [
160 "",
161 "பத்து",
162 "இருபது",
163 "முப்பது",
164 "நாற்பது",
165 "ஐம்பது",
166 "அறுபது",
167 "எழுபது",
168 "எண்பது",
169 "தொண்ணூறு",
170];
171
172/// `self.teens`, a dict keyed 11..=19 in Python. Indexed here by `n - 11`.
173const TEENS: [&str; 9] = [
174 "பதினொன்று",
175 "பன்னிரண்டு",
176 "பதின்மூன்று",
177 "பதினான்கு",
178 "பதினைந்து",
179 "பதினாறு",
180 "பதினேழு",
181 "பதினெட்டு",
182 "பத்தொன்பது",
183];
184
185/// `self.hundreds_special`, a dict keyed 100..=900 in Python. Indexed here by
186/// `n // 100 - 1`. Note 900 is "தொள்ளாயிரம்", which is morphologically a
187/// *thousand* word, not a hundred word — that is what Python ships.
188const HUNDREDS_SPECIAL: [&str; 9] = [
189 "நூறு",
190 "இருநூறு",
191 "முன்னூறு",
192 "நானூறு",
193 "ஐநூறு",
194 "அறுநூறு",
195 "எழுநூறு",
196 "எண்ணூறு",
197 "தொள்ளாயிரம்",
198];
199
200/// `self.ordinals`, a dict keyed 1..=10 in Python. Indexed here by `n - 1`.
201/// Every other value falls through to the cardinal + "ஆவது" path.
202const ORDINALS: [&str; 10] = [
203 "முதல்",
204 "இரண்டாம்",
205 "மூன்றாம்",
206 "நான்காம்",
207 "ஐந்தாம்",
208 "ஆறாம்",
209 "ஏழாம்",
210 "எட்டாம்",
211 "ஒன்பதாம்",
212 "பத்தாம்",
213];
214
215fn crore() -> BigInt {
216 BigInt::from(10_000_000u32)
217}
218fn lakh() -> BigInt {
219 BigInt::from(100_000u32)
220}
221fn thousand() -> BigInt {
222 BigInt::from(1_000u32)
223}
224fn hundred() -> BigInt {
225 BigInt::from(100u32)
226}
227
228/// Python's `_int_to_word`. Expects a **non-negative** `n`: the sign is peeled
229/// off by [`int_to_cardinal`] before this is ever called, and every recursive
230/// call passes a positive quotient.
231fn int_to_word(n: &BigInt) -> String {
232 if n.is_zero() {
233 return ZERO.to_string();
234 }
235
236 let mut n = n.clone();
237 let mut parts: Vec<String> = Vec::new();
238
239 // Crores (10^7). Recurses on the quotient, which is why crore stacks on
240 // itself for very large values (10^15 -> "பத்து கோடி கோடி").
241 let c = crore();
242 if n >= c {
243 let crores = n.div_floor(&c);
244 if crores.is_one() {
245 parts.push("ஒரு கோடி".to_string());
246 } else {
247 parts.push(format!("{} கோடி", int_to_word(&crores)));
248 }
249 n = n.mod_floor(&c);
250 }
251
252 // Lakhs (10^5).
253 let l = lakh();
254 if n >= l {
255 let lakhs = n.div_floor(&l);
256 if lakhs.is_one() {
257 parts.push("ஒரு இலட்சம்".to_string());
258 } else {
259 parts.push(format!("{} இலட்சம்", int_to_word(&lakhs)));
260 }
261 n = n.mod_floor(&l);
262 }
263
264 // Thousands. Unlike lakh/crore above, the `== 1` case emits a bare
265 // "ஆயிரம்" with no "ஒரு" — asymmetric in Python, preserved here.
266 let t = thousand();
267 if n >= t {
268 let thousands = n.div_floor(&t);
269 if thousands.is_one() {
270 parts.push("ஆயிரம்".to_string());
271 } else {
272 parts.push(format!("{} ஆயிரம்", int_to_word(&thousands)));
273 }
274 n = n.mod_floor(&t);
275 }
276
277 // Hundreds. `n < 1000` is guaranteed here (every larger scale was stripped
278 // above), so `h` is always 1..=9 and the `hundreds_special` lookup always
279 // hits. Python's `else: self.ones[n // 100] + " நூறு"` fallback is
280 // therefore dead code; it is kept below to mirror the structure, and the
281 // `h_idx >= 9` guard is the exact condition under which Python would have
282 // taken it.
283 let h = hundred();
284 if n >= h {
285 let hundreds = n.div_floor(&h);
286 // Safe: `hundreds` is 1..=9 by the argument above.
287 let h_idx = hundreds.to_usize().unwrap_or(usize::MAX);
288 if (1..=9).contains(&h_idx) {
289 parts.push(HUNDREDS_SPECIAL[h_idx - 1].to_string());
290 } else {
291 // Unreachable. Python: self.ones[n // 100] + " நூறு".
292 parts.push(format!("{} நூறு", ONES[h_idx % 10]));
293 }
294 n = n.mod_floor(&h);
295 }
296
297 // `n < 100` is guaranteed here, so the cast is total.
298 let mut small = n.to_u32().unwrap_or(0);
299
300 if 10 < small && small < 20 {
301 parts.push(TEENS[(small - 11) as usize].to_string());
302 } else {
303 if small >= 10 {
304 let tens_val = small / 10;
305 parts.push(TENS[tens_val as usize].to_string());
306 small %= 10;
307 }
308 if small > 0 {
309 parts.push(ONES[small as usize].to_string());
310 }
311 }
312
313 parts.join(" ")
314}
315
316/// Python's `_int_to_cardinal`.
317fn int_to_cardinal(n: &BigInt) -> String {
318 if n.is_zero() {
319 return ZERO.to_string();
320 }
321 if n.sign() == num_bigint::Sign::Minus {
322 // NEGWORD carries its own trailing space — plain concatenation.
323 return format!("{}{}", NEGWORD, int_to_word(&(-n)));
324 }
325 int_to_word(n)
326}
327
328/// Python's `_int_to_ordinal`.
329fn int_to_ordinal(n: &BigInt) -> String {
330 if n.is_zero() {
331 return ZERO.to_string();
332 }
333 // `n in self.ordinals` — keys 1..=10 only, so negatives never match.
334 if let Some(i) = n.to_usize() {
335 if (1..=10).contains(&i) {
336 return ORDINALS[i - 1].to_string();
337 }
338 }
339 // Note: no separator before the suffix. This is Python's behaviour.
340 format!("{}{}", int_to_cardinal(n), ORDINAL_SUFFIX)
341}
342
343/// Python's `self.to_cardinal(float(right))`, the fractional-cents branch.
344///
345/// TA's `to_cardinal` is
346/// `try: self._int_to_cardinal(n) except BaseException: self._int_to_cardinal(int(n))`,
347/// and handing it a float always lands on one of two outcomes, both equal to
348/// `_int_to_cardinal(int(n))`:
349///
350/// * the float has a fractional part, so `_int_to_word` reaches
351/// `self.tens[n // 10]` or `self.ones[n]` (TypeError: list indices must be
352/// integers) or `self.teens[n]` (KeyError), and the handler retries with
353/// `int(n)`. The fraction always survives to one of those lookups, because
354/// every scale is stripped with `n %= ...`, which preserves it; or
355/// * the float is whole and the lookup happens to succeed — `self.teens[15.0]`
356/// and `self.hundreds_special[100.0]` both hit, since `15.0 == 15` and
357/// `hash(15.0) == hash(15)` — in which case it returns exactly the string
358/// the int would have produced.
359///
360/// So this collapses to a truncation. The `float()` round-trip is kept because
361/// it is observable: `int(Decimal("2.9999999999999999999"))` is 2, but
362/// `int(float(Decimal("2.9999999999999999999")))` is 3, and Python takes the
363/// latter. Likewise `1.999999999999999999999` yields 99.9999999999999999999
364/// paise, which `float()` rounds to exactly `100.0` -> "நூறு".
365///
366/// `right` is `fraction * 100` with `fraction` in `[0, 1)`, so the value is in
367/// `[0, 100)` and the `as i64` cast cannot overflow.
368fn cardinal_from_fractional_cents(right: &BigDecimal) -> String {
369 // `to_f64` formats the coefficient and defers to Rust's float parser, so
370 // it is correctly rounded exactly as Python's `float(Decimal)` is.
371 let truncated = right.to_f64().map(f64::trunc).unwrap_or(0.0);
372 int_to_cardinal(&BigInt::from(truncated as i64))
373}
374
375/// `Num2Word_TA.CURRENCY_FORMS`, verbatim from TA's own class body.
376///
377/// Each entry carries two identical forms because Tamil does not inflect these
378/// nouns for number. The arity of 2 mirrors the Python tuples and is kept even
379/// though TA only ever reads index 0 (`cr_major[0]` / `cr_minor[0]`) and
380/// `to_cheque` only ever reads index -1.
381fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
382 const RUPEE: [&str; 2] = ["ரூபாய்", "ரூபாய்"];
383 const DOLLAR: [&str; 2] = ["டாலர்", "டாலர்"];
384 const CENT: [&str; 2] = ["சென்ட்", "சென்ட்"];
385
386 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
387 m.insert("INR", CurrencyForms::new(&RUPEE, &["பைசா", "பைசா"]));
388 m.insert("LKR", CurrencyForms::new(&RUPEE, &["சதம்", "சதம்"]));
389 m.insert("USD", CurrencyForms::new(&DOLLAR, &CENT));
390 m.insert("EUR", CurrencyForms::new(&["யூரோ", "யூரோ"], &CENT));
391 m.insert("GBP", CurrencyForms::new(&["பவுண்ட்", "பவுண்ட்"], &["பென்னி", "பென்னி"]));
392 m.insert("SGD", CurrencyForms::new(&DOLLAR, &CENT));
393 m.insert("MYR", CurrencyForms::new(&["ரிங்கிட்", "ரிங்கிட்"], &["சென்", "சென்"]));
394 m
395}
396
397pub struct LangTa {
398 currency_forms: HashMap<&'static str, CurrencyForms>,
399 /// `10**26`. See [`LangTa::to_currency`] — the magnitude at which Python's
400 /// `(Decimal(str(n)) * 100) % 1` overflows the decimal context and TA's
401 /// blanket `except BaseException` takes over.
402 decimal_ctx_limit: BigInt,
403}
404
405impl LangTa {
406 pub fn new() -> Self {
407 LangTa {
408 // Built once here, never per call.
409 currency_forms: build_currency_forms(),
410 decimal_ctx_limit: BigInt::from(10u8).pow(26),
411 }
412 }
413}
414
415impl Default for LangTa {
416 fn default() -> Self {
417 Self::new()
418 }
419}
420
421impl Lang for LangTa {
422 /// This language's own `to_currency(currency=...)` default,
423 /// read from the live Python signature. Only 44 of 156 use EUR.
424 fn default_currency(&self) -> &str {
425 "INR"
426 }
427
428 fn negword(&self) -> &str {
429 NEGWORD
430 }
431
432 fn pointword(&self) -> &str {
433 "புள்ளி"
434 }
435
436 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
437 Ok(int_to_cardinal(value))
438 }
439
440 /// TA's float/Decimal cardinal path — **pure truncation toward zero**.
441 ///
442 /// TA overrides `to_cardinal` (not `to_cardinal_float`), and its whole body
443 /// is:
444 ///
445 /// ```python
446 /// def to_cardinal(self, n):
447 /// try:
448 /// if isinstance(n, str): n = int(n)
449 /// return self._int_to_cardinal(n)
450 /// except BaseException:
451 /// return self._int_to_cardinal(int(n))
452 /// ```
453 ///
454 /// Handed a **non-integer** `float`/`Decimal`, `_int_to_word` strips every
455 /// scale with `n %= ...` (which preserves the fraction) and then always
456 /// reaches a `self.tens[...]`/`self.ones[...]` list index or a
457 /// `self.teens[...]` dict key with a non-integer value, raising TypeError or
458 /// KeyError. The blanket `except BaseException` retries with `int(n)`, which
459 /// truncates toward zero. A **whole** float/Decimal either raises the same
460 /// way (`self.ones[1.0]` is still a TypeError) or succeeds and returns the
461 /// identical string, because the lookups that can succeed are int-equal
462 /// (`15.0 == 15`, `hash(15.0) == hash(15)`, `hundreds_special[100.0]` hits).
463 /// So the method collapses in every case to `_int_to_cardinal(int(n))`.
464 ///
465 /// Consequences, all corpus-verified:
466 /// * the fraction is dropped entirely — `pointword` ("புள்ளி") is never
467 /// emitted (`12.34` -> "பன்னிரண்டு", `99.99` -> "தொண்ணூறு ஒன்பது");
468 /// * a value that truncates to 0 loses its sign, because `int(-0.5) == 0`
469 /// and `_int_to_cardinal(0)` prepends no negword (`-0.5` -> "பூஜ்ஜியம்",
470 /// whereas `-1.5` -> "கழித்தல் ஒன்று").
471 ///
472 /// The `Float`/`Decimal` split is load-bearing: the cardinal path applies
473 /// `int()` directly to the value (no `float()` round-trip — unlike the
474 /// currency fractional-cents branch), so a Decimal like
475 /// `98746251323029.99` truncates exactly to `98746251323029`. Routing that
476 /// through f64 first could truncate to a different integer.
477 ///
478 /// `precision_override` is ignored: TA's `to_cardinal` accepts no
479 /// `precision` argument, and a truncation cannot depend on one.
480 fn to_cardinal_float(
481 &self,
482 value: &FloatValue,
483 _precision_override: Option<u32>,
484 ) -> Result<String> {
485 let truncated = match value {
486 // int(float): `.trunc()` does the toward-zero part; from_f64 of an
487 // already-integral f64 is exact at any magnitude (and reproduces
488 // the f64 artefacts, since the raw double crossed the boundary).
489 // trunc(-0.5) == -0.0 -> BigInt 0, so the sign is dropped, matching
490 // Python's int(-0.5) == 0.
491 FloatValue::Float { value, .. } => {
492 BigInt::from_f64(value.trunc()).unwrap_or_else(BigInt::zero)
493 }
494 // int(Decimal): with_scale(0) truncates toward zero, exactly as
495 // Decimal.__int__ does — no float round-trip on this path.
496 FloatValue::Decimal { value, .. } => {
497 value.with_scale(0).as_bigint_and_exponent().0
498 }
499 };
500 Ok(int_to_cardinal(&truncated))
501 }
502
503 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
504 Ok(int_to_ordinal(value))
505 }
506
507 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
508 // Python: str(n) + "-வது". No ordinals table, no sign handling —
509 // to_ordinal_num(-1) == "-1-வது".
510 Ok(format!("{}{}", value, ORDINAL_NUM_SUFFIX))
511 }
512
513 fn to_year(&self, value: &BigInt) -> Result<String> {
514 let t = thousand();
515
516 // n < 1000: plain cardinal. This arm also swallows every negative
517 // year, so `to_year(-500)` == "கழித்தல் ஐநூறு".
518 if value < &t {
519 return Ok(int_to_cardinal(value));
520 }
521
522 let remainder = value.mod_floor(&t);
523
524 if value < &BigInt::from(2000u32) {
525 // Python computes `thousands` here and never uses it: the word is
526 // the bare literal "ஆயிரத்து".
527 let mut result = YEAR_THOUSAND.to_string();
528 if is_positive(&remainder) {
529 result.push(' ');
530 result.push_str(&int_to_cardinal(&remainder));
531 }
532 return Ok(result);
533 }
534
535 let thousands = value.div_floor(&t);
536 let mut result = format!("{} {}", int_to_cardinal(&thousands), YEAR_THOUSAND);
537 if is_positive(&remainder) {
538 result.push(' ');
539 result.push_str(&int_to_cardinal(&remainder));
540 }
541 Ok(result)
542 }
543
544 /// `to_ordinal(float/Decimal)` — collapses to
545 /// `_int_to_ordinal(int(n))`, exactly like the cardinal path.
546 ///
547 /// Python's `to_ordinal` wraps `_int_to_ordinal(n)` in
548 /// `except BaseException: return self._int_to_ordinal(int(n))`. A whole
549 /// float/Decimal either hits a hash-equal dict key (`5.0 in
550 /// self.ordinals`, `teens[11.0]`, `hundreds_special[100.0]`) and returns
551 /// the identical string, or raises on a list index
552 /// (`self.tens[2.0]` → TypeError) and retries with `int(n)` — same
553 /// string either way. A fractional value always raises somewhere
554 /// (`ordinals`/`teens` miss, or list index) and retries with the
555 /// truncation: `to_ordinal(1.5)` → `_int_to_ordinal(1)` → "முதல்",
556 /// `to_ordinal(-1.5)` → `_int_to_ordinal(-1)` → "கழித்தல் ஒன்றுஆவது".
557 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
558 Ok(int_to_ordinal(&ta_trunc(value)))
559 }
560
561 /// `to_ordinal_num(float/Decimal)` — `str(n) + "-வது"` for every numeric
562 /// input (the `int(n)` in the try-block only fires for `str` input, which
563 /// never reaches this hook). `repr_str` is Python's `str(value)`, so
564 /// `5.00` keeps its trailing zeros and `1E+2` its exponent form.
565 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
566 Ok(format!("{}{}", repr_str, ORDINAL_NUM_SUFFIX))
567 }
568
569 /// `to_year(float/Decimal)` — **no** try/except wrapper in Python, so
570 /// the non-int value walks `_int_to_cardinal`/`_int_to_word` live and
571 /// the list-index lookups raise TypeError uncaught. Only values whose
572 /// residual digits are absorbed entirely by dict lookups
573 /// (`hundreds_special`, `teens`) or by `== 1` scale quotients survive:
574 /// `to_year(1000.0)` → "ஆயிரத்து", `to_year(1e20)` →
575 /// "ஆயிரம் கோடி கோடி ஆயிரத்து", but `to_year(5.0)`,
576 /// `to_year(1234.0)`, `to_year(-3.0)` all raise TypeError on
577 /// `self.ones[...]`/`self.tens[...]`. See [`ta_year_sim`].
578 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
579 let (x, is_decimal) = match value {
580 FloatValue::Float { value, .. } => (
581 BigDecimal::from_f64(*value).ok_or_else(|| {
582 // NaN/inf never reach the year hook (the shim keeps
583 // non-finite input on the Python side).
584 N2WError::Value(format!("cannot convert {} to Decimal", value))
585 })?,
586 false,
587 ),
588 FloatValue::Decimal { value, .. } => (value.clone(), true),
589 };
590 ta_year_sim(&x, is_decimal)
591 }
592
593 // ---- currency -------------------------------------------------------
594 //
595 // TA overrides `to_currency` wholesale and inherits `to_cheque` from
596 // `Num2Word_Base` unchanged, so only the class name, the forms table and
597 // `to_currency` itself are language-specific here.
598 //
599 // Deliberately NOT overridden, because the trait defaults already match:
600 // * `currency_precision` — TA's CURRENCY_PRECISION is `{}`, so
601 // `.get(code, 100)` is 100 for every code, which is the default.
602 // * `currency_adjective` — CURRENCY_ADJECTIVES is `{}` -> None.
603 // * `pluralize` — TA never calls it; Base's is abstract and the default
604 // raises NotImplemented, which is the faithful behaviour if reached.
605 // * `money_verbose` — Base's `_money_verbose` is `self.to_cardinal(n)`,
606 // and the default routes to `to_cardinal` -> `int_to_cardinal`. That
607 // is what `to_cheque` needs.
608 // * `cardinal_from_decimal` — unreachable: TA's own `to_currency` never
609 // delegates to `default_to_currency`, and its fractional-cents branch
610 // goes through [`cardinal_from_fractional_cents`] instead.
611
612 fn lang_name(&self) -> &str {
613 "Num2Word_TA"
614 }
615
616 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
617 self.currency_forms.get(code)
618 }
619
620 /// Python's `Num2Word_TA.to_currency(n, currency="INR")`.
621 ///
622 /// `cents`, `separator` and `adjective` do not exist on the Python
623 /// signature, so they are ignored (quirk 8). `has_decimal` is ignored too:
624 /// the cents segment is gated on `right > 0` alone (quirk 9).
625 fn to_currency(
626 &self,
627 val: &CurrencyValue,
628 currency: &str,
629 _cents: bool,
630 _separator: Option<&str>,
631 _adjective: bool,
632 ) -> Result<String> {
633 // Python opens the method with
634 //
635 // decimal_val = Decimal(str(n))
636 // has_fractional_cents = (decimal_val * 100) % 1 != 0
637 //
638 // inside a `try:` whose `except BaseException` returns
639 // `str(n) + " " + currency`. For |n| >= 10**26 that arithmetic
640 // *raises*: `decimal_val * 100` is then >= 10**28, and decimal's
641 // default context precision is 28, so the `% 1` remainder — whose
642 // integer quotient would need 29+ digits — signals InvalidOperation.
643 // The handler swallows it and TA hands back the bare number:
644 //
645 // to_currency(10**26 - 1, "INR") == "தொண்ணூறு ஒன்பது ஆயிரம் ..."
646 // to_currency(10**26, "INR") == "100000000000000000000000000 INR"
647 //
648 // The threshold is exact for ints: below it the product needs at most
649 // 28 digits and is computed exactly, at or above it the product is
650 // rounded to 28 significant digits and still lands >= 10**28.
651 //
652 // This runs *before* the CURRENCY_FORMS lookup because Python's does
653 // too — the raise happens on the first line of the `try`, so the
654 // fallback beats the NotImplementedError:
655 //
656 // to_currency(10**30, "JPY") == "1000000000000000000000000000000 JPY"
657 //
658 // Reproduced for the int path only, where `str(n)` is exactly BigInt's
659 // Display (sign included). The float/Decimal path hits the same limit,
660 // but there `str(n)` is `repr(float)` — "1e+30", or "1E+26" for a
661 // Decimal — which is not recoverable from the parsed BigDecimal. See
662 // the port report's `concerns`.
663 if let CurrencyValue::Int(v) = val {
664 if v.abs() >= self.decimal_ctx_limit {
665 return Ok(format!("{} {}", v, currency));
666 }
667 }
668
669 // `(decimal_val * 100) % 1 != 0`. An int can never have fractional
670 // cents — the product is integral — which is also why the arm above is
671 // the only place the context limit can bite the int path.
672 let has_fractional_cents = match val {
673 CurrencyValue::Int(_) => false,
674 CurrencyValue::Decimal { value, .. } => {
675 let scaled = value * BigDecimal::from(100);
676 // with_scale(0) truncates toward zero, matching Decimal's
677 // sign-of-dividend `%`; either way this only tests != 0.
678 &scaled - scaled.with_scale(0) != BigDecimal::zero()
679 }
680 };
681
682 // divisor 100, not `self.currency_precision(currency)`: Python calls
683 // `parse_currency_parts(n, is_int_with_cents=False,
684 // keep_precision=has_fractional_cents)` and lets currency.py's
685 // `divisor=100` default stand (quirk 11).
686 let (left, right, is_negative) = parse_currency_parts(val, false, has_fractional_cents, 100);
687
688 let forms = self.currency_forms.get(currency).ok_or_else(|| {
689 N2WError::NotImplemented(format!(
690 "Currency code \"{}\" not implemented for \"{}\"",
691 currency,
692 self.lang_name()
693 ))
694 })?;
695
696 let mut result: Vec<String> = Vec::new();
697
698 // Python appends `self.negword.strip()`: the trailing space is
699 // stripped here and put back by the `" ".join(result)` below. `left`
700 // is already the absolute value (parse_currency_parts abs()es it), so
701 // `int_to_cardinal` cannot prepend a second negword.
702 if is_negative {
703 result.push(NEGWORD.trim().to_string());
704 }
705 result.push(int_to_cardinal(&left));
706 result.push(forms.unit[0].clone());
707
708 // Python: `if right > 0`. This is the *only* cents guard, so a whole
709 // float (1.0) or a scaled Decimal ("5.00") prints no subunit.
710 if right > BigDecimal::zero() {
711 // Python: `if isinstance(right, Decimal)`. `right` is a Decimal
712 // exactly when parse_currency_parts kept precision, i.e. exactly
713 // when has_fractional_cents — otherwise it returned a plain int.
714 let right_words = if has_fractional_cents {
715 cardinal_from_fractional_cents(&right)
716 } else {
717 // Safe: !keep_precision leaves `cents` at scale 0, so the
718 // coefficient is the value.
719 int_to_cardinal(&right.as_bigint_and_exponent().0)
720 };
721 result.push(right_words);
722 result.push(forms.subunit[0].clone());
723 }
724
725 Ok(result.join(" "))
726 }
727}
728
729/// Mirrors Python's `if remainder > 0` on the year path. `remainder` there is
730/// always non-negative (the branch is guarded by `value >= 1000`), so this is
731/// effectively `!= 0`.
732fn is_positive(n: &BigInt) -> bool {
733 n.sign() == num_bigint::Sign::Plus
734}
735
736/// `int(n)` for a float/Decimal — truncation toward zero, the value both
737/// `except BaseException` retries feed to `_int_to_cardinal`/`_int_to_ordinal`.
738fn ta_trunc(value: &FloatValue) -> BigInt {
739 match value {
740 // trunc(-0.5) == -0.0 -> BigInt 0, so the sign is dropped, matching
741 // Python's int(-0.5) == 0.
742 FloatValue::Float { value, .. } => {
743 BigInt::from_f64(value.trunc()).unwrap_or_else(BigInt::zero)
744 }
745 FloatValue::Decimal { value, .. } => value.with_scale(0).as_bigint_and_exponent().0,
746 }
747}
748
749/// `self.ones[<float/Decimal>]` / `self.tens[<float/Decimal>]` — CPython's
750/// message for a list indexed with a non-int. Only the exception *type* is
751/// corpus-observable.
752fn ta_type_error(is_decimal: bool) -> N2WError {
753 N2WError::Type(format!(
754 "list indices must be integers or slices, not {}",
755 if is_decimal { "decimal.Decimal" } else { "float" }
756 ))
757}
758
759/// Positive floor division, all operands positive: Python's `//` on
760/// float/Decimal truncates here. Exact for every corpus value (the floats
761/// are powers of ten or small, so the f64 ops Python performs are exact and
762/// agree with this arbitrary-precision replay).
763fn ta_floordiv(a: &BigDecimal, b: &BigDecimal) -> BigInt {
764 (a / b).with_scale(0).as_bigint_and_exponent().0
765}
766
767/// Positive `a % b`, consistent with [`ta_floordiv`].
768fn ta_mod(a: &BigDecimal, b: &BigDecimal) -> BigDecimal {
769 a - BigDecimal::from(ta_floordiv(a, b)) * b
770}
771
772/// `_int_to_word(n)` replayed with a **non-int-typed** `n` (float or
773/// Decimal), as `to_year` runs it — live, with no `except BaseException`
774/// safety net:
775///
776/// * dict lookups (`teens`, `hundreds_special`) succeed by hash equality
777/// whenever the value is whole (`teens[12.0]` hits);
778/// * list indexing (`self.tens[n // 10]`, `self.ones[n]`) raises
779/// **TypeError** for any non-int index — whole or not;
780/// * a fractional value in the open teens interval misses the dict:
781/// **KeyError**;
782/// * scale quotients recurse through this same function (they are
783/// floats/Decimals themselves), and `== 1` comparisons are numeric, so
784/// `1000.0 // 1000 == 1` takes the bare-"ஆயிரம்" arm without indexing.
785///
786/// Net effect: only values whose residue at every level is 0, a whole teen,
787/// or absorbed by `hundreds_special`/`== 1` produce words; everything else
788/// raises exactly where Python does.
789fn ta_word_sim(n: &BigDecimal, is_decimal: bool) -> Result<String> {
790 if n.is_zero() {
791 return Ok(ZERO.to_string());
792 }
793
794 let mut n = n.clone();
795 let mut parts: Vec<String> = Vec::new();
796
797 // Crores (10^7): `crores = n // 10000000` recurses on the quotient.
798 let crore_bd = BigDecimal::from(10_000_000u32);
799 if n >= crore_bd {
800 let crores = BigDecimal::from(ta_floordiv(&n, &crore_bd));
801 if crores == BigDecimal::from(1u32) {
802 parts.push("ஒரு கோடி".to_string());
803 } else {
804 parts.push(format!("{} கோடி", ta_word_sim(&crores, is_decimal)?));
805 }
806 n = ta_mod(&n, &crore_bd);
807 }
808
809 // Lakhs (10^5).
810 let lakh_bd = BigDecimal::from(100_000u32);
811 if n >= lakh_bd {
812 let lakhs = BigDecimal::from(ta_floordiv(&n, &lakh_bd));
813 if lakhs == BigDecimal::from(1u32) {
814 parts.push("ஒரு இலட்சம்".to_string());
815 } else {
816 parts.push(format!("{} இலட்சம்", ta_word_sim(&lakhs, is_decimal)?));
817 }
818 n = ta_mod(&n, &lakh_bd);
819 }
820
821 // Thousands — the `== 1` case is the bare "ஆயிரம்", as in the int path.
822 let thousand_bd = BigDecimal::from(1000u32);
823 if n >= thousand_bd {
824 let thousands = BigDecimal::from(ta_floordiv(&n, &thousand_bd));
825 if thousands == BigDecimal::from(1u32) {
826 parts.push("ஆயிரம்".to_string());
827 } else {
828 parts.push(format!("{} ஆயிரம்", ta_word_sim(&thousands, is_decimal)?));
829 }
830 n = ta_mod(&n, &thousand_bd);
831 }
832
833 // Hundreds: `(n // 100) * 100` is a whole 100..=900 whenever this branch
834 // runs, and Python's dict lookup hits it by hash equality — so this arm
835 // never raises, even for a fractional n.
836 let hundred_bd = BigDecimal::from(100u32);
837 if n >= hundred_bd {
838 let h = ta_floordiv(&n, &hundred_bd);
839 let h_idx = h.to_usize().unwrap_or(0);
840 debug_assert!((1..=9).contains(&h_idx));
841 parts.push(HUNDREDS_SPECIAL[h_idx - 1].to_string());
842 n = ta_mod(&n, &hundred_bd);
843 }
844
845 let ten_bd = BigDecimal::from(10u32);
846 let twenty_bd = BigDecimal::from(20u32);
847 if n > ten_bd && n < twenty_bd {
848 // `self.teens[n]` — dict: whole values hit by hash equality,
849 // fractional ones raise KeyError.
850 if n.is_integer() {
851 let i = n
852 .with_scale(0)
853 .as_bigint_and_exponent()
854 .0
855 .to_usize()
856 .expect("teens index 11..=19 fits usize");
857 parts.push(TEENS[i - 11].to_string());
858 } else {
859 return Err(N2WError::Key(format!("{}", n)));
860 }
861 } else {
862 if n >= ten_bd {
863 // `self.tens[n // 10]` — list index with a float/Decimal.
864 return Err(ta_type_error(is_decimal));
865 }
866 if n > BigDecimal::from(0u32) {
867 // `self.ones[n]` — same.
868 return Err(ta_type_error(is_decimal));
869 }
870 }
871
872 Ok(parts.join(" "))
873}
874
875/// `_int_to_cardinal(n)` over a non-int-typed `n`, no safety net.
876fn ta_cardinal_sim(n: &BigDecimal, is_decimal: bool) -> Result<String> {
877 if n.is_zero() {
878 // `-0.0 == 0` is true, so negative zero lands here and loses its sign.
879 return Ok(ZERO.to_string());
880 }
881 if n.is_negative() {
882 let pos = -n.clone();
883 return Ok(format!("{}{}", NEGWORD, ta_word_sim(&pos, is_decimal)?));
884 }
885 ta_word_sim(n, is_decimal)
886}
887
888/// `to_year(<float/Decimal>)` replayed live (see
889/// [`Lang::year_float_entry`] on [`LangTa`]).
890fn ta_year_sim(n: &BigDecimal, is_decimal: bool) -> Result<String> {
891 let thousand_bd = BigDecimal::from(1000u32);
892 if *n < thousand_bd {
893 // Also swallows every negative year; the cardinal sim raises the
894 // TypeError itself where the digits demand it.
895 return ta_cardinal_sim(n, is_decimal);
896 }
897
898 // n >= 1000 here, so `//`/`%` are plain truncation.
899 let remainder = ta_mod(n, &thousand_bd);
900 if *n < BigDecimal::from(2000u32) {
901 // Python computes `thousands` and never uses it on this branch.
902 let mut result = YEAR_THOUSAND.to_string();
903 if remainder > BigDecimal::from(0u32) {
904 result.push(' ');
905 result.push_str(&ta_cardinal_sim(&remainder, is_decimal)?);
906 }
907 return Ok(result);
908 }
909
910 let thousands = BigDecimal::from(ta_floordiv(n, &thousand_bd));
911 let mut result = format!("{} {}", ta_cardinal_sim(&thousands, is_decimal)?, YEAR_THOUSAND);
912 if remainder > BigDecimal::from(0u32) {
913 result.push(' ');
914 result.push_str(&ta_cardinal_sim(&remainder, is_decimal)?);
915 }
916 Ok(result)
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
922
923 /// Drives the same entry point the corpus harness does. `arg` is Python's
924 /// `repr(value)`, so "100" is an int and "12.34" a float.
925 fn cur(arg: &str, currency: &str) -> Result<String> {
926 let is_int = !arg.contains('.') && !arg.to_lowercase().contains('e');
927 let val = CurrencyValue::parse(arg, is_int, !is_int, !is_int).unwrap();
928 LangTa::new().to_currency(&val, currency, true, None, false)
929 }
930
931 fn cheque(arg: &str, currency: &str) -> Result<String> {
932 use std::str::FromStr;
933 LangTa::new().to_cheque(&BigDecimal::from_str(arg).unwrap(), currency)
934 }
935
936 /// Frozen-corpus rows, verbatim.
937 #[test]
938 fn corpus_currency() {
939 for (arg, want) in [
940 ("0", "பூஜ்ஜியம் யூரோ"),
941 ("1", "ஒன்று யூரோ"),
942 ("2", "இரண்டு யூரோ"),
943 ("100", "நூறு யூரோ"),
944 ("12.34", "பன்னிரண்டு யூரோ முப்பது நான்கு சென்ட்"),
945 ("0.01", "பூஜ்ஜியம் யூரோ ஒன்று சென்ட்"),
946 // A whole float still prints no cents: TA has no has_decimal guard.
947 ("1.0", "ஒன்று யூரோ"),
948 ("99.99", "தொண்ணூறு ஒன்பது யூரோ தொண்ணூறு ஒன்பது சென்ட்"),
949 ("1234.56", "ஆயிரம் இருநூறு முப்பது நான்கு யூரோ ஐம்பது ஆறு சென்ட்"),
950 ("-12.34", "கழித்தல் பன்னிரண்டு யூரோ முப்பது நான்கு சென்ட்"),
951 ("1000000", "பத்து இலட்சம் யூரோ"),
952 ("0.5", "பூஜ்ஜியம் யூரோ ஐம்பது சென்ட்"),
953 ] {
954 assert_eq!(cur(arg, "EUR").unwrap(), want, "EUR {}", arg);
955 }
956 assert_eq!(cur("12.34", "INR").unwrap(), "பன்னிரண்டு ரூபாய் முப்பது நான்கு பைசா");
957 assert_eq!(cur("12.34", "USD").unwrap(), "பன்னிரண்டு டாலர் முப்பது நான்கு சென்ட்");
958 assert_eq!(cur("12.34", "GBP").unwrap(), "பன்னிரண்டு பவுண்ட் முப்பது நான்கு பென்னி");
959 assert_eq!(cur("1.05", "LKR").unwrap(), "ஒன்று ரூபாய் ஐந்து சதம்");
960 assert_eq!(cur("1.05", "MYR").unwrap(), "ஒன்று ரிங்கிட் ஐந்து சென்");
961 assert_eq!(cur("1.05", "SGD").unwrap(), "ஒன்று டாலர் ஐந்து சென்ட்");
962 }
963
964 /// TA's table has seven codes and nothing else; JPY/KWD/BHD/CNY/CHF are
965 /// corpus NotImplementedError rows.
966 #[test]
967 fn corpus_currency_not_implemented() {
968 for code in ["JPY", "KWD", "BHD", "CNY", "CHF"] {
969 match cur("12.34", code) {
970 Err(N2WError::NotImplemented(m)) => assert_eq!(
971 m,
972 format!("Currency code \"{}\" not implemented for \"Num2Word_TA\"", code)
973 ),
974 other => panic!("{}: expected NotImplemented, got {:?}", code, other),
975 }
976 }
977 }
978
979 #[test]
980 fn corpus_cheque() {
981 for (code, want) in [
982 ("EUR", "ஆயிரம் இருநூறு முப்பது நான்கு AND 56/100 யூரோ"),
983 ("USD", "ஆயிரம் இருநூறு முப்பது நான்கு AND 56/100 டாலர்"),
984 ("GBP", "ஆயிரம் இருநூறு முப்பது நான்கு AND 56/100 பவுண்ட்"),
985 ("INR", "ஆயிரம் இருநூறு முப்பது நான்கு AND 56/100 ரூபாய்"),
986 ] {
987 assert_eq!(cheque("1234.56", code).unwrap(), want, "cheque {}", code);
988 }
989 for code in ["JPY", "KWD", "BHD", "CNY", "CHF"] {
990 assert!(matches!(cheque("1234.56", code), Err(N2WError::NotImplemented(_))));
991 }
992 assert_eq!(
993 cheque("-1234.56", "INR").unwrap(),
994 "MINUS ஆயிரம் இருநூறு முப்பது நான்கு AND 56/100 ரூபாய்"
995 );
996 assert_eq!(cheque("1.05", "INR").unwrap(), "ஒன்று AND 05/100 ரூபாய்");
997 }
998
999 #[test]
1000 fn negatives_take_one_stripped_negword() {
1001 assert_eq!(cur("-0.5", "INR").unwrap(), "கழித்தல் பூஜ்ஜியம் ரூபாய் ஐம்பது பைசா");
1002 assert_eq!(cur("-5", "INR").unwrap(), "கழித்தல் ஐந்து ரூபாய்");
1003 assert_eq!(cur("-1.0", "INR").unwrap(), "கழித்தல் ஒன்று ரூபாய்");
1004 assert_eq!(cur("-0.01", "INR").unwrap(), "கழித்தல் பூஜ்ஜியம் ரூபாய் ஒன்று பைசா");
1005 }
1006
1007 /// keep_precision skips the ROUND_HALF_UP quantize, so fractional cents
1008 /// truncate. All values checked against the live Python converter.
1009 #[test]
1010 fn fractional_cents_truncate() {
1011 assert_eq!(cur("2.675", "INR").unwrap(), "இரண்டு ரூபாய் அறுபது ஏழு பைசா");
1012 assert_eq!(cur("0.655", "INR").unwrap(), "பூஜ்ஜியம் ரூபாய் அறுபது ஐந்து பைசா");
1013 assert_eq!(cur("12.345", "INR").unwrap(), "பன்னிரண்டு ரூபாய் முப்பது நான்கு பைசா");
1014 // 15.5 paise -> teens[15.5] KeyError -> int(15.5).
1015 assert_eq!(cur("1.155", "INR").unwrap(), "ஒன்று ரூபாய் பதினைந்து பைசா");
1016 // 0.5 paise -> ones[0.5] TypeError -> int(0.5) == 0.
1017 assert_eq!(cur("1.005", "INR").unwrap(), "ஒன்று ரூபாய் பூஜ்ஜியம் பைசா");
1018 assert_eq!(cur("1.00001", "INR").unwrap(), "ஒன்று ரூபாய் பூஜ்ஜியம் பைசா");
1019 }
1020
1021 /// Python does `int(float(right))`, not `int(right)`, and the difference
1022 /// is observable.
1023 #[test]
1024 fn fractional_cents_go_through_float() {
1025 // 2.9999999999999999999 paise: float() rounds to 3.0, so "மூன்று" —
1026 // int(Decimal(...)) would have truncated to 2.
1027 assert_eq!(cur("1.029999999999999999999", "INR").unwrap(), "ஒன்று ரூபாய் மூன்று பைசா");
1028 // 99.9999999999999999999 paise: float() rounds to exactly 100.0, and
1029 // _int_to_word(100.0) succeeds outright via hundreds_special[100.0].
1030 assert_eq!(cur("1.999999999999999999999", "INR").unwrap(), "ஒன்று ரூபாய் நூறு பைசா");
1031 }
1032
1033 /// Past 10**26 the decimal context blows up and TA's `except
1034 /// BaseException` returns the bare number. Boundary verified against the
1035 /// live Python converter.
1036 #[test]
1037 fn int_decimal_context_limit() {
1038 let below = "99999999999999999999999999"; // 10**26 - 1
1039 assert!(cur(below, "INR").unwrap().starts_with("தொண்ணூறு ஒன்பது ஆயிரம்"));
1040 assert_eq!(
1041 cur("100000000000000000000000000", "INR").unwrap(),
1042 "100000000000000000000000000 INR"
1043 );
1044 assert_eq!(
1045 cur("-100000000000000000000000000", "INR").unwrap(),
1046 "-100000000000000000000000000 INR"
1047 );
1048 assert!(cur("-99999999999999999999999999", "INR").unwrap().starts_with("கழித்தல்"));
1049 // The fallback beats the NotImplementedError: Python raises on the
1050 // first line of the try, before the CURRENCY_FORMS lookup.
1051 assert_eq!(
1052 cur("1000000000000000000000000000000", "JPY").unwrap(),
1053 "1000000000000000000000000000000 JPY"
1054 );
1055 // ... but a value under the limit still raises for an unknown code.
1056 assert!(matches!(
1057 cur("10000000000000000000000000", "JPY"),
1058 Err(N2WError::NotImplemented(_))
1059 ));
1060 }
1061
1062 /// Float cardinal path == truncation toward zero. Every row is a frozen
1063 /// corpus `"to": "cardinal"` float row (arg has a dot).
1064 #[test]
1065 fn corpus_cardinal_float() {
1066 let l = LangTa::new();
1067 // (raw f64, repr-derived precision, expected)
1068 let cases: &[(f64, u32, &str)] = &[
1069 (0.0, 1, "பூஜ்ஜியம்"),
1070 (0.5, 1, "பூஜ்ஜியம்"),
1071 (1.0, 1, "ஒன்று"),
1072 (1.5, 1, "ஒன்று"),
1073 (2.25, 2, "இரண்டு"),
1074 (3.14, 2, "மூன்று"),
1075 (0.01, 2, "பூஜ்ஜியம்"),
1076 (0.1, 1, "பூஜ்ஜியம்"),
1077 (0.99, 2, "பூஜ்ஜியம்"),
1078 (1.01, 2, "ஒன்று"),
1079 (12.34, 2, "பன்னிரண்டு"),
1080 (99.99, 2, "தொண்ணூறு ஒன்பது"),
1081 (100.5, 1, "நூறு"),
1082 (1234.56, 2, "ஆயிரம் இருநூறு முப்பது நான்கு"),
1083 (-0.5, 1, "பூஜ்ஜியம்"),
1084 (-1.5, 1, "கழித்தல் ஒன்று"),
1085 (-12.34, 2, "கழித்தல் பன்னிரண்டு"),
1086 (1.005, 3, "ஒன்று"),
1087 (2.675, 3, "இரண்டு"),
1088 ];
1089 for (v, p, want) in cases {
1090 let fv = FloatValue::Float { value: *v, precision: *p };
1091 assert_eq!(l.to_cardinal_float(&fv, None).unwrap(), *want, "float {}", v);
1092 }
1093 }
1094
1095 /// Decimal cardinal path (`"to": "cardinal_dec"` corpus rows) — exact
1096 /// truncation toward zero, never routed through f64.
1097 #[test]
1098 fn corpus_cardinal_decimal() {
1099 use std::str::FromStr;
1100 let l = LangTa::new();
1101 let cases: &[(&str, u32, &str)] = &[
1102 ("0.01", 2, "பூஜ்ஜியம்"),
1103 ("1.10", 2, "ஒன்று"),
1104 ("12.345", 3, "பன்னிரண்டு"),
1105 (
1106 "98746251323029.99",
1107 2,
1108 "தொண்ணூறு எட்டு இலட்சம் எழுபது நான்கு ஆயிரம் அறுநூறு இருபது ஐந்து கோடி பதின்மூன்று இலட்சம் இருபது மூன்று ஆயிரம் இருபது ஒன்பது",
1109 ),
1110 ("0.001", 3, "பூஜ்ஜியம்"),
1111 // Negative Decimal truncates toward zero: int(Decimal("-1.5")) == -1.
1112 ("-1.5", 1, "கழித்தல் ஒன்று"),
1113 // Truncates to zero -> no negword, like the float -0.5 row.
1114 ("-0.5", 1, "பூஜ்ஜியம்"),
1115 ];
1116 for (s, p, want) in cases {
1117 let fv = FloatValue::Decimal {
1118 value: BigDecimal::from_str(s).unwrap(),
1119 precision: *p,
1120 };
1121 assert_eq!(l.to_cardinal_float(&fv, None).unwrap(), *want, "decimal {}", s);
1122 }
1123 }
1124
1125 /// `cents` / `separator` / `adjective` do not exist on TA's Python
1126 /// signature, so nothing may react to them.
1127 #[test]
1128 fn extra_kwargs_are_inert() {
1129 let l = LangTa::new();
1130 let v = CurrencyValue::parse("12.34", false, true, true).unwrap();
1131 let want = "பன்னிரண்டு ரூபாய் முப்பது நான்கு பைசா";
1132 assert_eq!(l.to_currency(&v, "INR", true, None, false).unwrap(), want);
1133 assert_eq!(l.to_currency(&v, "INR", false, Some(" X"), true).unwrap(), want);
1134 }
1135}