Skip to main content

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}