1use std::collections::HashMap;
5
6use chrono::{DateTime, Utc};
7
8use crate::format::{money, placeholders, substitute, updated_at_hm};
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::MoonshotSnapshot;
14use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
15use crate::waybar::{Class, WaybarOutput};
16
17use super::fetch::FetchOutcome;
18
19pub const DEFAULT_FORMAT: &str = "{km_balance}";
20
21pub fn build_placeholders(snap: &MoonshotSnapshot) -> HashMap<&'static str, String> {
22 placeholders(vec![
23 ("icon", "".to_string()),
24 ("vendor_short", VendorId::Moonshot.short_name().to_string()),
25 ("session_pct", "0".to_string()),
27 ("session_reset", "—".to_string()),
28 ("weekly_pct", "0".to_string()),
29 ("weekly_reset", "—".to_string()),
30 ("plan", "Kimi".to_string()),
31 ("km_balance", money(snap.available, &snap.currency)),
32 ("km_voucher", money(snap.voucher, &snap.currency)),
33 ("km_cash", money(snap.cash, &snap.currency)),
34 ("currency", snap.currency.clone()),
35 ])
36}
37
38pub fn severity(snap: &MoonshotSnapshot) -> PaceSeverity {
42 if snap.available <= 0.0 {
43 return PaceSeverity::Critical;
44 }
45 crate::pango::balance_severity(snap.available, &snap.currency)
46}
47
48pub fn render(
49 outcome: &VendorOutcome,
50 snap: &MoonshotSnapshot,
51 theme: &Theme,
52 opts: &RenderOpts,
53 now: DateTime<Utc>,
54) -> WaybarOutput {
55 let class = Class::from(severity(snap));
56 let format = opts
57 .format
58 .clone()
59 .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
60 let values = build_placeholders(snap);
61
62 let mut text = substitute(&format, &values);
63 if outcome.stale {
64 text.push_str(" ⏸");
65 }
66
67 let wrapper_color = severity_color(severity(snap), theme).to_string();
68 let icon_prefix = match opts.icon.as_deref() {
69 Some(ic) if !ic.is_empty() => format!("{ic} "),
70 _ => String::new(),
71 };
72 let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
73
74 let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
75 substitute(fmt, &values)
76 } else {
77 render_tooltip(outcome, snap, theme, now)
78 };
79
80 WaybarOutput {
81 text: bar_text,
82 tooltip,
83 class,
84 }
85}
86
87fn render_tooltip(
88 outcome: &VendorOutcome,
89 snap: &MoonshotSnapshot,
90 theme: &Theme,
91 now: DateTime<Utc>,
92) -> String {
93 let blue = &theme.blue;
94 let dim = &theme.dim;
95 let fg = &theme.fg;
96 let color = severity_color(severity(snap), theme);
97
98 let mut lines: Vec<TooltipLine> = Vec::new();
99 lines.push(TooltipLine::Center(format!(
100 "<span font_weight='bold' foreground='{blue}'>Kimi (Moonshot)</span>"
101 )));
102 lines.push(TooltipLine::Sep);
103 lines.push(TooltipLine::Body("".into()));
104
105 lines.push(TooltipLine::Body(format!(
106 " <span foreground='{fg}'> Balance</span>"
107 )));
108 lines.push(TooltipLine::Body(format!(
109 " <span font_weight='bold' foreground='{color}'>{bal}</span>",
110 bal = escape(&money(snap.available, &snap.currency))
111 )));
112 lines.push(TooltipLine::Body(format!(
113 " <span foreground='{dim}'> cash {cash} · voucher {voucher}</span>",
114 cash = escape(&money(snap.cash, &snap.currency)),
115 voucher = escape(&money(snap.voucher, &snap.currency))
116 )));
117
118 if snap.available <= 0.0 {
119 lines.push(TooltipLine::Body("".into()));
120 lines.push(TooltipLine::Body(format!(
121 " <span foreground='{}'> out of credit — inference blocked</span>",
122 theme.orange.as_str()
123 )));
124 }
125
126 if let Some((code, msg)) = outcome.last_error.as_ref()
127 && *code != 0
128 {
129 let (icon, ecolor) = if *code >= 500 {
130 ("", theme.red.as_str())
131 } else {
132 ("", theme.orange.as_str())
133 };
134 lines.push(TooltipLine::Body("".into()));
135 lines.push(TooltipLine::Sep);
136 lines.push(TooltipLine::Body(format!(
137 " <span foreground='{ecolor}'> {icon} HTTP {code}</span>"
138 )));
139 lines.push(TooltipLine::Body(format!(
140 " <span foreground='{dim}'>{}</span>",
141 escape(msg)
142 )));
143 }
144
145 let updated = updated_at_hm(now, outcome.cache_age);
146 lines.push(TooltipLine::Body("".into()));
147 lines.push(TooltipLine::Sep);
148 lines.push(TooltipLine::Body(format!(
149 " <span foreground='{dim}'> Updated {updated}</span>"
150 )));
151
152 render_bordered(&lines, theme)
153}
154
155impl From<FetchOutcome> for VendorOutcome {
156 fn from(o: FetchOutcome) -> Self {
157 o.map(crate::usage::VendorSnapshot::Moonshot)
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::usage::MoonshotSnapshot;
165
166 fn sample_snap() -> MoonshotSnapshot {
167 MoonshotSnapshot {
168 available: 49.58,
169 voucher: 46.58,
170 cash: 3.0,
171 currency: "USD".into(),
172 }
173 }
174
175 fn sample_outcome(snap: MoonshotSnapshot) -> VendorOutcome {
176 VendorOutcome {
177 snapshot: crate::usage::VendorSnapshot::Moonshot(snap),
178 stale: false,
179 last_error: None,
180 cache_age: Some(std::time::Duration::from_secs(10)),
181 }
182 }
183
184 fn opts() -> RenderOpts {
185 RenderOpts {
186 format: None,
187 tooltip_format: None,
188 icon: None,
189 pace_tolerance: 5,
190 format_pace_color: false,
191 tooltip_pace_pts: false,
192 }
193 }
194
195 #[test]
196 fn default_render_shows_balance() {
197 let snap = sample_snap();
198 let out = render(
199 &sample_outcome(snap.clone()),
200 &snap,
201 &Theme::default(),
202 &opts(),
203 Utc::now(),
204 );
205 assert!(out.text.contains("$49.58"));
206 }
207
208 #[test]
212 fn a_cash_debt_carries_its_sign_ahead_of_the_symbol() {
213 let mut snap = sample_snap();
214 snap.cash = -5.71;
215 let out = render(
216 &sample_outcome(snap.clone()),
217 &snap,
218 &Theme::default(),
219 &opts(),
220 Utc::now(),
221 );
222 assert!(out.tooltip.contains("cash -$5.71"), "{}", out.tooltip);
223 assert!(!out.tooltip.contains("$-5.71"), "{}", out.tooltip);
224
225 snap.currency = "CNY".into();
226 let out = render(
227 &sample_outcome(snap.clone()),
228 &snap,
229 &Theme::default(),
230 &opts(),
231 Utc::now(),
232 );
233 assert!(out.tooltip.contains("cash -¥5.71"), "{}", out.tooltip);
234 }
235
236 #[test]
237 fn tooltip_includes_cash_and_voucher() {
238 let snap = sample_snap();
239 let out = render(
240 &sample_outcome(snap.clone()),
241 &snap,
242 &Theme::default(),
243 &opts(),
244 Utc::now(),
245 );
246 assert!(out.tooltip.contains("cash $3.00"));
247 assert!(out.tooltip.contains("voucher $46.58"));
248 }
249
250 #[test]
251 fn cny_uses_yuan_symbol() {
252 let mut snap = sample_snap();
253 snap.currency = "CNY".into();
254 let out = render(
255 &sample_outcome(snap.clone()),
256 &snap,
257 &Theme::default(),
258 &opts(),
259 Utc::now(),
260 );
261 assert!(out.text.contains("¥49.58"));
262 }
263
264 #[test]
265 fn zero_balance_is_critical() {
266 let mut snap = sample_snap();
267 snap.available = 0.0;
268 assert_eq!(severity(&snap), PaceSeverity::Critical);
269 }
270}