use super::funejson::Value; const MAX_SAFE: i64 = 9_007_199_254_740_991; /// Powers of ten between each unit and basis points. fn exponent(unit: &str) -> i64 { match unit { "basis-points" => 0, "percent" => 2, "ratio" => 4, other => panic!("unknown rate unit \"{}\"", other), } } fn is_decimal(value: &str) -> bool { let body = value.strip_prefix('-').unwrap_or(value); let mut parts = body.splitn(2, '.'); let whole = parts.next().unwrap_or(""); let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); match parts.next() { Some(fraction) => digits(whole) && digits(fraction), None => digits(whole), } } /// Convert a rate between percent, basis points and a ratio. /// /// The units differ by powers of ten, so this moves the decimal point in the /// text itself: exact for any input, with no float anywhere. /// /// # Panics /// Panics if `value` is not a plain decimal or a unit is unknown. pub fn convert_rate(value: &str, from_unit: &str, to_unit: &str) -> String { if !is_decimal(value) { panic!("\"{}\" is not a decimal number", value); } let shift = exponent(from_unit) - exponent(to_unit); let negative = value.starts_with('-'); let body = if negative { &value[1..] } else { value }; let (mut digits, point) = match body.find('.') { Some(p) => (format!("{}{}", &body[..p], &body[p + 1..]), p as i64), None => (body.to_string(), body.len() as i64), }; let mut position = point + shift; if position > digits.len() as i64 { digits.push_str(&"0".repeat((position - digits.len() as i64) as usize)); } if position < 0 { digits = "0".repeat((-position) as usize) + &digits; position = 0; } let (whole, fraction) = digits.split_at(position as usize); let whole = whole.trim_start_matches('0'); let whole = if whole.is_empty() { "0" } else { whole }; let fraction = fraction.trim_end_matches('0'); let text = if fraction.is_empty() { whole.to_string() } else { format!("{}.{}", whole, fraction) }; if negative && text != "0" { format!("-{}", text) } else { text } } /// An integer number of basis points, refusing a rate that is not whole. /// /// # Panics /// Panics if the rate is not a whole number of basis points or is beyond ±(2^53 - 1). pub fn to_basis_points(value: &str, unit: &str) -> i64 { let text = convert_rate(value, unit, "basis-points"); if text.contains('.') { panic!("{} {} is not a whole number of basis points", value, unit); } match text.parse::() { Ok(bp) if (-MAX_SAFE..=MAX_SAFE).contains(&bp) => bp, _ => panic!("{} {} is too large for integer basis points", value, unit), } } /// Render integer basis points in another unit. pub fn from_basis_points(basis_points: i64, unit: &str) -> String { convert_rate(&basis_points.to_string(), "basis-points", unit) } pub fn fune_vector(args: &[Value]) -> Value { Value::Str(convert_rate( args[0].as_str(), args[1].as_str(), args[2].as_str(), )) }