Skip to main content

ai_usagebar/cline/
types.rs

1//! Cline plan and usage-limit response types.
2//!
3//! ClinePass has three limits — 5 hours, weekly, monthly — and Cline computes
4//! them server side. `GET /api/v1/users/me/plan/usage-limits` (the call the
5//! dashboard's "Usage limits" panel makes) returns, for each, the percentage
6//! used and, once the window has opened, when it resets.
7//!
8//! These are **fixed windows**: one opens at the first charge after the
9//! previous one closed and runs its full length. They are not rolling sums of
10//! the last N days. An earlier version of this vendor re-derived them by
11//! summing the charge ledger over a rolling 30 days; after one heavy month it
12//! showed 100 % while the dashboard said 2 %. Reading the endpoint removes the
13//! whole ledger walk and cannot disagree with the dashboard.
14//!
15//! Field names keep the old `seven_day` / `thirty_day` spelling (and the
16//! `cline_7d_*` / `cline_30d_*` placeholders) for the weekly and monthly
17//! limits, so existing waybar formats keep working.
18
19use chrono::{DateTime, Utc};
20use serde_json::Value;
21
22/// `type` strings of `usage-limits`, in display order: 5 hours, weekly, monthly.
23const LIMIT_TYPES: [&str; 3] = ["five_hour", "weekly", "monthly"];
24
25/// What `/api/v1/users/me/plan` contributes: the plan's name and when the
26/// current billing period ends.
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct Plan {
29    pub name: Option<String>,
30    pub period_end: Option<DateTime<Utc>>,
31}
32
33/// One limit as the server reports it.
34#[derive(Debug, Clone, PartialEq)]
35pub struct Window {
36    /// Share of the limit used, as the server states it (a whole percent).
37    pub percent: f64,
38    /// When the window resets. `None` while no window is open (nothing has
39    /// been charged since the last one closed).
40    pub reset: Option<DateTime<Utc>>,
41}
42
43impl Eq for Window {}
44
45impl Window {
46    /// Consumed percentage, rounded and clamped for display.
47    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    /// The window as it stands at `now`: one whose reset has passed is empty.
59    /// Cached figures are aged with this, so an idle night drains the bar
60    /// without another request.
61    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/// What the widget and TUI render for Cline.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct Snapshot {
75    pub plan: Option<String>,
76    pub five_hour: Option<Window>,
77    /// The weekly limit.
78    pub seven_day: Option<Window>,
79    /// The monthly limit.
80    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    /// The most-exhausted window — the single number the bar leads with.
94    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
102/// Parse `/api/v1/users/me/plan`.
103pub 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
122/// Parse `/api/v1/users/me/plan/usage-limits` into the three windows.
123///
124/// A limit the server does not list stays `None` (a plan without ClinePass has
125/// none). One that is listed must carry a numeric `percentUsed`, otherwise the
126/// whole response is rejected: a bar that quietly shows 0 % is worse than one
127/// that says it could not read the figure.
128pub 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}