1use crate::context::Context;
10use crate::state::State;
11use crate::usage::{Snapshot, Source, Window};
12use serde_json::Value;
13use std::collections::HashMap;
14
15const FRESH_FOR: i64 = 15 * 60;
17
18#[derive(Debug, Clone, Copy, Default, PartialEq)]
20pub struct Shares {
21 pub five_hour: Option<f64>,
22 pub weekly: Option<f64>,
23}
24
25#[derive(Debug, PartialEq)]
27pub struct Entry {
28 pub label: String,
29 pub shares: Shares,
30 pub age: Option<i64>,
32}
33
34#[derive(Debug, PartialEq)]
35pub struct StatusLine {
36 pub current: Option<String>,
38 pub session: Shares,
40 pub others: Vec<Entry>,
41}
42
43fn used_share(percent: f64, resets_at: Option<i64>, now: i64) -> f64 {
45 if resets_at.is_some_and(|at| at <= now) {
46 0.0
47 } else {
48 percent
49 }
50}
51
52fn session_shares(input: &Value, now: i64) -> Shares {
54 let share = |window: &str| {
55 let w = input.get("rate_limits")?.get(window)?;
56 Some(used_share(
57 w.get("used_percentage")?.as_f64()?,
58 w.get("resets_at").and_then(Value::as_i64),
59 now,
60 ))
61 };
62 Shares {
63 five_hour: share("five_hour"),
64 weekly: share("seven_day"),
65 }
66}
67
68fn remembered_shares(snapshot: &Snapshot, now: i64) -> Shares {
70 let share = |kinds: &[&str]| {
71 snapshot
72 .windows
73 .iter()
74 .find(|w| w.scope.is_none() && kinds.contains(&w.kind.as_str()))
75 .map(|w| used_share(w.percent, w.resets_at, now))
76 };
77 Shares {
78 five_hour: share(&["session", "five_hour"]),
79 weekly: share(&["weekly_all", "seven_day"]),
80 }
81}
82
83fn line(
85 input: &Value,
86 state: &State,
87 signed_in: Option<&str>,
88 remembered: &HashMap<String, Snapshot>,
89 now: i64,
90) -> StatusLine {
91 let current = signed_in.and_then(|uuid| state.by_uuid(uuid));
92 let others = state
93 .accounts
94 .iter()
95 .filter(|a| current.is_none_or(|c| c.account_uuid != a.account_uuid))
96 .map(|account| {
97 let reading = remembered.get(&account.account_uuid);
98 Entry {
99 label: account.label.clone(),
100 shares: reading.map_or_else(Shares::default, |r| remembered_shares(r, now)),
101 age: reading
102 .and_then(|r| r.observed_at)
103 .map(|at| now - at)
104 .filter(|age| *age > FRESH_FOR),
105 }
106 })
107 .collect();
108 StatusLine {
109 current: current.map(|a| a.label.clone()),
110 session: session_shares(input, now),
111 others,
112 }
113}
114
115fn session_snapshot(input: &Value, uuid: &str, now: i64) -> Option<Snapshot> {
118 let limits = input.get("rate_limits")?;
119 let window = |name: &str| -> Option<Window> {
120 let w = limits.get(name)?;
121 Some(Window {
122 kind: name.to_string(),
123 scope: None,
124 severity: None,
125 percent: w.get("used_percentage")?.as_f64()?,
126 resets_at: w.get("resets_at").and_then(Value::as_i64),
127 is_active: true,
128 })
129 };
130 let windows: Vec<Window> = ["five_hour", "seven_day"]
131 .iter()
132 .filter_map(|name| window(name))
133 .collect();
134 (!windows.is_empty()).then(|| Snapshot {
135 windows,
136 observed_at: Some(now),
137 account_uuid: Some(uuid.to_string()),
138 source: Source::Live,
139 })
140}
141
142pub fn read(ctx: &Context, input: &str) -> StatusLine {
149 let input: Value = serde_json::from_str(input).unwrap_or(Value::Null);
150 let state = crate::state::load(ctx).unwrap_or_default();
151 let signed_in = crate::claude::load_config(ctx)
152 .ok()
153 .as_ref()
154 .and_then(crate::claude::identity)
155 .map(|id| id.account_uuid);
156 let now = crate::time::now();
157 let remembered = crate::readings::load(ctx);
158 if let Some(uuid) = signed_in.as_deref() {
159 let stale = remembered
160 .get(uuid)
161 .and_then(|r| r.observed_at)
162 .is_none_or(|at| now - at > FRESH_FOR);
163 if stale && let Some(snapshot) = session_snapshot(&input, uuid, now) {
164 crate::readings::remember(ctx, &[(uuid.to_string(), snapshot)]);
165 }
166 }
167 line(&input, &state, signed_in.as_deref(), &remembered, now)
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use crate::state::Account;
174 use crate::usage::{Source, Window};
175 use serde_json::json;
176
177 const NOW: i64 = 1_789_935_000;
178
179 fn state() -> State {
180 let account = |label: &str| Account {
181 label: label.into(),
182 account_uuid: format!("{label}-uuid"),
183 email: format!("{label}@example.com"),
184 organization_uuid: "o".into(),
185 oauth_account: json!({}),
186 parked: None,
187 };
188 State {
189 accounts: vec![account("work"), account("personal"), account("side")],
190 ..State::default()
191 }
192 }
193
194 fn reading(five: f64, week: f64, observed_at: i64, resets_at: i64) -> Snapshot {
195 let window = |kind: &str, percent| Window {
196 kind: kind.into(),
197 scope: None,
198 percent,
199 resets_at: Some(resets_at),
200 is_active: false,
201 severity: None,
202 };
203 Snapshot {
204 windows: vec![window("session", five), window("weekly_all", week)],
205 observed_at: Some(observed_at),
206 account_uuid: None,
207 source: Source::Remembered,
208 }
209 }
210
211 fn shares(five_hour: f64, weekly: f64) -> Shares {
212 Shares {
213 five_hour: Some(five_hour),
214 weekly: Some(weekly),
215 }
216 }
217
218 #[test]
219 fn names_the_account_in_use_and_what_every_account_has_left() {
220 let input = json!({"rate_limits": {
221 "five_hour": {"used_percentage": 46.4, "resets_at": NOW + 600},
222 "seven_day": {"used_percentage": 70.0, "resets_at": NOW + 86_400}
223 }});
224 let remembered = HashMap::from([
225 (
226 "personal-uuid".to_string(),
227 reading(12.0, 40.0, NOW - 60, NOW + 600),
228 ),
229 (
230 "side-uuid".to_string(),
231 reading(90.0, 88.0, NOW - 3 * 3_600, NOW - 1),
232 ),
233 ]);
234 let line = line(&input, &state(), Some("work-uuid"), &remembered, NOW);
235 assert_eq!(line.current.as_deref(), Some("work"));
236 assert_eq!(line.session, shares(46.4, 70.0));
237 assert_eq!(
238 line.others,
239 [
240 Entry {
241 label: "personal".into(),
242 shares: shares(12.0, 40.0),
243 age: None,
244 },
245 Entry {
246 label: "side".into(),
247 shares: shares(0.0, 0.0),
248 age: Some(3 * 3_600),
249 },
250 ],
251 "a window past its reset counts as reset, and an old reading says how old"
252 );
253 }
254
255 #[test]
256 fn what_is_unknown_is_left_unknown_not_zero() {
257 let line = line(&json!({}), &state(), None, &HashMap::new(), NOW);
258 assert_eq!(line.current, None);
259 assert_eq!(line.session, Shares::default());
260 assert!(line.others.iter().all(|e| e.shares == Shares::default()));
261 assert_eq!(line.others.len(), 3);
262 }
263}