1use crate::models::{config::BudgetConfig, stats::StatsCache};
7use chrono::{Datelike, Local};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum AlertLevel {
12 Safe,
14 Warning,
16 Critical,
18 Exceeded,
20}
21
22#[derive(Debug, Clone)]
24pub struct QuotaStatus {
25 pub current_cost: f64,
27 pub budget_limit: Option<f64>,
29 pub usage_pct: f64,
31 pub projected_monthly_cost: f64,
33 pub projected_overage: Option<f64>,
35 pub alert_level: AlertLevel,
37}
38
39pub fn calculate_quota_status(stats: &StatsCache, budget: &BudgetConfig) -> QuotaStatus {
45 let current_cost = calculate_month_to_date_cost(stats);
47
48 let usage_pct = if let Some(limit) = budget.monthly_limit {
50 (current_cost / limit * 100.0).min(999.9)
51 } else {
52 0.0
53 };
54
55 let alert_level = determine_alert_level(usage_pct, budget);
57
58 let projected_monthly_cost = project_monthly_cost(stats, current_cost);
60
61 let projected_overage = if let Some(limit) = budget.monthly_limit {
63 if projected_monthly_cost > limit {
64 Some(projected_monthly_cost - limit)
65 } else {
66 None
67 }
68 } else {
69 None
70 };
71
72 QuotaStatus {
73 current_cost,
74 budget_limit: budget.monthly_limit,
75 usage_pct,
76 projected_monthly_cost,
77 projected_overage,
78 alert_level,
79 }
80}
81
82fn calculate_month_to_date_cost(stats: &StatsCache) -> f64 {
86 let total_cost: f64 = stats.model_usage.values().map(|m| m.cost_usd).sum();
88
89 if total_cost == 0.0 {
90 return 0.0;
91 }
92
93 let now = Local::now();
95 let month_prefix = format!("{}-{:02}", now.year(), now.month());
96
97 let mtd_tokens: u64 = stats
99 .daily_model_tokens
100 .iter()
101 .filter(|d| d.date.starts_with(&month_prefix))
102 .flat_map(|d| d.tokens_by_model.values())
103 .sum();
104
105 let total_tokens: u64 = stats
107 .model_usage
108 .values()
109 .map(|m| m.input_tokens + m.output_tokens)
110 .sum();
111
112 if total_tokens == 0 {
113 return 0.0;
114 }
115
116 total_cost * (mtd_tokens as f64 / total_tokens as f64)
118}
119
120fn project_monthly_cost(_stats: &StatsCache, mtd_cost: f64) -> f64 {
124 let now = Local::now();
125 let current_day = now.day() as f64;
126
127 if current_day < 1.0 {
128 return mtd_cost * 30.0; }
130
131 let daily_avg = mtd_cost / current_day;
133 daily_avg * 30.0
134}
135
136fn determine_alert_level(usage_pct: f64, budget: &BudgetConfig) -> AlertLevel {
138 if usage_pct >= 100.0 {
139 AlertLevel::Exceeded
140 } else if usage_pct >= budget.critical_threshold {
141 AlertLevel::Critical
142 } else if usage_pct >= budget.warning_threshold {
143 AlertLevel::Warning
144 } else {
145 AlertLevel::Safe
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use crate::models::stats::ModelUsage;
153 use std::collections::HashMap;
154
155 fn mock_stats_with_mtd_ratio(total_cost: f64, mtd_ratio: f64, first_date: &str) -> StatsCache {
160 use crate::models::stats::DailyModelTokens;
161
162 let total_tokens = 15000u64;
163 let mtd_tokens = (total_tokens as f64 * mtd_ratio) as u64;
164
165 let mut model_usage = HashMap::new();
166 model_usage.insert(
167 "claude-sonnet-4".to_string(),
168 ModelUsage {
169 input_tokens: total_tokens * 2 / 3,
170 output_tokens: total_tokens / 3,
171 cost_usd: total_cost,
172 ..Default::default()
173 },
174 );
175
176 let now = Local::now();
178 let current_day = now.day();
179 let mut daily_model_tokens = Vec::new();
180
181 if mtd_tokens > 0 {
183 for day in 1..=current_day {
184 let mut tokens_by_model = HashMap::new();
185 tokens_by_model.insert(
186 "claude-sonnet-4".to_string(),
187 mtd_tokens / current_day as u64,
188 );
189
190 daily_model_tokens.push(DailyModelTokens {
191 date: format!("{}-{:02}-{:02}", now.year(), now.month(), day),
192 tokens_by_model,
193 });
194 }
195 }
196
197 StatsCache {
198 model_usage,
199 daily_model_tokens,
200 first_session_date: Some(first_date.to_string()),
201 ..Default::default()
202 }
203 }
204
205 fn mock_stats_with_total_cost(total_cost: f64, first_date: &str) -> StatsCache {
207 mock_stats_with_mtd_ratio(total_cost, 1.0, first_date)
208 }
209
210 #[test]
211 fn test_quota_status_safe() {
212 let stats = mock_stats_with_mtd_ratio(100.0, 0.2, "2024-01-01");
215 let budget = BudgetConfig {
216 monthly_limit: Some(50.0),
217 warning_threshold: 75.0,
218 critical_threshold: 90.0,
219 };
220 let status = calculate_quota_status(&stats, &budget);
221
222 assert_eq!(status.alert_level, AlertLevel::Safe);
223 assert!(status.usage_pct < 75.0);
224 }
225
226 #[test]
227 fn test_quota_status_warning() {
228 let stats = mock_stats_with_total_cost(500.0, "2024-01-01");
230 let budget = BudgetConfig {
231 monthly_limit: Some(100.0),
232 warning_threshold: 75.0,
233 critical_threshold: 90.0,
234 };
235 let status = calculate_quota_status(&stats, &budget);
236
237 assert!(
239 status.alert_level == AlertLevel::Warning
240 || status.alert_level == AlertLevel::Critical
241 || status.alert_level == AlertLevel::Exceeded
242 );
243 }
244
245 #[test]
246 fn test_quota_no_limit() {
247 let stats = mock_stats_with_total_cost(1000.0, "2024-01-01");
248 let budget = BudgetConfig {
249 monthly_limit: None,
250 warning_threshold: 75.0,
251 critical_threshold: 90.0,
252 };
253 let status = calculate_quota_status(&stats, &budget);
254
255 assert_eq!(status.alert_level, AlertLevel::Safe);
256 assert_eq!(status.usage_pct, 0.0);
257 assert!(status.projected_overage.is_none());
258 }
259
260 #[test]
261 fn test_determine_alert_level() {
262 let budget = BudgetConfig {
263 monthly_limit: Some(100.0),
264 warning_threshold: 75.0,
265 critical_threshold: 90.0,
266 };
267
268 assert_eq!(determine_alert_level(50.0, &budget), AlertLevel::Safe);
269 assert_eq!(determine_alert_level(75.0, &budget), AlertLevel::Warning);
270 assert_eq!(determine_alert_level(90.0, &budget), AlertLevel::Critical);
271 assert_eq!(determine_alert_level(100.0, &budget), AlertLevel::Exceeded);
272 assert_eq!(determine_alert_level(120.0, &budget), AlertLevel::Exceeded);
273 }
274}