use yo_common::{Code, Error, Result};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Num {
Int(i64),
Float(f64),
}
impl Num {
#[must_use]
pub const fn is_int(self) -> bool {
matches!(self, Num::Int(_))
}
#[must_use]
const fn zero_like(self) -> Num {
match self {
Num::Int(_) => Num::Int(0),
Num::Float(_) => Num::Float(0.0),
}
}
fn as_int(self, what: &str) -> Result<i64> {
match self {
Num::Int(n) => Ok(n),
Num::Float(_) => Err(Error::fmt(
Code::Invalid,
format_args!("{what} is not an integer or out of range"),
)),
}
}
fn as_float(self, what: &str) -> Result<f64> {
match self {
Num::Float(f) => Ok(f),
Num::Int(n) => {
let _ = what;
Ok(n as f64)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IncrExpire {
#[default]
Keep,
Persist,
At(u64),
AtIfNone(u64),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct IncrEx {
pub by: Num,
pub saturate: bool,
pub lower: Option<Num>,
pub upper: Option<Num>,
pub expire: IncrExpire,
}
impl Default for IncrEx {
fn default() -> IncrEx {
IncrEx {
by: Num::Int(1),
saturate: false,
lower: None,
upper: None,
expire: IncrExpire::Keep,
}
}
}
impl IncrEx {
pub const PLAIN: IncrEx = IncrEx {
by: Num::Int(1),
saturate: false,
lower: None,
upper: None,
expire: IncrExpire::Keep,
};
#[must_use]
pub const fn by(mut self, by: Num) -> IncrEx {
self.by = by;
self
}
#[must_use]
pub const fn saturating(mut self) -> IncrEx {
self.saturate = true;
self
}
#[must_use]
pub const fn between(mut self, lower: Option<Num>, upper: Option<Num>) -> IncrEx {
self.lower = lower;
self.upper = upper;
self
}
#[must_use]
pub const fn expiring(mut self, expire: IncrExpire) -> IncrEx {
self.expire = expire;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Counted {
pub value: Num,
pub applied: Num,
pub stored: bool,
}
pub fn apply(current: Num, opts: &IncrEx) -> Result<Counted> {
match opts.by {
Num::Int(by) => {
let now = current.as_int("value")?;
let lo = opts.lower.map_or(Ok(i64::MIN), |b| b.as_int("LBOUND"))?;
let hi = opts.upper.map_or(Ok(i64::MAX), |b| b.as_int("UBOUND"))?;
if lo > hi {
return Err(bounds_crossed());
}
let want = now.checked_add(by);
let out = match want {
Some(v) if v >= lo && v <= hi => Some(v),
_ if !opts.saturate => None,
_ if by >= 0 => Some(hi),
_ => Some(lo),
};
Ok(match out {
Some(v) => Counted {
value: Num::Int(v),
applied: Num::Int(v.checked_sub(now).ok_or_else(applied_overflow)?),
stored: true,
},
None => Counted {
value: Num::Int(now),
applied: Num::Int(0),
stored: false,
},
})
}
Num::Float(by) => {
if by.is_nan() {
return Err(Error::new(Code::Invalid, "value is not a valid float"));
}
let now = match current {
Num::Float(f) => f,
Num::Int(n) => n as f64,
};
let lo = opts.lower.map_or(Ok(f64::MIN), |b| b.as_float("LBOUND"))?;
let hi = opts.upper.map_or(Ok(f64::MAX), |b| b.as_float("UBOUND"))?;
if lo > hi {
return Err(bounds_crossed());
}
let want = now + by;
let out = if want.is_finite() && want >= lo && want <= hi {
Some(want)
} else if !opts.saturate {
None
} else if by >= 0.0 {
Some(hi)
} else {
Some(lo)
};
Ok(match out {
Some(v) => Counted {
value: Num::Float(v),
applied: Num::Float(v - now),
stored: true,
},
None => Counted {
value: Num::Float(now),
applied: opts.by.zero_like(),
stored: false,
},
})
}
}
}
fn bounds_crossed() -> Error {
Error::new(Code::Invalid, "LBOUND can't be greater than UBOUND")
}
fn applied_overflow() -> Error {
Error::new(Code::Invalid, "applied increment would overflow")
}
#[cfg(test)]
mod tests {
use super::*;
fn int(n: i64) -> Num {
Num::Int(n)
}
#[test]
fn the_plain_form_adds_one() {
let c = apply(int(5), &IncrEx::PLAIN).unwrap();
assert_eq!(c.value, int(6));
assert_eq!(c.applied, int(1));
assert!(c.stored);
}
#[test]
fn a_result_past_a_bound_is_refused_and_nothing_is_written() {
let opts = IncrEx::PLAIN.by(int(10)).between(None, Some(int(5)));
let c = apply(int(0), &opts).unwrap();
assert_eq!(c.value, int(0));
assert_eq!(c.applied, int(0));
assert!(!c.stored);
}
#[test]
fn saturate_lands_on_the_bound_and_reports_what_it_managed() {
let opts = IncrEx::PLAIN
.by(int(10))
.between(None, Some(int(5)))
.saturating();
let c = apply(int(0), &opts).unwrap();
assert_eq!(c.value, int(5));
assert_eq!(c.applied, int(5));
assert!(c.stored);
let down = IncrEx::PLAIN
.by(int(-10))
.between(Some(int(0)), None)
.saturating();
let c = apply(int(5), &down).unwrap();
assert_eq!(c.value, int(0));
assert_eq!(c.applied, int(-5));
}
#[test]
fn overflow_is_a_bound_and_not_a_wrap() {
let c = apply(int(i64::MAX), &IncrEx::PLAIN).unwrap();
assert_eq!(c.value, int(i64::MAX));
assert_eq!(c.applied, int(0));
assert!(!c.stored);
let sat = apply(int(i64::MAX), &IncrEx::PLAIN.saturating()).unwrap();
assert_eq!(sat.value, int(i64::MAX));
assert_eq!(sat.applied, int(0));
let down = apply(int(i64::MIN), &IncrEx::PLAIN.by(int(-1)).saturating()).unwrap();
assert_eq!(down.value, int(i64::MIN));
assert_eq!(down.applied, int(0));
}
#[test]
fn an_amount_applied_that_does_not_fit_is_refused_rather_than_wrapped() {
let opts = IncrEx::PLAIN
.by(int(1))
.between(None, Some(int(i64::MIN)))
.saturating();
let e = apply(int(i64::MAX - 7), &opts).unwrap_err();
assert_eq!(e.message(), "applied increment would overflow");
let up = IncrEx::PLAIN
.by(int(-1))
.between(Some(int(i64::MAX)), None)
.saturating();
assert!(apply(int(i64::MIN + 7), &up).is_err());
let ok = IncrEx::PLAIN
.by(int(1))
.between(None, Some(int(i64::MIN + 8)))
.saturating();
let c = apply(int(-3), &ok).unwrap();
assert_eq!(c.value, int(i64::MIN + 8));
assert_eq!(c.applied, int(i64::MIN + 11));
assert!(c.stored);
}
#[test]
fn bounds_the_wrong_way_round_are_refused_rather_than_obeyed() {
let opts = IncrEx::PLAIN.between(Some(int(10)), Some(int(5)));
let e = apply(int(0), &opts).unwrap_err();
assert_eq!(e.message(), "LBOUND can't be greater than UBOUND");
let f = IncrEx::PLAIN
.by(Num::Float(1.0))
.between(Some(Num::Float(10.0)), Some(Num::Float(5.0)));
assert!(apply(Num::Float(0.0), &f).is_err());
}
#[test]
fn a_float_bound_on_an_integer_increment_is_an_error() {
let opts = IncrEx::PLAIN.between(None, Some(Num::Float(5.5)));
let e = apply(int(1), &opts).unwrap_err();
assert!(e.message().contains("UBOUND"), "{e}");
}
#[test]
fn a_float_increment_counts_in_floats() {
let c = apply(Num::Float(1.0), &IncrEx::PLAIN.by(Num::Float(0.5))).unwrap();
assert_eq!(c.value, Num::Float(1.5));
assert_eq!(c.applied, Num::Float(0.5));
let bounded = IncrEx::PLAIN
.by(Num::Float(10.0))
.between(None, Some(int(5)))
.saturating();
let c = apply(Num::Float(0.0), &bounded).unwrap();
assert_eq!(c.value, Num::Float(5.0));
}
#[test]
fn a_float_that_overflows_to_infinity_is_out_of_range() {
let opts = IncrEx::PLAIN.by(Num::Float(f64::MAX));
let c = apply(Num::Float(f64::MAX), &opts).unwrap();
assert!(!c.stored);
assert_eq!(c.value, Num::Float(f64::MAX));
let sat = apply(Num::Float(f64::MAX), &opts.saturating()).unwrap();
assert_eq!(sat.value, Num::Float(f64::MAX));
assert_eq!(sat.applied, Num::Float(0.0));
}
}