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