1use chrono::{DateTime, Utc};
20use serde_json::Value;
21
22const LIMIT_TYPES: [&str; 3] = ["five_hour", "weekly", "monthly"];
24
25#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct Plan {
29 pub name: Option<String>,
30 pub period_end: Option<DateTime<Utc>>,
31}
32
33#[derive(Debug, Clone, PartialEq)]
35pub struct Window {
36 pub percent: f64,
38 pub reset: Option<DateTime<Utc>>,
41}
42
43impl Eq for Window {}
44
45impl Window {
46 pub fn pct(&self) -> i32 {
48 if !self.percent.is_finite() {
49 return 0;
50 }
51 self.percent.round().clamp(0.0, 100.0) as i32
52 }
53
54 pub fn resets_at(&self) -> Option<DateTime<Utc>> {
55 self.reset
56 }
57
58 pub fn aged(&self, now: DateTime<Utc>) -> Window {
62 match self.reset {
63 Some(reset) if reset <= now => Window {
64 percent: 0.0,
65 reset: None,
66 },
67 _ => self.clone(),
68 }
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct Snapshot {
75 pub plan: Option<String>,
76 pub five_hour: Option<Window>,
77 pub seven_day: Option<Window>,
79 pub thirty_day: Option<Window>,
81 pub period_end: Option<DateTime<Utc>>,
82}
83
84impl Snapshot {
85 pub fn windows(&self) -> [(&'static str, Option<&Window>); 3] {
86 [
87 ("5h", self.five_hour.as_ref()),
88 ("7d", self.seven_day.as_ref()),
89 ("30d", self.thirty_day.as_ref()),
90 ]
91 }
92
93 pub fn worst_pct(&self) -> Option<i32> {
95 self.windows()
96 .into_iter()
97 .filter_map(|(_, window)| window.map(Window::pct))
98 .max()
99 }
100}
101
102pub fn parse_plan(value: &Value) -> Result<Plan, String> {
104 let data = envelope(value, "plan")?;
105 let plan = data
106 .get("plan")
107 .ok_or_else(|| "Cline plan response has no plan".to_string())?;
108
109 Ok(Plan {
110 name: plan
111 .get("displayName")
112 .or_else(|| plan.get("name"))
113 .and_then(Value::as_str)
114 .map(str::to_string),
115 period_end: data
116 .get("currentPeriodEnd")
117 .and_then(Value::as_str)
118 .and_then(parse_timestamp),
119 })
120}
121
122pub fn parse_limits(value: &Value) -> Result<[Option<Window>; 3], String> {
129 let data = envelope(value, "usage limits")?;
130 let limits = data
131 .get("limits")
132 .and_then(Value::as_array)
133 .ok_or_else(|| "Cline usage-limits response has no limits".to_string())?;
134
135 let mut windows: [Option<Window>; 3] = [None, None, None];
136 for limit in limits {
137 let Some(index) = limit
138 .get("type")
139 .and_then(Value::as_str)
140 .and_then(|kind| LIMIT_TYPES.iter().position(|known| *known == kind))
141 else {
142 continue;
143 };
144 let percent = limit
145 .get("percentUsed")
146 .and_then(Value::as_f64)
147 .filter(|percent| percent.is_finite())
148 .ok_or_else(|| "Cline usage limit has no percentUsed".to_string())?;
149 windows[index] = Some(Window {
150 percent,
151 reset: limit
152 .get("resetsAt")
153 .and_then(Value::as_str)
154 .and_then(parse_timestamp),
155 });
156 }
157 Ok(windows)
158}
159
160fn envelope<'a>(value: &'a Value, what: &str) -> Result<&'a Value, String> {
161 if value.get("success").and_then(Value::as_bool) == Some(false) {
162 return Err(format!("Cline {what} response reports failure"));
163 }
164 value
165 .get("data")
166 .or(Some(value))
167 .filter(|data| data.is_object())
168 .ok_or_else(|| format!("Cline {what} response must be a JSON object"))
169}
170
171fn parse_timestamp(text: &str) -> Option<DateTime<Utc>> {
172 text.parse::<DateTime<chrono::FixedOffset>>()
173 .ok()
174 .map(|at| at.with_timezone(&Utc))
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 fn at(value: &str) -> DateTime<Utc> {
182 value.parse().expect("RFC3339 timestamp")
183 }
184
185 #[test]
186 fn parses_the_plan_fixture() {
187 let value: Value =
188 serde_json::from_str(include_str!("../../tests/fixtures/cline/plan.json"))
189 .expect("fixture JSON must be valid");
190
191 let plan = parse_plan(&value).expect("plan must parse");
192 assert_eq!(plan.name.as_deref(), Some("Cline Pass (Monthly)"));
193 assert_eq!(plan.period_end, Some(at("2026-10-01T05:17:51Z")));
194 }
195
196 #[test]
197 fn parses_the_real_usage_limits_response() {
198 let value: Value =
199 serde_json::from_str(include_str!("../../tests/fixtures/cline/usage-limits.json"))
200 .expect("fixture JSON must be valid");
201
202 let [five, week, month] = parse_limits(&value).expect("limits must parse");
203 let five = five.expect("5h");
204 assert_eq!(five.pct(), 0);
205 assert_eq!(five.reset, None);
206 let week = week.expect("weekly");
207 assert_eq!(week.pct(), 5);
208 assert_eq!(week.reset, Some(at("2026-10-10T13:41:41.925010062Z")));
209 let month = month.expect("monthly");
210 assert_eq!(month.pct(), 2);
211 assert_eq!(month.reset, Some(at("2026-11-02T13:41:41.927618642Z")));
212 }
213
214 #[test]
215 fn a_plan_without_clinepass_has_no_limits() {
216 let value: Value = serde_json::json!({"data": {"limits": []}, "success": true});
217 assert_eq!(
218 parse_limits(&value).expect("empty is valid"),
219 [None, None, None]
220 );
221 }
222
223 #[test]
224 fn unknown_limit_types_are_ignored_but_a_listed_one_needs_a_percentage() {
225 let ok = serde_json::json!({"data": {"limits": [
226 {"type": "daily", "percentUsed": 90},
227 {"type": "weekly", "percentUsed": 7}
228 ]}});
229 let [five, week, month] = parse_limits(&ok).expect("parses");
230 assert!(five.is_none() && month.is_none());
231 assert_eq!(week.expect("weekly").pct(), 7);
232
233 let bad = serde_json::json!({"data": {"limits": [{"type": "weekly"}]}});
234 assert!(parse_limits(&bad).is_err());
235 let failed = serde_json::json!({"success": false});
236 assert!(parse_limits(&failed).is_err());
237 }
238
239 #[test]
240 fn a_window_past_its_reset_reads_as_empty() {
241 let window = Window {
242 percent: 40.0,
243 reset: Some(at("2026-10-10T13:41:41Z")),
244 };
245 assert_eq!(window.aged(at("2026-10-10T13:41:40Z")).pct(), 40);
246 let after = window.aged(at("2026-10-10T13:41:41Z"));
247 assert_eq!(after.pct(), 0);
248 assert_eq!(after.reset, None);
249 }
250
251 #[test]
252 fn percentages_are_clamped_and_worst_pct_leads() {
253 let snapshot = Snapshot {
254 plan: None,
255 five_hour: Some(Window {
256 percent: 0.0,
257 reset: None,
258 }),
259 seven_day: Some(Window {
260 percent: 5.0,
261 reset: None,
262 }),
263 thirty_day: Some(Window {
264 percent: 130.0,
265 reset: None,
266 }),
267 period_end: None,
268 };
269 assert_eq!(snapshot.worst_pct(), Some(100));
270 assert_eq!(
271 Window {
272 percent: f64::NAN,
273 reset: None
274 }
275 .pct(),
276 0
277 );
278 }
279}