1use crate::context::Context;
10use crate::state::State;
11use crate::usage::Snapshot;
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
115pub fn read(ctx: &Context, input: &str) -> StatusLine {
118 let input: Value = serde_json::from_str(input).unwrap_or(Value::Null);
119 let state = crate::state::load(ctx).unwrap_or_default();
120 let signed_in = crate::claude::load_config(ctx)
121 .ok()
122 .as_ref()
123 .and_then(crate::claude::identity)
124 .map(|id| id.account_uuid);
125 line(
126 &input,
127 &state,
128 signed_in.as_deref(),
129 &crate::readings::load(ctx),
130 crate::time::now(),
131 )
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use crate::state::Account;
138 use crate::usage::{Source, Window};
139 use serde_json::json;
140
141 const NOW: i64 = 1_789_935_000;
142
143 fn state() -> State {
144 let account = |label: &str| Account {
145 label: label.into(),
146 account_uuid: format!("{label}-uuid"),
147 email: format!("{label}@example.com"),
148 organization_uuid: "o".into(),
149 oauth_account: json!({}),
150 parked: None,
151 };
152 State {
153 accounts: vec![account("work"), account("personal"), account("side")],
154 ..State::default()
155 }
156 }
157
158 fn reading(five: f64, week: f64, observed_at: i64, resets_at: i64) -> Snapshot {
159 let window = |kind: &str, percent| Window {
160 kind: kind.into(),
161 scope: None,
162 percent,
163 resets_at: Some(resets_at),
164 is_active: false,
165 };
166 Snapshot {
167 windows: vec![window("session", five), window("weekly_all", week)],
168 observed_at: Some(observed_at),
169 account_uuid: None,
170 source: Source::Remembered,
171 }
172 }
173
174 fn shares(five_hour: f64, weekly: f64) -> Shares {
175 Shares {
176 five_hour: Some(five_hour),
177 weekly: Some(weekly),
178 }
179 }
180
181 #[test]
182 fn names_the_account_in_use_and_what_every_account_has_left() {
183 let input = json!({"rate_limits": {
184 "five_hour": {"used_percentage": 46.4, "resets_at": NOW + 600},
185 "seven_day": {"used_percentage": 70.0, "resets_at": NOW + 86_400}
186 }});
187 let remembered = HashMap::from([
188 (
189 "personal-uuid".to_string(),
190 reading(12.0, 40.0, NOW - 60, NOW + 600),
191 ),
192 (
193 "side-uuid".to_string(),
194 reading(90.0, 88.0, NOW - 3 * 3_600, NOW - 1),
195 ),
196 ]);
197 let line = line(&input, &state(), Some("work-uuid"), &remembered, NOW);
198 assert_eq!(line.current.as_deref(), Some("work"));
199 assert_eq!(line.session, shares(46.4, 70.0));
200 assert_eq!(
201 line.others,
202 [
203 Entry {
204 label: "personal".into(),
205 shares: shares(12.0, 40.0),
206 age: None,
207 },
208 Entry {
209 label: "side".into(),
210 shares: shares(0.0, 0.0),
211 age: Some(3 * 3_600),
212 },
213 ],
214 "a window past its reset counts as reset, and an old reading says how old"
215 );
216 }
217
218 #[test]
219 fn what_is_unknown_is_left_unknown_not_zero() {
220 let line = line(&json!({}), &state(), None, &HashMap::new(), NOW);
221 assert_eq!(line.current, None);
222 assert_eq!(line.session, Shares::default());
223 assert!(line.others.iter().all(|e| e.shares == Shares::default()));
224 assert_eq!(line.others.len(), 3);
225 }
226}