1use std::collections::HashMap;
6
7use chrono::{DateTime, Utc};
8
9use crate::countdown;
10use crate::format::{placeholders, substitute, updated_at_hm};
11use crate::pacing::PaceSeverity;
12use crate::pango::{color_span, escape, severity_color, severity_for};
13use crate::theme::Theme;
14use crate::tooltip::{Line as TooltipLine, render_bordered};
15use crate::usage::KiroSnapshot;
16use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
17use crate::waybar::{Class, WaybarOutput};
18
19use super::fetch::FetchOutcome;
20
21pub const DEFAULT_FORMAT: &str = "{kiro_pct}%";
22
23const DEFAULT_ICON: &str = "◆";
26
27fn credits(v: f64) -> String {
28 if (v.fract()).abs() < f64::EPSILON {
31 format!("{v:.0}")
32 } else {
33 format!("{v:.2}")
34 }
35}
36
37pub fn build_placeholders(
38 snap: &KiroSnapshot,
39 now: DateTime<Utc>,
40) -> HashMap<&'static str, String> {
41 let pct = snap.pct();
42 let reset = countdown::format(snap.reset_at, now);
43 placeholders(vec![
44 ("icon", DEFAULT_ICON.to_string()),
45 ("vendor_short", VendorId::Kiro.short_name().to_string()),
46 ("plan", snap.plan.clone()),
48 ("session_pct", pct.to_string()),
49 ("session_reset", reset.clone()),
50 ("weekly_pct", pct.to_string()),
51 ("weekly_reset", reset.clone()),
52 ("kiro_plan", snap.plan.clone()),
54 ("kiro_pct", pct.to_string()),
55 ("kiro_used", credits(snap.used)),
56 ("kiro_limit", credits(snap.limit)),
57 ("kiro_reset", reset),
58 ])
59}
60
61pub fn severity(snap: &KiroSnapshot) -> PaceSeverity {
62 severity_for(snap.pct())
63}
64
65pub fn render(
66 outcome: &VendorOutcome,
67 snap: &KiroSnapshot,
68 theme: &Theme,
69 opts: &RenderOpts,
70 now: DateTime<Utc>,
71) -> WaybarOutput {
72 let class = Class::from(severity(snap));
73 let format = opts
74 .format
75 .clone()
76 .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
77 let mut values = build_placeholders(snap, now);
78 for key in ["plan", "kiro_plan"] {
82 if let Some(value) = values.get_mut(key) {
83 *value = escape(value);
84 }
85 }
86
87 let mut text = substitute(&format, &values);
88 if outcome.stale {
89 text.push_str(" ⏸");
90 }
91
92 let wrapper_color = severity_color(severity(snap), theme).to_string();
93 let icon_prefix = match opts.icon.as_deref() {
94 Some(ic) if !ic.is_empty() => format!("{ic} "),
95 _ => String::new(),
96 };
97 let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
98
99 let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
100 substitute(fmt, &values)
101 } else {
102 render_tooltip(outcome, snap, theme, now)
103 };
104
105 WaybarOutput {
106 text: bar_text,
107 tooltip,
108 class,
109 }
110}
111
112fn render_tooltip(
113 outcome: &VendorOutcome,
114 snap: &KiroSnapshot,
115 theme: &Theme,
116 now: DateTime<Utc>,
117) -> String {
118 let blue = &theme.blue;
119 let dim = &theme.dim;
120 let fg = &theme.fg;
121 let pct = snap.pct();
122 let color = severity_color(severity(snap), theme);
123
124 let mut lines: Vec<TooltipLine> = Vec::new();
125 lines.push(TooltipLine::Center(format!(
126 "<span font_weight='bold' foreground='{blue}'>Kiro {}</span>",
127 escape(&snap.plan)
128 )));
129 lines.push(TooltipLine::Sep);
130 lines.push(TooltipLine::Body("".into()));
131 lines.push(TooltipLine::Body(format!(
132 " <span foreground='{fg}'> Credits</span>"
133 )));
134 lines.push(TooltipLine::Body(format!(
135 " <span font_weight='bold' foreground='{color}'>{pct}%</span> used \
136 <span foreground='{dim}'>({} of {})</span>",
137 credits(snap.used),
138 credits(snap.limit)
139 )));
140
141 lines.push(TooltipLine::Body("".into()));
142 lines.push(TooltipLine::Body(format!(
143 " <span foreground='{dim}'> Resets {}</span>",
144 escape(&countdown::format(snap.reset_at, now))
145 )));
146
147 if let Some((code, msg)) = outcome.last_error.as_ref()
148 && *code != 0
149 {
150 let (icon, ecolor) = if *code >= 500 {
151 ("", theme.red.as_str())
152 } else {
153 ("", theme.orange.as_str())
154 };
155 lines.push(TooltipLine::Body("".into()));
156 lines.push(TooltipLine::Sep);
157 lines.push(TooltipLine::Body(format!(
158 " <span foreground='{ecolor}'> {icon} HTTP {code}</span>"
159 )));
160 lines.push(TooltipLine::Body(format!(
161 " <span foreground='{dim}'>{}</span>",
162 escape(msg)
163 )));
164 }
165
166 let updated = updated_at_hm(now, outcome.cache_age);
167 lines.push(TooltipLine::Body("".into()));
168 lines.push(TooltipLine::Sep);
169 lines.push(TooltipLine::Body(format!(
170 " <span foreground='{dim}'> Updated {updated}</span>"
171 )));
172
173 render_bordered(&lines, theme)
174}
175
176impl From<FetchOutcome> for VendorOutcome {
177 fn from(o: FetchOutcome) -> Self {
178 Self {
179 snapshot: crate::usage::VendorSnapshot::Kiro(o.snapshot),
180 stale: o.stale,
181 last_error: o.last_error,
182 cache_age: o.cache_age,
183 }
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use chrono::TimeZone;
191
192 fn now() -> DateTime<Utc> {
193 Utc.with_ymd_and_hms(2026, 7, 26, 12, 0, 0).unwrap()
194 }
195
196 fn sample_snap() -> KiroSnapshot {
197 KiroSnapshot {
198 plan: "KIRO POWER".into(),
199 used: 9943.38,
200 limit: 10000.0,
201 reset_at: Some(now() + chrono::Duration::days(1)),
202 }
203 }
204
205 fn sample_outcome(snap: KiroSnapshot) -> VendorOutcome {
206 VendorOutcome {
207 snapshot: crate::usage::VendorSnapshot::Kiro(snap),
208 stale: false,
209 last_error: None,
210 cache_age: Some(std::time::Duration::from_secs(10)),
211 }
212 }
213
214 fn opts() -> RenderOpts {
215 RenderOpts {
216 format: None,
217 tooltip_format: None,
218 icon: None,
219 pace_tolerance: 5,
220 format_pace_color: false,
221 tooltip_pace_pts: false,
222 }
223 }
224
225 #[test]
226 fn default_bar_shows_the_percentage() {
227 let snap = sample_snap();
228 let out = render(
229 &sample_outcome(snap.clone()),
230 &snap,
231 &Theme::default(),
232 &opts(),
233 now(),
234 );
235 assert!(out.text.contains("99%"), "text: {}", out.text);
236 }
237
238 #[test]
239 fn tooltip_shows_plan_credits_and_reset() {
240 let snap = sample_snap();
241 let out = render(
242 &sample_outcome(snap.clone()),
243 &snap,
244 &Theme::default(),
245 &opts(),
246 now(),
247 );
248 assert!(out.tooltip.contains("Kiro KIRO POWER"));
249 assert!(out.tooltip.contains("99%"));
250 assert!(out.tooltip.contains("9943.38"));
251 assert!(out.tooltip.contains("10000"));
252 assert!(out.tooltip.contains("1d"));
253 }
254
255 #[test]
256 fn whole_number_credits_have_no_decimals() {
257 assert_eq!(credits(10000.0), "10000");
258 assert_eq!(credits(9943.38), "9943.38");
259 }
260
261 #[test]
262 fn severity_tracks_the_credit_percentage() {
263 let mut snap = sample_snap();
264 snap.used = 99.0;
265 snap.limit = 100.0;
266 assert_eq!(severity(&snap), PaceSeverity::Critical);
267 }
268
269 #[test]
270 fn stale_appends_pause() {
271 let snap = sample_snap();
272 let mut outcome = sample_outcome(snap.clone());
273 outcome.stale = true;
274 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
275 assert!(out.text.contains("⏸"));
276 }
277
278 #[test]
279 fn custom_tooltip_uses_placeholders() {
280 let snap = sample_snap();
281 let mut o = opts();
282 o.tooltip_format = Some("{kiro_pct}% of {kiro_limit} · {kiro_plan}".into());
283 let out = render(
284 &sample_outcome(snap.clone()),
285 &snap,
286 &Theme::default(),
287 &o,
288 now(),
289 );
290 assert_eq!(out.tooltip, "99% of 10000 · KIRO POWER");
291 }
292
293 #[test]
294 fn api_plan_is_inert_in_custom_pango_formats() {
295 let mut snap = sample_snap();
296 snap.plan = "<b>not markup</b> & control\u{1b}".into();
297 let mut o = opts();
298 o.format = Some("{kiro_plan}".into());
299 o.tooltip_format = Some("{plan}".into());
300
301 let out = render(
302 &sample_outcome(snap.clone()),
303 &snap,
304 &Theme::default(),
305 &o,
306 now(),
307 );
308
309 assert!(!out.text.contains("<b>"));
310 assert!(!out.tooltip.contains("<b>"));
311 assert!(
312 out.text
313 .contains("<b>not markup</b> & control")
314 );
315 assert_eq!(out.tooltip, "<b>not markup</b> & control");
316 assert!(!out.text.contains('\u{1b}'));
317 }
318
319 #[test]
320 fn generic_windows_map_to_the_single_pool() {
321 let values = build_placeholders(&sample_snap(), now());
322 assert_eq!(values["session_pct"], "99");
323 assert_eq!(values["weekly_pct"], "99");
324 assert_eq!(values["plan"], "KIRO POWER");
325 }
326
327 #[test]
328 fn placeholder_set_contains_all_keys() {
329 let values = build_placeholders(&sample_snap(), now());
330 for key in [
331 "icon",
332 "vendor_short",
333 "plan",
334 "session_pct",
335 "session_reset",
336 "weekly_pct",
337 "weekly_reset",
338 "kiro_plan",
339 "kiro_pct",
340 "kiro_used",
341 "kiro_limit",
342 "kiro_reset",
343 ] {
344 assert!(values.contains_key(key), "missing placeholder {key}");
345 }
346 }
347
348 #[test]
349 fn fetch_outcome_conversion_preserves_metadata() {
350 let fetch = FetchOutcome {
351 snapshot: sample_snap(),
352 stale: true,
353 last_error: Some((401, "bad".into())),
354 cache_age: Some(std::time::Duration::from_secs(42)),
355 };
356 let vendor: VendorOutcome = fetch.into();
357 assert!(matches!(
358 vendor.snapshot,
359 crate::usage::VendorSnapshot::Kiro(_)
360 ));
361 assert!(vendor.stale);
362 }
363}