1use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6
7use crate::countdown;
8use crate::format::{placeholders, substitute, updated_at_hm};
9use crate::pacing::PaceSeverity;
10use crate::pango::{color_span, escape, severity_color, severity_for};
11use crate::theme::Theme;
12use crate::tooltip::{Line as TooltipLine, render_bordered};
13use crate::usage::KimiSnapshot;
14use crate::vendor::{RenderOpts, VendorOutcome};
15use crate::waybar::{Class, WaybarOutput};
16
17use super::fetch::{FetchOutcome, SCHEMA_DRIFT_MESSAGE};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum WarningKind {
25 Http(u16),
26 SchemaDrift,
27 Other,
28}
29
30pub fn warning_kind(code: u16, message: &str) -> WarningKind {
31 if code != 0 {
32 WarningKind::Http(code)
33 } else if message == SCHEMA_DRIFT_MESSAGE {
34 WarningKind::SchemaDrift
35 } else {
36 WarningKind::Other
37 }
38}
39
40pub const DEFAULT_FORMAT: &str = "{kimi_weekly_pct}%";
41
42pub fn build_placeholders(
43 snap: &KimiSnapshot,
44 now: DateTime<Utc>,
45) -> HashMap<&'static str, String> {
46 let plan = snap.plan.as_deref().unwrap_or("Kimi");
47 let weekly_pct = snap.weekly_pct();
48 let window_pct = snap.window_pct();
49 placeholders(vec![
50 ("icon", "".to_string()),
51 ("vendor_short", "kmi".to_string()),
52 ("plan", plan.to_string()),
54 ("weekly_pct", weekly_pct.to_string()),
55 ("weekly_reset", countdown::format(snap.weekly_reset_at, now)),
56 ("session_pct", window_pct.to_string()),
57 (
58 "session_reset",
59 countdown::format(snap.window_reset_at, now),
60 ),
61 ("kimi_plan", plan.to_string()),
63 ("kimi_weekly_pct", weekly_pct.to_string()),
64 ("kimi_weekly_used", snap.weekly_used.to_string()),
65 ("kimi_weekly_limit", snap.weekly_limit.to_string()),
66 ("kimi_weekly_remaining", snap.weekly_remaining.to_string()),
67 (
68 "kimi_weekly_reset",
69 countdown::format(snap.weekly_reset_at, now),
70 ),
71 ("kimi_window_pct", window_pct.to_string()),
72 ("kimi_window_used", snap.window_used.to_string()),
73 ("kimi_window_limit", snap.window_limit.to_string()),
74 ("kimi_window_remaining", snap.window_remaining.to_string()),
75 (
76 "kimi_window_reset",
77 countdown::format(snap.window_reset_at, now),
78 ),
79 ])
80}
81
82pub fn severity(snap: &KimiSnapshot) -> PaceSeverity {
83 severity_for(snap.weekly_pct().max(snap.window_pct()))
84}
85
86pub fn render(
87 outcome: &VendorOutcome,
88 snap: &KimiSnapshot,
89 theme: &Theme,
90 opts: &RenderOpts,
91 now: DateTime<Utc>,
92) -> WaybarOutput {
93 let class = Class::from(severity(snap));
94 let format = opts
95 .format
96 .clone()
97 .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
98 let values = build_placeholders(snap, now);
99 let mut pango_values = values.clone();
103 for key in ["plan", "kimi_plan"] {
104 if let Some(value) = pango_values.get_mut(key) {
105 *value = escape(value);
106 }
107 }
108
109 let mut text = substitute(&format, &pango_values);
110 if outcome.stale {
111 text.push_str(" ⏸");
112 }
113
114 let wrapper_color = severity_color(severity(snap), theme).to_string();
115 let icon_prefix = match opts.icon.as_deref() {
116 Some(ic) if !ic.is_empty() => format!("{ic} "),
117 _ => String::new(),
118 };
119 let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
120
121 let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
122 substitute(fmt, &pango_values)
123 } else {
124 render_tooltip(outcome, snap, theme, now)
125 };
126
127 WaybarOutput {
128 text: bar_text,
129 tooltip,
130 class,
131 }
132}
133
134fn render_tooltip(
135 outcome: &VendorOutcome,
136 snap: &KimiSnapshot,
137 theme: &Theme,
138 now: DateTime<Utc>,
139) -> String {
140 let blue = &theme.blue;
141 let dim = &theme.dim;
142 let fg = &theme.fg;
143
144 let weekly_pct = snap.weekly_pct();
145 let weekly_color = severity_color(severity_for(weekly_pct), theme);
146 let window_pct = snap.window_pct();
147 let window_color = severity_color(severity_for(window_pct), theme);
148
149 let mut lines: Vec<TooltipLine> = Vec::new();
150 lines.push(TooltipLine::Center(format!(
151 "<span font_weight='bold' foreground='{blue}'>Kimi</span>"
152 )));
153 lines.push(TooltipLine::Sep);
154 lines.push(TooltipLine::Body("".into()));
155
156 let plan = snap.plan.as_deref().unwrap_or("Kimi");
157 lines.push(TooltipLine::Body(format!(
158 " <span foreground='{fg}'> Plan</span>"
159 )));
160 lines.push(TooltipLine::Body(format!(
161 " <span font_weight='bold' foreground='{weekly_color}'>{}</span>",
162 escape(plan)
163 )));
164
165 lines.push(TooltipLine::Body("".into()));
166 lines.push(TooltipLine::Body(format!(
167 " <span foreground='{fg}'> Weekly quota</span>"
168 )));
169 lines.push(TooltipLine::Body(format!(
170 " <span font_weight='bold' foreground='{weekly_color}'>{used} / {limit}</span> ({pct}%)",
171 used = snap.weekly_used,
172 limit = snap.weekly_limit,
173 pct = weekly_pct
174 )));
175 lines.push(TooltipLine::Body(format!(
176 " <span foreground='{dim}'> {remaining} remaining · reset {reset}</span>",
177 remaining = snap.weekly_remaining,
178 reset = escape(&countdown::format(snap.weekly_reset_at, now))
179 )));
180
181 if snap.window_limit > 0 {
182 lines.push(TooltipLine::Body("".into()));
183 lines.push(TooltipLine::Body(format!(
184 " <span foreground='{fg}'> Rolling window</span>"
185 )));
186 lines.push(TooltipLine::Body(format!(
187 " <span font_weight='bold' foreground='{window_color}'>{used} / {limit}</span> ({pct}%)",
188 used = snap.window_used,
189 limit = snap.window_limit,
190 pct = window_pct
191 )));
192 lines.push(TooltipLine::Body(format!(
193 " <span foreground='{dim}'> {remaining} remaining · reset {reset}</span>",
194 remaining = snap.window_remaining,
195 reset = escape(&countdown::format(snap.window_reset_at, now))
196 )));
197 }
198
199 if let Some((code, msg)) = outcome.last_error.as_ref() {
200 let (label, icon, ecolor) = match warning_kind(*code, msg) {
201 WarningKind::SchemaDrift => {
202 ("Kimi API schema drift".to_string(), "", theme.red.as_str())
203 }
204 WarningKind::Other => ("Kimi error".to_string(), "", theme.red.as_str()),
205 WarningKind::Http(code) if code >= 500 => {
206 (format!("HTTP {code}"), "", theme.red.as_str())
207 }
208 WarningKind::Http(code) => (format!("HTTP {code}"), "", theme.orange.as_str()),
209 };
210 lines.push(TooltipLine::Body("".into()));
211 lines.push(TooltipLine::Sep);
212 lines.push(TooltipLine::Body(format!(
213 " <span foreground='{ecolor}'> {icon} {label}</span>"
214 )));
215 if msg != &label {
216 lines.push(TooltipLine::Body(format!(
217 " <span foreground='{dim}'>{}</span>",
218 escape(msg)
219 )));
220 }
221 }
222
223 let updated = updated_at_hm(now, outcome.cache_age);
224 lines.push(TooltipLine::Body("".into()));
225 lines.push(TooltipLine::Sep);
226 lines.push(TooltipLine::Body(format!(
227 " <span foreground='{dim}'> Updated {updated}</span>"
228 )));
229
230 render_bordered(&lines, theme)
231}
232
233impl From<FetchOutcome> for VendorOutcome {
234 fn from(o: FetchOutcome) -> Self {
235 Self {
236 snapshot: crate::usage::VendorSnapshot::Kimi(o.snapshot),
237 stale: o.stale,
238 last_error: o.last_error,
239 cache_age: o.cache_age,
240 }
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use chrono::TimeZone;
248
249 fn now() -> DateTime<Utc> {
250 Utc.with_ymd_and_hms(2026, 2, 7, 12, 0, 0).unwrap()
251 }
252
253 fn sample_snap() -> KimiSnapshot {
254 KimiSnapshot {
255 plan: Some("LEVEL_INTERMEDIATE".into()),
256 weekly_limit: 100,
257 weekly_used: 26,
258 weekly_remaining: 74,
259 weekly_reset_at: Some(now() + chrono::Duration::days(4)),
260 window_limit: 100,
261 window_used: 15,
262 window_remaining: 85,
263 window_reset_at: Some(now() + chrono::Duration::hours(2)),
264 }
265 }
266
267 fn sample_outcome(snap: KimiSnapshot) -> VendorOutcome {
268 VendorOutcome {
269 snapshot: crate::usage::VendorSnapshot::Kimi(snap),
270 stale: false,
271 last_error: None,
272 cache_age: Some(std::time::Duration::from_secs(10)),
273 }
274 }
275
276 fn opts() -> RenderOpts {
277 RenderOpts {
278 format: None,
279 tooltip_format: None,
280 icon: None,
281 pace_tolerance: 5,
282 format_pace_color: false,
283 tooltip_pace_pts: false,
284 }
285 }
286
287 #[test]
288 fn default_render_has_exactly_one_percent() {
289 let snap = sample_snap();
290 let outcome = sample_outcome(snap.clone());
291 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
292 assert!(out.text.contains("26%"), "text: {}", out.text);
294 assert!(
295 !out.text.contains("%%"),
296 "double percent in text: {}",
297 out.text
298 );
299 assert_eq!(out.text.matches('%').count(), 1, "text: {}", out.text);
300 }
301
302 #[test]
303 fn pct_placeholders_are_bare_integers() {
304 let snap = sample_snap();
305 let values = build_placeholders(&snap, now());
306 assert_eq!(values["kimi_weekly_pct"], "26");
307 assert_eq!(values["weekly_pct"], "26");
308 assert_eq!(values["kimi_window_pct"], "15");
309 assert_eq!(values["session_pct"], "15");
310 }
311
312 #[test]
313 fn severity_worst_of_windows() {
314 let mut snap = sample_snap();
315 snap.weekly_used = 10;
316 snap.weekly_remaining = 90;
317 snap.window_used = 95;
318 snap.window_remaining = 5;
319 assert_eq!(severity(&snap), PaceSeverity::Critical);
321 }
322
323 #[test]
324 fn zero_limits_are_low() {
325 let snap = KimiSnapshot {
326 weekly_limit: 0,
327 weekly_used: 0,
328 weekly_remaining: 0,
329 window_limit: 0,
330 window_used: 0,
331 window_remaining: 0,
332 ..sample_snap()
333 };
334 assert_eq!(severity(&snap), PaceSeverity::Low);
335 }
336
337 #[test]
338 fn missing_window_omitted_from_tooltip() {
339 let mut snap = sample_snap();
340 snap.window_limit = 0;
341 let outcome = sample_outcome(snap.clone());
342 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
343 assert!(out.tooltip.contains("Weekly quota"));
344 assert!(!out.tooltip.contains("Rolling window"));
345 }
346
347 #[test]
348 fn custom_tooltip_format_substitutes_exactly() {
349 let snap = sample_snap();
350 let outcome = sample_outcome(snap.clone());
351 let mut o = opts();
352 o.tooltip_format = Some("W:{kimi_weekly_pct} R:{kimi_window_pct}".into());
353 let out = render(&outcome, &snap, &Theme::default(), &o, now());
354 assert_eq!(out.tooltip, "W:26 R:15");
355 }
356
357 #[test]
358 fn plan_is_pango_escaped() {
359 let mut snap = sample_snap();
360 snap.plan = Some("A&B <beta>".into());
361 let outcome = sample_outcome(snap.clone());
362 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
363 assert!(
364 out.tooltip.contains("A&B <beta>"),
365 "tooltip: {}",
366 out.tooltip
367 );
368 }
369
370 #[test]
371 fn custom_plan_placeholder_is_pango_escaped_once() {
372 let mut snap = sample_snap();
373 snap.plan = Some("A&B <beta>".into());
374 let outcome = sample_outcome(snap.clone());
375 let mut o = opts();
376 o.tooltip_format = Some("{kimi_plan}".into());
377 let out = render(&outcome, &snap, &Theme::default(), &o, now());
378 assert_eq!(out.tooltip, "A&B <beta>");
379 }
380
381 #[test]
382 fn schema_error_has_schema_label_not_http_422() {
383 let snap = sample_snap();
384 let mut outcome = sample_outcome(snap.clone());
385 outcome.stale = true;
386 outcome.last_error = Some((0, SCHEMA_DRIFT_MESSAGE.into()));
387 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
388 assert!(out.tooltip.contains("Kimi API schema drift"));
389 assert!(!out.tooltip.contains("HTTP 422"));
390 assert_eq!(out.tooltip.matches("Kimi API schema drift").count(), 1);
391 }
392
393 #[test]
394 fn generic_code_zero_error_is_not_labeled_schema_drift() {
395 let snap = sample_snap();
396 let mut outcome = sample_outcome(snap.clone());
397 outcome.stale = true;
398 outcome.last_error = Some((0, "cache lock unavailable".into()));
399 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
400 assert!(out.tooltip.contains("Kimi error"));
401 assert!(out.tooltip.contains("cache lock unavailable"));
402 assert!(!out.tooltip.contains("Kimi API schema drift"));
403 }
404
405 #[test]
406 fn warning_kind_uses_schema_marker_without_treating_code_zero_as_http() {
407 assert_eq!(
408 warning_kind(0, SCHEMA_DRIFT_MESSAGE),
409 WarningKind::SchemaDrift
410 );
411 assert_eq!(
412 warning_kind(0, "cache lock unavailable"),
413 WarningKind::Other
414 );
415 assert_eq!(warning_kind(503, "unavailable"), WarningKind::Http(503));
416 }
417
418 #[test]
419 fn fetch_outcome_conversion_preserves_metadata() {
420 let snap = sample_snap();
421 let fetch = FetchOutcome {
422 snapshot: snap.clone(),
423 stale: true,
424 last_error: Some((401, "bad".into())),
425 cache_age: Some(std::time::Duration::from_secs(42)),
426 };
427 let vendor: VendorOutcome = fetch.into();
428 assert!(matches!(
429 vendor.snapshot,
430 crate::usage::VendorSnapshot::Kimi(_)
431 ));
432 assert!(vendor.stale);
433 assert_eq!(vendor.last_error, Some((401, "bad".into())));
434 assert_eq!(vendor.cache_age, Some(std::time::Duration::from_secs(42)));
435 }
436
437 #[test]
438 fn tooltip_includes_plan_and_usage_and_countdowns() {
439 let snap = sample_snap();
440 let outcome = sample_outcome(snap.clone());
441 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
442 assert!(out.tooltip.contains("Kimi"));
443 assert!(out.tooltip.contains("LEVEL_INTERMEDIATE"));
444 assert!(out.tooltip.contains("Weekly quota"));
445 assert!(out.tooltip.contains("26 / 100"));
446 assert!(out.tooltip.contains("Rolling window"));
447 assert!(out.tooltip.contains("15 / 100"));
448 assert!(!out.tooltip.contains("2026-02-11T17:32:50"));
450 assert!(!out.tooltip.contains("2026-02-07T12:32:50"));
451 }
452
453 #[test]
454 fn stale_appends_pause() {
455 let snap = sample_snap();
456 let mut outcome = sample_outcome(snap.clone());
457 outcome.stale = true;
458 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
459 assert!(out.text.contains("⏸"));
460 }
461
462 #[test]
463 fn placeholder_set_contains_all_keys() {
464 let snap = sample_snap();
465 let values = build_placeholders(&snap, now());
466 for key in [
467 "kimi_plan",
468 "kimi_weekly_pct",
469 "kimi_weekly_used",
470 "kimi_weekly_limit",
471 "kimi_weekly_remaining",
472 "kimi_weekly_reset",
473 "kimi_window_pct",
474 "kimi_window_used",
475 "kimi_window_limit",
476 "kimi_window_remaining",
477 "kimi_window_reset",
478 "plan",
479 "weekly_pct",
480 "session_pct",
481 ] {
482 assert!(values.contains_key(key), "missing placeholder {key}");
483 }
484 }
485}