ai_usagebar/balance.rs
1//! Prepaid-balance display policy: which denominator a tank is measured
2//! against, how much of it is consumed, and which number goes on the bar.
3//!
4//! A prepaid vendor's API reports money *remaining* and nothing else — there is
5//! no denominator to turn that into a meter. `[vendor] display_limit` lets the
6//! user state the tank size themselves, in the currency that vendor already
7//! reports. It is a fallback, never an override: a vendor that states a limit of
8//! its own (OpenRouter's credits purchased, or a per-key limit) keeps it.
9//!
10//! Which of the two numbers a frontend puts on the bar is a separate choice.
11//! [`Headline`] is the user's preference; [`MetricHeadline`] is what a report
12//! metric actually declares after the preference meets the available data.
13//!
14//! Who acts on it, as of this commit:
15//! - The Omarchy panel and the KDE plasmoid read the declaration and draw the
16//! number it names, instead of guessing from the row's label.
17//! - The tray popover (Windows WebView2, macOS WKWebView) reads it too: a
18//! `"value"` metric puts the money figure under its meter and moves the
19//! percentage and the detail to the hover text. A `"percent"` metric keeps
20//! the popover's own used/left toggle, whose "used" reading is the consumed
21//! percentage.
22//! - Waybar and GNOME are fed by the per-vendor `{placeholder}` formats rather
23//! than by report sections, so neither setting reaches them at all.
24
25use serde::{Deserialize, Serialize};
26
27/// Which number a vendor puts on the bar.
28///
29/// A balance vendor defaults to [`Headline::Amount`], a quota vendor to
30/// [`Headline::Percent`]. Setting `display_limit` does not change this by
31/// itself — the tank size and the headline are independent choices.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
33#[serde(rename_all = "lowercase")]
34pub enum Headline {
35 /// The money figure — a balance, in the vendor's currency.
36 Amount,
37 /// The consumed percentage of the tank. Falls back to the amount when
38 /// nothing supplies a denominator.
39 Percent,
40}
41
42/// The headline a report metric declares, after [`Headline`] has met the data.
43///
44/// Frontends draw this number and leave the other one in the detail line.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum MetricHeadline {
47 /// Draw `percent`.
48 Percent,
49 /// Draw `value` (the money figure).
50 Value,
51}
52
53impl MetricHeadline {
54 /// The word this headline serializes as in the report.
55 pub fn as_str(self) -> &'static str {
56 match self {
57 Self::Percent => "percent",
58 Self::Value => "value",
59 }
60 }
61}
62
63/// Per-vendor bar-number settings, resolved from config for one tab.
64///
65/// The default is the quota-vendor shape — no user denominator, percent on the
66/// bar — so a vendor that never opts in is unaffected.
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub struct DisplayPrefs {
69 /// `[vendor] display_limit`: the user's tank size, if they stated one.
70 pub display_limit: Option<f64>,
71 /// `[vendor] headline`.
72 pub headline: Headline,
73}
74
75impl Default for DisplayPrefs {
76 fn default() -> Self {
77 Self {
78 display_limit: None,
79 headline: Headline::Percent,
80 }
81 }
82}
83
84impl DisplayPrefs {
85 /// Prefs for a vendor whose API states no limit of its own.
86 pub fn balance(display_limit: Option<f64>, headline: Headline) -> Self {
87 Self {
88 display_limit,
89 headline,
90 }
91 }
92}
93
94/// A usable tank size, or `None`.
95///
96/// The API's own limit wins; `display_limit` is the fallback; without either
97/// there is no denominator and no meter can be drawn. A value that cannot
98/// divide — zero, negative, infinite, NaN — counts as absent at either
99/// position, so a bad API response degrades to the user's number rather than
100/// producing a nonsense percentage.
101///
102/// # Examples
103///
104/// ```
105/// use ai_usagebar::balance::denominator;
106///
107/// assert_eq!(denominator(Some(50.0), Some(200.0)), Some(50.0));
108/// assert_eq!(denominator(None, Some(200.0)), Some(200.0));
109/// assert_eq!(denominator(None, None), None);
110/// ```
111pub fn denominator(api_limit: Option<f64>, display_limit: Option<f64>) -> Option<f64> {
112 api_limit
113 .filter(usable)
114 .or_else(|| display_limit.filter(usable))
115}
116
117fn usable(limit: &f64) -> bool {
118 limit.is_finite() && *limit > 0.0
119}
120
121/// Whole percent of `limit` already consumed, given how much is left.
122///
123/// Consumed rather than remaining, so the meter fills the way the quota meters
124/// do, and clamped through [`crate::format::clamp_pct`]: a balance above the cap
125/// reads as 0% used (the money figure still says how far above), and an
126/// overdrawn balance stops at 100%.
127///
128/// # Examples
129///
130/// ```
131/// use ai_usagebar::balance::consumed_pct;
132///
133/// assert_eq!(consumed_pct(200.0, 50.0), 75);
134/// assert_eq!(consumed_pct(200.0, 250.0), 0);
135/// assert_eq!(consumed_pct(200.0, -10.0), 100);
136/// ```
137pub fn consumed_pct(limit: f64, remaining: f64) -> u16 {
138 if !usable(&limit) {
139 return 0;
140 }
141 crate::format::clamp_pct((limit - remaining) / limit * 100.0)
142}
143
144/// Which number this metric puts on the bar.
145///
146/// `percent` needs a denominator; asked for one without a limit from either
147/// source, the amount stays on the bar rather than a fabricated percentage.
148pub fn resolve_headline(choice: Headline, denominator: Option<f64>) -> MetricHeadline {
149 match choice {
150 Headline::Amount => MetricHeadline::Value,
151 Headline::Percent if denominator.is_some() => MetricHeadline::Percent,
152 Headline::Percent => MetricHeadline::Value,
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 // --- the fallback order ---
161
162 #[test]
163 fn an_api_limit_wins_over_the_users_display_limit() {
164 assert_eq!(denominator(Some(50.0), Some(200.0)), Some(50.0));
165 }
166
167 #[test]
168 fn display_limit_is_used_only_when_the_api_states_no_limit() {
169 assert_eq!(denominator(None, Some(200.0)), Some(200.0));
170 assert_eq!(denominator(Some(50.0), None), Some(50.0));
171 assert_eq!(denominator(None, None), None);
172 }
173
174 /// No baked-in default: absent means absent, at both positions.
175 #[test]
176 fn an_unusable_limit_counts_as_absent_at_either_position() {
177 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
178 assert_eq!(denominator(Some(bad), Some(200.0)), Some(200.0), "{bad}");
179 assert_eq!(denominator(None, Some(bad)), None, "{bad}");
180 assert_eq!(denominator(Some(bad), None), None, "{bad}");
181 }
182 }
183
184 // --- the math ---
185
186 #[test]
187 fn percent_is_consumed_not_remaining() {
188 assert_eq!(consumed_pct(200.0, 200.0), 0);
189 assert_eq!(consumed_pct(200.0, 150.0), 25);
190 assert_eq!(consumed_pct(200.0, 100.0), 50);
191 assert_eq!(consumed_pct(200.0, 0.0), 100);
192 }
193
194 /// A balance above the cap is 0% used, not a negative percentage — the
195 /// money figure is what says how far above it sits.
196 #[test]
197 fn a_balance_above_the_cap_reads_as_zero_percent_used() {
198 assert_eq!(consumed_pct(200.0, 250.0), 0);
199 assert_eq!(consumed_pct(20.0, 1_000.0), 0);
200 }
201
202 #[test]
203 fn an_overdrawn_balance_stops_at_one_hundred() {
204 assert_eq!(consumed_pct(200.0, -0.01), 100);
205 assert_eq!(consumed_pct(200.0, -5_000.0), 100);
206 }
207
208 #[test]
209 fn percent_rounds_to_the_nearest_whole() {
210 // 5.5 / 20 consumed = 27.5% -> 28.
211 assert_eq!(consumed_pct(20.0, 14.5), 28);
212 // 4.9 / 20 = 24.5% -> 25 (round-half-away-from-zero).
213 assert_eq!(consumed_pct(20.0, 15.1), 25);
214 }
215
216 #[test]
217 fn a_limit_that_cannot_divide_yields_zero_rather_than_a_nonsense_percent() {
218 for bad in [0.0, -10.0, f64::NAN, f64::INFINITY] {
219 assert_eq!(consumed_pct(bad, 5.0), 0, "{bad}");
220 }
221 assert_eq!(consumed_pct(200.0, f64::NAN), 0);
222 }
223
224 // --- the headline switch ---
225
226 #[test]
227 fn amount_keeps_the_money_figure_on_the_bar_even_with_a_tank() {
228 assert_eq!(
229 resolve_headline(Headline::Amount, Some(200.0)),
230 MetricHeadline::Value
231 );
232 assert_eq!(
233 resolve_headline(Headline::Amount, None),
234 MetricHeadline::Value
235 );
236 }
237
238 #[test]
239 fn percent_needs_a_denominator_and_otherwise_keeps_the_amount() {
240 assert_eq!(
241 resolve_headline(Headline::Percent, Some(200.0)),
242 MetricHeadline::Percent
243 );
244 assert_eq!(
245 resolve_headline(Headline::Percent, None),
246 MetricHeadline::Value
247 );
248 }
249
250 #[test]
251 fn the_report_words_are_percent_and_value() {
252 assert_eq!(MetricHeadline::Percent.as_str(), "percent");
253 assert_eq!(MetricHeadline::Value.as_str(), "value");
254 }
255
256 #[test]
257 fn the_default_prefs_are_the_quota_shape() {
258 let prefs = DisplayPrefs::default();
259 assert_eq!(prefs.display_limit, None);
260 assert_eq!(prefs.headline, Headline::Percent);
261 }
262
263 #[test]
264 fn headline_parses_from_the_config_words_and_rejects_anything_else() {
265 #[derive(Deserialize)]
266 struct Wrapper {
267 headline: Headline,
268 }
269 let parse = |body: &str| toml::from_str::<Wrapper>(body).map(|w| w.headline);
270
271 assert_eq!(parse("headline = \"amount\"").unwrap(), Headline::Amount);
272 assert_eq!(parse("headline = \"percent\"").unwrap(), Headline::Percent);
273 // A typo is loud at load time rather than silently drawing the wrong
274 // number for the life of the install.
275 assert!(parse("headline = \"dollars\"").is_err());
276 assert!(parse("headline = \"Amount\"").is_err());
277 }
278}