use std::collections::HashMap;
use chrono::{DateTime, Utc};
use crate::countdown;
use crate::format::{placeholders, substitute, updated_at_hm};
use crate::pacing::{self, PaceSeverity, Pacing};
use crate::pango::{color_span, escape, severity_color, severity_for};
use crate::theme::Theme;
use crate::tooltip::{Line as TooltipLine, WindowRow, push_window_with_row, render_bordered};
use crate::usage::{GrokbotSnapshot, UsageWindow};
use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
use crate::waybar::{Class, WaybarOutput};
use super::fetch::FetchOutcome;
pub const DEFAULT_FORMAT: &str = "{gbt_weekly_pct}%";
pub fn build_placeholders(
snap: &GrokbotSnapshot,
now: DateTime<Utc>,
) -> HashMap<&'static str, String> {
build_placeholders_with_tolerance(snap, pacing::DEFAULT_TOLERANCE, now)
}
fn weekly_pacing(snap: &GrokbotSnapshot, tolerance: u32, now: DateTime<Utc>) -> Pacing {
if !snap.has_included_allowance {
return Pacing::neutral();
}
pacing::calc(
snap.weekly_pct,
snap.reset_at,
now,
snap.window.unwrap_or_else(chrono::Duration::zero),
tolerance,
)
}
fn build_placeholders_with_tolerance(
snap: &GrokbotSnapshot,
tolerance: u32,
now: DateTime<Utc>,
) -> HashMap<&'static str, String> {
let reset = countdown::format(snap.reset_at, now);
let pace = weekly_pacing(snap, tolerance, now);
let elapsed =
if snap.window.is_some_and(|window| window.num_seconds() > 0) && snap.reset_at.is_some() {
pace.elapsed_pct.to_string()
} else {
String::new()
};
let allowance = |value: String| {
if snap.has_included_allowance {
value
} else {
String::new()
}
};
placeholders(vec![
("icon", VendorId::Grokbot.bar_icon().to_string()),
("vendor_short", VendorId::Grokbot.short_name().to_string()),
("plan", snap.plan.clone()),
("weekly_pct", allowance(snap.weekly_pct.to_string())),
("weekly_reset", allowance(reset.clone())),
("weekly_elapsed", allowance(elapsed.clone())),
("gbt_plan", snap.plan.clone()),
("gbt_weekly_pct", allowance(snap.weekly_pct.to_string())),
("gbt_weekly_reset", allowance(reset)),
("gbt_weekly_elapsed", allowance(elapsed)),
("gbt_weekly_pace", allowance(pace.ratio_pace.glyph().into())),
("gbt_weekly_pace_pct", allowance(pace.ratio_label)),
(
"gbt_weekly_pace_indicator",
allowance(pace.point_pace.glyph().into()),
),
("gbt_weekly_pace_pts", allowance(pace.point_label)),
("gbt_weekly_pace_delta", allowance(pace.delta.to_string())),
(
"gbt_on_demand",
if snap.on_demand_enabled { "on" } else { "off" }.to_string(),
),
])
}
pub fn severity(snap: &GrokbotSnapshot) -> PaceSeverity {
if snap.has_included_allowance {
severity_for(snap.weekly_pct)
} else {
PaceSeverity::Low
}
}
pub fn render(
outcome: &VendorOutcome,
snap: &GrokbotSnapshot,
theme: &Theme,
opts: &RenderOpts,
now: DateTime<Utc>,
) -> WaybarOutput {
let class = Class::from(severity(snap));
let format = opts
.format
.clone()
.unwrap_or_else(|| DEFAULT_FORMAT.to_string());
let values = build_placeholders_with_tolerance(snap, opts.pace_tolerance, now);
let mut pango_values = values.clone();
for key in ["plan", "gbt_plan"] {
if let Some(value) = pango_values.get_mut(key) {
*value = escape(value);
}
}
if opts.format_pace_color && snap.has_included_allowance {
let pace = weekly_pacing(snap, opts.pace_tolerance, now);
let color = severity_color(pacing::pace_severity(pace.delta), theme);
for key in [
"gbt_weekly_pace",
"gbt_weekly_pace_pct",
"gbt_weekly_pace_indicator",
"gbt_weekly_pace_pts",
"gbt_weekly_pace_delta",
] {
if let Some(value) = pango_values.get_mut(key) {
*value = color_span(color, value);
}
}
}
let mut text = substitute(&format, &pango_values);
if outcome.stale {
text.push_str(" ⏸");
}
let wrapper_color = if opts.format_pace_color && format.contains("_pace") {
theme.fg.clone()
} else {
severity_color(severity(snap), theme).to_string()
};
let icon_prefix = match opts.icon.as_deref() {
Some(ic) if !ic.is_empty() => format!("{ic} "),
_ => String::new(),
};
let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
substitute(fmt, &pango_values)
} else {
render_tooltip(outcome, snap, theme, opts, now)
};
WaybarOutput {
text: bar_text,
tooltip,
class,
}
}
fn render_tooltip(
outcome: &VendorOutcome,
snap: &GrokbotSnapshot,
theme: &Theme,
opts: &RenderOpts,
now: DateTime<Utc>,
) -> String {
let blue = &theme.blue;
let dim = &theme.dim;
let fg = &theme.fg;
let plan_color = severity_color(severity(snap), theme);
let mut lines: Vec<TooltipLine> = Vec::new();
lines.push(TooltipLine::Center(format!(
"<span font_weight='bold' foreground='{blue}'>Grok Bot</span>"
)));
lines.push(TooltipLine::Sep);
lines.push(TooltipLine::Body("".into()));
lines.push(TooltipLine::Body(format!(
" <span foreground='{fg}'> Plan</span>"
)));
lines.push(TooltipLine::Body(format!(
" <span font_weight='bold' foreground='{plan_color}'>{}</span>",
escape(&snap.plan)
)));
lines.push(TooltipLine::Body("".into()));
if snap.has_included_allowance {
let window = UsageWindow {
utilization_pct: snap.weekly_pct,
resets_at: snap.reset_at,
window_duration: snap.window.unwrap_or_else(chrono::Duration::zero),
};
let row = if snap.window.is_some_and(|window| window.num_seconds() > 0)
&& snap.reset_at.is_some()
{
WindowRow::paced(&window, now, opts.pace_tolerance, opts.tooltip_pace_pts)
} else {
WindowRow::default()
};
push_window_with_row(
&mut lines,
" Weekly included usage",
&window,
theme,
now,
row,
);
if let Some(note) = snap.on_demand_note() {
lines.push(TooltipLine::Body(format!(
" <span foreground='{dim}'>{note}</span>"
)));
}
} else {
lines.push(TooltipLine::Body(format!(
" <span foreground='{dim}'> No included allowance on this account</span>"
)));
}
if let Some((code, msg)) = outcome.last_error.as_ref() {
let (label, icon, ecolor) = match *code {
0 => ("Grok Bot error".to_string(), "", theme.red.as_str()),
code if code >= 500 => (format!("HTTP {code}"), "", theme.red.as_str()),
code => (format!("HTTP {code}"), "", theme.orange.as_str()),
};
lines.push(TooltipLine::Body("".into()));
lines.push(TooltipLine::Sep);
lines.push(TooltipLine::Body(format!(
" <span foreground='{ecolor}'> {icon} {label}</span>"
)));
if msg != &label {
lines.push(TooltipLine::Body(format!(
" <span foreground='{dim}'>{}</span>",
escape(msg)
)));
}
}
let updated = updated_at_hm(now, outcome.cache_age);
lines.push(TooltipLine::Body("".into()));
lines.push(TooltipLine::Sep);
lines.push(TooltipLine::Body(format!(
" <span foreground='{dim}'> Updated {updated}</span>"
)));
render_bordered(&lines, theme)
}
impl From<FetchOutcome> for VendorOutcome {
fn from(o: FetchOutcome) -> Self {
o.map(crate::usage::VendorSnapshot::Grokbot)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 9, 14, 12, 0, 0).unwrap()
}
fn sample_snap() -> GrokbotSnapshot {
GrokbotSnapshot {
plan: "Grok Bot Plan".into(),
billed_by: None,
has_included_allowance: true,
weekly_pct: 42,
has_available_usage: true,
on_demand_enabled: false,
period_start: Some(now() - chrono::Duration::days(3)),
reset_at: Some(now() + chrono::Duration::days(4)),
window: Some(chrono::Duration::days(7)),
}
}
fn sample_outcome(snap: GrokbotSnapshot) -> VendorOutcome {
VendorOutcome {
snapshot: crate::usage::VendorSnapshot::Grokbot(snap),
stale: false,
last_error: None,
cache_age: Some(std::time::Duration::from_secs(10)),
}
}
fn opts() -> RenderOpts {
RenderOpts {
format: None,
tooltip_format: None,
icon: None,
pace_tolerance: 5,
format_pace_color: false,
tooltip_pace_pts: false,
}
}
#[test]
fn weekly_pace_uses_the_reported_period_and_exposes_both_conventions() {
let snap = GrokbotSnapshot {
weekly_pct: 70,
period_start: Some(now() - chrono::Duration::days(5)),
reset_at: Some(now() + chrono::Duration::days(5)),
window: Some(chrono::Duration::days(10)),
..sample_snap()
};
let values = build_placeholders(&snap, now());
assert_eq!(values["weekly_elapsed"], "50");
assert_eq!(values["gbt_weekly_elapsed"], "50");
assert_eq!(values["gbt_weekly_pace"], "↑");
assert_eq!(values["gbt_weekly_pace_indicator"], "↑");
assert_eq!(values["gbt_weekly_pace_pct"], "40% ahead");
assert_eq!(values["gbt_weekly_pace_pts"], "20pts ahead");
assert_eq!(values["gbt_weekly_pace_delta"], "20");
}
#[test]
fn weekly_pace_respects_tolerance_color_and_tooltip_point_mode() {
let snap = GrokbotSnapshot {
weekly_pct: 70,
reset_at: Some(now() + chrono::Duration::days(5)),
window: Some(chrono::Duration::days(10)),
..sample_snap()
};
let outcome = sample_outcome(snap.clone());
let theme = Theme::default();
let mut o = opts();
o.format = Some("{gbt_weekly_pace} {gbt_weekly_pace_indicator}".into());
o.pace_tolerance = 50;
let plain = render(&outcome, &snap, &theme, &o, now());
assert!(plain.text.contains("→ ↑"));
assert!(plain.tooltip.contains('→'));
o.format_pace_color = true;
o.tooltip_pace_pts = true;
let colored = render(&outcome, &snap, &theme, &o, now());
assert!(
colored
.text
.contains(&format!("foreground='{}'>↑", theme.red))
);
assert!(colored.tooltip.contains('↑'));
assert_ne!(plain.tooltip, colored.tooltip);
}
#[test]
fn weekly_pace_covers_under_even_and_over_consumption() {
for (usage, glyph, delta) in [(30, "↓", "-20"), (50, "→", "0"), (70, "↑", "20")] {
let snap = GrokbotSnapshot {
weekly_pct: usage,
reset_at: Some(now() + chrono::Duration::days(5)),
window: Some(chrono::Duration::days(10)),
..sample_snap()
};
let values = build_placeholders(&snap, now());
assert_eq!(values["gbt_weekly_pace"], glyph);
assert_eq!(values["gbt_weekly_pace_delta"], delta);
}
}
#[test]
fn invalid_periods_have_neutral_placeholders_and_no_tooltip_marker() {
for (reset_at, window) in [
(None, Some(chrono::Duration::days(7))),
(sample_snap().reset_at, None),
(sample_snap().reset_at, Some(chrono::Duration::zero())),
(sample_snap().reset_at, Some(chrono::Duration::days(-7))),
] {
let snap = GrokbotSnapshot {
reset_at,
window,
..sample_snap()
};
let values = build_placeholders(&snap, now());
assert_eq!(values["weekly_elapsed"], "");
assert_eq!(values["gbt_weekly_pace"], "→");
assert_eq!(values["gbt_weekly_pace_delta"], "0");
let outcome = sample_outcome(snap.clone());
let plain = render(&outcome, &snap, &Theme::default(), &opts(), now());
let mut o = opts();
o.tooltip_pace_pts = true;
let marked = render(&outcome, &snap, &Theme::default(), &o, now());
assert_eq!(plain.tooltip, marked.tooltip);
}
}
#[test]
fn default_render_shows_the_weekly_percent() {
let snap = sample_snap();
let outcome = sample_outcome(snap.clone());
let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
assert!(out.text.contains("42%"), "text: {}", out.text);
assert!(!out.text.contains("%%"), "text: {}", out.text);
}
#[test]
fn placeholder_set_contains_all_keys() {
let snap = sample_snap();
let values = build_placeholders(&snap, now());
for key in [
"gbt_plan",
"gbt_weekly_pct",
"gbt_weekly_reset",
"gbt_on_demand",
"plan",
"weekly_pct",
"weekly_reset",
"vendor_short",
] {
assert!(values.contains_key(key), "missing placeholder {key}");
}
assert_eq!(values["gbt_weekly_pct"], "42");
assert_eq!(values["weekly_pct"], "42");
assert_eq!(values["gbt_on_demand"], "off");
assert!(!values["gbt_weekly_reset"].is_empty());
}
#[test]
fn no_allowance_renders_empty_weekly_placeholders_not_a_zero() {
let snap = GrokbotSnapshot {
has_included_allowance: false,
weekly_pct: 0,
..sample_snap()
};
let values = build_placeholders(&snap, now());
for key in [
"gbt_weekly_pct",
"gbt_weekly_reset",
"weekly_pct",
"weekly_reset",
"weekly_elapsed",
"gbt_weekly_elapsed",
"gbt_weekly_pace",
"gbt_weekly_pace_pct",
"gbt_weekly_pace_indicator",
"gbt_weekly_pace_pts",
"gbt_weekly_pace_delta",
] {
assert_eq!(values[key], "", "{key} must render empty");
}
let outcome = sample_outcome(snap.clone());
let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
assert!(!out.text.contains('{'), "{}", out.text);
assert!(!out.text.contains('}'), "{}", out.text);
assert!(out.text.contains('%'), "{}", out.text);
assert!(
!out.text.contains('0'),
"a fabricated 0 must not appear: {}",
out.text
);
assert_eq!(severity(&snap), PaceSeverity::Low);
}
#[test]
fn tooltip_draws_the_weekly_bar_and_a_reset_countdown() {
let snap = sample_snap();
let outcome = sample_outcome(snap.clone());
let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
assert!(out.tooltip.contains("Grok Bot"), "{}", out.tooltip);
assert!(out.tooltip.contains("Grok Bot Plan"), "{}", out.tooltip);
assert!(
out.tooltip.contains("Weekly included usage"),
"{}",
out.tooltip
);
assert!(out.tooltip.contains("42%"), "{}", out.tooltip);
assert!(out.tooltip.contains("Resets in"), "{}", out.tooltip);
assert!(!out.tooltip.contains("2026-09-18"), "{}", out.tooltip);
}
#[test]
fn the_no_allowance_state_is_a_text_line_not_a_meter() {
let snap = GrokbotSnapshot {
has_included_allowance: false,
weekly_pct: 0,
..sample_snap()
};
let outcome = sample_outcome(snap.clone());
let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
assert!(
out.tooltip.contains("No included allowance"),
"{}",
out.tooltip
);
assert!(
!out.tooltip.contains("Weekly included usage"),
"{}",
out.tooltip
);
}
#[test]
fn the_on_demand_footnote_appears_only_at_an_exhausted_pool_with_on_demand() {
let mut snap = sample_snap();
snap.weekly_pct = 100;
snap.has_available_usage = true;
snap.on_demand_enabled = true;
let outcome = sample_outcome(snap.clone());
let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
assert!(out.tooltip.contains("on-demand"), "{}", out.tooltip);
snap.on_demand_enabled = false;
let outcome = sample_outcome(snap.clone());
let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
assert!(!out.tooltip.contains("on-demand may"), "{}", out.tooltip);
}
#[test]
fn plan_is_pango_escaped() {
let mut snap = sample_snap();
snap.plan = "A&B <beta>".into();
let outcome = sample_outcome(snap.clone());
let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
assert!(
out.tooltip.contains("A&B <beta>"),
"tooltip: {}",
out.tooltip
);
let mut o = opts();
o.tooltip_format = Some("{gbt_plan}".into());
let out = render(&outcome, &snap, &Theme::default(), &o, now());
assert_eq!(out.tooltip, "A&B <beta>");
}
#[test]
fn stale_appends_pause() {
let snap = sample_snap();
let mut outcome = sample_outcome(snap.clone());
outcome.stale = true;
let out = render(&outcome, &snap, &Theme::default(), &opts(), now());
assert!(out.text.contains("⏸"));
}
#[test]
fn fetch_outcome_conversion_preserves_metadata() {
let snap = sample_snap();
let fetch = FetchOutcome {
snapshot: snap,
stale: true,
last_error: Some((401, "bad".into())),
cache_age: Some(std::time::Duration::from_secs(42)),
};
let vendor: VendorOutcome = fetch.into();
assert!(matches!(
vendor.snapshot,
crate::usage::VendorSnapshot::Grokbot(_)
));
assert!(vendor.stale);
assert_eq!(vendor.last_error, Some((401, "bad".into())));
assert_eq!(vendor.cache_age, Some(std::time::Duration::from_secs(42)));
}
}