actionqueue_budget/
gate.rs1use actionqueue_core::budget::BudgetDimension;
8use actionqueue_core::ids::TaskId;
9use tracing;
10
11use crate::tracker::BudgetTracker;
12
13#[derive(Debug)]
18pub struct BudgetGate<'a> {
19 tracker: &'a BudgetTracker,
20}
21
22impl<'a> BudgetGate<'a> {
23 pub fn new(tracker: &'a BudgetTracker) -> Self {
25 Self { tracker }
26 }
27
28 pub fn can_dispatch(&self, task_id: TaskId) -> bool {
30 let allowed = !self.tracker.is_any_exhausted(task_id);
31 if !allowed {
32 tracing::debug!(%task_id, "dispatch blocked by exhausted budget");
33 }
34 allowed
35 }
36
37 pub fn check_threshold(
41 &self,
42 task_id: TaskId,
43 threshold_pct: u8,
44 ) -> Vec<(BudgetDimension, u8)> {
45 let dimensions =
46 [BudgetDimension::Token, BudgetDimension::CostCents, BudgetDimension::TimeSecs];
47 dimensions
48 .into_iter()
49 .filter_map(|dim| {
50 let pct = self.tracker.threshold_pct(task_id, dim)?;
51 if pct >= threshold_pct {
52 Some((dim, pct))
53 } else {
54 None
55 }
56 })
57 .collect()
58 }
59}
60
61#[cfg(test)]
62mod tests {
63 use actionqueue_core::budget::BudgetDimension;
64 use actionqueue_core::ids::TaskId;
65
66 use super::BudgetGate;
67 use crate::tracker::BudgetTracker;
68
69 #[test]
70 fn can_dispatch_when_no_budget_allocated() {
71 let tracker = BudgetTracker::new();
72 let gate = BudgetGate::new(&tracker);
73 let task = TaskId::new();
74 assert!(gate.can_dispatch(task));
75 }
76
77 #[test]
78 fn can_dispatch_within_budget() {
79 let mut tracker = BudgetTracker::new();
80 let task = TaskId::new();
81 tracker.allocate(task, BudgetDimension::Token, 1000);
82 tracker.consume(task, BudgetDimension::Token, 500);
83 let gate = BudgetGate::new(&tracker);
84 assert!(gate.can_dispatch(task));
85 }
86
87 #[test]
88 fn cannot_dispatch_when_exhausted() {
89 let mut tracker = BudgetTracker::new();
90 let task = TaskId::new();
91 tracker.allocate(task, BudgetDimension::Token, 100);
92 tracker.consume(task, BudgetDimension::Token, 100);
93 let gate = BudgetGate::new(&tracker);
94 assert!(!gate.can_dispatch(task));
95 }
96
97 #[test]
98 fn check_threshold_at_zero_returns_all_allocated() {
99 let mut tracker = BudgetTracker::new();
100 let task = TaskId::new();
101 tracker.allocate(task, BudgetDimension::Token, 100);
102 tracker.allocate(task, BudgetDimension::CostCents, 50);
103 let gate = BudgetGate::new(&tracker);
104 let crossing = gate.check_threshold(task, 0);
106 assert_eq!(crossing.len(), 2);
107 }
108
109 #[test]
110 fn check_threshold_at_100_only_returns_exhausted() {
111 let mut tracker = BudgetTracker::new();
112 let task = TaskId::new();
113 tracker.allocate(task, BudgetDimension::Token, 100);
114 tracker.consume(task, BudgetDimension::Token, 100);
115 tracker.allocate(task, BudgetDimension::CostCents, 100);
116 tracker.consume(task, BudgetDimension::CostCents, 50);
117 let gate = BudgetGate::new(&tracker);
118 let crossing = gate.check_threshold(task, 100);
119 assert_eq!(crossing.len(), 1);
120 assert_eq!(crossing[0].0, BudgetDimension::Token);
121 }
122
123 #[test]
124 fn check_threshold_no_allocations_returns_empty() {
125 let tracker = BudgetTracker::new();
126 let gate = BudgetGate::new(&tracker);
127 let task = TaskId::new();
128 let crossing = gate.check_threshold(task, 50);
129 assert!(crossing.is_empty());
130 }
131
132 #[test]
133 fn check_threshold_multiple_dimensions_crossing() {
134 let mut tracker = BudgetTracker::new();
135 let task = TaskId::new();
136 tracker.allocate(task, BudgetDimension::Token, 100);
137 tracker.consume(task, BudgetDimension::Token, 90); tracker.allocate(task, BudgetDimension::CostCents, 200);
139 tracker.consume(task, BudgetDimension::CostCents, 160); tracker.allocate(task, BudgetDimension::TimeSecs, 50);
141 tracker.consume(task, BudgetDimension::TimeSecs, 20); let gate = BudgetGate::new(&tracker);
143 let crossing = gate.check_threshold(task, 80);
144 assert_eq!(crossing.len(), 2);
145 }
146
147 #[test]
148 fn check_threshold_returns_crossing_dimensions() {
149 let mut tracker = BudgetTracker::new();
150 let task = TaskId::new();
151 tracker.allocate(task, BudgetDimension::Token, 100);
152 tracker.consume(task, BudgetDimension::Token, 85);
153 let gate = BudgetGate::new(&tracker);
154 let crossing = gate.check_threshold(task, 80);
155 assert_eq!(crossing.len(), 1);
156 assert_eq!(crossing[0].0, BudgetDimension::Token);
157 }
158}