use std::collections::VecDeque;
use std::time::Duration;
use jiff::{SignedDuration, Timestamp};
use crate::itinerary::Denial;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ReserveState {
pub cap_usd: Option<f64>,
pub spent_usd: f64,
pub remaining_usd: Option<f64>,
pub window: Duration,
pub exhausted: bool,
}
#[derive(Debug, Clone)]
pub struct Reserve {
cap_usd: Option<f64>,
window: Duration,
entries: VecDeque<(Timestamp, f64)>,
}
impl Reserve {
#[must_use]
pub fn new(cap_usd: f64, window: Duration) -> Self {
let cap_usd = (cap_usd.is_finite() && cap_usd > 0.0).then_some(cap_usd);
Self {
cap_usd,
window,
entries: VecDeque::new(),
}
}
#[must_use]
pub fn unlimited() -> Self {
Self {
cap_usd: None,
window: Duration::from_hours(24),
entries: VecDeque::new(),
}
}
#[must_use]
pub fn is_unlimited(&self) -> bool {
self.cap_usd.is_none()
}
pub fn record(&mut self, at: Timestamp, usd: f64) {
if usd.is_finite() && usd > 0.0 {
self.entries.push_back((at, usd));
}
}
pub fn spent(&mut self, now: Timestamp) -> f64 {
self.expire(now);
self.entries.iter().map(|(_, usd)| usd).sum()
}
pub fn remaining(&mut self, now: Timestamp) -> Option<f64> {
let cap = self.cap_usd?;
Some((cap - self.spent(now)).max(0.0))
}
pub fn is_exhausted(&mut self, now: Timestamp) -> bool {
match self.cap_usd {
None => false,
Some(cap) => self.spent(now) >= cap,
}
}
pub fn authorize(&mut self, now: Timestamp) -> Result<(), Denial> {
if self.is_exhausted(now) {
return Err(Denial::ReserveExhausted);
}
Ok(())
}
pub fn state(&mut self, now: Timestamp) -> ReserveState {
ReserveState {
cap_usd: self.cap_usd,
spent_usd: self.spent(now),
remaining_usd: self.remaining(now),
window: self.window,
exhausted: self.is_exhausted(now),
}
}
fn expire(&mut self, now: Timestamp) {
let Ok(window) = SignedDuration::try_from(self.window) else {
return;
};
let Ok(cutoff) = now.checked_sub(window) else {
return;
};
while let Some((at, _)) = self.entries.front() {
if *at < cutoff {
self.entries.pop_front();
} else {
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const HOUR: Duration = Duration::from_secs(3_600);
const DAY: Duration = Duration::from_hours(24);
fn reserve() -> Reserve {
Reserve::new(50.0, DAY)
}
#[test]
fn spend_accumulates_across_itineraries() {
let now = Timestamp::now();
let mut reserve = reserve();
for _ in 0..3 {
reserve.record(now, 10.0);
}
assert!((reserve.spent(now) - 30.0).abs() < 1e-9);
assert!((reserve.remaining(now).expect("capped") - 20.0).abs() < 1e-9);
assert!(!reserve.is_exhausted(now));
}
#[test]
fn work_is_refused_once_the_cap_is_reached() {
let now = Timestamp::now();
let mut reserve = reserve();
reserve.record(now, 50.0);
assert!(reserve.is_exhausted(now));
assert_eq!(reserve.authorize(now), Err(Denial::ReserveExhausted));
assert!((reserve.remaining(now).expect("capped") - 0.0).abs() < f64::EPSILON);
}
#[test]
fn spend_falls_out_of_the_window_as_it_ages() {
let now = Timestamp::now();
let mut reserve = reserve();
reserve.record(now - DAY - HOUR, 40.0);
reserve.record(now, 10.0);
assert!(
(reserve.spent(now) - 10.0).abs() < 1e-9,
"yesterday's spend must not count against today"
);
}
#[test]
fn a_rolling_window_cannot_be_doubled_across_a_boundary() {
let now = Timestamp::now();
let mut reserve = reserve();
reserve.record(now - Duration::from_secs(60), 50.0);
assert!(
reserve.is_exhausted(now),
"a rolling window has no boundary to reset across"
);
assert!(
reserve.is_exhausted(now + Duration::from_secs(60)),
"and one minute later it is still exhausted"
);
assert!(
!reserve.is_exhausted(now + DAY + HOUR),
"only the passage of the whole window releases it"
);
}
#[test]
fn an_unlimited_reserve_never_refuses() {
let now = Timestamp::now();
let mut reserve = Reserve::unlimited();
reserve.record(now, 1_000_000.0);
assert!(reserve.is_unlimited());
assert!(!reserve.is_exhausted(now));
assert_eq!(reserve.remaining(now), None);
assert_eq!(reserve.authorize(now), Ok(()));
}
#[test]
fn an_unusable_cap_is_unlimited_rather_than_zero() {
for bad in [0.0, -5.0, f64::NAN, f64::INFINITY] {
assert!(
Reserve::new(bad, DAY).is_unlimited(),
"{bad} must not become a hard stop"
);
}
}
#[test]
fn implausible_spend_reports_are_ignored() {
let now = Timestamp::now();
let mut reserve = reserve();
reserve.record(now, f64::NAN);
reserve.record(now, f64::INFINITY);
reserve.record(now, -100.0);
assert!((reserve.spent(now) - 0.0).abs() < f64::EPSILON);
assert!(
!reserve.is_exhausted(now),
"a NaN must not disable the rail"
);
}
#[test]
fn the_state_snapshot_reports_the_whole_picture() {
let now = Timestamp::now();
let mut reserve = reserve();
reserve.record(now, 20.0);
let state = reserve.state(now);
assert_eq!(state.cap_usd, Some(50.0));
assert!((state.spent_usd - 20.0).abs() < 1e-9);
assert!((state.remaining_usd.expect("capped") - 30.0).abs() < 1e-9);
assert_eq!(state.window, DAY);
assert!(!state.exhausted);
}
}