rational_extensions 0.4.7

Extensions for rational numbers.
Documentation
//! This crate extends how `num_rational::Ratio<T>` can be converted
//! from a string specifically by allowing decimal notation with the
//! ability to constrain the minimum and maximum number of fractional
//! digits allowed.
#![cfg_attr(docsrs, feature(doc_cfg))]
#![no_std]
extern crate alloc;
use crate::FromDecStrErr::{IntParseErr, TooFewFractionalDigits, TooManyFractionalDigits};
use alloc::{
    string::{String, ToString as _},
    vec::Vec,
};
use core::{
    fmt::{self, Debug, Display, Formatter},
    ops::Mul,
    str::FromStr,
};
use num_integer::Integer;
use num_rational::Ratio;
use num_traits::Pow;
/// An ordered pair whose first value is <= to the second.
#[derive(Clone, Copy, Debug)]
pub struct MinMax<T> {
    /// The first value which is <= the second.
    min: T,
    /// The second value which is >= the first.
    max: T,
}
impl<T> MinMax<T> {
    /// Returns a reference to the first value.
    #[inline]
    pub const fn min(&self) -> &T {
        &self.min
    }
    /// Returns a reference to the second value.
    #[inline]
    pub const fn max(&self) -> &T {
        &self.max
    }
}
impl<T> MinMax<T>
where
    T: PartialOrd<T>,
{
    /// Returns `Some(T)` iff `min` `<=` `max`.
    #[inline]
    pub fn new(min: T, max: T) -> Option<Self> {
        (min <= max).then_some(Self { min, max })
    }
    /// Returns `MinMax` without verifying `min` `<=` `max`.
    ///
    /// # Safety
    ///
    /// `min` `<=` `max`.
    #[expect(
        unsafe_code,
        reason = "want to expose a function that does not uphold the invariants"
    )]
    #[inline]
    pub const unsafe fn new_unchecked(min: T, max: T) -> Self {
        Self { min, max }
    }
}
/// The error returned when parsing a string in decimal notation into
/// a `num_rational::Ratio<T>`.
pub enum FromDecStrErr<T> {
    /// Contains the error returned when parsing a string into a `T`.
    IntParseErr(T),
    /// The variant returned when a decimal string has fewer rational
    /// digits than allowed.
    TooFewFractionalDigits(usize),
    /// The variant returned when a decimal string has more rational
    /// digits than allowed.
    TooManyFractionalDigits(usize),
}
impl<T> Display for FromDecStrErr<T>
where
    T: Display,
{
    #[inline]
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        match *self {
            IntParseErr(ref x) => x.fmt(f),
            TooFewFractionalDigits(ref x) => write!(
                f,
                "There were only {x} fractional digits which is fewer than the minimum required."
            ),
            TooManyFractionalDigits(ref x) => write!(
                f,
                "There were {x} fractional digits which is more than the maximum required."
            ),
        }
    }
}
impl<T> Debug for FromDecStrErr<T>
where
    T: Display,
{
    #[inline]
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        <Self as Display>::fmt(self, f)
    }
}
impl<T> From<T> for FromDecStrErr<T> {
    #[inline]
    fn from(x: T) -> Self {
        IntParseErr(x)
    }
}
/// Converts a string in decimal notation into a `Ratio<T>`.
///
/// # Panics
///
/// May `panic` if `T` implements arithmetic in a way where `panic`s occur on overflow or underflow.
///
/// # Errors
///
/// Will return `FromDecStrErr` iff `val` is not a valid rational number in decimal notation with number of
/// fractional digits inclusively between `frac_digit_count.min()` and `frac_digit_count.max()`.
#[expect(
    clippy::arithmetic_side_effects,
    reason = "calling code's responsibility to ensure T implements arithmetic correctly"
)]
#[inline]
pub fn try_from_dec_str<T>(
    val: &str,
    frac_digit_count: &MinMax<usize>,
) -> Result<Ratio<T>, FromDecStrErr<<T as FromStr>::Err>>
where
    T: Clone
        + From<u8>
        + FromStr
        + Integer
        + for<'a> Mul<&'a T, Output = T>
        + Pow<usize, Output = T>,
{
    val.split_once('.').map_or_else(
        || {
            if *frac_digit_count.min() == 0 {
                Ok(Ratio::from(T::from_str(val)?))
            } else {
                Err(TooFewFractionalDigits(val.len()))
            }
        },
        |(l, r)| {
            if r.len() >= *frac_digit_count.min() {
                if r.len() <= *frac_digit_count.max() {
                    let mult = T::from(10).pow(r.len());
                    let numer = T::from_str(l)? * &mult;
                    let addend = T::from_str(r)?;
                    let zero = T::from(0);
                    Ok(Ratio::new(
                        if numer < zero
                            || (numer == zero
                                && l.as_bytes().first().is_some_and(|fst| *fst == b'-'))
                        {
                            numer - addend
                        } else {
                            numer + addend
                        },
                        mult,
                    ))
                } else {
                    Err(TooManyFractionalDigits(r.len()))
                }
            } else {
                Err(TooFewFractionalDigits(r.len()))
            }
        },
    )
}
/// The error returned when parsing a string in decimal or
/// rational notation into a `num_rational::Ratio<T>`.
pub enum FromStrErr<T> {
    /// Contains the error when a string fails to parse into a `T`.
    IntParseErr(T),
    /// The variant that is returned when a string in rational
    /// notation has a denominator that is zero.
    DenominatorIsZero,
}
impl<T> Display for FromStrErr<T>
where
    T: Display,
{
    #[inline]
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        match *self {
            Self::IntParseErr(ref x) => x.fmt(f),
            Self::DenominatorIsZero => f.write_str("denominator is zero"),
        }
    }
}
impl<T> Debug for FromStrErr<T>
where
    T: Display,
{
    #[inline]
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        <Self as Display>::fmt(self, f)
    }
}
impl<T> From<T> for FromStrErr<T> {
    #[inline]
    fn from(x: T) -> Self {
        Self::IntParseErr(x)
    }
}
/// Converts a string in rational or decimal notation into a `Ratio<T>`.
///
/// # Panics
///
/// May `panic` if `T` implements arithmetic in a way where `panic`s occur on overflow or underflow.
///
/// # Errors
///
/// Will return `FromStrErr` iff `val` is not a rational number in
/// rational or decimal notation.
#[expect(clippy::unreachable, reason = "want to crash when there is a bug")]
#[expect(
    clippy::arithmetic_side_effects,
    reason = "calling code's responsibility to ensure T implements arithmetic correctly"
)]
#[inline]
pub fn try_from_str<T>(val: &str) -> Result<Ratio<T>, FromStrErr<<T as FromStr>::Err>>
where
    T: Clone
        + From<u8>
        + FromStr
        + Integer
        + for<'a> Mul<&'a T, Output = T>
        + Pow<usize, Output = T>,
{
    val.split_once('/').map_or_else(
        || {
            try_from_dec_str(
                val,
                &MinMax {
                    min: 0,
                    max: usize::MAX,
                },
            )
            .map_err(|err| match err {
                IntParseErr(x) => FromStrErr::IntParseErr(x),
                TooFewFractionalDigits(_) | TooManyFractionalDigits(_) => unreachable!(
                    "There is a bug in rational::try_from_dec_str. 0 and usize::MAX were passed as the minimum and maximum number of fractional digits allowed respectively, but it still errored due to too few or too many fractional digits"
                ),
            })
        },
        |split| {
            let denom = T::from_str(split.1)?;
            let zero = T::from(0);
            if denom == zero {
                Err(FromStrErr::DenominatorIsZero)
            } else {
                split.0.split_once(' ').map_or_else(
                    || Ok(Ratio::new(T::from_str(split.0)?, denom.clone())),
                    |(l2, r2)| {
                        let numer = T::from_str(l2)? * &denom;
                        let addend = T::from_str(r2)?;
                        Ok(Ratio::new(
                            if numer < zero {
                                numer - addend
                            } else {
                                numer + addend
                            },
                            denom.clone(),
                        ))
                    },
                )
            }
        }
    )
}
/// Returns a `String` representing `val` in decimal notation with `frac_digit_count` fractional digits
/// using normal rounding rules.
///
/// # Panics
///
/// May `panic` if `T` implements arithmetic in a way where `panic`s occur on overflow or underflow.
#[expect(unsafe_code, reason = "comment justifies correctness")]
#[expect(
    clippy::arithmetic_side_effects,
    clippy::expect_used,
    clippy::indexing_slicing,
    reason = "calling code's responsibility to ensure T implements arithmetic correctly"
)]
#[inline]
pub fn to_dec_string<T>(val: &Ratio<T>, frac_digit_count: usize) -> String
where
    T: Clone + Display + From<u8> + Integer + Pow<usize, Output = T>,
{
    let mult = T::from(10).pow(frac_digit_count);
    let (int, frac) = (val * &mult).round().numer().div_rem(&mult);
    let int_str = int.to_string();
    let mut v = Vec::with_capacity(
        int_str
            .len()
            .saturating_add(frac_digit_count.saturating_add(2)),
    );
    let zero = T::from(0);
    if int >= zero && frac < zero {
        v.push(b'-');
    }
    v.extend_from_slice(int.to_string().as_bytes());
    if frac_digit_count > 0 {
        v.push(b'.');
        let len = v.len();
        let frac_vec = frac.to_string().into_bytes();
        // This cannot `panic` since we start at index 0 when it's empty or does not begin with `b'-'`;
        // otherwise we start at index 1.
        let frac_val = &frac_vec[frac_vec
            .first()
            .map_or(0, |start| usize::from(*start == b'-'))..];
        // We rely on saturating add. If overflow occurs, then the code will `panic` anyway due to
        // the below loop causing the underlying `Vec` to be too large.
        let term = len.saturating_add(
            frac_digit_count
                .checked_sub(frac_val.len())
                .expect("T::to_string returns an unexpected large string"),
        );
        while v.len() < term {
            v.push(b'0');
        }
        v.extend_from_slice(frac_val);
    }
    // SAFETY:
    // `v` contains precisely the UTF-8 code units returned from `String`s
    // returned from `to_string` on the integer and fraction part of
    // the passed value plus optionally the single byte encodings of ".", "-", and "0".
    unsafe { String::from_utf8_unchecked(v) }
}
/// Enables deserialization of strings in decimal or fractional format into [`rational::Rational<T>`].
#[cfg_attr(docsrs, doc(cfg(feature = "rational")))]
#[cfg(feature = "rational")]
pub mod rational;

#[cfg(test)]
mod tests {
    use super::*;
    use alloc::format;
    use core::{assert_eq, num::ParseIntError};
    use serde_json as _;

    #[test]
    fn test_min_max() -> Result<(), String> {
        let mut m: MinMax<u32>;
        for i in 0..1000 {
            for j in 0..1000 {
                m = MinMax::new(i, i + j)
                    .ok_or_else(|| format!("Failed for {} and {}.", i, i + j))?;
                assert_eq!(*m.min(), i);
                assert_eq!(*m.max(), i + j);
            }
        }
        Ok(())
    }
    #[test]
    #[should_panic]
    fn test_min_max_err() {
        _ = MinMax::new(f64::NAN, 0.0).unwrap();
    }
    #[test]
    fn test_dec_string() -> Result<(), String> {
        assert_eq!("0", &to_dec_string(&Ratio::<u32>::new(0, 1), 0));
        assert_eq!("-1.33", &to_dec_string(&Ratio::<i32>::new(-4, 3), 2));
        assert_eq!("-0.33", &to_dec_string(&Ratio::<i32>::new(-1, 3), 2));
        assert_eq!("5.000", &to_dec_string(&Ratio::<u32>::new(5, 1), 3));
        assert_eq!("0.66667", &to_dec_string(&Ratio::<u32>::new(2, 3), 5));
        Ok(())
    }
    #[test]
    fn test_from_str() -> Result<(), FromStrErr<ParseIntError>> {
        assert_eq!(try_from_str::<u32>("4")?, Ratio::new(4, 1));
        assert_eq!(try_from_str::<u32>("4/8")?, Ratio::new(1, 2));
        assert_eq!(try_from_str::<u32>("5 9/8")?, Ratio::new(49, 8));
        assert_eq!(try_from_str::<i32>("-2 7/6")?, Ratio::new(-19, 6));
        assert_eq!(try_from_str::<i32>("-5/6")?, Ratio::new(-5, 6));
        assert_eq!(try_from_str::<u32>("0.1249")?, Ratio::new(1249, 10000));
        assert_eq!(try_from_str::<i32>("-1.33")?, Ratio::new(-133, 100));
        assert_eq!(try_from_str::<i32>("-0.33")?, Ratio::new(-33, 100));
        assert_eq!(try_from_str::<u32>("0.0")?, Ratio::new(0, 1));
        try_from_str::<u32>("1/0").map_or_else(
            |e| match e {
                FromStrErr::DenominatorIsZero => (),
                _ => assert_eq!(false, true),
            },
            |_| assert_eq!(false, true),
        );
        Ok(())
    }
}