Skip to main content

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}