1#[derive(Debug, Clone, PartialEq)]
4pub enum BudgetStatus {
5 Ok,
7 Warning { used_usd: f64, budget_usd: f64 },
9 Exceeded { used_usd: f64, budget_usd: f64 },
11}
12
13impl BudgetStatus {
14 pub fn is_exceeded(&self) -> bool {
15 matches!(self, Self::Exceeded { .. })
16 }
17}
18
19#[derive(Debug, Clone, Default)]
21pub struct UsageTracker {
22 pub input_tokens: u64,
23 pub output_tokens: u64,
24 pub cache_read_tokens: u64,
25 pub cache_write_tokens: u64,
26 pub total_cost_usd: f64,
27 budget_usd: Option<f64>,
28}
29
30impl UsageTracker {
31 pub fn new(budget_usd: Option<f64>) -> Self {
32 Self { budget_usd, ..Default::default() }
33 }
34
35 pub fn record(
38 &mut self,
39 input: u64,
40 output: u64,
41 cache_read: u64,
42 cache_write: u64,
43 cost_usd: f64,
44 ) -> BudgetStatus {
45 self.input_tokens += input;
46 self.output_tokens += output;
47 self.cache_read_tokens += cache_read;
48 self.cache_write_tokens += cache_write;
49 self.total_cost_usd += cost_usd;
50 self.budget_status()
51 }
52
53 pub fn budget_status(&self) -> BudgetStatus {
54 match self.budget_usd {
55 None => BudgetStatus::Ok,
56 Some(budget) => {
57 let ratio = self.total_cost_usd / budget;
58 if ratio >= 1.0 {
59 BudgetStatus::Exceeded { used_usd: self.total_cost_usd, budget_usd: budget }
60 } else if ratio >= 0.8 {
61 BudgetStatus::Warning { used_usd: self.total_cost_usd, budget_usd: budget }
62 } else {
63 BudgetStatus::Ok
64 }
65 }
66 }
67 }
68
69 pub fn format_bar(&self, model: &str, security_mode: &str) -> String {
72 fn fmt_k(n: u64) -> String {
73 if n >= 1_000 {
74 format!("{:.1}K", n as f64 / 1_000.0)
75 } else {
76 n.to_string()
77 }
78 }
79 format!(
80 "{} ↑{} ↓{} ${:.4} [{}]",
81 model,
82 fmt_k(self.input_tokens),
83 fmt_k(self.output_tokens),
84 self.total_cost_usd,
85 security_mode,
86 )
87 }
88}
89
90#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn record_accumulates_tokens() {
98 let mut t = UsageTracker::new(None);
99 t.record(1000, 500, 0, 0, 0.01);
100 t.record(2000, 1000, 0, 0, 0.02);
101 assert_eq!(t.input_tokens, 3000);
102 assert_eq!(t.output_tokens, 1500);
103 assert!((t.total_cost_usd - 0.03).abs() < 1e-9);
104 }
105
106 #[test]
107 fn budget_exceeded_when_over_limit() {
108 let mut t = UsageTracker::new(Some(0.05));
109 let status = t.record(0, 0, 0, 0, 0.06);
110 assert!(status.is_exceeded());
111 }
112
113 #[test]
114 fn budget_warning_at_80_percent() {
115 let mut t = UsageTracker::new(Some(0.10));
116 let status = t.record(0, 0, 0, 0, 0.09);
117 assert!(matches!(status, BudgetStatus::Warning { .. }));
118 }
119
120 #[test]
121 fn no_budget_is_always_ok() {
122 let mut t = UsageTracker::new(None);
123 let status = t.record(0, 0, 0, 0, 9999.0);
124 assert_eq!(status, BudgetStatus::Ok);
125 }
126
127 #[test]
128 fn format_bar_contains_model_and_cost() {
129 let mut t = UsageTracker::new(None);
130 t.record(12_300, 4_100, 0, 0, 0.053);
131 let bar = t.format_bar("claude-sonnet-4-5", "ask");
132 assert!(bar.contains("claude-sonnet-4-5"));
133 assert!(bar.contains("↑12.3K"));
134 assert!(bar.contains("↓4.1K"));
135 assert!(bar.contains("[ask]"));
136 }
137}