use crate::complexity::ComplexityClass;
use core::fmt;
#[derive(Debug, Clone)]
pub struct PlanBudget {
max_class: ComplexityClass,
remaining_ops: usize,
worst_seen: Option<ComplexityClass>,
}
impl PlanBudget {
pub fn new(max_class: ComplexityClass, max_ops: usize) -> Self {
Self {
max_class,
remaining_ops: max_ops,
worst_seen: None,
}
}
pub fn try_consume(&mut self, class: ComplexityClass) -> Result<(), BudgetExhausted> {
if class > self.max_class {
return Err(BudgetExhausted::ClassExceeded {
attempted: class,
ceiling: self.max_class,
});
}
if self.remaining_ops == 0 {
return Err(BudgetExhausted::OpsExhausted {
worst_seen: self.worst_seen,
});
}
self.remaining_ops -= 1;
self.worst_seen = Some(match self.worst_seen {
Some(prev) if prev >= class => prev,
_ => class,
});
Ok(())
}
pub const fn remaining_ops(&self) -> usize {
self.remaining_ops
}
pub const fn max_class(&self) -> ComplexityClass {
self.max_class
}
pub const fn worst_seen(&self) -> Option<ComplexityClass> {
self.worst_seen
}
pub const fn has_capacity(&self) -> bool {
self.remaining_ops > 0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BudgetExhausted {
ClassExceeded {
attempted: ComplexityClass,
ceiling: ComplexityClass,
},
OpsExhausted { worst_seen: Option<ComplexityClass> },
}
impl fmt::Display for BudgetExhausted {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BudgetExhausted::ClassExceeded { attempted, ceiling } => write!(
f,
"PlanBudget: attempted class {} exceeds ceiling {}",
attempted.short_label(),
ceiling.short_label(),
),
BudgetExhausted::OpsExhausted { worst_seen } => match worst_seen {
Some(c) => write!(
f,
"PlanBudget: ops exhausted (worst seen: {})",
c.short_label()
),
None => write!(f, "PlanBudget: ops exhausted (none consumed)"),
},
}
}
}
#[cfg(any(feature = "std", test))]
impl std::error::Error for BudgetExhausted {}
#[cfg(test)]
mod tests {
use super::*;
use crate::complexity::ComplexityClass;
#[test]
fn fresh_budget_has_capacity_and_no_worst_seen() {
let b = PlanBudget::new(ComplexityClass::SubLinear, 10);
assert_eq!(b.remaining_ops(), 10);
assert!(b.has_capacity());
assert_eq!(b.worst_seen(), None);
}
#[test]
fn consume_within_class_and_count_succeeds() {
let mut b = PlanBudget::new(ComplexityClass::SubLinear, 3);
assert!(b.try_consume(ComplexityClass::Logarithmic).is_ok());
assert!(b.try_consume(ComplexityClass::SubLinear).is_ok());
assert_eq!(b.remaining_ops(), 1);
assert_eq!(b.worst_seen(), Some(ComplexityClass::SubLinear));
}
#[test]
fn consume_above_ceiling_refuses_without_counting() {
let mut b = PlanBudget::new(ComplexityClass::SubLinear, 5);
let err = b.try_consume(ComplexityClass::Linear).unwrap_err();
match err {
BudgetExhausted::ClassExceeded { attempted, ceiling } => {
assert_eq!(attempted, ComplexityClass::Linear);
assert_eq!(ceiling, ComplexityClass::SubLinear);
}
_ => panic!("expected ClassExceeded"),
}
assert_eq!(b.remaining_ops(), 5);
assert_eq!(b.worst_seen(), None);
}
#[test]
fn consume_after_ops_exhausted_refuses() {
let mut b = PlanBudget::new(ComplexityClass::SubLinear, 2);
assert!(b.try_consume(ComplexityClass::Logarithmic).is_ok());
assert!(b.try_consume(ComplexityClass::SubLinear).is_ok());
assert!(!b.has_capacity());
let err = b.try_consume(ComplexityClass::Logarithmic).unwrap_err();
match err {
BudgetExhausted::OpsExhausted { worst_seen } => {
assert_eq!(worst_seen, Some(ComplexityClass::SubLinear));
}
_ => panic!("expected OpsExhausted"),
}
}
#[test]
fn worst_seen_is_monotone() {
let mut b = PlanBudget::new(ComplexityClass::Linear, 10);
b.try_consume(ComplexityClass::SubLinear).unwrap();
assert_eq!(b.worst_seen(), Some(ComplexityClass::SubLinear));
b.try_consume(ComplexityClass::Logarithmic).unwrap();
assert_eq!(b.worst_seen(), Some(ComplexityClass::SubLinear));
b.try_consume(ComplexityClass::Linear).unwrap();
assert_eq!(b.worst_seen(), Some(ComplexityClass::Linear));
}
#[test]
fn ops_exhausted_carries_worst_seen() {
let mut b = PlanBudget::new(ComplexityClass::Linear, 1);
b.try_consume(ComplexityClass::Linear).unwrap();
let err = b.try_consume(ComplexityClass::Logarithmic).unwrap_err();
match err {
BudgetExhausted::OpsExhausted { worst_seen } => {
assert_eq!(worst_seen, Some(ComplexityClass::Linear));
}
_ => panic!("expected OpsExhausted"),
}
}
#[test]
fn zero_ops_budget_refuses_first_consume() {
let mut b = PlanBudget::new(ComplexityClass::Linear, 0);
assert!(!b.has_capacity());
let err = b.try_consume(ComplexityClass::Logarithmic).unwrap_err();
assert!(matches!(
err,
BudgetExhausted::OpsExhausted { worst_seen: None }
));
}
#[test]
fn budget_exhausted_display_strings() {
let class_err = BudgetExhausted::ClassExceeded {
attempted: ComplexityClass::Linear,
ceiling: ComplexityClass::SubLinear,
};
let s = format!("{class_err}");
assert!(s.contains("O(n)"), "expected O(n) in {s:?}");
assert!(s.contains("c<1"), "expected c<1 in {s:?}");
assert!(s.contains("exceeds"));
let ops_err = BudgetExhausted::OpsExhausted { worst_seen: None };
let s = format!("{ops_err}");
assert!(s.contains("exhausted"));
}
}