Skip to main content

num2words2_core/
lang_it.rs

1//! Port of `lang_IT.py` (Italian).
2//!
3//! Registry check: `__init__.py` maps `"it"` → `lang_IT.Num2Word_IT()`, so
4//! this ports `Num2Word_IT`.
5//!
6//! Shape: **self-contained**. `Num2Word_IT` subclasses `Num2Word_EUR` →
7//! `Num2Word_Base`, but it overrides `__init__` to call *only* `self.setup()`
8//! and never chains to `Num2Word_Base.__init__`. Two consequences drive this
9//! whole port:
10//!
11//!   1. `self.cards` / `self.MAXVAL` are never built (the `high_numwords`
12//!      guard in `Num2Word_Base.__init__` never runs), so `splitnum`/`clean`/
13//!      `merge` are dead code and **there is no overflow check**. The only
14//!      ceiling is `big_number_to_cardinal`'s explicit `length >= 66` guard,
15//!      which raises `NotImplementedError`. `cards`/`maxval`/`merge` therefore
16//!      stay at their trait defaults here.
17//!   2. The `errmsg_*` attributes are never assigned either — see bug 4 below.
18//!
19//! `Num2Word_EUR.setup` only populates `self.high_numwords`, which is dead
20//! for the same reason; `Num2Word_IT.setup` additionally sets
21//! `self.negword = "meno"`. `negword` is used by the four in-scope modes only
22//! via `to_ordinal_num`'s `verify_ordinal` (which crashes first, see bug 4),
23//! so the sign handling below goes through `MINUS_PREFIX_WORD` (`"meno "`)
24//! exactly as Python does.
25//!
26//! Inherited from `Num2Word_Base` and left alone by IT:
27//!   * `to_year(value) -> self.to_cardinal(value)` → the trait default
28//!     delegates through `&self` and picks up the `to_cardinal` override
29//!     below. Verified against the corpus (`year -500` → "meno cinquecento").
30//!
31//! The `float_to_words`/`to_cardinal_float` float-cardinal path IS ported —
32//! see [`LangIt::float_body_f64`] / [`LangIt::float_body_dec`] and the
33//! `to_cardinal_float` override. `to_fraction` remains out of scope (a later
34//! phase).
35//!
36//! # Currency
37//!
38//! `Num2Word_IT` declares its **own** `CURRENCY_FORMS` class attribute, which
39//! *shadows* `Num2Word_EUR`'s rather than sharing it. This is the one language
40//! family where `PORTING_CURRENCY.md`'s "lang_EUR.py's source is not what runs"
41//! warning does **not** apply: `Num2Word_EN.__init__` mutates
42//! `Num2Word_EUR.CURRENCY_FORMS` in place, but IT never reads that dict, so
43//! none of EN's rewrites (`EUR` → `("euro","euros")`, `GBP` → `("pound",…)`)
44//! nor its ~24 added codes leak in. Verified against the live interpreter:
45//! IT's table is exactly its 14 source entries, and `EUR` really is
46//! `("euro", "euro")`. The corpus agrees — `currency:EUR 2` → "due euro", not
47//! "due euros".
48//!
49//! `CURRENCY_ADJECTIVES` is the opposite case: IT does *not* define one, so it
50//! inherits `Num2Word_EUR`'s dict (EN only rebinds `CURRENCY_PRECISION` and
51//! mutates `CURRENCY_FORMS`, never the adjectives), and all 16 EUR entries are
52//! live here — see [`build_currency_adjectives`].
53//!
54//! `CURRENCY_PRECISION` is never defined by IT or EUR, and EN *rebinds* rather
55//! than mutates it, so IT sees `Num2Word_Base`'s empty `{}` and
56//! `.get(code, 100)` always yields 100. That is already the trait default, so
57//! `currency_precision` is deliberately **not** overridden. Consequences, both
58//! corpus-confirmed:
59//!   * **JPY is not zero-decimal here.** `currency:JPY 12.34` →
60//!     "dodici yen e trentaquattro sen", not "dodici yen". The `divisor == 1`
61//!     branch in `default_to_currency` is unreachable for Italian.
62//!   * **KWD/BHD are not 3-decimal here** — they are simply absent from
63//!     `CURRENCY_FORMS`, so all 24 of their corpus rows are
64//!     `NotImplementedError`.
65//!
66//! Inherited unchanged from `Num2Word_Base`, hence left at their trait
67//! defaults: `to_cheque`, `_money_verbose`, `_cents_verbose`, `_cents_terse`.
68//! The defaults route through `&self`, so they pick up the `to_cardinal`
69//! override — `to_cheque(1234.56, "EUR")` → "MILLEDUECENTOTRENTAQUATTRO AND
70//! 56/100 EURO". `pluralize` comes from `Num2Word_EUR` and *is* overridden,
71//! because the trait default raises.
72//!
73//! # Faithfully reproduced Python bugs
74//!
75//! This is a port, not a rewrite. Every item below is wrong-looking but is
76//! exactly what Python emits, verified against the interpreter:
77//!
78//! 1. **`accentuate` strips the accent it just added, one level up.**
79//!    `accentuate` accents a word-final "tre" → "tré", but it is re-applied at
80//!    every level of `to_cardinal`'s recursion, and its `w[-3:] == "tre"` test
81//!    fails against an already-accented "tré". So an inner "tré" that ends up
82//!    *word-final* survives, while one that lands mid-word gets silently
83//!    de-accented by the `w.replace("tré", "tre")` else-branch:
84//!      * `to_cardinal(103)` == "centotré"        (inner "tre" was unaccented)
85//!      * `to_cardinal(123)` == "centoventitre"   (inner "ventitré" → de-accented!)
86//!      * `to_cardinal(23)`  == "ventitré"
87//!    So 103 keeps its accent and 123 loses it. Reproduced by [`accentuate`].
88//!
89//! 2. **`exponent_length_to_string` indexes `EXPONENT` mod 5**, but
90//!    `EXPONENT[0]` is the string "zero" and the table has 11 entries. The
91//!    prefix therefore cycles with period 30 and lands on "zero" every fifth
92//!    step, so `to_cardinal(10**30)` == "un **zeroilione**" and
93//!    `to_cardinal(10**33)` == "un **zeroiliardo**". Worse, the cycle makes
94//!    exponents collide outright: `to_cardinal(10**36)` == "un milione",
95//!    identical to `to_cardinal(10**6)`. Entries `EXPONENT[5..]`
96//!    ("quint", "sest", "sett", "ott", "nov", "dec") are unreachable dead
97//!    data. Reproduced by [`exponent_length_to_string`].
98//!
99//! 3. **`exponent_length_to_string`'s `"mila"` branch is unreachable.**
100//!    `exponent_length == 3` requires `len(digits) <= 6`, but
101//!    `big_number_to_cardinal` is only called for `number >= 10**6`
102//!    (`len(digits) >= 7`). Kept verbatim anyway. The same reasoning makes the
103//!    empty-`exponent` path (`int("")` → `ValueError`) unreachable; it is
104//!    modelled as `N2WError::Value` rather than a panic, for fidelity.
105//!
106//! 4. **`to_ordinal_num` of a negative raises `AttributeError`, not
107//!    `TypeError`.** `verify_ordinal` reaches
108//!    `raise TypeError(self.errmsg_negord % value)`, but `errmsg_negord` is
109//!    only ever assigned in `Num2Word_Base.__init__`, which `Num2Word_IT`
110//!    never calls. Evaluating `self.errmsg_negord` blows up first with
111//!    `AttributeError: 'Num2Word_IT' object has no attribute 'errmsg_negord'`.
112//!    The corpus confirms this for every negative `ordinal_num` row.
113//!    **`base.rs` has no `N2WError::Attribute` variant**, so this is emitted as
114//!    `N2WError::Type` carrying a message that names `AttributeError`
115//!    explicitly — see [`attribute_error`] and the porting report's `concerns`.
116//!
117//! 5. **`to_ordinal` of a negative is not a real ordinal**: it prefixes "meno "
118//!    and recurses, so `to_ordinal(-1)` == "meno primo" rather than raising.
119//!    `to_ordinal` never calls `verify_ordinal`, unlike `to_ordinal_num`.
120//!
121//! 6. **`to_currency` silently ignores `adjective=True` for `int` input.**
122//!    `Num2Word_IT.to_currency` reimplements the integer branch and simply
123//!    never consults `CURRENCY_ADJECTIVES`, while the float branch delegates to
124//!    `Num2Word_Base.to_currency`, which does apply it. So the flag flips
125//!    behaviour on the *type* of the argument:
126//!      * `to_currency(1,   "USD", adjective=True)` == "uno dollaro"
127//!      * `to_currency(1.0, "USD", adjective=True)` == "uno US dollaro e zero
128//!        centesimi"
129//!    Both verified against the live interpreter. Reproduced in
130//!    [`LangIt::to_currency`] by dropping `adjective` on the `Int` arm only.
131//!
132//! 7. **`CURRENCIES_UNA = "GBP"` is dead data.** `lang_IT.py` defines it,
133//!    plainly intending "una sterlina" for the feminine noun, but nothing ever
134//!    reads it. So Italian emits the ungrammatical masculine article:
135//!    `to_currency(1, "GBP")` == "**uno** sterlina", and `1.0` gives "uno
136//!    sterlina e zero penny". The corpus enshrines both. Likewise
137//!    `to_currency(1000000, "EUR")` == "un milione euro" — no "di".
138//!
139//! Note on the `phonetic_contraction` "diciotto" guard: that one is *not* a
140//! bug but a deliberate fix in this fork (the `"io"` → `"o"` rule would
141//! otherwise mangle "centodiciotto" into "centodicotto"). It is ported as-is.
142//!
143//! # Python semantics that matter here
144//!
145//! All slicing is **character**-based, and the strings genuinely contain "é":
146//! `to_cardinal(23)[:-1]` must yield "ventitr", not a byte-truncated mess. All
147//! slicing below therefore goes through [`drop_last_chars`] / [`last_chars`],
148//! never byte offsets. Python's `s[-3:]` on a shorter string returns the whole
149//! string rather than raising, which [`last_chars`] mirrors via
150//! `saturating_sub`. `str.split()` with no argument splits on whitespace runs
151//! and drops empties — `split_whitespace` matches it.
152
153use crate::base::{Lang, N2WError, Result};
154use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
155use crate::floatpath::FloatValue;
156use crate::strnum::{python_decimal_parse, ParsedNumber};
157use bigdecimal::BigDecimal;
158use num_bigint::BigInt;
159use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
160use std::collections::HashMap;
161
162const ZERO: &str = "zero";
163
164/// `MINUS_PREFIX_WORD`. Note the trailing space — it is the separator.
165const MINUS_PREFIX_WORD: &str = "meno ";
166
167const CARDINAL_WORDS: [&str; 20] = [
168    ZERO,
169    "uno",
170    "due",
171    "tre",
172    "quattro",
173    "cinque",
174    "sei",
175    "sette",
176    "otto",
177    "nove",
178    "dieci",
179    "undici",
180    "dodici",
181    "tredici",
182    "quattordici",
183    "quindici",
184    "sedici",
185    "diciassette",
186    "diciotto",
187    "diciannove",
188];
189
190const ORDINAL_WORDS: [&str; 20] = [
191    ZERO,
192    "primo",
193    "secondo",
194    "terzo",
195    "quarto",
196    "quinto",
197    "sesto",
198    "settimo",
199    "ottavo",
200    "nono",
201    "decimo",
202    "undicesimo",
203    "dodicesimo",
204    "tredicesimo",
205    "quattordicesimo",
206    "quindicesimo",
207    "sedicesimo",
208    "diciassettesimo",
209    "diciottesimo",
210    "diciannovesimo",
211];
212
213/// `EXPONENT_PREFIXES` (aliased as `EXPONENT` in Python). Index 0 is the
214/// literal "zero", and indices 5..=10 are unreachable — see module bug 2.
215const EXPONENT: [&str; 11] = [
216    ZERO, "m", "b", "tr", "quadr", "quint", "sest", "sett", "ott", "nov", "dec",
217];
218
219/// Python `s[:-n]`, character-based. `s[:-n]` on a string of `<= n` chars
220/// yields "" rather than raising.
221fn drop_last_chars(s: &str, n: usize) -> String {
222    let cs: Vec<char> = s.chars().collect();
223    if cs.len() <= n {
224        String::new()
225    } else {
226        cs[..cs.len() - n].iter().collect()
227    }
228}
229
230/// Python `s[-n:]`, character-based. On a string of `< n` chars Python returns
231/// the whole string, hence `saturating_sub`.
232fn last_chars(s: &str, n: usize) -> String {
233    let cs: Vec<char> = s.chars().collect();
234    cs[cs.len().saturating_sub(n)..].iter().collect()
235}
236
237/// `omitt_if_zero` (Python's spelling of the name, typo included).
238fn omitt_if_zero(number_to_string: &str) -> &str {
239    if number_to_string == ZERO {
240        ""
241    } else {
242        number_to_string
243    }
244}
245
246/// `accentuate`: accent a word-final "tre", and strip "tré" everywhere else.
247///
248/// See module bug 1 — the `last_chars(w, 3) == "tre"` test is checked against
249/// the *original* word (so an already-accented "tré" fails it and falls into
250/// the de-accenting else-branch), while the `[:-3]` slice is taken from the
251/// *replaced* string. Both details are load-bearing.
252fn accentuate(string: &str) -> String {
253    string
254        .split_whitespace()
255        .map(|w| {
256            // We shouldn't accentuate a single "tre": it has to be a composite
257            // word — hence the `> 3` length test (in *characters*).
258            if last_chars(w, 3) == "tre" && w.chars().count() > 3 {
259                drop_last_chars(&w.replace("tré", "tre"), 3) + "tré"
260            } else {
261                w.replace("tré", "tre")
262            }
263        })
264        .collect::<Vec<_>>()
265        .join(" ")
266}
267
268/// `phonetic_contraction`: elide colliding vowels at compound seams.
269///
270/// The `"diciotto"` early-return is a deliberate fix in this fork, not a bug:
271/// without it the `"io"` → `"o"` rule would turn "centodiciotto" into
272/// "centodicotto". The remaining rules are applied globally and *in order* —
273/// `.replace()` in both languages rewrites every occurrence, and each stage
274/// sees the previous stage's output.
275fn phonetic_contraction(string: &str) -> String {
276    if string.contains("diciotto") {
277        return string.to_string();
278    }
279
280    string
281        .replace("oo", "o") // ex. "centootto"
282        .replace("ao", "o") // ex. "settantaotto"
283        .replace("io", "o") // ex. "ventiotto"
284        .replace("au", "u") // ex. "trentauno"
285        .replace("iu", "u") // ex. "ventiunesimo"
286}
287
288/// `exponent_length_to_string`. Always called with a length of the form 3n.
289///
290/// The `% 5` and the `"zero"` at `EXPONENT[0]` are module bug 2; the
291/// `== 3` branch is module bug 3 (unreachable). Both ported verbatim.
292fn exponent_length_to_string(exponent_length: usize) -> String {
293    let prefix = EXPONENT[(exponent_length / 6) % 5];
294
295    if exponent_length == 3 {
296        "mila".to_string()
297    } else if exponent_length % 6 == 0 {
298        format!("{}ilione", prefix)
299    } else {
300        format!("{}iliardo", prefix)
301    }
302}
303
304/// Python raised `AttributeError`, which `base.rs` cannot express. See module
305/// bug 4: emitted as `N2WError::Type` with a message naming the real type, so
306/// the integration layer can remap it.
307fn attribute_error(msg: &str) -> N2WError {
308    N2WError::Attribute(msg.to_string())
309}
310
311/// Python raised `ValueError` (`int("")`). Unreachable in practice — see
312/// module bug 3 — but modelled rather than panicked on.
313fn value_error(msg: &str) -> N2WError {
314    N2WError::Value(msg.to_string())
315}
316
317/// `abs(Decimal(str(f)).as_tuple().exponent)` for an f64 — the fractional-digit
318/// count of `repr(f)`. Mirrors the private `floatpath::float_repr_precision`,
319/// reproduced here because the currency fractional-cents entry
320/// ([`LangIt::cardinal_from_decimal`]) reconstructs `float(right)`'s precision
321/// before handing off to the float path. Rust's `{}` for f64 is shortest
322/// round-trip like Python's `repr`, so the count of digits after the point
323/// matches (exponent-notation reprs carry no `'.'` and yield 0, as in Python's
324/// exponent-0 tuple).
325fn float_repr_precision(f: f64) -> u32 {
326    let s = format!("{}", f);
327    match s.split_once('.') {
328        Some((_, frac)) if !frac.contains('e') => frac.len() as u32,
329        _ => 0,
330    }
331}
332
333/// `Num2Word_IT.CURRENCY_FORMS`, transcribed from the class body.
334///
335/// Unlike the 16 classes that read `Num2Word_EUR`'s mutated dict, IT declares
336/// its own attribute, so this really is the source literal — including
337/// `EUR: ("euro", "euro")`, which EN would have rewritten to `("euro",
338/// "euros")` had IT shared the dict. Verified against the live interpreter.
339fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
340    // GENERIC_DOLLARS / GENERIC_CENTS in lang_IT.py.
341    const DOLLARS: [&str; 2] = ["dollaro", "dollari"];
342    const CENTS: [&str; 2] = ["centesimo", "centesimi"];
343
344    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
345    m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &CENTS));
346    m.insert("USD", CurrencyForms::new(&DOLLARS, &CENTS));
347    m.insert("CAD", CurrencyForms::new(&DOLLARS, &CENTS));
348    m.insert("AUD", CurrencyForms::new(&DOLLARS, &CENTS));
349    m.insert("NZD", CurrencyForms::new(&DOLLARS, &CENTS));
350    m.insert("HKD", CurrencyForms::new(&DOLLARS, &CENTS));
351    m.insert("GBP", CurrencyForms::new(&["sterlina", "sterline"], &["penny", "penny"]));
352    m.insert("CNY", CurrencyForms::new(&["yuan", "yuan"], &["fen", "fen"]));
353    m.insert("CHF", CurrencyForms::new(&["franco", "franchi"], &CENTS));
354    m.insert("JPY", CurrencyForms::new(&["yen", "yen"], &["sen", "sen"]));
355    m.insert("INR", CurrencyForms::new(&["rupia", "rupie"], &["paisa", "paise"]));
356    m.insert("RUB", CurrencyForms::new(&["rublo", "rubli"], &["copeco", "copechi"]));
357    m.insert("KRW", CurrencyForms::new(&["won", "won"], &["jeon", "jeon"]));
358    m.insert("MXN", CurrencyForms::new(&["peso", "pesos"], &CENTS));
359    m
360}
361
362/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited verbatim — IT defines none of
363/// its own, and EN never mutates this dict (it only rewrites `CURRENCY_FORMS`
364/// and *rebinds* `CURRENCY_PRECISION`), so all 16 EUR entries are live.
365///
366/// Eight of them (BYN, EEK, NOK, RON, HUF, ISK, UZS, SAR) name codes that are
367/// absent from IT's `CURRENCY_FORMS`, so the forms lookup raises
368/// NotImplementedError long before the adjective is consulted: dead data,
369/// ported for fidelity. Of the rest, only the float path can reach them at all
370/// — see bug 6.
371fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
372    [
373        ("AUD", "Australian"),
374        ("BYN", "Belarusian"),
375        ("CAD", "Canadian"),
376        ("EEK", "Estonian"),
377        ("USD", "US"),
378        ("RUB", "Russian"),
379        ("NOK", "Norwegian"),
380        ("MXN", "Mexican"),
381        ("RON", "Romanian"),
382        ("INR", "Indian"),
383        ("HUF", "Hungarian"),
384        ("ISK", "íslenskar"),
385        ("UZS", "Uzbekistan"),
386        ("SAR", "Saudi"),
387        ("JPY", "Japanese"),
388        ("KRW", "Korean"),
389    ]
390    .into_iter()
391    .collect()
392}
393
394pub struct LangIt {
395    currency_forms: HashMap<&'static str, CurrencyForms>,
396    currency_adjectives: HashMap<&'static str, &'static str>,
397}
398
399impl LangIt {
400    pub fn new() -> Self {
401        LangIt {
402            // Built once here, never per call: `to_currency` only ever reads
403            // these, and rebuilding them on each call is what made an earlier
404            // revision of this port slower than the Python it replaces.
405            currency_forms: build_currency_forms(),
406            currency_adjectives: build_currency_adjectives(),
407        }
408    }
409
410    /// `tens_to_cardinal`. Only ever called with `20 <= number < 100`, so
411    /// `tens` is 2..=9 and both table lookups are in range.
412    fn tens_to_cardinal(&self, number: u32) -> String {
413        let tens = (number / 10) as usize;
414        let units = (number % 10) as usize;
415        // STR_TENS = {2: "venti", 3: "trenta", 4: "quaranta", 6: "sessanta"};
416        // the script extrapolates the rest from the base forms.
417        let prefix = match tens {
418            2 => "venti".to_string(),
419            3 => "trenta".to_string(),
420            4 => "quaranta".to_string(),
421            6 => "sessanta".to_string(),
422            _ => drop_last_chars(CARDINAL_WORDS[tens], 1) + "anta",
423        };
424        let postfix = omitt_if_zero(CARDINAL_WORDS[units]);
425        phonetic_contraction(&format!("{}{}", prefix, postfix))
426    }
427
428    /// `hundreds_to_cardinal`. Only ever called with `100 <= number < 1000`.
429    fn hundreds_to_cardinal(&self, number: u32) -> Result<String> {
430        let hundreds = (number / 100) as usize;
431        let mut prefix = "cento".to_string();
432        if hundreds != 1 {
433            prefix = format!("{}{}", CARDINAL_WORDS[hundreds], prefix);
434        }
435        // Recurses through the *override*, so the inner value is accentuated.
436        let inner = self.cardinal(&BigInt::from(number % 100))?;
437        let postfix = omitt_if_zero(&inner);
438        Ok(phonetic_contraction(&format!("{}{}", prefix, postfix)))
439    }
440
441    /// `thousands_to_cardinal`. Only ever called with `1000 <= number < 10**6`.
442    ///
443    /// Note the absence of `phonetic_contraction` here: Python's comment says
444    /// "mille" and "mila" don't need any. Hence `to_cardinal(2008)` is
445    /// "duemilaotto", *not* "duemilotto".
446    fn thousands_to_cardinal(&self, number: u32) -> Result<String> {
447        let thousands = number / 1000;
448        let prefix = if thousands == 1 {
449            "mille".to_string()
450        } else {
451            format!("{}mila", self.cardinal(&BigInt::from(thousands))?)
452        };
453        let inner = self.cardinal(&BigInt::from(number % 1000))?;
454        let postfix = omitt_if_zero(&inner);
455        Ok(format!("{}{}", prefix, postfix))
456    }
457
458    /// `big_number_to_cardinal`. Only ever called with `number >= 10**6`, and
459    /// `number` is genuinely unbounded here (`BigInt`, never a fixed-width
460    /// cast) up to the 66-digit guard.
461    fn big_number_to_cardinal(&self, number: &BigInt) -> Result<String> {
462        // Python: digits = [c for c in str(int(number))]. `number` is known
463        // non-negative at this point, so no sign leaks into the digit list.
464        let digits: Vec<char> = number.to_string().chars().collect();
465        let length = digits.len();
466        if length >= 66 {
467            return Err(N2WError::NotImplemented(
468                "The given number is too large.".to_string(),
469            ));
470        }
471        // This is how many digits come before the "illion" term.
472        //   cento miliardi => 3
473        //   dieci milioni => 2
474        //   un miliardo => 1
475        // `length % 3 or 3`: Python's `or` yields 3 when the modulo is 0.
476        let predigits = if length % 3 != 0 { length % 3 } else { 3 };
477        let multiplier: String = digits[..predigits].iter().collect();
478        let exponent: String = digits[predigits..].iter().collect();
479        // Default infix string: "milione", "biliardo", "sestilione", ecc.
480        let mut infix = exponent_length_to_string(exponent.len());
481
482        // Python compares the *list* `multiplier == ["1"]`, i.e. exactly one
483        // digit and it is '1' — equivalent to this string compare.
484        let prefix = if multiplier == "1" {
485            "un ".to_string()
486        } else {
487            let p = self.cardinal(
488                &multiplier
489                    .parse::<BigInt>()
490                    .map_err(|_| value_error("invalid literal for int() with base 10"))?,
491            )?;
492            // Plural form: "milione" -> " milioni".
493            infix = format!(" {}i", drop_last_chars(&infix, 1));
494            p
495        };
496
497        // Python: `if set(exponent) != set("0")`, read as "does the value of
498        // exponent equal 0?". set("0") == {'0'}, so the two are equal exactly
499        // when `exponent` is non-empty and every char is '0'. An empty
500        // `exponent` takes the *postfix* branch and feeds "" to int() —
501        // unreachable (see module bug 3) but modelled as ValueError.
502        let exponent_is_zero = !exponent.is_empty() && exponent.chars().all(|c| c == '0');
503        let postfix = if !exponent_is_zero {
504            let p = self.cardinal(
505                &exponent
506                    .parse::<BigInt>()
507                    .map_err(|_| value_error("invalid literal for int() with base 10: ''"))?,
508            )?;
509            if p.contains(" e ") {
510                infix.push_str(", ");
511            } else {
512                infix.push_str(" e ");
513            }
514            p
515        } else {
516            String::new()
517        };
518
519        Ok(format!("{}{}{}", prefix, infix, postfix))
520    }
521
522    /// `Num2Word_IT.to_cardinal`, integer path only.
523    ///
524    /// Python's negative branch recurses (which accentuates the positive part),
525    /// prepends "meno ", and then `return string if number < 0 else
526    /// accentuate(string)` deliberately skips a second `accentuate` pass. The
527    /// early return below is exactly equivalent — and the skip matters, because
528    /// a second pass would be *lossy*, not idempotent (see module bug 1).
529    fn cardinal(&self, number: &BigInt) -> Result<String> {
530        if number.is_negative() {
531            let positive_part = self.cardinal(&-number)?;
532            return Ok(format!("{}{}", MINUS_PREFIX_WORD, positive_part));
533        }
534        // Python's `elif int(number) != number` float branch is out of scope:
535        // integer input only.
536        let string = if number < &BigInt::from(20) {
537            // Safe: 0 <= number < 20.
538            CARDINAL_WORDS[number.to_usize().expect("0 <= number < 20")].to_string()
539        } else if number < &BigInt::from(100) {
540            self.tens_to_cardinal(number.to_u32().expect("20 <= number < 100"))
541        } else if number < &BigInt::from(1000) {
542            self.hundreds_to_cardinal(number.to_u32().expect("100 <= number < 1000"))?
543        } else if number < &BigInt::from(1_000_000) {
544            self.thousands_to_cardinal(number.to_u32().expect("1000 <= number < 10**6"))?
545        } else {
546            self.big_number_to_cardinal(number)?
547        };
548        Ok(accentuate(&string))
549    }
550
551    /// `Num2Word_IT.to_ordinal`, integer path only.
552    fn ordinal(&self, number: &BigInt) -> Result<String> {
553        if number.is_negative() {
554            // No verify_ordinal call here — see module bug 5.
555            return Ok(format!("{}{}", MINUS_PREFIX_WORD, self.ordinal(&-number)?));
556        }
557        // Python's float branch is out of scope: integer input only.
558
559        // `number` is non-negative, so Rust's truncating `%` agrees with
560        // Python's floor `%` and `tens` lands in 0..=99.
561        let tens = (number % BigInt::from(100)).to_u32().expect("0 <= tens < 100");
562        // Italian grammar is poorly defined here ¯\_(ツ)_/¯:
563        //   centodecimo VS centodieciesimo VS centesimo decimo?
564        let is_outside_teens = !(10 < tens && tens < 20);
565
566        if number < &BigInt::from(20) {
567            Ok(ORDINAL_WORDS[number.to_usize().expect("0 <= number < 20")].to_string())
568        } else if is_outside_teens && tens % 10 == 3 {
569            // Gets rid of the accent: "ventitré" -> "ventitr" + "eesimo".
570            Ok(drop_last_chars(&self.cardinal(number)?, 1) + "eesimo")
571        } else if is_outside_teens && tens % 10 == 6 {
572            Ok(self.cardinal(number)? + "esimo")
573        } else {
574            let mut string = drop_last_chars(&self.cardinal(number)?, 1);
575            // "duemila" -> "duemil" -> "duemill" -> "duemillesimo".
576            if last_chars(&string, 3) == "mil" {
577                string.push('l');
578            }
579            Ok(string + "esimo")
580        }
581    }
582
583    /// `Num2Word_Base.verify_ordinal`, as reached from `to_ordinal_num`.
584    ///
585    /// The float check (`errmsg_floatord`) cannot fire on integer input. The
586    /// negative check is module bug 4: Python means to raise `TypeError` but
587    /// dies evaluating the missing `errmsg_negord` attribute first.
588    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
589        if value.is_negative() {
590            return Err(attribute_error(
591                "'Num2Word_IT' object has no attribute 'errmsg_negord'",
592            ));
593        }
594        Ok(())
595    }
596
597    /// `Num2Word_IT.float_to_words(float_number, ordinal=True)` for a
598    /// **non-negative, non-integral f64** — the ordinal float grammar:
599    /// `to_ordinal(int(x))` + " virgola " + one cardinal per repr digit.
600    /// Unlike the cardinal entry, `to_ordinal` returns this **without** a
601    /// trailing `accentuate` pass.
602    fn float_ordinal_body_f64(&self, x: f64, precision: u32) -> Result<String> {
603        let s = format!("{:.*}", precision as usize, x);
604        let (int_str, frac) = match s.split_once('.') {
605            Some(p) => p,
606            None => return Err(N2WError::Index("list index out of range".into())),
607        };
608        let pre: BigInt = int_str
609            .parse()
610            .map_err(|_| value_error("invalid literal for int() with base 10"))?;
611        let prefix = self.ordinal(&pre)?;
612
613        let mut parts: Vec<String> = Vec::new();
614        for c in frac.chars() {
615            let d = c
616                .to_digit(10)
617                .ok_or_else(|| value_error("invalid literal for int() with base 10"))?;
618            parts.push(self.cardinal(&BigInt::from(d))?);
619        }
620        Ok(format!("{} virgola {}", prefix, parts.join(" ")))
621    }
622
623    /// `Num2Word_IT.float_to_words` for the **f64** arm, applied to a
624    /// **non-negative** value (the caller strips the sign).
625    ///
626    /// ```python
627    /// def float_to_words(self, float_number, ordinal=False):
628    ///     prefix = self.to_cardinal(int(float_number))
629    ///     float_part = str(float_number).split(".")[1]
630    ///     postfix = " ".join([self.to_cardinal(int(c)) for c in float_part])
631    ///     return prefix + Num2Word_IT.FLOAT_INFIX_WORD + postfix
632    /// ```
633    ///
634    /// This never touches `base.float2tuple`: the fractional digits come
635    /// straight out of `str(float_number)`, one character at a time, so `2.675`
636    /// renders "sei sette cinque" (6 7 5) because `str(2.675)` is literally
637    /// `"2.675"` — no `< 0.01` artefact heuristic, no banker's rounding.
638    ///
639    /// The `int(number) != number` gate in `to_cardinal` means an
640    /// integer-valued float never enters `float_to_words`; it falls through to
641    /// the integer branches instead (so `1.0` → "uno", not "uno virgola zero").
642    ///
643    /// `precision` is the repr-derived fractional-digit count. In the normal
644    /// range it equals `len(str(float_number).split(".")[1])`, so a fixed format
645    /// to that many places reproduces the exact repr digits (verified
646    /// byte-for-byte against the interpreter over the corpus float set; `{:.N}`
647    /// rounds the exact binary value half-to-even, agreeing with CPython where a
648    /// shortest-repr reconstruction would not). Exponent-notation reprs
649    /// (`abs < 1e-4`) are out of corpus scope — see the port report.
650    fn float_body_f64(&self, x: f64, precision: u32) -> Result<String> {
651        // `elif int(number) != number` — integer-valued floats skip float_to_words.
652        if x == x.trunc() {
653            // `int(x)`: the exact integer value of the float. `{:.0}` on an
654            // integer-valued f64 is exact (no tie to resolve).
655            let int_part: BigInt = format!("{:.0}", x)
656                .parse()
657                .map_err(|_| value_error("invalid literal for int() with base 10"))?;
658            // `self.cardinal` already applies the final `accentuate` pass, which
659            // is exactly what `to_cardinal` does for the integer branches.
660            return self.cardinal(&int_part);
661        }
662
663        // Reconstruct `str(float_number)`: shortest round-trip == fixed format to
664        // the repr-derived precision (see the doc note above).
665        let s = format!("{:.*}", precision as usize, x);
666        let (int_str, frac) = match s.split_once('.') {
667            Some(p) => p,
668            // Python: `str(float_number).split(".")[1]` with no '.' → IndexError.
669            None => return Err(N2WError::Index("list index out of range".into())),
670        };
671
672        // prefix = to_cardinal(int(float_number)); `int_str` equals int(x) for
673        // the fixed form the reconstruction produces (int() truncates toward
674        // zero on a non-negative).
675        let pre: BigInt = int_str
676            .parse()
677            .map_err(|_| value_error("invalid literal for int() with base 10"))?;
678        let prefix = self.cardinal(&pre)?;
679
680        // postfix = " ".join(to_cardinal(int(c)) for c in float_part)
681        let mut parts: Vec<String> = Vec::new();
682        for c in frac.chars() {
683            // Python `int(c)`: a non-digit (e.g. 'e' from exponent notation)
684            // raises ValueError. Out of corpus scope, reproduced as the variant.
685            let d = c
686                .to_digit(10)
687                .ok_or_else(|| value_error("invalid literal for int() with base 10"))?;
688            parts.push(self.cardinal(&BigInt::from(d))?);
689        }
690
691        // prefix + FLOAT_INFIX_WORD (" virgola ") + postfix, then the outer
692        // `accentuate` pass `to_cardinal` applies for non-negative input. The
693        // prefix was accentuated once inside `self.cardinal`; this second pass
694        // reproduces Python's double application (bug 1's map is not idempotent,
695        // so the repeat is load-bearing, not redundant).
696        let raw = format!("{} virgola {}", prefix, parts.join(" "));
697        Ok(accentuate(&raw))
698    }
699
700    /// `Num2Word_IT.float_to_words` for the **Decimal** arm, applied to a
701    /// **non-negative** value.
702    ///
703    /// A `Decimal` is not a `float`, but IT's `to_cardinal` gates on
704    /// `int(number) != number` (not `isinstance(number, float)`), so a Decimal
705    /// flows through the *same* `float_to_words` — reading `str(Decimal)`, which
706    /// preserves trailing zeros and exact digits: `Decimal("1.10")` →
707    /// "uno virgola uno zero", and `Decimal("2.00")` is integer-valued →
708    /// "due".
709    ///
710    /// The digit split is reconstructed from the coefficient at scale
711    /// `precision` rather than from `str(Decimal)` directly, so it is
712    /// independent of `BigDecimal`'s Display. For the fixed-notation Decimals in
713    /// scope this equals `int(x)` and `str(x).split(".")[1]` exactly. Scientific
714    /// -notation Decimals (adjusted exponent `< -6`, or exponent `> 0`) are out
715    /// of corpus scope — see the port report.
716    fn float_body_dec(&self, x: &BigDecimal, precision: u32) -> Result<String> {
717        // `x` is non-negative; force scale = precision (exact padding, no
718        // rounding, since the value carries at most `precision` fractional
719        // digits) so the coefficient's low `precision` digits are the fraction.
720        let scaled = x.with_scale(precision as i64);
721        let coeff = scaled.as_bigint_and_exponent().0; // >= 0
722        let divisor = BigInt::from(10).pow(precision);
723        let int_part = &coeff / &divisor;
724        let frac_val = &coeff % &divisor;
725
726        // `int(number) == number` — an integer-valued Decimal skips float_to_words.
727        if frac_val.is_zero() {
728            return self.cardinal(&int_part);
729        }
730
731        let prefix = self.cardinal(&int_part)?;
732
733        // Zero-pad to `precision` digits: str(Decimal) keeps the full fractional
734        // field, so "0.001" → "001", not "1".
735        let frac_raw = frac_val.to_string();
736        let frac_str = format!(
737            "{}{}",
738            "0".repeat((precision as usize).saturating_sub(frac_raw.len())),
739            frac_raw
740        );
741
742        let mut parts: Vec<String> = Vec::new();
743        for c in frac_str.chars() {
744            // Every char is a decimal digit by construction.
745            let d = c.to_digit(10).expect("frac_str is all decimal digits");
746            parts.push(self.cardinal(&BigInt::from(d))?);
747        }
748
749        let raw = format!("{} virgola {}", prefix, parts.join(" "));
750        Ok(accentuate(&raw))
751    }
752}
753
754impl Default for LangIt {
755    fn default() -> Self {
756        LangIt::new()
757    }
758}
759
760impl Lang for LangIt {
761    /// This language's own `to_currency(currency=...)` default,
762    /// read from the live Python signature. Only 44 of 156 use EUR.
763    fn default_currency(&self) -> &str {
764        "EUR"
765    }
766
767    /// This language's own `to_currency(separator=...)` default,
768    /// read from the live Python signature. Base's is ",", but only
769    /// 36 of 149 languages actually use it — most default to " " or a
770    /// conjunction, so inheriting Base's comma silently corrupts them.
771    fn default_separator(&self) -> &str {
772        " e"
773    }
774
775    // cards/maxval/merge stay at their trait defaults: Python never builds
776    // self.cards for this class, so splitnum/clean/merge are unreachable and
777    // there is no MAXVAL overflow check. See the module docs.
778
779    /// `Num2Word_IT.setup` sets `self.negword = "meno"`.
780    fn negword(&self) -> &str {
781        "meno"
782    }
783
784    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
785        self.cardinal(value)
786    }
787
788    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
789        self.ordinal(value)
790    }
791
792    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
793        self.verify_ordinal(value)?;
794        // Python: return str(int(value))
795        Ok(value.to_string())
796    }
797
798    /// `Num2Word_IT.to_cardinal(<float | Decimal>)` — the float/Decimal path.
799    ///
800    /// IT does **not** override Python's `to_cardinal_float`; instead its
801    /// overridden `to_cardinal` handles non-integers inline:
802    ///
803    /// ```python
804    /// def to_cardinal(self, number):
805    ///     if number < 0:
806    ///         positive_part = self.to_cardinal(-number)
807    ///         string = Num2Word_IT.MINUS_PREFIX_WORD + positive_part
808    ///     elif int(number) != number:
809    ///         string = self.float_to_words(number)
810    ///     ...
811    ///     return string if number < 0 else accentuate(string)
812    /// ```
813    ///
814    /// So the sign branch recurses on the positive part, prepends "meno ", and
815    /// returns **without** the trailing `accentuate` (the positive body already
816    /// got it). This override reproduces exactly that: render the non-negative
817    /// body, then prepend the sign.
818    ///
819    /// `precision_override` (the `precision=` kwarg) is ignored, exactly as
820    /// Python ignores it here: IT never calls `Num2Word_Base.__init__`, so it
821    /// has no `self.precision`, the dispatcher's `hasattr(converter,
822    /// "precision")` guard is false, and `float_to_words` reads `str(number)`
823    /// regardless (confirmed live: `num2words(2.675, lang="it", precision=1)`
824    /// is unchanged).
825    fn to_cardinal_float(
826        &self,
827        value: &FloatValue,
828        _precision_override: Option<u32>,
829    ) -> Result<String> {
830        let neg = value.is_negative();
831        let body = match value {
832            FloatValue::Float { value, precision } => {
833                self.float_body_f64(value.abs(), *precision)?
834            }
835            FloatValue::Decimal { value, precision } => {
836                self.float_body_dec(&value.abs(), *precision)?
837            }
838        };
839        if neg {
840            Ok(format!("{}{}", MINUS_PREFIX_WORD, body))
841        } else {
842            Ok(body)
843        }
844    }
845
846    /// `to_ordinal(float/Decimal)` — `Num2Word_IT.to_ordinal`'s full routing.
847    ///
848    /// ```python
849    /// if number < 0:
850    ///     return Num2Word_IT.MINUS_PREFIX_WORD + self.to_ordinal(-number)
851    /// if isinstance(number, float) and not number.is_integer():
852    ///     return self.float_to_words(number, ordinal=True)
853    /// number = int(number)
854    /// ...
855    /// ```
856    ///
857    /// The float branch is gated on `isinstance(float)`, so a fractional
858    /// `Decimal` skips it and is silently truncated by `int()` —
859    /// `Decimal("1.5")` → "primo" while `1.5` → "primo virgola cinque".
860    /// `-0.0` is not `< 0`, so it truncates to `ORDINAL_WORDS[0]` == "zero".
861    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
862        let strictly_negative = match value {
863            FloatValue::Float { value, .. } => *value < 0.0,
864            FloatValue::Decimal { value, .. } => value.is_negative(),
865        };
866        if strictly_negative {
867            let abs = match value {
868                FloatValue::Float { value, precision } => FloatValue::Float {
869                    value: -*value,
870                    precision: *precision,
871                },
872                FloatValue::Decimal { value, precision } => FloatValue::Decimal {
873                    value: -value.clone(),
874                    precision: *precision,
875                },
876            };
877            return Ok(format!(
878                "{}{}",
879                MINUS_PREFIX_WORD,
880                self.ordinal_float_entry(&abs)?
881            ));
882        }
883        match value {
884            FloatValue::Float { value: v, precision } if v.fract() != 0.0 => {
885                self.float_ordinal_body_f64(*v, *precision)
886            }
887            FloatValue::Float { value: v, .. } => {
888                let i = BigInt::from_f64(v.trunc()).ok_or_else(|| {
889                    value_error("cannot convert float infinity to integer")
890                })?;
891                self.ordinal(&i)
892            }
893            FloatValue::Decimal { value: d, .. } => {
894                // int(Decimal) truncates toward zero.
895                self.ordinal(&d.with_scale(0).as_bigint_and_exponent().0)
896            }
897        }
898    }
899
900    /// `to_ordinal_num(float/Decimal)`: `verify_ordinal(value)` then
901    /// `str(int(value))`.
902    ///
903    /// `Num2Word_IT.__init__` never chains to `Num2Word_Base.__init__`, so
904    /// neither `errmsg_floatord` nor `errmsg_negord` exists — both
905    /// verify_ordinal arms die on the attribute lookup (AttributeError, not
906    /// TypeError; module bug 4). A fractional value hits the float arm first,
907    /// so `-1.5` is the `errmsg_floatord` AttributeError.
908    fn ordinal_num_float_entry(&self, value: &FloatValue, _repr_str: &str) -> Result<String> {
909        match value.as_whole_int() {
910            None => Err(attribute_error(
911                "'Num2Word_IT' object has no attribute 'errmsg_floatord'",
912            )),
913            Some(i) if i.is_negative() => Err(attribute_error(
914                "'Num2Word_IT' object has no attribute 'errmsg_negord'",
915            )),
916            Some(i) => Ok(i.to_string()),
917        }
918    }
919
920    /// `Num2Word_IT.to_fraction` (issue #584): Italian '-o → -i' plurals, the
921    /// idiomatic "mezzo"/"mezzi" for halves, and "un" (not "uno") as the
922    /// unit numerator.
923    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
924        if denominator.is_zero() {
925            return Err(N2WError::ZeroDivision(
926                "denominator must not be zero".into(),
927            ));
928        }
929        if denominator == &BigInt::one() || numerator.is_zero() {
930            return self.cardinal(numerator);
931        }
932        let is_negative = numerator.is_negative() ^ denominator.is_negative();
933        let abs_n = numerator.abs();
934        let abs_d = denominator.abs();
935
936        let den_word = if abs_d == BigInt::from(2) {
937            if abs_n.is_one() { "mezzo" } else { "mezzi" }.to_string()
938        } else {
939            let mut den = self.ordinal(&abs_d)?;
940            // Italian -o → -i for plural masculine nouns.
941            if !abs_n.is_one() && den.ends_with('o') {
942                den.pop();
943                den.push('i');
944            }
945            den
946        };
947        let num_word = if abs_n.is_one() {
948            "un".to_string()
949        } else {
950            self.cardinal(&abs_n)?
951        };
952        let sign = if is_negative { "meno " } else { "" };
953        Ok(format!("{}{} {}", sign, num_word, den_word))
954    }
955
956    /// `converter.str_to_number` — Base's `Decimal(value)`. `Decimal("NaN")`
957    /// parses fine; IT's `to_cardinal` then dies on the very first comparison
958    /// (`number < 0`) with `decimal.InvalidOperation`. The binding otherwise
959    /// maps `ParsedNumber::NaN` to `int(NaN)`'s ValueError, so the
960    /// InvalidOperation must be raised here. Infinity keeps the default
961    /// routing: `int(Decimal("Infinity"))` → OverflowError, which the binding
962    /// already produces.
963    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
964        match python_decimal_parse(s)? {
965            ParsedNumber::NaN => Err(N2WError::Custom {
966                module: "decimal",
967                class: "InvalidOperation",
968                msg: "[<class 'decimal.InvalidOperation'>]".into(),
969            }),
970            other => Ok(other),
971        }
972    }
973
974    /// Currency's fractional-cents entry point. `base.to_currency` computes
975    /// `cents_str = self.to_cardinal(float(right))` (base.py line 476) — it
976    /// routes the fractional cents through IT's own float path, so this must go
977    /// through the overridden [`to_cardinal_float`](Self::to_cardinal_float),
978    /// **not** the base "(.)"-pointword path the trait default would use:
979    ///
980    /// * pure-Python IT: `to_currency(1.011, "USD")` → "uno dollaro e uno
981    ///   **virgola** uno centesimi"
982    /// * trait default:  "uno dollaro e uno **(.)** uno centesimi"
983    ///
984    /// Unreachable from the frozen corpus (no IT currency row has fractional
985    /// cents), verified against the pure-Python interpreter.
986    fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
987        // Python: `float(right)`. Reproduce the f64 cast and its repr-derived
988        // precision, then hand off to the float path.
989        let f = value
990            .to_f64()
991            .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", value)))?;
992        let precision = float_repr_precision(f);
993        self.to_cardinal_float(&FloatValue::Float { value: f, precision }, None)
994    }
995
996    // to_year is inherited unchanged from Num2Word_Base
997    // (`return self.to_cardinal(value)`); the trait default does exactly that
998    // and dispatches back through the override above.
999
1000    // ---- currency -------------------------------------------------------
1001    //
1002    // IT overrides `to_currency` (integer branch only) and inherits
1003    // `pluralize` from `Num2Word_EUR`. Everything else on the currency path —
1004    // `to_cheque`, `_money_verbose`, `_cents_verbose`, `_cents_terse` — comes
1005    // from `Num2Word_Base` unchanged, and the trait defaults already mirror it.
1006    //
1007    // `currency_precision` is *deliberately* not overridden: IT's
1008    // CURRENCY_PRECISION resolves to Base's empty `{}`, so `.get(code, 100)`
1009    // is always 100 — already the trait default. See the module docs for why
1010    // that makes JPY 2-decimal and KWD/BHD unreachable here.
1011    //
1012    // `cardinal_from_decimal` IS overridden (below), because IT's float path is
1013    // now ported. `Num2Word_IT` overrides `float_to_words` with
1014    // `FLOAT_INFIX_WORD = " virgola "` and never consults `pointword` — indeed
1015    // it has no `pointword` attribute at all, since it never calls
1016    // `Num2Word_Base.__init__`, so the trait's "(.)" default is a phantom here.
1017    // The float path and the currency fractional-cents entry are overridden
1018    // together, as required. Unreachable from the frozen corpus: no IT row has
1019    // fractional cents (every arg is integral at 2 decimals), so
1020    // `default_to_currency`'s `has_fractional_cents` branch never fires — the
1021    // override is verified against the pure-Python interpreter instead.
1022
1023    fn lang_name(&self) -> &str {
1024        "Num2Word_IT"
1025    }
1026
1027    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1028        self.currency_forms.get(code)
1029    }
1030
1031    fn currency_adjective(&self, code: &str) -> Option<&str> {
1032        self.currency_adjectives.get(code).copied()
1033    }
1034
1035    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
1036    ///
1037    /// Reached only from the float path (`default_to_currency`); IT's integer
1038    /// branch open-codes the same rule inline instead of calling this. Python
1039    /// indexes the tuple directly, so a one-form entry with `n != 1` would
1040    /// raise IndexError — every entry in IT's table has exactly two forms, so
1041    /// that is unreachable, but it is mapped to `Index` rather than panicking
1042    /// in case the table ever changes.
1043    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1044        let form = if n.is_one() { 0 } else { 1 };
1045        forms
1046            .get(form)
1047            .cloned()
1048            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
1049    }
1050
1051    /// `Num2Word_IT.to_currency`.
1052    ///
1053    /// Python special-cases `isinstance(val, int)` with a hand-rolled branch
1054    /// and delegates everything else to `Num2Word_Base.to_currency` via
1055    /// `super()`. The int branch differs from Base's in three ways, all
1056    /// preserved here:
1057    ///
1058    ///   1. It ignores `adjective` entirely (module bug 6).
1059    ///   2. It hardcodes the literal `"meno"` instead of reading
1060    ///      `self.negword.strip()`. Same string either way, so unobservable —
1061    ///      transcribed literally regardless.
1062    ///   3. It calls `self.to_cardinal` directly rather than
1063    ///      `self._money_verbose`. Base's `_money_verbose` *is*
1064    ///      `self.to_cardinal`, so again unobservable.
1065    ///
1066    /// The `except (KeyError, AttributeError)` fallback re-enters Base's
1067    /// `to_currency`, which repeats the same failed lookup and turns it into
1068    /// `NotImplementedError` — so an unknown code raises identically on both
1069    /// arms. (`AttributeError` cannot fire: `CURRENCY_FORMS` always exists.)
1070    fn to_currency(
1071        &self,
1072        val: &CurrencyValue,
1073        currency: &str,
1074        cents: bool,
1075        separator: Option<&str>,
1076        adjective: bool,
1077    ) -> Result<String> {
1078        // Trait hands us None when the caller omitted separator=; resolve it to
1079        // this language's own default (" e") before the ported body runs.
1080        let separator = separator.unwrap_or(self.default_separator());
1081
1082        // `isinstance(val, int)` — a true Python int. A float that happens to
1083        // be whole (1.0) is NOT this arm and still renders cents.
1084        if let CurrencyValue::Int(v) = val {
1085            let forms = match self.currency_forms.get(currency) {
1086                Some(f) => f,
1087                // KeyError -> super().to_currency(...), which raises
1088                // NotImplementedError from its own identical lookup.
1089                None => {
1090                    return default_to_currency(self, val, currency, cents, separator, adjective)
1091                }
1092            };
1093            let cr1 = &forms.unit;
1094
1095            // Python: minus_str = "meno" if val < 0 else "" — the literal, not
1096            // negword. `adjective` is not consulted at all here (bug 6).
1097            let minus = v.is_negative();
1098            let abs_val = v.abs();
1099            let money_str = self.cardinal(&abs_val)?;
1100
1101            // Python: cr1[0] if abs_val == 1 else (cr1[1] if len(cr1) > 1 else
1102            // cr1[0]). Both arms index a tuple, so an empty one would raise
1103            // IndexError; IT's entries all have two forms, so unreachable.
1104            let currency_str = if abs_val.is_one() {
1105                cr1.first()
1106            } else {
1107                cr1.get(1).or_else(|| cr1.first())
1108            }
1109            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
1110
1111            let result = if minus {
1112                format!("meno {} {}", money_str, currency_str)
1113            } else {
1114                format!("{} {}", money_str, currency_str)
1115            };
1116            // Python's trailing .strip(); a no-op for every real entry, kept
1117            // because it is what the source does.
1118            return Ok(result.trim().to_string());
1119        }
1120
1121        // Floats/Decimals: `super().to_currency(...)` == Num2Word_Base's, which
1122        // pluralizes via Num2Word_EUR and *does* honour `adjective`.
1123        default_to_currency(self, val, currency, cents, separator, adjective)
1124    }
1125}