Skip to main content

ai_usagebar/novita/
vendor.rs

1//! Novita AI renderer — bar text + bordered Pango tooltip. Balance-only, like
2//! Kilo/DeepSeek, with a top-up / credit-limit / owed breakdown in the tooltip.
3
4use std::collections::HashMap;
5
6use chrono::{DateTime, Utc};
7
8use crate::format::{placeholders, substitute, updated_at_hm, usd};
9use crate::pacing::PaceSeverity;
10use crate::pango::{color_span, escape, severity_color};
11use crate::theme::Theme;
12use crate::tooltip::{Line as TooltipLine, render_bordered};
13use crate::usage::NovitaSnapshot;
14use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
15use crate::waybar::{Class, WaybarOutput};
16
17use super::fetch::FetchOutcome;
18
19pub const DEFAULT_FORMAT: &str = "{nv_balance}";
20
21pub fn build_placeholders(snap: &NovitaSnapshot) -> HashMap<&'static str, String> {
22    placeholders(vec![
23        ("icon", "󰄔".to_string()),
24        ("vendor_short", VendorId::Novita.short_name().to_string()),
25        // Cross-vendor aliases — Novita has no rate-limit windows.
26        ("session_pct", "0".to_string()),
27        ("session_reset", "—".to_string()),
28        ("weekly_pct", "0".to_string()),
29        ("weekly_reset", "—".to_string()),
30        ("plan", "Novita".to_string()),
31        ("nv_balance", usd(snap.available)),
32        ("nv_cash", usd(snap.cash)),
33        ("nv_credit_limit", usd(snap.credit_limit)),
34        ("nv_owed", usd(snap.outstanding)),
35    ])
36}
37
38/// Severity keys on the absolute remaining USD balance (same thresholds as
39/// Kilo/DeepSeek): running low = warmer color, empty = critical.
40pub fn severity(snap: &NovitaSnapshot) -> PaceSeverity {
41    crate::pango::balance_severity(snap.available, "USD")
42}
43
44pub fn render(
45    outcome: &VendorOutcome,
46    snap: &NovitaSnapshot,
47    theme: &Theme,
48    opts: &RenderOpts,
49    now: DateTime<Utc>,
50) -> WaybarOutput {
51    let class = Class::from(severity(snap));
52    let format = opts
53        .format
54        .clone()
55        .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
56    let values = build_placeholders(snap);
57
58    let mut text = substitute(&format, &values);
59    if outcome.stale {
60        text.push_str(" ⏸");
61    }
62
63    let wrapper_color = severity_color(severity(snap), theme).to_string();
64    let icon_prefix = match opts.icon.as_deref() {
65        Some(ic) if !ic.is_empty() => format!("{ic} "),
66        _ => String::new(),
67    };
68    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
69
70    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
71        substitute(fmt, &values)
72    } else {
73        render_tooltip(outcome, snap, theme, now)
74    };
75
76    WaybarOutput {
77        text: bar_text,
78        tooltip,
79        class,
80    }
81}
82
83fn render_tooltip(
84    outcome: &VendorOutcome,
85    snap: &NovitaSnapshot,
86    theme: &Theme,
87    now: DateTime<Utc>,
88) -> String {
89    let blue = &theme.blue;
90    let dim = &theme.dim;
91    let fg = &theme.fg;
92    let color = severity_color(severity(snap), theme);
93
94    let mut lines: Vec<TooltipLine> = Vec::new();
95    lines.push(TooltipLine::Center(format!(
96        "<span font_weight='bold' foreground='{blue}'>Novita</span>"
97    )));
98    lines.push(TooltipLine::Sep);
99    lines.push(TooltipLine::Body("".into()));
100
101    lines.push(TooltipLine::Body(format!(
102        " <span foreground='{fg}'>  󰢗  Balance</span>"
103    )));
104    lines.push(TooltipLine::Body(format!(
105        "   <span font_weight='bold' foreground='{color}'>{bal}</span>",
106        bal = escape(&usd(snap.available))
107    )));
108    lines.push(TooltipLine::Body(format!(
109        " <span foreground='{dim}'>     top-up {cash} · credit limit {lim}</span>",
110        cash = escape(&usd(snap.cash)),
111        lim = escape(&usd(snap.credit_limit))
112    )));
113
114    if snap.outstanding > 0.0 {
115        lines.push(TooltipLine::Body("".into()));
116        lines.push(TooltipLine::Body(format!(
117            " <span foreground='{dim}'>  󰆑  owed {owed}</span>",
118            owed = escape(&usd(snap.outstanding))
119        )));
120    }
121
122    if let Some((code, msg)) = outcome.last_error.as_ref()
123        && *code != 0
124    {
125        let (icon, ecolor) = if *code >= 500 {
126            ("󰅚", theme.red.as_str())
127        } else {
128            ("󰀪", theme.orange.as_str())
129        };
130        lines.push(TooltipLine::Body("".into()));
131        lines.push(TooltipLine::Sep);
132        lines.push(TooltipLine::Body(format!(
133            " <span foreground='{ecolor}'>  {icon}  HTTP {code}</span>"
134        )));
135        lines.push(TooltipLine::Body(format!(
136            "     <span foreground='{dim}'>{}</span>",
137            escape(msg)
138        )));
139    }
140
141    let updated = updated_at_hm(now, outcome.cache_age);
142    lines.push(TooltipLine::Body("".into()));
143    lines.push(TooltipLine::Sep);
144    lines.push(TooltipLine::Body(format!(
145        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
146    )));
147
148    render_bordered(&lines, theme)
149}
150
151impl From<FetchOutcome> for VendorOutcome {
152    fn from(o: FetchOutcome) -> Self {
153        o.map(crate::usage::VendorSnapshot::Novita)
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::usage::NovitaSnapshot;
161
162    fn sample_snap() -> NovitaSnapshot {
163        NovitaSnapshot {
164            available: 100.0,
165            cash: 80.0,
166            credit_limit: 20.0,
167            outstanding: 0.0,
168        }
169    }
170
171    fn sample_outcome(snap: NovitaSnapshot) -> VendorOutcome {
172        VendorOutcome {
173            snapshot: crate::usage::VendorSnapshot::Novita(snap),
174            stale: false,
175            last_error: None,
176            cache_age: Some(std::time::Duration::from_secs(10)),
177        }
178    }
179
180    fn opts() -> RenderOpts {
181        RenderOpts {
182            format: None,
183            tooltip_format: None,
184            icon: None,
185            pace_tolerance: 5,
186            format_pace_color: false,
187            tooltip_pace_pts: false,
188        }
189    }
190
191    #[test]
192    fn default_render_shows_available_balance() {
193        let snap = sample_snap();
194        let out = render(
195            &sample_outcome(snap.clone()),
196            &snap,
197            &Theme::default(),
198            &opts(),
199            Utc::now(),
200        );
201        assert!(out.text.contains("$100.00"));
202    }
203
204    #[test]
205    fn tooltip_includes_balance_and_breakdown() {
206        let snap = sample_snap();
207        let out = render(
208            &sample_outcome(snap.clone()),
209            &snap,
210            &Theme::default(),
211            &opts(),
212            Utc::now(),
213        );
214        assert!(out.tooltip.contains("Balance"));
215        assert!(out.tooltip.contains("$100.00"));
216        assert!(out.tooltip.contains("top-up $80.00"));
217        assert!(out.tooltip.contains("credit limit $20.00"));
218    }
219
220    #[test]
221    fn owed_line_only_when_positive() {
222        let mut snap = sample_snap();
223        snap.outstanding = 3.0;
224        let out = render(
225            &sample_outcome(snap.clone()),
226            &snap,
227            &Theme::default(),
228            &opts(),
229            Utc::now(),
230        );
231        assert!(out.tooltip.contains("owed $3.00"));
232    }
233
234    #[test]
235    fn severity_scales_with_balance() {
236        let mk = |b: f64| NovitaSnapshot {
237            available: b,
238            cash: 0.0,
239            credit_limit: 0.0,
240            outstanding: 0.0,
241        };
242        assert_eq!(severity(&mk(0.5)), PaceSeverity::Critical);
243        assert_eq!(severity(&mk(3.0)), PaceSeverity::High);
244        assert_eq!(severity(&mk(12.0)), PaceSeverity::Mid);
245        assert_eq!(severity(&mk(50.0)), PaceSeverity::Low);
246    }
247
248    #[test]
249    fn custom_tooltip_uses_placeholder() {
250        let snap = sample_snap();
251        let mut o = opts();
252        o.tooltip_format = Some("bal: {nv_balance} · owed {nv_owed}".into());
253        let out = render(
254            &sample_outcome(snap.clone()),
255            &snap,
256            &Theme::default(),
257            &o,
258            Utc::now(),
259        );
260        assert_eq!(out.tooltip, "bal: $100.00 · owed $0.00");
261    }
262}