use crate::base::{Lang, N2WError, Result};
use crate::currency::{CurrencyForms, CurrencyValue};
use crate::floatpath::FloatValue;
use bigdecimal::BigDecimal;
use num_bigint::BigInt;
use num_integer::Integer;
use num_traits::{One, Signed, ToPrimitive, Zero};
use std::collections::HashMap;
const ONES: [&str; 10] = [
"sifiri", "ɗaya", "biyu", "uku", "huɗu", "biyar", "shida", "bakwai", "takwas", "tara",
];
const TEENS: [&str; 10] = [
"goma",
"sha ɗaya",
"sha biyu",
"sha uku",
"sha huɗu",
"sha biyar",
"sha shida",
"sha bakwai",
"sha takwas",
"sha tara",
];
const TENS: [&str; 10] = [
"", "", "ashirin", "talatin", "arba'in", "hamsin", "sittin", "saba'in", "tamanin", "casa'in",
];
const NEGWORD: &str = "ban ";
const SCALE: [(u32, &str); 5] = [
(12, "tiriliyan"),
(9, "biliyan"),
(6, "miliyan"),
(3, "dubu"),
(2, "ɗari"),
];
fn pow10(exp: u32) -> BigInt {
BigInt::from(10u8).pow(exp)
}
enum Minor {
Int(BigInt),
Dec(BigDecimal),
}
pub struct LangHa {
scale: Vec<(BigInt, &'static str)>,
currency_forms: HashMap<&'static str, CurrencyForms>,
}
impl LangHa {
pub fn new() -> Self {
let mut currency_forms = HashMap::new();
currency_forms.insert(
"NGN",
CurrencyForms::new(&["naira", "naira"], &["kobo", "kobo"]),
);
currency_forms.insert(
"USD",
CurrencyForms::new(&["dala", "dala"], &["cent", "cent"]),
);
currency_forms.insert(
"EUR",
CurrencyForms::new(&["yuro", "yuro"], &["cent", "cent"]),
);
currency_forms.insert(
"GBP",
CurrencyForms::new(&["fam", "fam"], &["pence", "pence"]),
);
currency_forms.insert("JPY", CurrencyForms::new(&["yen", "yen"], &["sen", "sen"]));
currency_forms.insert(
"CNY",
CurrencyForms::new(&["yuan", "yuan"], &["fen", "fen"]),
);
LangHa {
scale: SCALE.iter().map(|(e, w)| (pow10(*e), *w)).collect(),
currency_forms,
}
}
fn int_to_hausa(&self, number: &BigInt) -> String {
if number.is_zero() {
return String::new();
}
if number < &BigInt::from(10u8) {
let i = number.to_usize().expect("0 <= number < 10");
return ONES[i].to_string();
}
if number < &BigInt::from(20u8) {
let i = number.to_usize().expect("10 <= number < 20") - 10;
return TEENS[i].to_string();
}
if number < &BigInt::from(100u8) {
let n = number.to_usize().expect("20 <= number < 100");
let (tens, units) = (n / 10, n % 10);
let mut result = TENS[tens].to_string();
if units > 0 {
result.push_str(" da ");
result.push_str(ONES[units]);
}
return result;
}
if number < &BigInt::from(1000u16) {
let n = number.to_usize().expect("100 <= number < 1000");
let (hundreds, remainder) = (n / 100, n % 100);
let mut result = if hundreds == 1 {
"ɗari".to_string()
} else {
format!("ɗari {}", ONES[hundreds])
};
if remainder > 0 {
if remainder < 10 {
result.push_str(" da ");
} else {
result.push(' ');
}
result.push_str(&self.int_to_hausa(&BigInt::from(remainder)));
}
return result;
}
for (scale_value, scale_name) in &self.scale {
if number >= scale_value {
let (quotient, remainder) = number.div_mod_floor(scale_value);
let mut result;
if scale_value == &BigInt::from(100u8) {
result = if quotient.is_one() {
"ɗari".to_string()
} else {
format!("ɗari {}", self.int_to_hausa("ient))
};
} else if scale_value == &BigInt::from(1000u16) {
result = if quotient.is_one() {
"dubu".to_string()
} else {
format!("dubu {}", self.int_to_hausa("ient))
};
} else {
result = if quotient.is_one() {
scale_name.to_string()
} else {
format!("{} {}", scale_name, self.int_to_hausa("ient))
};
}
if remainder.is_positive() {
if remainder < BigInt::from(10u8) {
result.push_str(" da ");
} else {
result.push(' ');
}
result.push_str(&self.int_to_hausa(&remainder));
}
return result;
}
}
number.to_string()
}
fn cardinal_float(&self, value: f64) -> Result<String> {
if value == 0.0 {
return Ok(ONES[0].to_string());
}
if value < 0.0 {
return Ok(format!("{}{}", NEGWORD, self.cardinal_float(value.abs())?));
}
self.float_to_words(value)
}
fn float_to_words(&self, value: f64) -> Result<String> {
let trunc = value.trunc();
if value == trunc {
return self.to_cardinal(&BigInt::from(trunc as i128));
}
let decimal_part = value - trunc;
let s = format!("{}", decimal_part);
let decimal_str: String = s.chars().skip(2).collect();
let mut result = self.to_cardinal(&BigInt::from(trunc as i128))?;
result.push(' ');
result.push_str(self.pointword());
result.push(' ');
let decimal_num: BigInt = decimal_str.parse().map_err(|_| {
N2WError::Value(format!(
"invalid literal for int() with base 10: {:?}",
decimal_str
))
})?;
result.push_str(&self.to_cardinal(&decimal_num)?);
Ok(result)
}
fn cardinal_decimal(&self, value: &BigDecimal) -> Result<String> {
if value.is_zero() {
return Ok(ONES[0].to_string());
}
if value.is_negative() {
return Ok(format!(
"{}{}",
NEGWORD,
self.cardinal_decimal(&value.abs())?
));
}
let truncated = value.with_scale(0);
if (value - truncated.clone()).is_zero() {
Ok(self.int_to_hausa(&truncated.as_bigint_and_exponent().0))
} else {
Err(N2WError::Key(format!("{}", value)))
}
}
}
impl Default for LangHa {
fn default() -> Self {
Self::new()
}
}
fn ordinal_suffix(value: &BigInt) -> &'static str {
let hundred = BigInt::from(100u8);
let ten = BigInt::from(10u8);
let mod100 = value.mod_floor(&hundred);
if mod100 >= BigInt::from(10u8) && mod100 <= BigInt::from(20u8) {
return "th";
}
let last_digit = value.mod_floor(&ten);
if last_digit == BigInt::one() {
"st"
} else if last_digit == BigInt::from(2u8) {
"nd"
} else if last_digit == BigInt::from(3u8) {
"rd"
} else {
"th"
}
}
fn decimal_trunc_mod(value: &BigDecimal, modulus: i64) -> BigDecimal {
let m = BigDecimal::from(modulus);
let q = (value / &m).with_scale(0); value - q * m
}
fn float_floor_mod(value: f64, modulus: f64) -> f64 {
value.rem_euclid(modulus)
}
fn ordinal_suffix_float(v: &FloatValue) -> &'static str {
match v {
FloatValue::Float { value, .. } => {
let mod100 = float_floor_mod(*value, 100.0);
if (10.0..=20.0).contains(&mod100) {
return "th";
}
let last = float_floor_mod(*value, 10.0);
if last == 1.0 {
"st"
} else if last == 2.0 {
"nd"
} else if last == 3.0 {
"rd"
} else {
"th"
}
}
FloatValue::Decimal { value, .. } => {
let mod100 = decimal_trunc_mod(value, 100);
if mod100 >= BigDecimal::from(10) && mod100 <= BigDecimal::from(20) {
return "th";
}
let last = decimal_trunc_mod(value, 10);
if last == BigDecimal::from(1) {
"st"
} else if last == BigDecimal::from(2) {
"nd"
} else if last == BigDecimal::from(3) {
"rd"
} else {
"th"
}
}
}
}
impl Lang for LangHa {
fn default_currency(&self) -> &str {
"NGN"
}
fn negword(&self) -> &str {
NEGWORD
}
fn pointword(&self) -> &str {
"wajen"
}
fn to_cardinal(&self, value: &BigInt) -> Result<String> {
if value.is_zero() {
return Ok(ONES[0].to_string());
}
if value.is_negative() {
return Ok(format!("{}{}", NEGWORD, self.to_cardinal(&value.abs())?));
}
Ok(self.int_to_hausa(value))
}
fn to_ordinal(&self, value: &BigInt) -> Result<String> {
if value.is_one() {
return Ok("na farko".to_string());
}
Ok(format!("na {}", self.to_cardinal(value)?))
}
fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
Ok(format!("{}{}", value, ordinal_suffix(value)))
}
fn to_year(&self, value: &BigInt) -> Result<String> {
self.to_cardinal(value)
}
fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
if let Some(i) = value.as_whole_int() {
if i.is_one() {
return Ok("na farko".to_string());
}
}
Ok(format!("na {}", self.cardinal_float_entry(value, None)?))
}
fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
Ok(format!("{}{}", repr_str, ordinal_suffix_float(value)))
}
fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
match crate::strnum::python_decimal_parse(s)? {
crate::strnum::ParsedNumber::Inf { .. } | crate::strnum::ParsedNumber::NaN => {
Err(N2WError::Custom {
module: "decimal",
class: "InvalidOperation",
msg: "[<class 'decimal.InvalidOperation'>]".into(),
})
}
other => Ok(other),
}
}
fn to_cardinal_float(
&self,
value: &FloatValue,
_precision_override: Option<u32>,
) -> Result<String> {
match value {
FloatValue::Float { value, .. } => self.cardinal_float(*value),
FloatValue::Decimal { value, .. } => self.cardinal_decimal(value),
}
}
fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
let f = value
.to_f64()
.ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", value)))?;
self.cardinal_float(f)
}
fn lang_name(&self) -> &str {
"Num2Word_HA"
}
fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
self.currency_forms.get(code)
}
fn pluralize(&self, _n: &BigInt, forms: &[String]) -> Result<String> {
Ok(forms.first().cloned().unwrap_or_default())
}
fn to_currency(
&self,
val: &CurrencyValue,
currency: &str,
cents: bool,
_separator: Option<&str>,
_adjective: bool,
) -> Result<String> {
let code = if self.currency_forms.contains_key(currency) {
currency
} else {
"NGN"
};
let forms = self
.currency_forms
.get(code)
.expect("NGN is always present in CURRENCY_FORMS");
let is_negative = val.is_negative();
let decimal_val: BigDecimal = match val {
CurrencyValue::Int(i) => BigDecimal::from(i.abs()),
CurrencyValue::Decimal { value: d, .. } => d.abs(),
};
let scaled = &decimal_val * BigDecimal::from(100);
let scaled_trunc = scaled.with_scale(0);
let has_fractional_cents = &scaled - &scaled_trunc != BigDecimal::zero();
let major_units: BigInt;
let minor_units: Minor;
if cents {
if has_fractional_cents {
major_units = decimal_val.with_scale(0).as_bigint_and_exponent().0;
minor_units =
Minor::Dec(&scaled - BigDecimal::from(&major_units * BigInt::from(100)));
} else {
let cents_value = scaled_trunc.as_bigint_and_exponent().0;
let (maj, min) = cents_value.div_mod_floor(&BigInt::from(100));
major_units = maj;
minor_units = Minor::Int(min);
}
} else {
major_units = decimal_val.with_scale(0).as_bigint_and_exponent().0;
minor_units = Minor::Int(BigInt::zero());
}
let mut result: Vec<String> = Vec::new();
if major_units.is_positive() {
result.push(format!(
"{} {}",
forms.unit[0],
self.to_cardinal(&major_units)?
));
}
let minor_is_positive = match &minor_units {
Minor::Int(i) => i.is_positive(),
Minor::Dec(d) => d.is_positive(),
};
if minor_is_positive {
let minor_words = match &minor_units {
Minor::Dec(d) => self.cardinal_from_decimal(d)?,
Minor::Int(i) => self.to_cardinal(i)?,
};
if major_units.is_positive() {
result.push(format!("da {} {}", forms.subunit[0], minor_words));
} else {
result.push(format!("{} {}", forms.subunit[0], minor_words));
}
}
if result.is_empty() {
result.push(format!("{} sifiri", forms.unit[0]));
}
if is_negative {
result.insert(0, NEGWORD.to_string());
}
Ok(result.join(" "))
}
}
#[cfg(test)]
mod float_tests {
use super::*;
use crate::base::N2WError;
use crate::floatpath::FloatValue;
use std::str::FromStr;
fn f(value: f64, precision: u32) -> String {
LangHa::new()
.to_cardinal_float(&FloatValue::Float { value, precision }, None)
.unwrap()
}
fn d(s: &str, precision: u32) -> Result<String> {
LangHa::new().to_cardinal_float(
&FloatValue::Decimal {
value: BigDecimal::from_str(s).unwrap(),
precision,
},
None,
)
}
#[test]
fn corpus_cardinal_float() {
let rows: &[(f64, u32, &str)] = &[
(0.5, 1, "sifiri wajen biyar"),
(1.5, 1, "ɗaya wajen biyar"),
(2.25, 2, "biyu wajen ashirin da biyar"),
(3.14, 2, "uku wajen tiriliyan dubu sha huɗu sha biyu"),
(0.01, 2, "sifiri wajen ɗaya"),
(0.1, 1, "sifiri wajen ɗaya"),
(0.99, 2, "sifiri wajen casa'in da tara"),
(1.01, 2, "ɗaya wajen tiriliyan dubu goma da tara"),
(12.34, 2, "sha biyu wajen tiriliyan dubu talatin da uku ɗari tara casa'in da tara biliyan ɗari tara casa'in da tara miliyan ɗari tara casa'in da tara dubu ɗari tara casa'in da tara ɗari tara tamanin da shida"),
(99.99, 2, "casa'in da tara wajen tiriliyan dubu tara ɗari takwas casa'in da tara biliyan ɗari tara casa'in da tara miliyan ɗari tara casa'in da tara dubu ɗari tara casa'in da tara ɗari tara arba'in da tara"),
(100.5, 1, "ɗari wajen biyar"),
(1234.56, 2, "dubu ɗari biyu talatin da huɗu wajen tiriliyan dubu biyar ɗari biyar casa'in da tara biliyan ɗari tara casa'in da tara miliyan ɗari tara casa'in da tara dubu ɗari tara casa'in da tara ɗari huɗu hamsin da huɗu"),
(-0.5, 1, "ban sifiri wajen biyar"),
(-1.5, 1, "ban ɗaya wajen biyar"),
(-12.34, 2, "ban sha biyu wajen tiriliyan dubu talatin da uku ɗari tara casa'in da tara biliyan ɗari tara casa'in da tara miliyan ɗari tara casa'in da tara dubu ɗari tara casa'in da tara ɗari tara tamanin da shida"),
(1.005, 3, "ɗaya wajen tiriliyan dubu huɗu ɗari tara casa'in da tara biliyan ɗari tara casa'in da tara miliyan ɗari tara casa'in da tara dubu ɗari tara casa'in da tara ɗari takwas casa'in da uku"),
(2.675, 3, "biyu wajen tiriliyan dubu shida ɗari bakwai arba'in da tara biliyan ɗari tara casa'in da tara miliyan ɗari tara casa'in da tara dubu ɗari tara casa'in da tara ɗari tara casa'in da takwas"),
];
for &(v, p, want) in rows {
assert_eq!(f(v, p), want, "float {}", v);
}
}
#[test]
fn int_valued_floats() {
assert_eq!(f(0.0, 1), "sifiri");
assert_eq!(f(1.0, 1), "ɗaya");
}
#[test]
fn corpus_cardinal_dec() {
for s in ["0.01", "1.10", "12.345", "98746251323029.99", "0.001"] {
let prec = s.split_once('.').map_or(0, |(_, frac)| frac.len() as u32);
match d(s, prec) {
Err(N2WError::Key(_)) => {}
other => panic!("Decimal {s} → {other:?}, expected KeyError"),
}
}
}
#[test]
fn integral_decimal_resolves() {
assert_eq!(d("5", 0).unwrap(), "biyar");
assert_eq!(d("12.00", 2).unwrap(), "sha biyu");
assert_eq!(d("0", 0).unwrap(), "sifiri");
}
}