1use serde::{Deserialize, Serialize};
4
5use hel::hel_config::HarnessKind;
6
7pub const API_LABEL: &str = "API";
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct QuotaWindow {
13 pub label: String,
14 pub remaining_percent: Option<u8>,
15 pub used: Option<i64>,
16 pub limit: Option<i64>,
17 pub resets: Option<String>,
18 #[serde(default)]
19 pub resets_at_epoch_seconds: Option<i64>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct ProfileQuota {
25 pub profile_id: String,
26 pub harness: HarnessKind,
27 pub windows: Vec<QuotaWindow>,
28 pub extra: Option<String>,
29 pub error: Option<String>,
30 pub refreshed_at_epoch_seconds: u64,
31}
32
33impl ProfileQuota {
34 pub fn weekly_window(&self) -> Option<&QuotaWindow> {
35 self.windows
36 .iter()
37 .find(|window| is_weekly_quota_window(&window.label))
38 }
39
40 pub fn five_hour_window(&self) -> Option<&QuotaWindow> {
41 self.windows
42 .iter()
43 .find(|window| is_short_quota_window(&window.label))
44 }
45
46 pub fn is_usage_priced(&self) -> bool {
50 self.error.is_none() && self.windows.is_empty() && self.extra.as_deref() == Some(API_LABEL)
51 }
52
53 pub fn five_hour_projects_exhaustion(&self) -> bool {
54 self.five_hour_window().is_some_and(|window| {
55 projects_exhaustion_before_reset(window, self.refreshed_at_epoch_seconds)
56 })
57 }
58
59 pub fn compact(&self) -> String {
60 if let Some(error) = &self.error {
61 return quota_error_label(error);
62 }
63 let mut seen_resets = std::collections::BTreeSet::new();
64 let mut parts = self
65 .windows
66 .iter()
67 .filter(|window| {
68 !is_short_quota_window(&window.label)
69 || projects_exhaustion_before_reset(window, self.refreshed_at_epoch_seconds)
70 })
71 .map(|window| {
72 let usage = match (window.remaining_percent, window.used, window.limit) {
73 (Some(remaining), _, _) => format!("{remaining}% left"),
74 (_, Some(used), Some(limit)) => format!("{used}/{limit}"),
75 _ => "available".to_string(),
76 };
77 match window
78 .resets
79 .as_ref()
80 .filter(|reset| seen_resets.insert((*reset).clone()))
81 {
82 Some(reset) => format!("{} {usage}, resets {reset}", window.label),
83 None => format!("{} {usage}", window.label),
84 }
85 })
86 .collect::<Vec<_>>();
87 if let Some(extra) = &self.extra {
88 parts.push(extra.clone());
89 }
90 if parts.is_empty() {
91 "no quota windows reported".to_string()
92 } else {
93 parts.join(" ยท ")
94 }
95 }
96
97 pub fn error_label(&self) -> Option<String> {
98 self.error.as_deref().map(quota_error_label)
99 }
100}
101
102fn quota_error_label(error: &str) -> String {
103 if error == "login expired" {
106 error.to_string()
107 } else if error.starts_with("rate limited") {
108 "rate limited".to_string()
111 } else {
112 "unavailable".to_string()
113 }
114}
115
116fn is_weekly_quota_window(label: &str) -> bool {
119 matches!(
120 label.to_ascii_lowercase().as_str(),
121 "week" | "weekly" | "7d" | "month" | "monthly"
122 )
123}
124
125fn is_short_quota_window(label: &str) -> bool {
126 matches!(
127 label.to_ascii_lowercase().as_str(),
128 "5h" | "5-hour" | "5 hour"
129 )
130}
131
132#[must_use]
134pub fn projects_exhaustion(window: &QuotaWindow, now: u64) -> bool {
135 projects_exhaustion_before_reset(window, now)
136}
137
138fn projects_exhaustion_before_reset(window: &QuotaWindow, now: u64) -> bool {
139 const FIVE_HOURS_SECONDS: i64 = 5 * 60 * 60;
140 let Some(reset) = window.resets_at_epoch_seconds else {
141 return false;
142 };
143 let Ok(now) = i64::try_from(now) else {
144 return false;
145 };
146 let remaining_time = reset - now;
147 let elapsed = FIVE_HOURS_SECONDS - remaining_time;
148 if remaining_time <= 0 || elapsed <= 0 || elapsed >= FIVE_HOURS_SECONDS {
149 return false;
150 }
151 if let (Some(used), Some(limit)) = (window.used, window.limit)
152 && limit > 0
153 {
154 return i128::from(used.clamp(0, limit)) * i128::from(FIVE_HOURS_SECONDS)
155 > i128::from(limit) * i128::from(elapsed);
156 }
157 window
158 .remaining_percent
159 .is_some_and(|remaining| i64::from(100 - remaining) * FIVE_HOURS_SECONDS > 100 * elapsed)
160}