num2words2_core/lang_sw.rs
1//! Port of `lang_SW.py` (Swahili / Kiswahili).
2//!
3//! Shape: **engine**. `Num2Word_SW` subclasses `Num2Word_Base` directly and
4//! defines `high_numwords` / `mid_numwords` / `low_numwords` plus `merge`, so
5//! Python builds `self.cards`, sets `MAXVAL`, and lets the inherited
6//! `to_cardinal` drive `splitnum`/`clean`. Cards + merge live here; the
7//! default `to_cardinal` in `base.rs` does the rest.
8//!
9//! Registry check: `__init__.py` maps `"sw" -> lang_SW.Num2Word_SW()`, so this
10//! is the class the key actually resolves to.
11//!
12//! # Card table
13//!
14//! `set_high_numwords` computes `cap = 9 + 9*len(high)` = 18 and zips
15//! `high` against `range(cap, 8, -9)` = `[18, 9]`. `high` holds a single word
16//! ("trilioni"), and `zip` stops at the shorter sequence, so **only** `10**18`
17//! is ever registered — the `10**9` slot from the range is silently dropped
18//! (1e9 comes from `mid_numwords` as "bilioni" instead). `MAXVAL` is
19//! `1000 * 10**18` = `10**21`, hence `to_cardinal(10**21)` → `OverflowError`.
20//!
21//! Note `mid_numwords` includes `100000 -> "laki"` (a South-Asian-style lakh
22//! borrowing), which sits between "elfu" (1000) and "milioni" (1e6). It is a
23//! real card, so 123456 renders as "laki moja na elfu ishirini na tatu ..."
24//! rather than a pure thousands decomposition.
25//!
26//! # Faithfully reproduced Python bugs
27//!
28//! This is a port, not a rewrite. The following are wrong-looking but are
29//! exactly what Python emits, and are covered by the frozen corpus:
30//!
31//! 1. **`merge` branch 4 reverses operand order and *multiplies* whenever the
32//! right operand is >= 1e6, even when the left operand is the larger,
33//! already-merged partial.** This mangles any value that pairs a billion
34//! with a >= 1e6 remainder. The corpus pins it:
35//! `to_cardinal(1234567890)` == "milioni mia mbili na thelathini na nne na
36//! laki tano na elfu sitini na saba na mia nane na tisini bilioni moja" —
37//! i.e. the 234,567,890 remainder is emitted *before* "bilioni moja" with
38//! no "na" joining them, and the returned numeric value is `1e9 *
39//! 234567890` rather than their sum. The numeric value is discarded by
40//! `to_cardinal` (only the text survives), so the bogus product is
41//! unobservable here, but the word order is. Smaller siblings such as
42//! `to_cardinal(1000000001)` are unaffected (remainder < 1e6 takes
43//! branch 3).
44//!
45//! 2. **Thousands are ambiguous by construction.** `to_cardinal(10001)` ==
46//! "elfu kumi na moja", which is also exactly what 11000 would read as in
47//! natural Swahili. This is why the corpus's `w2n_cardinal` rows for
48//! "elfu kumi na moja" and friends are all recorded as failures — the
49//! round trip genuinely does not close. Not our problem to fix.
50//!
51//! 3. **`ordinal_words` is keyed on the *whole* cardinal string, not on the
52//! last word.** So only bare "moja" and "mbili" ever get their suppletive
53//! ordinals ("kwanza"/"pili"); `to_ordinal(21)` == "wa ishirini na moja",
54//! never "...na kwanza". The remaining eight entries ("tatu" -> "tatu",
55//! ... "kumi" -> "kumi") are identity mappings and change nothing — the
56//! fallback arm would produce the same string. Kept anyway for fidelity.
57//!
58//! 4. **`to_currency` accepts `adjective=` and never reads it**, and spells
59//! `cents=False` as an inline `"%02d"` rather than deferring to
60//! `_cents_terse`. Both are unobservable given SW's data (empty
61//! `CURRENCY_ADJECTIVES`, divisor always 100) but both are real divergences
62//! from `Num2Word_Base.to_currency`. See [`LangSw::to_currency`].
63//!
64//! # Inherited from `Num2Word_Base`
65//!
66//! `verify_ordinal` raises `TypeError` on negatives, so `to_ordinal(-1)` and
67//! `to_ordinal_num(-1)` are `N2WError::Type` — reproduced in [`LangSw::
68//! verify_ordinal`]. `to_year` is overridden in Python but its body is just
69//! `self.to_cardinal(value)`, identical to the base/trait default; it is
70//! spelled out below to keep the mapping to the source obvious. Negative
71//! years therefore render via the plain negword path
72//! (`to_year(-500)` == "hasi mia tano"), with no BC/AD suffix.
73
74use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
75use crate::currency::{parse_currency_parts, CurrencyForms, CurrencyValue};
76use bigdecimal::BigDecimal;
77use num_bigint::BigInt;
78use num_traits::{One, Signed, ToPrimitive, Zero};
79use std::collections::HashMap;
80
81/// `Num2Word_SW.CURRENCY_FORMS`, verbatim.
82///
83/// SW declares this in its own class body, so — unlike the 16 classes that read
84/// `Num2Word_EUR`'s dict — it is **not** the table `Num2Word_EN.__init__`
85/// mutates in place. Confirmed against the live interpreter: SW's runtime dict
86/// is exactly these six codes, with none of EN's ~24 additions. Hence
87/// JPY/KWD/BHD/INR/CNY/CHF all raise NotImplementedError, which the corpus pins
88/// (54 of its 108 currency rows are that error).
89///
90/// Every entry carries two forms that are identical to each other — Swahili
91/// nouns of this class do not inflect for number — so `pluralize`'s choice is
92/// unobservable here. The arity is kept at Python's two all the same.
93fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
94 const SENTI: [&str; 2] = ["senti", "senti"];
95 const SHILINGI: [&str; 2] = ["shilingi", "shilingi"];
96
97 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
98 m.insert("TZS", CurrencyForms::new(&SHILINGI, &SENTI)); // Tanzanian Shilling
99 m.insert("KES", CurrencyForms::new(&SHILINGI, &SENTI)); // Kenyan Shilling
100 m.insert("UGX", CurrencyForms::new(&SHILINGI, &SENTI)); // Ugandan Shilling
101 m.insert("USD", CurrencyForms::new(&["dola", "dola"], &SENTI)); // US Dollar
102 m.insert("EUR", CurrencyForms::new(&["yuro", "yuro"], &SENTI)); // Euro
103 m.insert("GBP", CurrencyForms::new(&["pauni", "pauni"], &["peni", "peni"])); // British Pound
104 m
105}
106
107/// Python's `str(float)` for the values SW's fractional-cents branch can reach.
108///
109/// Only `to_currency(..., cents=False)` on a value with sub-cent precision gets
110/// here, via `str(float(right))`. `right` is then a fractional count of cents,
111/// so `0 < right < 100`; across that domain Rust's `{}` and Python's `repr`
112/// agree (both are shortest-round-trip) except that Python switches to exponent
113/// form below `1e-4` (`str(1e-05) == "1e-05"`, `str(0.0001) == "0.0001"`) where
114/// Rust would print `"0.00001"`. Rust's `{:e}` has the right mantissa but an
115/// unpadded exponent (`"1e-5"`), so the two-digit padding is reapplied.
116///
117/// Duplicated from the identical helper in `lang_id.rs` rather than shared: it
118/// is private to that module and this port may edit only this file.
119fn py_repr_float(f: f64) -> String {
120 if f == 0.0 || f.abs() >= 1e-4 {
121 return format!("{}", f);
122 }
123 let s = format!("{:e}", f);
124 match s.split_once('e') {
125 Some((mantissa, exp)) => {
126 let (sign, digits) = match exp.strip_prefix('-') {
127 Some(d) => ("-", d),
128 None => ("+", exp.strip_prefix('+').unwrap_or(exp)),
129 };
130 format!("{}e{}{:0>2}", mantissa, sign, digits)
131 }
132 None => s,
133 }
134}
135
136pub struct LangSw {
137 cards: Cards,
138 maxval: BigInt,
139 ordinal_words: HashMap<&'static str, &'static str>,
140 currency_forms: HashMap<&'static str, CurrencyForms>,
141}
142
143impl Default for LangSw {
144 fn default() -> Self {
145 Self::new()
146 }
147}
148
149impl LangSw {
150 pub fn new() -> Self {
151 let mut cards = Cards::new();
152
153 // set_high_numwords: cap = 9 + 9*len(high); zip(high, range(cap, 8, -9)).
154 // zip() stops at the shorter sequence, so with one high word only the
155 // first exponent (18) is consumed. The loop below reproduces that.
156 let high = ["trilioni"];
157 let cap = 9 + 9 * high.len() as i64;
158 let mut n = cap;
159 for word in high.iter() {
160 // range(cap, 8, -9) is exhausted once n <= 8.
161 if n <= 8 {
162 break;
163 }
164 cards.insert(BigInt::from(10u8).pow(n as u32), *word);
165 n -= 9;
166 }
167
168 set_mid_numwords(
169 &mut cards,
170 &[
171 (1000000000, "bilioni"),
172 (1000000, "milioni"),
173 (100000, "laki"),
174 (1000, "elfu"),
175 (100, "mia"),
176 (90, "tisini"),
177 (80, "themanini"),
178 (70, "sabini"),
179 (60, "sitini"),
180 (50, "hamsini"),
181 (40, "arobaini"),
182 (30, "thelathini"),
183 (20, "ishirini"),
184 ],
185 );
186
187 // 19 down to 0.
188 set_low_numwords(
189 &mut cards,
190 &[
191 "kumi na tisa",
192 "kumi na nane",
193 "kumi na saba",
194 "kumi na sita",
195 "kumi na tano",
196 "kumi na nne",
197 "kumi na tatu",
198 "kumi na mbili",
199 "kumi na moja",
200 "kumi",
201 "tisa",
202 "nane",
203 "saba",
204 "sita",
205 "tano",
206 "nne",
207 "tatu",
208 "mbili",
209 "moja",
210 "sifuri",
211 ],
212 );
213
214 // MAXVAL = 1000 * highest card = 1000 * 10**18 = 10**21.
215 let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
216
217 let ordinal_words: HashMap<&str, &str> = [
218 ("moja", "kwanza"),
219 ("mbili", "pili"),
220 ("tatu", "tatu"),
221 ("nne", "nne"),
222 ("tano", "tano"),
223 ("sita", "sita"),
224 ("saba", "saba"),
225 ("nane", "nane"),
226 ("tisa", "tisa"),
227 ("kumi", "kumi"),
228 ]
229 .into_iter()
230 .collect();
231
232 LangSw {
233 cards,
234 maxval,
235 ordinal_words,
236 // Built once here, never per call: `to_currency`/`to_cheque` only
237 // read this table, and rebuilding it on each call is what made an
238 // earlier revision of this port slower than the Python it replaces.
239 currency_forms: build_currency_forms(),
240 }
241 }
242
243 /// `Num2Word_Base.verify_ordinal`. Input is integral by construction here,
244 /// so only the negative check can fire — as `TypeError`, not ValueError.
245 fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
246 if value.sign() == num_bigint::Sign::Minus {
247 return Err(N2WError::Type(format!(
248 "Cannot treat negative num {} as ordinal.",
249 value
250 )));
251 }
252 Ok(())
253 }
254
255 /// `Num2Word_Base.verify_ordinal` over the float/Decimal entry, plus the
256 /// truncating `int(value)` the two checks perform first: NaN raises
257 /// ValueError and ±inf OverflowError inside the first comparison;
258 /// fractional values are `errmsg_floatord`, negative whole ones
259 /// `errmsg_negord` (both TypeError). `-0.0` passes both checks and
260 /// ordinalises as zero → "wa sifuri". Returns the whole value as the
261 /// integer the ordinal path then renders.
262 fn verify_ordinal_float(&self, value: &crate::floatpath::FloatValue) -> Result<BigInt> {
263 if let crate::floatpath::FloatValue::Float { value: f, .. } = value {
264 if f.is_nan() {
265 return Err(N2WError::Value(
266 "cannot convert float NaN to integer".into(),
267 ));
268 }
269 if f.is_infinite() {
270 return Err(N2WError::Overflow(
271 "cannot convert float infinity to integer".into(),
272 ));
273 }
274 }
275 match value.as_whole_int() {
276 None => Err(N2WError::Type(
277 "Cannot treat float as ordinal.".into(),
278 )),
279 Some(i) if i.sign() == num_bigint::Sign::Minus => Err(N2WError::Type(format!(
280 "Cannot treat negative num {} as ordinal.",
281 i
282 ))),
283 Some(i) => Ok(i),
284 }
285 }
286}
287
288impl Lang for LangSw {
289 /// This language's own `to_currency(currency=...)` default,
290 /// read from the live Python signature. Only 44 of 156 use EUR.
291 fn default_currency(&self) -> &str {
292 "TZS"
293 }
294
295 /// This language's own `to_currency(separator=...)` default,
296 /// read from the live Python signature. Base's is ",", but only
297 /// 36 of 149 languages actually use it — most default to " " or a
298 /// conjunction, so inheriting Base's comma silently corrupts them.
299 fn default_separator(&self) -> &str {
300 " na"
301 }
302
303 fn cards(&self) -> &Cards {
304 &self.cards
305 }
306
307 fn maxval(&self) -> &BigInt {
308 &self.maxval
309 }
310
311 fn negword(&self) -> &str {
312 // Python's "hasi " — base strips it and re-adds one space, giving
313 // "hasi " either way.
314 "hasi "
315 }
316
317 fn pointword(&self) -> &str {
318 "nukta"
319 }
320
321 /// Port of `Num2Word_SW.merge`.
322 ///
323 /// Branch order is load-bearing and reproduced verbatim. Note the Python
324 /// chained comparisons: `100 > lnum > rnum` means `lnum < 100 && lnum >
325 /// rnum`, and `lnum >= 100 > rnum` means `lnum >= 100 && rnum < 100`.
326 fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
327 let (ltext, lnum) = l;
328 let (rtext, rnum) = r;
329 let hundred = BigInt::from(100);
330 let thousand = BigInt::from(1000);
331 let million = BigInt::from(1000000);
332
333 if lnum.is_one() && rnum < &hundred {
334 // "moja" is absorbed: 1 * anything below 100 is just that thing.
335 (rtext.to_string(), rnum.clone())
336 } else if &hundred > lnum && lnum > rnum {
337 // Tens + units: "ishirini na moja".
338 (format!("{} na {}", ltext, rtext), lnum + rnum)
339 } else if lnum >= &hundred && &hundred > rnum {
340 // Big left, sub-hundred remainder: "mia moja na kumi".
341 (format!("{} na {}", ltext, rtext), lnum + rnum)
342 } else if rnum >= &million {
343 // Millions/billions/trillions: scale word leads — "milioni moja".
344 // Bug 1 lives here: this fires even when lnum is the larger,
345 // already-merged partial, reversing the phrase and multiplying.
346 (format!("{} {}", rtext, ltext), lnum * rnum)
347 } else if rnum >= &thousand {
348 if lnum < &thousand {
349 // "elfu mbili"
350 (format!("{} {}", rtext, ltext), lnum * rnum)
351 } else {
352 (format!("{} na {}", ltext, rtext), lnum + rnum)
353 }
354 } else if rnum >= &hundred {
355 if lnum < &hundred {
356 // "mia mbili"
357 (format!("{} {}", rtext, ltext), lnum * rnum)
358 } else {
359 (format!("{} na {}", ltext, rtext), lnum + rnum)
360 }
361 } else if rnum > lnum {
362 (format!("{} {}", ltext, rtext), lnum * rnum)
363 } else {
364 (format!("{} na {}", ltext, rtext), lnum + rnum)
365 }
366 }
367
368 /// `wa <cardinal>`, with a suppletive lookup on the *entire* cardinal
369 /// string (see bug 3). `verify_ordinal` runs first, so a negative yields
370 /// `Type` even when the magnitude would also overflow.
371 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
372 self.verify_ordinal(value)?;
373 let cardinal_text = self.to_cardinal(value)?;
374 match self.ordinal_words.get(cardinal_text.as_str()) {
375 Some(word) => Ok(format!("wa {}", word)),
376 None => Ok(format!("wa {}", cardinal_text)),
377 }
378 }
379
380 /// Python: `f"{value}."` — the digits, then a full stop.
381 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
382 self.verify_ordinal(value)?;
383 Ok(format!("{}.", value))
384 }
385
386 /// `to_ordinal(float/Decimal)`: SW's `to_ordinal` opens with
387 /// `verify_ordinal(value)`, so a fractional or negative value raises
388 /// TypeError; a whole non-negative one rides the base `to_cardinal`
389 /// whole-value route into the integer ordinal — `5.0` → "wa tano",
390 /// `1.0` → "wa kwanza" (the suppletive lookup on the cardinal), `1e20` →
391 /// "wa trilioni mia moja".
392 fn ordinal_float_entry(&self, value: &crate::floatpath::FloatValue) -> Result<String> {
393 let i = self.verify_ordinal_float(value)?;
394 self.to_ordinal(&i)
395 }
396
397 /// `to_ordinal_num(float/Decimal)`: `verify_ordinal(value)`, then
398 /// `f"{value}."` — the repr verbatim, ".0" tails and all: `5.0` → "5.0.",
399 /// `Decimal("5.00")` → "5.00.", `-0.0` → "-0.0."; `0.5`/`-1.0` raise
400 /// TypeError from the verify.
401 fn ordinal_num_float_entry(
402 &self,
403 value: &crate::floatpath::FloatValue,
404 repr_str: &str,
405 ) -> Result<String> {
406 self.verify_ordinal_float(value)?;
407 Ok(format!("{}.", repr_str))
408 }
409
410 /// Python overrides `to_year` but the body is just `to_cardinal` — no
411 /// century splitting, no BC/AD suffix, negatives fall through to negword.
412 fn to_year(&self, value: &BigInt) -> Result<String> {
413 self.to_cardinal(value)
414 }
415
416 // ---- currency -------------------------------------------------------
417 //
418 // `Num2Word_SW` overrides `to_currency` and `pluralize`, and carries its
419 // own `CURRENCY_FORMS`. Everything else stays at its trait default, which
420 // already matches:
421 //
422 // * `to_cheque` is `Num2Word_Base.to_cheque` (verified by introspection),
423 // so `currency::default_to_cheque` is exactly right — it needs only
424 // `currency_forms` + `money_verbose` from below.
425 // * `_money_verbose`/`_cents_verbose` *are* defined by SW, but both bodies
426 // are `return self.to_cardinal(number)` — byte-identical to Base's and to
427 // the trait default. Overriding them here would add noise, not fidelity.
428 // (SW's own `to_currency` never calls them anyway: it calls
429 // `self.to_cardinal` directly. Only `to_cheque` reaches `_money_verbose`.)
430 // * `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both `{}` at runtime
431 // (SW defines neither, and EN *rebinds* rather than mutates precision, so
432 // nothing leaks in). `.get(code, 100)` is therefore always 100 and the
433 // adjective lookup always misses — the trait defaults for both.
434 // * `_cents_terse` is never reached: SW's `to_currency` inlines its own
435 // `"%02d" % right` instead of calling it.
436
437 fn lang_name(&self) -> &str {
438 "Num2Word_SW"
439 }
440
441 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
442 self.currency_forms.get(code)
443 }
444
445 /// `Num2Word_SW.pluralize`: `forms[0] if len(forms) > 0 else forms`.
446 ///
447 /// The count is ignored outright — the docstring's reasoning is that
448 /// Swahili "typically doesn't change the noun form for plurals". So this is
449 /// a constant function of `forms`, and every entry in SW's table has
450 /// matching singular/plural anyway.
451 ///
452 /// The empty-`forms` arm returns the empty *tuple* in Python, not a string;
453 /// it then lands in a `"%s"` slot and renders as `"()"`. Reproduced
454 /// literally rather than mapped to an exception, because Python raises
455 /// nothing here. Unreachable in practice: `forms` only ever comes from
456 /// `CURRENCY_FORMS`, whose every entry holds two forms.
457 fn pluralize(&self, _n: &BigInt, forms: &[String]) -> Result<String> {
458 Ok(forms.first().cloned().unwrap_or_else(|| "()".to_string()))
459 }
460
461 /// Port of `Num2Word_SW.to_currency`.
462 ///
463 /// A wholesale override that shares nothing with
464 /// `Num2Word_Base.to_currency`, so `currency::default_to_currency` is
465 /// deliberately not delegated to. What differs from Base, and is
466 /// load-bearing:
467 ///
468 /// 1. **`is_int_with_cents=False`**, so a plain `int` is a count of whole
469 /// shillings, never of cents: `to_currency(100, "USD")` is
470 /// `"mia moja dola"`, not one dollar. Base would have split it.
471 /// 2. **The divisor is a hardcoded 100 throughout.** SW never passes
472 /// `divisor=` to `parse_currency_parts` and has no `CURRENCY_PRECISION`,
473 /// so neither the 3-decimal (KWD/BHD) nor the 0-decimal (JPY) convention
474 /// exists here. Moot in practice — none of those codes is in SW's table,
475 /// so they raise before the divisor could matter.
476 /// 3. **`adjective` is accepted and never read.** Base prefixes `cr1` with
477 /// `CURRENCY_ADJECTIVES[currency]`; SW drops the parameter on the floor.
478 /// Unobservable, since SW's `CURRENCY_ADJECTIVES` is empty and Base's
479 /// branch would miss too — but it is a real divergence in the code.
480 /// 4. **`cents=False` yields `"%02d"`, not `_cents_terse`.** Same result at
481 /// divisor 100, and SW has no other divisor, so this too is unobservable.
482 ///
483 /// The `has_decimal or right > 0` guard is Base's and is kept: it is what
484 /// makes `1.0` print `"moja yuro na sifuri senti"` while the int `1` prints
485 /// `"moja yuro"`.
486 fn to_currency(
487 &self,
488 val: &CurrencyValue,
489 currency: &str,
490 cents: bool,
491 separator: Option<&str>,
492 _adjective: bool,
493 ) -> Result<String> {
494 // Trait hands us None when the caller omitted separator=; resolve it to
495 // this language's own default (" na") before the ported body runs.
496 let separator = separator.unwrap_or(self.default_separator());
497
498 // decimal_val = Decimal(str(val))
499 // has_fractional_cents = (decimal_val * 100) % 1 != 0
500 //
501 // Hardcoded 100, not currency_precision() — see the doc comment. An
502 // int's `str()` can never carry a remainder, so ints are always False.
503 let has_fractional_cents = match val {
504 CurrencyValue::Int(_) => false,
505 CurrencyValue::Decimal { value, .. } => {
506 // Decimal's `%` truncates toward zero, as with_scale(0) does;
507 // the `!= 0` test is insensitive to the tie rule either way.
508 let scaled = value * BigDecimal::from(100);
509 &scaled - scaled.with_scale(0) != BigDecimal::zero()
510 }
511 };
512
513 // parse_currency_parts(val, is_int_with_cents=False,
514 // keep_precision=has_fractional_cents)
515 // — divisor defaults to 100 on the Python side.
516 let (left, right, is_negative) =
517 parse_currency_parts(val, false, has_fractional_cents, 100);
518
519 // Python looks the currency up *after* parsing but *before* any
520 // to_cardinal call, so NotImplementedError beats an OverflowError that
521 // a huge `left` would otherwise raise. Order preserved.
522 let forms = self.currency_forms(currency).ok_or_else(|| {
523 N2WError::NotImplemented(format!(
524 "Currency code \"{}\" not implemented for \"{}\"",
525 currency,
526 self.lang_name()
527 ))
528 })?;
529 let cr1 = &forms.unit;
530 let cr2 = &forms.subunit;
531
532 // negword is "hasi ": strip, then re-add one space -> "hasi ".
533 let minus_str = if is_negative {
534 format!("{} ", self.negword().trim())
535 } else {
536 String::new()
537 };
538 let money_str = self.to_cardinal(&left)?;
539
540 // has_decimal = isinstance(val, float) or str(val).find(".") != -1.
541 // Computed Python-side; an int can never satisfy either disjunct.
542 let has_decimal = match val {
543 CurrencyValue::Int(_) => false,
544 CurrencyValue::Decimal { has_decimal, .. } => *has_decimal,
545 };
546
547 // Python's `int(right)`. In the whole-cents case `right` already has
548 // scale 0 and this is exact; in the fractional case it is only ever fed
549 // to `pluralize`, which ignores its argument.
550 let right_int = right.with_scale(0).as_bigint_and_exponent().0;
551
552 // `if has_decimal or right > 0:` — compared on the Decimal, since a
553 // fractional `right` such as 1.1 must count as > 0.
554 if !has_decimal && !right.is_positive() {
555 return Ok(format!(
556 "{}{} {}",
557 minus_str,
558 money_str,
559 self.pluralize(&left, cr1)?
560 ));
561 }
562
563 // Python: `if isinstance(right, Decimal)` — true exactly when
564 // parse_currency_parts kept precision, i.e. when has_fractional_cents.
565 let cents_str = if has_fractional_cents {
566 // `self.to_cardinal_float(float(right)) if cents else str(float(right))`
567 if cents {
568 // cardinal_from_decimal's default is float-cast +
569 // Num2Word_Base.to_cardinal_float — precisely this call. SW
570 // inherits to_cardinal_float unchanged, so the default stands.
571 self.cardinal_from_decimal(&right)?
572 } else {
573 let f = right
574 .to_f64()
575 .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", right)))?;
576 py_repr_float(f)
577 }
578 } else {
579 // `self.to_cardinal(right) if cents else "%02d" % right`
580 if cents {
581 self.to_cardinal(&right_int)?
582 } else {
583 format!("{:0>2}", right_int.to_string())
584 }
585 };
586
587 // "%s%s %s%s %s %s" % (minus_str, money_str, cr1_form, separator,
588 // cents_str, cr2_form)
589 // The separator abuts the unit with no space of its own, so the default
590 // " na" reads "... yuro na thelathini ...".
591 Ok(format!(
592 "{}{} {}{} {} {}",
593 minus_str,
594 money_str,
595 self.pluralize(&left, cr1)?,
596 separator,
597 cents_str,
598 self.pluralize(&right_int, cr2)?
599 ))
600 }
601}