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, WindowRow, push_window_with_row, render_bordered};
13use crate::usage::{KimiSnapshot, UsageWindow};
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}% · {kimi_weekly_reset}";
41
42const WEEKLY_WINDOW: chrono::Duration = chrono::Duration::days(7);
45const ROLLING_WINDOW: chrono::Duration = chrono::Duration::hours(5);
48
49fn window(pct: i32, resets_at: Option<DateTime<Utc>>, duration: chrono::Duration) -> UsageWindow {
51 UsageWindow {
52 utilization_pct: pct,
53 resets_at,
54 window_duration: duration,
55 }
56}
57
58pub fn build_placeholders(
59 snap: &KimiSnapshot,
60 now: DateTime<Utc>,
61) -> HashMap<&'static str, String> {
62 let plan = snap.plan.as_deref().unwrap_or("Kimi");
63 let weekly_pct = snap.weekly_pct();
64 let window_pct = snap.window_pct();
65 placeholders(vec![
66 ("icon", "".to_string()),
67 ("vendor_short", "kmi".to_string()),
68 ("plan", plan.to_string()),
70 ("weekly_pct", weekly_pct.to_string()),
71 ("weekly_reset", countdown::format(snap.weekly_reset_at, now)),
72 ("session_pct", window_pct.to_string()),
73 (
74 "session_reset",
75 countdown::format(snap.window_reset_at, now),
76 ),
77 ("kimi_plan", plan.to_string()),
79 ("kimi_weekly_pct", weekly_pct.to_string()),
80 ("kimi_weekly_used", snap.weekly_used.to_string()),
81 ("kimi_weekly_limit", snap.weekly_limit.to_string()),
82 ("kimi_weekly_remaining", snap.weekly_remaining.to_string()),
83 (
84 "kimi_weekly_reset",
85 countdown::format(snap.weekly_reset_at, now),
86 ),
87 ("kimi_window_pct", window_pct.to_string()),
88 ("kimi_window_used", snap.window_used.to_string()),
89 ("kimi_window_limit", snap.window_limit.to_string()),
90 ("kimi_window_remaining", snap.window_remaining.to_string()),
91 (
92 "kimi_window_reset",
93 countdown::format(snap.window_reset_at, now),
94 ),
95 ])
96}
97
98pub fn severity(snap: &KimiSnapshot) -> PaceSeverity {
99 severity_for(snap.weekly_pct().max(snap.window_pct()))
100}
101
102pub fn render(
103 outcome: &VendorOutcome,
104 snap: &KimiSnapshot,
105 theme: &Theme,
106 opts: &RenderOpts,
107 now: DateTime<Utc>,
108) -> WaybarOutput {
109 let class = Class::from(severity(snap));
110 let format = opts
111 .format
112 .clone()
113 .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
114 let values = build_placeholders(snap, now);
115 let mut pango_values = values.clone();
119 for key in ["plan", "kimi_plan"] {
120 if let Some(value) = pango_values.get_mut(key) {
121 *value = escape(value);
122 }
123 }
124
125 let mut text = substitute(&format, &pango_values);
126 if outcome.stale {
127 text.push_str(" ⏸");
128 }
129
130 let wrapper_color = severity_color(severity(snap), theme).to_string();
131 let icon_prefix = match opts.icon.as_deref() {
132 Some(ic) if !ic.is_empty() => format!("{ic} "),
133 _ => String::new(),
134 };
135 let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
136
137 let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
138 substitute(fmt, &pango_values)
139 } else {
140 render_tooltip(outcome, snap, theme, now)
141 };
142
143 WaybarOutput {
144 text: bar_text,
145 tooltip,
146 class,
147 }
148}
149
150fn render_tooltip(
151 outcome: &VendorOutcome,
152 snap: &KimiSnapshot,
153 theme: &Theme,
154 now: DateTime<Utc>,
155) -> String {
156 let blue = &theme.blue;
157 let dim = &theme.dim;
158 let fg = &theme.fg;
159
160 let weekly_pct = snap.weekly_pct();
161 let weekly_color = severity_color(severity_for(weekly_pct), theme);
162
163 let mut lines: Vec<TooltipLine> = Vec::new();
164 lines.push(TooltipLine::Center(format!(
165 "<span font_weight='bold' foreground='{blue}'>Kimi</span>"
166 )));
167 lines.push(TooltipLine::Sep);
168 lines.push(TooltipLine::Body("".into()));
169
170 let plan = snap.plan.as_deref().unwrap_or("Kimi");
171 lines.push(TooltipLine::Body(format!(
172 " <span foreground='{fg}'> Plan</span>"
173 )));
174 lines.push(TooltipLine::Body(format!(
175 " <span font_weight='bold' foreground='{weekly_color}'>{}</span>",
176 escape(plan)
177 )));
178
179 lines.push(TooltipLine::Body("".into()));
184 let weekly_detail = format!(
188 "{used} / {limit} · {remaining} left",
189 used = snap.weekly_used,
190 limit = snap.weekly_limit,
191 remaining = snap.weekly_remaining
192 );
193 push_window_with_row(
194 &mut lines,
195 " Weekly quota",
196 &window(weekly_pct, snap.weekly_reset_at, WEEKLY_WINDOW),
197 theme,
198 now,
199 WindowRow::default().with_detail(&weekly_detail),
200 );
201
202 if snap.window_limit > 0 {
203 lines.push(TooltipLine::Body("".into()));
204 let window_detail = format!(
205 "{used} / {limit} · {remaining} left",
206 used = snap.window_used,
207 limit = snap.window_limit,
208 remaining = snap.window_remaining
209 );
210 push_window_with_row(
211 &mut lines,
212 " Rolling window (5h)",
213 &window(snap.window_pct(), snap.window_reset_at, ROLLING_WINDOW),
214 theme,
215 now,
216 WindowRow::default().with_detail(&window_detail),
217 );
218 }
219
220 if let Some((code, msg)) = outcome.last_error.as_ref() {
221 let (label, icon, ecolor) = match warning_kind(*code, msg) {
222 WarningKind::SchemaDrift => {
223 ("Kimi API schema drift".to_string(), "", theme.red.as_str())
224 }
225 WarningKind::Other => ("Kimi error".to_string(), "", theme.red.as_str()),
226 WarningKind::Http(code) if code >= 500 => {
227 (format!("HTTP {code}"), "", theme.red.as_str())
228 }
229 WarningKind::Http(code) => (format!("HTTP {code}"), "", theme.orange.as_str()),
230 };
231 lines.push(TooltipLine::Body("".into()));
232 lines.push(TooltipLine::Sep);
233 lines.push(TooltipLine::Body(format!(
234 " <span foreground='{ecolor}'> {icon} {label}</span>"
235 )));
236 if msg != &label {
237 lines.push(TooltipLine::Body(format!(
238 " <span foreground='{dim}'>{}</span>",
239 escape(msg)
240 )));
241 }
242 }
243
244 let updated = updated_at_hm(now, outcome.cache_age);
245 lines.push(TooltipLine::Body("".into()));
246 lines.push(TooltipLine::Sep);
247 lines.push(TooltipLine::Body(format!(
248 " <span foreground='{dim}'> Updated {updated}</span>"
249 )));
250
251 render_bordered(&lines, theme)
252}
253
254impl From<FetchOutcome> for VendorOutcome {
255 fn from(o: FetchOutcome) -> Self {
256 Self {
257 snapshot: crate::usage::VendorSnapshot::Kimi(o.snapshot),
258 stale: o.stale,
259 last_error: o.last_error,
260 cache_age: o.cache_age,
261 }
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use chrono::TimeZone;
269
270 fn now() -> DateTime<Utc> {
271 Utc.with_ymd_and_hms(2026, 2, 7, 12, 0, 0).unwrap()
272 }
273
274 fn sample_snap() -> KimiSnapshot {
275 KimiSnapshot {
276 plan: Some("LEVEL_INTERMEDIATE".into()),
277 weekly_limit: 100,
278 weekly_used: 26,
279 weekly_remaining: 74,
280 weekly_reset_at: Some(now() + chrono::Duration::days(4)),
281 window_limit: 100,
282 window_used: 15,
283 window_remaining: 85,
284 window_reset_at: Some(now() + chrono::Duration::hours(2)),
285 }
286 }
287
288 fn sample_outcome(snap: KimiSnapshot) -> VendorOutcome {
289 VendorOutcome {
290 snapshot: crate::usage::VendorSnapshot::Kimi(snap),
291 stale: false,
292 last_error: None,
293 cache_age: Some(std::time::Duration::from_secs(10)),
294 }
295 }
296
297 fn opts() -> RenderOpts {
298 RenderOpts {
299 format: None,
300 tooltip_format: None,
301 icon: None,
302 pace_tolerance: 5,
303 format_pace_color: false,
304 tooltip_pace_pts: false,
305 }
306 }
307
308 #[test]
309 fn default_render_has_exactly_one_percent() {
310 let snap = sample_snap();
311 let outcome = sample_outcome(snap.clone());
312 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
313 assert!(out.text.contains("26%"), "text: {}", out.text);
315 assert!(
316 !out.text.contains("%%"),
317 "double percent in text: {}",
318 out.text
319 );
320 assert_eq!(out.text.matches('%').count(), 1, "text: {}", out.text);
321 }
322
323 #[test]
324 fn pct_placeholders_are_bare_integers() {
325 let snap = sample_snap();
326 let values = build_placeholders(&snap, now());
327 assert_eq!(values["kimi_weekly_pct"], "26");
328 assert_eq!(values["weekly_pct"], "26");
329 assert_eq!(values["kimi_window_pct"], "15");
330 assert_eq!(values["session_pct"], "15");
331 }
332
333 #[test]
334 fn severity_worst_of_windows() {
335 let mut snap = sample_snap();
336 snap.weekly_used = 10;
337 snap.weekly_remaining = 90;
338 snap.window_used = 95;
339 snap.window_remaining = 5;
340 assert_eq!(severity(&snap), PaceSeverity::Critical);
342 }
343
344 #[test]
345 fn zero_limits_are_low() {
346 let snap = KimiSnapshot {
347 weekly_limit: 0,
348 weekly_used: 0,
349 weekly_remaining: 0,
350 window_limit: 0,
351 window_used: 0,
352 window_remaining: 0,
353 ..sample_snap()
354 };
355 assert_eq!(severity(&snap), PaceSeverity::Low);
356 }
357
358 #[test]
359 fn missing_window_omitted_from_tooltip() {
360 let mut snap = sample_snap();
361 snap.window_limit = 0;
362 let outcome = sample_outcome(snap.clone());
363 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
364 assert!(out.tooltip.contains("Weekly quota"));
365 assert!(!out.tooltip.contains("Rolling window"));
366 }
367
368 #[test]
369 fn custom_tooltip_format_substitutes_exactly() {
370 let snap = sample_snap();
371 let outcome = sample_outcome(snap.clone());
372 let mut o = opts();
373 o.tooltip_format = Some("W:{kimi_weekly_pct} R:{kimi_window_pct}".into());
374 let out = render(&outcome, &snap, &Theme::default(), &o, now());
375 assert_eq!(out.tooltip, "W:26 R:15");
376 }
377
378 #[test]
379 fn plan_is_pango_escaped() {
380 let mut snap = sample_snap();
381 snap.plan = Some("A&B <beta>".into());
382 let outcome = sample_outcome(snap.clone());
383 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
384 assert!(
385 out.tooltip.contains("A&B <beta>"),
386 "tooltip: {}",
387 out.tooltip
388 );
389 }
390
391 #[test]
392 fn custom_plan_placeholder_is_pango_escaped_once() {
393 let mut snap = sample_snap();
394 snap.plan = Some("A&B <beta>".into());
395 let outcome = sample_outcome(snap.clone());
396 let mut o = opts();
397 o.tooltip_format = Some("{kimi_plan}".into());
398 let out = render(&outcome, &snap, &Theme::default(), &o, now());
399 assert_eq!(out.tooltip, "A&B <beta>");
400 }
401
402 #[test]
403 fn schema_error_has_schema_label_not_http_422() {
404 let snap = sample_snap();
405 let mut outcome = sample_outcome(snap.clone());
406 outcome.stale = true;
407 outcome.last_error = Some((0, SCHEMA_DRIFT_MESSAGE.into()));
408 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
409 assert!(out.tooltip.contains("Kimi API schema drift"));
410 assert!(!out.tooltip.contains("HTTP 422"));
411 assert_eq!(out.tooltip.matches("Kimi API schema drift").count(), 1);
412 }
413
414 #[test]
415 fn generic_code_zero_error_is_not_labeled_schema_drift() {
416 let snap = sample_snap();
417 let mut outcome = sample_outcome(snap.clone());
418 outcome.stale = true;
419 outcome.last_error = Some((0, "cache lock unavailable".into()));
420 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
421 assert!(out.tooltip.contains("Kimi error"));
422 assert!(out.tooltip.contains("cache lock unavailable"));
423 assert!(!out.tooltip.contains("Kimi API schema drift"));
424 }
425
426 #[test]
427 fn warning_kind_uses_schema_marker_without_treating_code_zero_as_http() {
428 assert_eq!(
429 warning_kind(0, SCHEMA_DRIFT_MESSAGE),
430 WarningKind::SchemaDrift
431 );
432 assert_eq!(
433 warning_kind(0, "cache lock unavailable"),
434 WarningKind::Other
435 );
436 assert_eq!(warning_kind(503, "unavailable"), WarningKind::Http(503));
437 }
438
439 #[test]
440 fn fetch_outcome_conversion_preserves_metadata() {
441 let snap = sample_snap();
442 let fetch = FetchOutcome {
443 snapshot: snap.clone(),
444 stale: true,
445 last_error: Some((401, "bad".into())),
446 cache_age: Some(std::time::Duration::from_secs(42)),
447 };
448 let vendor: VendorOutcome = fetch.into();
449 assert!(matches!(
450 vendor.snapshot,
451 crate::usage::VendorSnapshot::Kimi(_)
452 ));
453 assert!(vendor.stale);
454 assert_eq!(vendor.last_error, Some((401, "bad".into())));
455 assert_eq!(vendor.cache_age, Some(std::time::Duration::from_secs(42)));
456 }
457
458 #[test]
459 fn tooltip_includes_plan_and_usage_and_countdowns() {
460 let snap = sample_snap();
461 let outcome = sample_outcome(snap.clone());
462 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
463 assert!(out.tooltip.contains("Kimi"));
464 assert!(out.tooltip.contains("LEVEL_INTERMEDIATE"));
465 assert!(out.tooltip.contains("Weekly quota"));
466 assert!(out.tooltip.contains("26 / 100"));
467 assert!(out.tooltip.contains("Rolling window"));
468 assert!(out.tooltip.contains("15 / 100"));
469 assert!(!out.tooltip.contains("2026-02-11T17:32:50"));
471 assert!(!out.tooltip.contains("2026-02-07T12:32:50"));
472 }
473
474 #[test]
475 fn stale_appends_pause() {
476 let snap = sample_snap();
477 let mut outcome = sample_outcome(snap.clone());
478 outcome.stale = true;
479 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
480 assert!(out.text.contains("⏸"));
481 }
482
483 #[test]
484 fn placeholder_set_contains_all_keys() {
485 let snap = sample_snap();
486 let values = build_placeholders(&snap, now());
487 for key in [
488 "kimi_plan",
489 "kimi_weekly_pct",
490 "kimi_weekly_used",
491 "kimi_weekly_limit",
492 "kimi_weekly_remaining",
493 "kimi_weekly_reset",
494 "kimi_window_pct",
495 "kimi_window_used",
496 "kimi_window_limit",
497 "kimi_window_remaining",
498 "kimi_window_reset",
499 "plan",
500 "weekly_pct",
501 "session_pct",
502 ] {
503 assert!(values.contains_key(key), "missing placeholder {key}");
504 }
505 }
506
507 #[test]
510 fn tooltip_draws_a_progress_bar_for_both_quotas() {
511 let snap = sample_snap();
512 let outcome = sample_outcome(snap.clone());
513 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
514 assert_eq!(
515 out.tooltip.matches('░').count() + out.tooltip.matches('█').count(),
516 2 * crate::pango::BAR_LEN as usize,
517 "expected one full-width bar per quota: {}",
518 out.tooltip
519 );
520 assert!(out.tooltip.contains("Resets in"), "{}", out.tooltip);
521 }
522
523 #[test]
526 fn tooltip_keeps_the_raw_counts_on_the_reset_line() {
527 let snap = sample_snap();
528 let outcome = sample_outcome(snap.clone());
529 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
530 assert!(out.tooltip.contains("· 26 / 100"), "{}", out.tooltip);
531 assert!(out.tooltip.contains("· 15 / 100"), "{}", out.tooltip);
532 }
533
534 #[test]
540 fn tooltip_keeps_the_vendors_own_remaining_count() {
541 let mut snap = sample_snap();
542 snap.weekly_remaining = 70; snap.window_remaining = 80; let outcome = sample_outcome(snap.clone());
545 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
546 assert!(
547 out.tooltip.contains("· 26 / 100 · 70 left"),
548 "{}",
549 out.tooltip
550 );
551 assert!(
552 out.tooltip.contains("· 15 / 100 · 80 left"),
553 "{}",
554 out.tooltip
555 );
556 }
557
558 #[test]
561 fn tooltip_rows_carry_no_pace_glyph() {
562 let snap = sample_snap();
563 let outcome = sample_outcome(snap.clone());
564 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
565 for glyph in ['↑', '→', '↓'] {
566 assert!(!out.tooltip.contains(glyph), "{}", out.tooltip);
567 }
568 }
569
570 #[test]
573 fn default_bar_text_pairs_the_percentage_with_its_reset() {
574 let snap = sample_snap();
575 let outcome = sample_outcome(snap.clone());
576 let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
577 assert!(out.text.contains("26% · 4d 0h"), "{}", out.text);
578 }
579}