use std::collections::BTreeMap;
use crate::error::CoreError;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct BudgetLedger {
spent_cents: BTreeMap<String, u64>,
}
impl BudgetLedger {
pub fn new() -> Self {
Self::default()
}
pub fn spent_cents(&self, delegation_id: &str) -> u64 {
self.spent_cents.get(delegation_id).copied().unwrap_or(0)
}
pub fn remaining_cents(&self, delegation_id: &str, cap_cents: u64) -> u64 {
cap_cents.saturating_sub(self.spent_cents(delegation_id))
}
pub fn commit(&mut self, delegation_id: &str, amount_cents: u64) -> Result<u64, CoreError> {
let spent = self.spent_cents(delegation_id);
let after = spent.checked_add(amount_cents).ok_or_else(|| {
CoreError::LedgerOverflow(format!(
"预算累计溢出:spent({spent}) + amount({amount_cents}) 超出 u64"
))
})?;
self.spent_cents.insert(delegation_id.to_string(), after);
Ok(after)
}
pub fn entries(&self) -> impl Iterator<Item = (&String, &u64)> {
self.spent_cents.iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_delegation_reads_as_zero_spent() {
let l = BudgetLedger::new();
assert_eq!(l.spent_cents("nope"), 0);
assert_eq!(l.remaining_cents("nope", 1000), 1000);
}
#[test]
fn commit_accumulates_and_reports_spent_after() {
let mut l = BudgetLedger::new();
assert_eq!(l.commit("d1", 300).unwrap(), 300);
assert_eq!(l.commit("d1", 200).unwrap(), 500);
assert_eq!(l.commit("d2", 1).unwrap(), 1);
assert_eq!(l.spent_cents("d1"), 500);
assert_eq!(l.remaining_cents("d1", 1000), 500);
}
#[test]
fn commit_rejects_overflow_instead_of_wrapping() {
let mut l = BudgetLedger::new();
l.commit("d1", u64::MAX - 10).unwrap();
let err = l.commit("d1", 100).unwrap_err();
assert!(matches!(err, CoreError::LedgerOverflow(_)), "{err}");
assert_eq!(l.spent_cents("d1"), u64::MAX - 10);
}
#[test]
fn zero_amount_commit_is_a_noop_record() {
let mut l = BudgetLedger::new();
assert_eq!(l.commit("d1", 0).unwrap(), 0);
assert_eq!(l.spent_cents("d1"), 0);
}
}