Skip to main content

ic_host_text/decimal/
mod.rs

1//! Exact two-place decimal text without token, unit or output policy.
2
3use std::fmt;
4
5#[cfg(test)]
6mod tests;
7
8/// Input is not an unsigned ASCII decimal with an optional fractional part.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub struct InvalidDecimal;
11
12impl fmt::Display for InvalidDecimal {
13    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
14        formatter.write_str("expected unsigned decimal text")
15    }
16}
17
18impl std::error::Error for InvalidDecimal {}
19
20/// Divide decimal text by `10^exponent` and render exactly two decimal places.
21///
22/// Uses exact round-half-up arithmetic on text, including values larger than
23/// `u128`. Input accepts leading zeroes and an optional decimal point; signs,
24/// whitespace and separators refuse. At least one whole digit is required;
25/// a trailing point is accepted. No numeric conversion or unit selection.
26///
27/// # Errors
28/// Returns [`InvalidDecimal`] for invalid syntax.
29pub fn fixed_decimal_text(value: &str, exponent: usize) -> Result<String, InvalidDecimal> {
30    render(value, exponent, false)
31}
32
33/// As [`fixed_decimal_text`], trimming trailing fractional zeroes and the point.
34///
35/// # Errors
36/// Returns [`InvalidDecimal`] for invalid syntax.
37pub fn compact_decimal_text(value: &str, exponent: usize) -> Result<String, InvalidDecimal> {
38    render(value, exponent, true)
39}
40
41fn render(value: &str, exponent: usize, compact: bool) -> Result<String, InvalidDecimal> {
42    let (whole, fraction) = value.split_once('.').unwrap_or((value, ""));
43    if whole.is_empty()
44        || !whole.bytes().all(|digit| digit.is_ascii_digit())
45        || !fraction.bytes().all(|digit| digit.is_ascii_digit())
46    {
47        return Err(InvalidDecimal);
48    }
49    let whole = whole.trim_start_matches('0');
50    let (integer, integer_fraction) = if whole.len() > exponent {
51        whole.split_at(whole.len() - exponent)
52    } else {
53        ("0", whole)
54    };
55    let mut digits = std::iter::repeat_n(b'0', exponent.saturating_sub(whole.len()))
56        .chain(integer_fraction.bytes())
57        .chain(fraction.bytes());
58    let tenths = digits.next().unwrap_or(b'0') - b'0';
59    let hundredth = digits.next().unwrap_or(b'0') - b'0';
60    let mut hundredths = tenths * 10 + hundredth;
61    if digits.next().unwrap_or(b'0') >= b'5' {
62        hundredths += 1;
63    }
64    let mut integer = integer.to_string();
65    if hundredths == 100 {
66        increment(&mut integer);
67        hundredths = 0;
68    }
69    if !compact || hundredths != 0 {
70        use std::fmt::Write as _;
71        write!(integer, ".{hundredths:02}").expect("writing to a String cannot fail");
72        if compact && hundredths.is_multiple_of(10) {
73            integer.pop();
74        }
75    }
76    Ok(integer)
77}
78
79fn increment(value: &mut String) {
80    let mut digits = value.as_bytes().to_vec();
81    for digit in digits.iter_mut().rev() {
82        if *digit < b'9' {
83            *digit += 1;
84            *value = String::from_utf8(digits).expect("decimal digits remain UTF-8");
85            return;
86        }
87        *digit = b'0';
88    }
89    digits.insert(0, b'1');
90    *value = String::from_utf8(digits).expect("decimal digits remain UTF-8");
91}