use crate::fixed_map::FixedMap;
use crate::{BuilderError, ChargeError, Dim, Verdict};
#[derive(Debug)]
pub struct Budget {
pub(crate) limits: FixedMap,
pub(crate) warn_thresholds: FixedMap,
pub(crate) spent: FixedMap,
}
#[derive(Debug)]
pub struct BudgetBuilder {
limits: FixedMap,
warn_thresholds: FixedMap,
duplicate: Option<Dim>,
}
impl BudgetBuilder {
const fn new() -> Self {
Self {
limits: FixedMap::new(),
warn_thresholds: FixedMap::new(),
duplicate: None,
}
}
#[must_use]
pub fn limit(mut self, dim: Dim, limit: u64) -> Self {
if self.limits.contains(dim) && self.duplicate.is_none() {
self.duplicate = Some(dim);
}
let _ = self.limits.insert(dim, limit);
self
}
#[must_use]
pub fn limit_with_warn(mut self, dim: Dim, limit: u64, warn: u64) -> Self {
if self.limits.contains(dim) && self.duplicate.is_none() {
self.duplicate = Some(dim);
}
let _ = self.limits.insert(dim, limit);
let _ = self.warn_thresholds.insert(dim, warn);
self
}
pub fn build(self) -> Result<Budget, BuilderError> {
if let Some(dim) = self.duplicate {
return Err(BuilderError::DuplicateDimension(dim));
}
if self.limits.is_empty() {
return Err(BuilderError::NoDimensions);
}
debug_assert!(
Dim::ALL
.iter()
.all(|d| !self.warn_thresholds.contains(*d) || self.limits.contains(*d)),
"warn_thresholds invariant violated: a warn threshold exists for an undeclared dimension"
);
for dim in Dim::ALL {
if let Some(limit) = self.limits.get(dim) {
if limit == 0 {
return Err(BuilderError::ZeroLimit(dim));
}
if let Some(warn) = self.warn_thresholds.get(dim) {
if warn >= limit {
return Err(BuilderError::WarnNotBelowLimit(dim));
}
}
}
}
Ok(Budget {
limits: self.limits,
warn_thresholds: self.warn_thresholds,
spent: FixedMap::new(),
})
}
}
impl Budget {
#[must_use]
pub const fn builder() -> BudgetBuilder {
BudgetBuilder::new()
}
pub fn charge(&mut self, dim: Dim, amount: u64) -> Result<Verdict, ChargeError> {
let Some(limit) = self.limits.get(dim) else {
return Err(ChargeError::UnknownDimension(dim));
};
let current = self.spent.get_or(dim, 0);
let new_spent = current.saturating_add(amount);
let _ = self.spent.insert(dim, new_spent);
if new_spent > limit {
return Ok(Verdict::Exhausted(dim));
}
if let Some(warn) = self.warn_thresholds.get(dim) {
if new_spent > warn {
return Ok(Verdict::Warn(dim));
}
}
Ok(Verdict::Continue)
}
#[must_use]
pub fn remaining(&self, dim: Dim) -> Option<u64> {
let limit = self.limits.get(dim)?;
let spent = self.spent.get_or(dim, 0);
Some(limit.saturating_sub(spent))
}
#[must_use]
pub fn spent(&self, dim: Dim) -> Option<u64> {
if self.limits.contains(dim) {
Some(self.spent.get_or(dim, 0))
} else {
None
}
}
pub fn reset(&mut self) {
self.spent = FixedMap::new();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build_ok(builder: BudgetBuilder) -> Option<Budget> {
let result = builder.build();
assert!(result.is_ok());
result.ok()
}
fn build_err(builder: BudgetBuilder) -> Option<BuilderError> {
let result = builder.build();
assert!(result.is_err());
result.err()
}
#[test]
fn build_rejects_empty_budget() {
assert_eq!(
build_err(Budget::builder()),
Some(BuilderError::NoDimensions)
);
}
#[test]
fn build_rejects_zero_limit() {
assert_eq!(
build_err(Budget::builder().limit(Dim::Tokens, 0)),
Some(BuilderError::ZeroLimit(Dim::Tokens))
);
}
#[test]
fn build_rejects_warn_equal_to_limit() {
assert_eq!(
build_err(Budget::builder().limit_with_warn(Dim::Tokens, 100, 100)),
Some(BuilderError::WarnNotBelowLimit(Dim::Tokens))
);
}
#[test]
fn build_rejects_warn_above_limit() {
assert_eq!(
build_err(Budget::builder().limit_with_warn(Dim::Tokens, 100, 101)),
Some(BuilderError::WarnNotBelowLimit(Dim::Tokens))
);
}
#[test]
fn build_rejects_duplicate_declaration() {
assert_eq!(
build_err(
Budget::builder()
.limit(Dim::Tokens, 100)
.limit(Dim::Tokens, 200),
),
Some(BuilderError::DuplicateDimension(Dim::Tokens))
);
}
#[test]
fn build_accepts_single_dimension() {
let Some(budget) = build_ok(Budget::builder().limit(Dim::Tokens, 100)) else {
return;
};
assert_eq!(budget.remaining(Dim::Tokens), Some(100));
}
#[test]
fn build_accepts_all_dimensions() {
let mut builder = Budget::builder();
for dim in Dim::ALL {
builder = builder.limit(dim, 1);
}
let Some(mut budget) = build_ok(builder) else {
return;
};
for dim in Dim::ALL {
assert_eq!(budget.charge(dim, 1), Ok(Verdict::Continue));
}
}
#[test]
fn charge_unknown_dim_returns_error() {
let Some(mut budget) = build_ok(Budget::builder().limit(Dim::Tokens, 100)) else {
return;
};
assert_eq!(
budget.charge(Dim::Bytes, 1),
Err(ChargeError::UnknownDimension(Dim::Bytes))
);
}
#[test]
fn charge_below_limit_returns_continue() {
let Some(mut budget) = build_ok(Budget::builder().limit(Dim::Tokens, 100)) else {
return;
};
assert_eq!(budget.charge(Dim::Tokens, 50), Ok(Verdict::Continue));
}
#[test]
fn charge_exactly_to_limit_returns_continue() {
let Some(mut budget) = build_ok(Budget::builder().limit(Dim::Tokens, 100)) else {
return;
};
assert_eq!(budget.charge(Dim::Tokens, 100), Ok(Verdict::Continue));
}
#[test]
fn charge_one_past_limit_returns_exhausted() {
let Some(mut budget) = build_ok(Budget::builder().limit(Dim::Tokens, 100)) else {
return;
};
assert_eq!(
budget.charge(Dim::Tokens, 101),
Ok(Verdict::Exhausted(Dim::Tokens))
);
}
#[test]
fn charge_incremental_to_exact_limit_returns_continue() {
let Some(mut budget) = build_ok(Budget::builder().limit(Dim::Tokens, 100)) else {
return;
};
assert_eq!(budget.charge(Dim::Tokens, 40), Ok(Verdict::Continue));
assert_eq!(budget.charge(Dim::Tokens, 60), Ok(Verdict::Continue));
}
#[test]
fn charge_incremental_past_limit_returns_exhausted() {
let Some(mut budget) = build_ok(Budget::builder().limit(Dim::Tokens, 100)) else {
return;
};
assert_eq!(budget.charge(Dim::Tokens, 40), Ok(Verdict::Continue));
assert_eq!(
budget.charge(Dim::Tokens, 61),
Ok(Verdict::Exhausted(Dim::Tokens))
);
}
#[test]
fn charge_crosses_warn_returns_warn() {
let Some(mut budget) = build_ok(Budget::builder().limit_with_warn(Dim::Tokens, 100, 80))
else {
return;
};
assert_eq!(
budget.charge(Dim::Tokens, 81),
Ok(Verdict::Warn(Dim::Tokens))
);
}
#[test]
fn warn_fires_every_call_above_threshold() {
let Some(mut budget) = build_ok(Budget::builder().limit_with_warn(Dim::Tokens, 100, 80))
else {
return;
};
assert_eq!(
budget.charge(Dim::Tokens, 81),
Ok(Verdict::Warn(Dim::Tokens))
);
assert_eq!(
budget.charge(Dim::Tokens, 1),
Ok(Verdict::Warn(Dim::Tokens))
);
}
#[test]
fn charge_past_limit_reports_exhausted_not_warn() {
let Some(mut budget) = build_ok(Budget::builder().limit_with_warn(Dim::Tokens, 100, 80))
else {
return;
};
assert_eq!(
budget.charge(Dim::Tokens, 101),
Ok(Verdict::Exhausted(Dim::Tokens))
);
}
#[test]
fn charge_saturates_at_u64_max() {
let Some(mut budget) = build_ok(Budget::builder().limit(Dim::Tokens, u64::MAX - 1)) else {
return;
};
assert_eq!(
budget.charge(Dim::Tokens, u64::MAX - 1),
Ok(Verdict::Continue)
);
assert_eq!(
budget.charge(Dim::Tokens, 10),
Ok(Verdict::Exhausted(Dim::Tokens))
);
assert_eq!(budget.spent(Dim::Tokens), Some(u64::MAX));
}
#[test]
fn charge_independent_across_dimensions() {
let Some(mut budget) = build_ok(Budget::builder().limit(Dim::Tokens, 100).limit_with_warn(
Dim::Bytes,
1000,
500,
)) else {
return;
};
assert_eq!(budget.charge(Dim::Tokens, 100), Ok(Verdict::Continue));
assert_eq!(
budget.charge(Dim::Bytes, 600),
Ok(Verdict::Warn(Dim::Bytes))
);
assert_eq!(budget.spent(Dim::Tokens), Some(100));
assert_eq!(budget.spent(Dim::Bytes), Some(600));
}
#[test]
fn remaining_reports_headroom() {
let Some(mut budget) = build_ok(Budget::builder().limit(Dim::Tokens, 100)) else {
return;
};
assert_eq!(budget.charge(Dim::Tokens, 30), Ok(Verdict::Continue));
assert_eq!(budget.remaining(Dim::Tokens), Some(70));
}
#[test]
fn remaining_saturates_at_zero_on_exhaustion() {
let Some(mut budget) = build_ok(Budget::builder().limit(Dim::Tokens, 100)) else {
return;
};
assert_eq!(
budget.charge(Dim::Tokens, 200),
Ok(Verdict::Exhausted(Dim::Tokens))
);
assert_eq!(budget.remaining(Dim::Tokens), Some(0));
}
#[test]
fn remaining_none_for_undeclared_dim() {
let Some(budget) = build_ok(Budget::builder().limit(Dim::Tokens, 100)) else {
return;
};
assert_eq!(budget.remaining(Dim::Bytes), None);
}
#[test]
fn spent_reports_zero_before_first_charge() {
let Some(budget) = build_ok(Budget::builder().limit(Dim::Tokens, 100)) else {
return;
};
assert_eq!(budget.spent(Dim::Tokens), Some(0));
}
#[test]
fn spent_none_for_undeclared_dim() {
let Some(budget) = build_ok(Budget::builder().limit(Dim::Tokens, 100)) else {
return;
};
assert_eq!(budget.spent(Dim::Bytes), None);
}
#[test]
fn reset_zeros_spent_preserves_limits() {
let Some(mut budget) = build_ok(Budget::builder().limit_with_warn(Dim::Tokens, 100, 80))
else {
return;
};
assert_eq!(
budget.charge(Dim::Tokens, 90),
Ok(Verdict::Warn(Dim::Tokens))
);
budget.reset();
assert_eq!(budget.spent(Dim::Tokens), Some(0));
assert_eq!(budget.remaining(Dim::Tokens), Some(100));
assert_eq!(
budget.charge(Dim::Tokens, 100),
Ok(Verdict::Warn(Dim::Tokens))
);
}
#[test]
fn reset_on_fresh_budget_is_noop() {
let Some(mut budget) = build_ok(Budget::builder().limit(Dim::Tokens, 100)) else {
return;
};
budget.reset();
assert_eq!(budget.spent(Dim::Tokens), Some(0));
assert_eq!(budget.remaining(Dim::Tokens), Some(100));
}
#[test]
fn charge_zero_polls_state_without_consuming_budget() {
let Some(mut budget) = build_ok(Budget::builder().limit(Dim::Tokens, 100)) else {
return;
};
assert_eq!(budget.charge(Dim::Tokens, 50), Ok(Verdict::Continue));
assert_eq!(budget.charge(Dim::Tokens, 0), Ok(Verdict::Continue));
assert_eq!(budget.spent(Dim::Tokens), Some(50));
}
#[test]
fn charge_zero_reports_exhausted_when_already_exhausted() {
let Some(mut budget) = build_ok(Budget::builder().limit(Dim::Tokens, 100)) else {
return;
};
assert_eq!(
budget.charge(Dim::Tokens, 200),
Ok(Verdict::Exhausted(Dim::Tokens))
);
assert_eq!(
budget.charge(Dim::Tokens, 0),
Ok(Verdict::Exhausted(Dim::Tokens))
);
assert_eq!(budget.spent(Dim::Tokens), Some(200));
}
#[test]
fn warn_zero_fires_on_positive_charge_and_zero_poll_afterward() {
let Some(mut budget) = build_ok(Budget::builder().limit_with_warn(Dim::Tokens, 100, 0))
else {
return;
};
assert_eq!(budget.charge(Dim::Tokens, 0), Ok(Verdict::Continue));
assert_eq!(
budget.charge(Dim::Tokens, 1),
Ok(Verdict::Warn(Dim::Tokens))
);
assert_eq!(
budget.charge(Dim::Tokens, 0),
Ok(Verdict::Warn(Dim::Tokens))
);
}
}