Skip to main content

num2words2_core/
presentation.rs

1//! Presentation / dispatch helpers ported from `num2words2/__init__.py`.
2//!
3//! These three functions were the last pieces of pure-Python logic living in
4//! the `num2words2` binder: language-code resolution, the `style=`
5//! post-processing, and the `cents=` mode mapping. They are conversion-agnostic
6//! string/enum transforms, so they belong in the pure core rather than the
7//! Python surface. The PyO3 binder classifies the caller's arguments into the
8//! plain types below and calls these directly.
9
10/// Resolve a caller's language code to a core key, mirroring the historic
11/// dispatcher (`num2words2.__init__._normalize_lang`):
12///
13/// 1. exact match, then
14/// 2. hyphen -> underscore, then
15/// 3. `xx_YY` casing, then
16/// 4. the bare two-letter prefix.
17///
18/// Returns `None` when none match; the binder turns that into the same empty
19/// `NotImplementedError` the Python raised.
20pub fn resolve_lang(raw: &str) -> Option<String> {
21    let keys = crate::supported_lang_keys();
22    // `keys.contains(&s)` cannot be used: the keys are `&'static str` and the
23    // needle is a borrowed `&str`, so the slice `contains` type-checks fail.
24    #[allow(clippy::manual_contains)]
25    let known = |s: &str| keys.iter().any(|k| *k == s);
26
27    if known(raw) {
28        return Some(raw.to_string());
29    }
30    let nl = raw.replace('-', "_");
31    if known(&nl) {
32        return Some(nl);
33    }
34    let parts: Vec<&str> = nl.split('_').collect();
35    if parts.len() >= 2 {
36        let candidate = format!("{}_{}", parts[0].to_lowercase(), parts[1].to_uppercase());
37        if known(&candidate) {
38            return Some(candidate);
39        }
40        if known(parts[0]) {
41            return Some(parts[0].to_string());
42        }
43    }
44    let prefix: String = nl.chars().take(2).collect();
45    if known(&prefix) {
46        return Some(prefix);
47    }
48    None
49}
50
51/// `style=` presentation post-processing (issues #535, #562), mirroring
52/// `num2words2.__init__._apply_style`. Operates on the rendered string, so it
53/// is conversion-independent:
54///
55/// * `style="terse"` + `to == "ordinal"` strips a leading `"one "`/`"un "`/
56///   `"uno "` (but never reduces the whole string to empty).
57/// * `style="us"` + an English `lang` replaces `" and "` with `" "`.
58pub fn apply_style(result: &str, style: Option<&str>, to: &str, lang: &str) -> String {
59    let mut out = result.to_string();
60    if style == Some("terse") && to == "ordinal" {
61        for prefix in ["one ", "un ", "uno "] {
62            if out.starts_with(prefix) && out.len() > prefix.len() {
63                out = out[prefix.len()..].to_string();
64                break;
65            }
66        }
67    }
68    if style == Some("us") && lang.starts_with("en") {
69        out = out.replace(" and ", " ");
70    }
71    out
72}
73
74/// The `cents=` argument as the binder classifies the caller's Python object.
75pub enum CentsArg<'a> {
76    /// The kwarg was not supplied at all (Python default `True`).
77    Absent,
78    /// A real Python `bool`.
79    Bool(bool),
80    /// A Python `str` (only the three keywords are meaningful).
81    Str(&'a str),
82    /// Any other type — Python keeps it as-is, and the caller's
83    /// `_cents in (True, False)` guard then rejects it.
84    Other,
85}
86
87/// `cents='omit'|'verbose'|'terse'` -> the legacy bool the core expects
88/// (issue #554), mirroring `num2words2.__init__._normalize_cents` **plus** the
89/// caller's follow-up `_cents in (True, False)` guard.
90///
91/// Returns `Some((cents_bool, drop_cents))` when the value resolves to a usable
92/// bool (absent, a real bool, or one of the three keywords); `drop_cents` means
93/// the value should be truncated to an int so no cents segment appears.
94/// Returns `None` when Python would have kept a non-bool value and the guard
95/// would reject it (the binder then declines the call).
96pub fn normalize_cents(cents: CentsArg) -> Option<(bool, bool)> {
97    match cents {
98        CentsArg::Absent => Some((true, false)),
99        CentsArg::Str("omit") => Some((true, true)),
100        CentsArg::Str("verbose") => Some((true, false)),
101        CentsArg::Str("terse") => Some((false, false)),
102        CentsArg::Bool(b) => Some((b, false)),
103        CentsArg::Str(_) | CentsArg::Other => None,
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn resolve_exact_and_hyphen() {
113        assert_eq!(resolve_lang("en").as_deref(), Some("en"));
114        assert_eq!(resolve_lang("pt_BR").as_deref(), Some("pt_BR"));
115        // hyphen -> underscore, exact key
116        assert_eq!(resolve_lang("pt-BR").as_deref(), Some("pt_BR"));
117    }
118
119    #[test]
120    fn resolve_casing_and_prefix() {
121        // en-US: no en_US key, casing candidate missing, prefix "en" wins.
122        assert_eq!(resolve_lang("en-US").as_deref(), Some("en"));
123        assert_eq!(resolve_lang("en_US").as_deref(), Some("en"));
124        // Two-letter-prefix fallback on a longer word.
125        assert_eq!(resolve_lang("english").as_deref(), Some("en"));
126    }
127
128    #[test]
129    fn resolve_unknown() {
130        assert_eq!(resolve_lang("unknown_lang"), None);
131        assert_eq!(resolve_lang("zz"), None);
132    }
133
134    #[test]
135    fn style_terse_strips_leading_one() {
136        assert_eq!(
137            apply_style("one hundredth", Some("terse"), "ordinal", "en"),
138            "hundredth"
139        );
140        // Exactly "first" is untouched (no leading "one ").
141        assert_eq!(apply_style("first", Some("terse"), "ordinal", "en"), "first");
142        // terse only applies to ordinal.
143        assert_eq!(
144            apply_style("one hundred", Some("terse"), "cardinal", "en"),
145            "one hundred"
146        );
147    }
148
149    #[test]
150    fn style_us_drops_and() {
151        assert_eq!(
152            apply_style("one thousand and one", Some("us"), "cardinal", "en"),
153            "one thousand one"
154        );
155        // Non-English lang is untouched.
156        assert_eq!(
157            apply_style("mil y uno", Some("us"), "cardinal", "es"),
158            "mil y uno"
159        );
160        // No style is a no-op.
161        assert_eq!(apply_style("x and y", None, "cardinal", "en"), "x and y");
162    }
163
164    #[test]
165    fn cents_modes() {
166        assert_eq!(normalize_cents(CentsArg::Absent), Some((true, false)));
167        assert_eq!(normalize_cents(CentsArg::Str("omit")), Some((true, true)));
168        assert_eq!(normalize_cents(CentsArg::Str("verbose")), Some((true, false)));
169        assert_eq!(normalize_cents(CentsArg::Str("terse")), Some((false, false)));
170        assert_eq!(normalize_cents(CentsArg::Bool(true)), Some((true, false)));
171        assert_eq!(normalize_cents(CentsArg::Bool(false)), Some((false, false)));
172        assert_eq!(normalize_cents(CentsArg::Str("nope")), None);
173        assert_eq!(normalize_cents(CentsArg::Other), None);
174    }
175}