use core::{cmp::Ordering, fmt};
use crate::TimeError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Fraction {
num: i64,
den: u64,
}
impl Fraction {
pub const ZERO: Self = Self { num: 0, den: 1 };
pub fn new(num: i64, den: u64) -> Result<Self, TimeError> {
if den == 0 {
return Err(TimeError::ZeroDenominator);
}
let common_u128 = gcd(u128::from(num.unsigned_abs()), u128::from(den));
#[allow(clippy::cast_possible_truncation)]
let common = common_u128 as u64;
let reduced_den = den / common;
let reduced_num_i128 = i128::from(num) / i128::from(common);
#[allow(clippy::cast_possible_truncation)]
let reduced_num = reduced_num_i128 as i64;
Ok(Self {
num: reduced_num,
den: reduced_den,
})
}
#[must_use]
pub const fn numerator(self) -> i64 {
self.num
}
#[must_use]
pub const fn denominator(self) -> u64 {
self.den
}
#[must_use]
pub const fn parts(self) -> (i64, u64) {
(self.num, self.den)
}
#[must_use]
pub const fn is_zero(self) -> bool {
self.num == 0
}
#[must_use]
pub const fn is_negative(self) -> bool {
self.num < 0
}
#[must_use]
pub const fn checked_neg(self) -> Option<Self> {
match self.num.checked_neg() {
Some(num) => Some(Self { num, den: self.den }),
None => None,
}
}
#[must_use]
pub fn checked_add(self, other: Self) -> Option<Self> {
let lhs_num = i128::from(self.num);
let rhs_num = i128::from(other.num);
let lhs_den = i128::from(self.den);
let rhs_den = i128::from(other.den);
let num = lhs_num
.checked_mul(rhs_den)?
.checked_add(rhs_num.checked_mul(lhs_den)?)?;
let den = lhs_den.checked_mul(rhs_den)?;
let common_unsigned = gcd(num.unsigned_abs(), den.unsigned_abs());
let common = i128::try_from(common_unsigned).ok()?;
let reduced_num = num / common;
let reduced_den = den / common;
Some(Self {
num: i64::try_from(reduced_num).ok()?,
den: u64::try_from(reduced_den).ok()?,
})
}
#[must_use]
pub fn checked_mul(self, other: Self) -> Option<Self> {
let num = i128::from(self.num).checked_mul(i128::from(other.num))?;
let den = i128::from(self.den).checked_mul(i128::from(other.den))?;
let common_unsigned = gcd(num.unsigned_abs(), den.unsigned_abs());
let common = i128::try_from(common_unsigned).ok()?;
let reduced_num = num / common;
let reduced_den = den / common;
Some(Self {
num: i64::try_from(reduced_num).ok()?,
den: u64::try_from(reduced_den).ok()?,
})
}
#[must_use]
pub fn cmp_cross(self, other: Self) -> Ordering {
let lhs = i128::from(self.num) * i128::from(other.den);
let rhs = i128::from(other.num) * i128::from(self.den);
lhs.cmp(&rhs)
}
}
impl Default for Fraction {
fn default() -> Self {
Self::ZERO
}
}
impl PartialOrd for Fraction {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Fraction {
fn cmp(&self, other: &Self) -> Ordering {
self.cmp_cross(*other)
}
}
impl fmt::Display for Fraction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.num, self.den)
}
}
impl From<Fraction> for (i64, u64) {
fn from(yf: Fraction) -> Self {
yf.parts()
}
}
const fn gcd(mut a: u128, mut b: u128) -> u128 {
while b != 0 {
let t = b;
b = a % b;
a = t;
}
a
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
extern crate alloc;
use super::*;
use proptest::prelude::*;
#[test]
fn rejects_zero_denominator() {
assert_eq!(Fraction::new(0, 0), Err(TimeError::ZeroDenominator));
assert_eq!(Fraction::new(7, 0), Err(TimeError::ZeroDenominator));
assert_eq!(Fraction::new(-7, 0), Err(TimeError::ZeroDenominator));
}
#[test]
fn reduces_on_construction() {
assert_eq!(Fraction::new(2, 4).unwrap().parts(), (1, 2));
assert_eq!(Fraction::new(0, 5).unwrap().parts(), (0, 1));
assert_eq!(Fraction::new(7, 360).unwrap().parts(), (7, 360));
assert_eq!(Fraction::new(360, 360).unwrap().parts(), (1, 1));
}
#[test]
fn negative_numerator_round_trips() {
assert_eq!(Fraction::new(-30, 360).unwrap().parts(), (-1, 12));
assert_eq!(Fraction::new(-7, 360).unwrap().parts(), (-7, 360));
}
#[test]
fn negation_through_construction() {
let pos = Fraction::new(7, 360).unwrap();
let neg = Fraction::new(-7, 360).unwrap();
assert_eq!(pos.numerator(), 7);
assert_eq!(neg.numerator(), -7);
assert_eq!(pos.denominator(), neg.denominator());
}
#[test]
fn already_reduced_is_no_op() {
let f = Fraction::new(7, 360).unwrap();
let again = Fraction::new(f.numerator(), f.denominator()).unwrap();
assert_eq!(f, again);
}
#[test]
fn zero_constant() {
assert_eq!(Fraction::ZERO, Fraction::new(0, 1).unwrap());
assert_eq!(Fraction::ZERO, Fraction::new(0, 12345).unwrap());
assert!(Fraction::ZERO.is_zero());
}
#[test]
fn default_is_zero() {
assert_eq!(Fraction::default(), Fraction::ZERO);
let parts = Fraction::default().parts();
assert_eq!(parts, (0, 1));
assert_ne!(parts.1, 0);
}
#[test]
fn is_negative_examples() {
assert!(!Fraction::ZERO.is_negative());
assert!(!Fraction::new(1, 2).unwrap().is_negative());
assert!(Fraction::new(-1, 2).unwrap().is_negative());
}
#[test]
fn unreduced_inputs_compare_equal() {
let half = Fraction::new(1, 2).unwrap();
let two_quarters = Fraction::new(2, 4).unwrap();
assert_eq!(half, two_quarters);
assert_eq!(half.cmp_cross(two_quarters), Ordering::Equal);
}
#[test]
fn cmp_cross_orders_correctly() {
let third = Fraction::new(1, 3).unwrap();
let half = Fraction::new(1, 2).unwrap();
let two_thirds = Fraction::new(2, 3).unwrap();
let neg_third = Fraction::new(-1, 3).unwrap();
assert_eq!(third.cmp_cross(half), Ordering::Less);
assert_eq!(half.cmp_cross(third), Ordering::Greater);
assert_eq!(half.cmp_cross(two_thirds), Ordering::Less);
assert_eq!(neg_third.cmp_cross(third), Ordering::Less);
assert_eq!(neg_third.cmp_cross(Fraction::ZERO), Ordering::Less);
}
#[test]
fn checked_add_basic_examples() {
let third = Fraction::new(1, 3).unwrap();
let two_thirds = third.checked_add(third).unwrap();
assert_eq!(two_thirds.parts(), (2, 3));
let a = Fraction::new(1, 2).unwrap();
let b = Fraction::new(1, 4).unwrap();
assert_eq!(a.checked_add(b).unwrap().parts(), (3, 4));
}
#[test]
fn checked_add_zero_is_identity() {
let f = Fraction::new(7, 360).unwrap();
assert_eq!(f.checked_add(Fraction::ZERO), Some(f));
assert_eq!(Fraction::ZERO.checked_add(f), Some(f));
}
#[test]
fn checked_add_with_negation_cancels() {
let f = Fraction::new(7, 360).unwrap();
let neg_f = Fraction::new(-7, 360).unwrap();
assert_eq!(f.checked_add(neg_f), Some(Fraction::ZERO));
}
#[test]
fn checked_add_mixed_signs() {
let three_quarters = Fraction::new(3, 4).unwrap();
let neg_half = Fraction::new(-1, 2).unwrap();
assert_eq!(
three_quarters.checked_add(neg_half).unwrap().parts(),
(1, 4),
);
}
#[test]
fn checked_add_overflows_when_result_exceeds_i64() {
let half_max = i64::MAX / 2 + 1;
let a = Fraction::new(half_max, 1).unwrap();
let b = Fraction::new(half_max, 1).unwrap();
assert_eq!(a.checked_add(b), None);
}
#[test]
fn display_renders_reduced_form() {
assert_eq!(alloc::format!("{}", Fraction::new(2, 4).unwrap()), "1/2");
assert_eq!(alloc::format!("{}", Fraction::ZERO), "0/1");
assert_eq!(
alloc::format!("{}", Fraction::new(7, 360).unwrap()),
"7/360",
);
assert_eq!(
alloc::format!("{}", Fraction::new(-30, 360).unwrap()),
"-1/12",
);
}
#[test]
fn into_tuple_round_trips() {
let f = Fraction::new(7, 360).unwrap();
let parts: (i64, u64) = f.into();
assert_eq!(parts, (7, 360));
let neg = Fraction::new(-7, 360).unwrap();
let neg_parts: (i64, u64) = neg.into();
assert_eq!(neg_parts, (-7, 360));
}
#[test]
fn ord_consistent_with_cmp_cross() {
let a = Fraction::new(1, 3).unwrap();
let b = Fraction::new(1, 2).unwrap();
let c = Fraction::new(-1, 2).unwrap();
assert!(a < b);
assert!(b > a);
assert!(c < a);
assert_eq!(a.cmp(&a), Ordering::Equal);
}
#[test]
fn checked_neg_round_trip() {
let pos = Fraction::new(7, 360).unwrap();
let neg = pos.checked_neg().unwrap();
assert_eq!(neg.parts(), (-7, 360));
assert_eq!(neg.checked_neg().unwrap(), pos);
assert_eq!(Fraction::ZERO.checked_neg(), Some(Fraction::ZERO));
}
#[test]
fn checked_neg_returns_none_at_i64_min() {
let edge = Fraction::new(i64::MIN, 1).unwrap();
assert_eq!(edge.checked_neg(), None);
}
#[test]
fn handles_i64_min_numerator() {
let yf = Fraction::new(i64::MIN, 1).unwrap();
assert_eq!(yf.parts(), (i64::MIN, 1));
let yf = Fraction::new(i64::MIN, 2).unwrap();
assert_eq!(yf.parts(), (i64::MIN / 2, 1));
}
proptest! {
#[test]
fn new_is_idempotent_on_reduced_inputs(
num in -10_000i64..=10_000,
den in 1u64..=10_000,
) {
let once = Fraction::new(num, den).unwrap();
let twice = Fraction::new(once.numerator(), once.denominator()).unwrap();
prop_assert_eq!(once, twice);
}
#[test]
fn equality_matches_cross_multiplication(
n1 in -10_000i64..=10_000, d1 in 1u64..=10_000,
n2 in -10_000i64..=10_000, d2 in 1u64..=10_000,
) {
let a = Fraction::new(n1, d1).unwrap();
let b = Fraction::new(n2, d2).unwrap();
prop_assert_eq!(a == b, a.cmp_cross(b) == Ordering::Equal);
}
#[test]
fn ord_matches_cross_multiplication(
n1 in -10_000i64..=10_000, d1 in 1u64..=10_000,
n2 in -10_000i64..=10_000, d2 in 1u64..=10_000,
) {
let a = Fraction::new(n1, d1).unwrap();
let b = Fraction::new(n2, d2).unwrap();
prop_assert_eq!(a.cmp(&b), a.cmp_cross(b));
}
#[test]
fn add_zero_identity(num in -10_000i64..=10_000, den in 1u64..=10_000) {
let f = Fraction::new(num, den).unwrap();
prop_assert_eq!(f.checked_add(Fraction::ZERO), Some(f));
prop_assert_eq!(Fraction::ZERO.checked_add(f), Some(f));
}
#[test]
fn add_negation_cancels(num in -10_000i64..=10_000, den in 1u64..=10_000) {
let f = Fraction::new(num, den).unwrap();
let neg = Fraction::new(-num, den).unwrap();
prop_assert_eq!(f.checked_add(neg), Some(Fraction::ZERO));
}
#[test]
fn add_is_commutative(
n1 in -10_000i64..=10_000, d1 in 1u64..=10_000,
n2 in -10_000i64..=10_000, d2 in 1u64..=10_000,
) {
let a = Fraction::new(n1, d1).unwrap();
let b = Fraction::new(n2, d2).unwrap();
prop_assert_eq!(a.checked_add(b), b.checked_add(a));
}
#[test]
fn add_is_associative(
n1 in -200i64..=200, d1 in 1u64..=200,
n2 in -200i64..=200, d2 in 1u64..=200,
n3 in -200i64..=200, d3 in 1u64..=200,
) {
let a = Fraction::new(n1, d1).unwrap();
let b = Fraction::new(n2, d2).unwrap();
let c = Fraction::new(n3, d3).unwrap();
let lhs = a.checked_add(b).and_then(|r| r.checked_add(c));
let rhs = b.checked_add(c).and_then(|r| a.checked_add(r));
prop_assert_eq!(lhs, rhs);
}
}
}