1use std::collections::HashMap;
12use std::time::Duration;
13
14use chrono::{DateTime, Local, Utc};
15
16use crate::countdown;
17use crate::pacing;
18use crate::usage::{ResetCredit, ResetCredits, UsageWindow};
19use crate::vendor::RenderOpts;
20
21#[derive(Default)]
25pub struct WindowPlaceholders {
26 pub pct: String,
27 pub reset: String,
28 pub elapsed: String,
29 pub ratio_pace: String,
30 pub point_pace: String,
31}
32
33pub fn window_placeholders(
34 window: Option<&UsageWindow>,
35 opts: &RenderOpts,
36 now: DateTime<Utc>,
37) -> WindowPlaceholders {
38 let Some(window) = window else {
39 return WindowPlaceholders::default();
40 };
41 let pace = pacing::calc(
42 window.utilization_pct,
43 window.resets_at,
44 now,
45 window.window_duration,
46 opts.pace_tolerance,
47 );
48 WindowPlaceholders {
49 pct: window.utilization_pct.to_string(),
50 reset: countdown::format(window.resets_at, now),
51 elapsed: pace.elapsed_pct.to_string(),
52 ratio_pace: pace.ratio_pace.glyph().to_string(),
53 point_pace: pace.point_pace.glyph().to_string(),
54 }
55}
56
57pub fn money(v: f64, currency: &str) -> String {
68 let magnitude = format!("{:.2}", v.abs());
69 let sign = if v < 0.0 && magnitude != "0.00" {
70 "-"
71 } else {
72 ""
73 };
74 with_currency(sign, &magnitude, Some(currency))
75}
76
77pub fn with_currency(sign: &str, number: &str, currency: Option<&str>) -> String {
90 match currency {
91 None | Some("USD") => format!("{sign}${number}"),
92 Some("BRL") => format!("{sign}R${number}"),
93 Some("EUR") => format!("{sign}€{number}"),
94 Some("GBP") => format!("{sign}£{number}"),
95 Some("JPY") | Some("CNY") => format!("{sign}¥{number}"),
96 Some(other) => format!("{sign}{number} {other}"),
97 }
98}
99
100pub fn capitalize(s: &str) -> String {
106 let mut chars = s.chars();
107 match chars.next() {
108 Some(first) => {
109 let mut out = String::with_capacity(s.len());
110 out.extend(first.to_uppercase());
111 out.push_str(chars.as_str());
112 out
113 }
114 None => String::new(),
115 }
116}
117
118pub fn usd(v: f64) -> String {
120 money(v, "USD")
121}
122
123pub fn tokens(v: i64) -> String {
130 let sign = if v < 0 { "-" } else { "" };
131 let magnitude = (v as f64).abs();
132 for (scale, suffix) in [(1e9, "B"), (1e6, "M"), (1e3, "K")] {
133 if magnitude >= scale {
134 let mut text = format!("{:.1}", magnitude / scale);
135 if text.ends_with(".0") {
136 text.truncate(text.len() - 2);
137 }
138 return format!("{sign}{text}{suffix}");
139 }
140 }
141 format!("{v}")
142}
143
144pub fn clamp_pct(v: f64) -> u16 {
170 if v.is_nan() {
171 0
172 } else {
173 v.round().clamp(0.0, 100.0) as u16
174 }
175}
176
177pub fn local_time_hm(when: DateTime<Utc>) -> String {
178 when.with_timezone(&Local).format("%H:%M").to_string()
179}
180
181pub fn local_time_hms(when: DateTime<Utc>) -> String {
182 when.with_timezone(&Local).format("%H:%M:%S").to_string()
183}
184
185pub fn local_date_hm(when: DateTime<Utc>) -> String {
186 when.with_timezone(&Local)
187 .format("%b %-d %H:%M")
188 .to_string()
189}
190
191pub fn reset_credits(credits: &ResetCredits) -> String {
193 let noun = if credits.available == 1 {
194 "reset"
195 } else {
196 "resets"
197 };
198 format!("{} {noun} available", credits.available)
199}
200
201pub fn reset_credit_lines(credits: &ResetCredits, now: DateTime<Utc>) -> Vec<String> {
205 let mut items = credits.credits.clone();
206 items.sort_by_key(|credit| credit.expires_at);
207 let mut lines: Vec<String> = items
208 .iter()
209 .map(|credit| reset_credit_line(credit, now))
210 .collect();
211 if lines.is_empty() {
212 lines.push(if credits.available > 0 {
215 reset_credits(credits)
216 } else {
217 "None available".to_string()
218 });
219 }
220 lines
221}
222
223fn reset_credit_line(credit: &ResetCredit, now: DateTime<Utc>) -> String {
224 let expiry = match credit.expires_at {
225 Some(expires) if expires <= now => format!("expired {}", local_date_hm(expires)),
226 Some(expires) => format!(
227 "expires {} ({})",
228 local_date_hm(expires),
229 crate::countdown::format(Some(expires), now)
230 ),
231 None => "no expiry reported".into(),
232 };
233 match credit
234 .title
235 .as_deref()
236 .map(str::trim)
237 .filter(|title| !title.is_empty())
238 {
239 Some(title) => format!("{title} · {expiry}"),
240 None => capitalize(&expiry),
241 }
242}
243
244pub fn cursor_credit_line(
251 remaining_cents: i64,
252 total_cents: i64,
253 expires_at: Option<DateTime<Utc>>,
254 now: DateTime<Utc>,
255) -> String {
256 let pair = format!(
257 "{}/{}",
258 crate::usage::fmt_minor(remaining_cents, 2, Some("USD")),
259 crate::usage::fmt_minor(total_cents, 2, Some("USD"))
260 );
261 let balance = format!("{pair} remaining");
262 match credit_expiry_clause(expires_at, now) {
263 Some(clause) => format!("{balance} · {clause}"),
264 None => balance,
265 }
266}
267
268pub fn cursor_credit_meter(
275 remaining_cents: i64,
276 total_cents: i64,
277 expires_at: Option<DateTime<Utc>>,
278 now: DateTime<Utc>,
279) -> (u16, String, String) {
280 let total = total_cents.max(0);
281 let remaining = remaining_cents.max(0).min(total);
282 let spent = total.saturating_sub(remaining);
283 let pct = if total <= 0 {
284 0
285 } else {
286 let rounded = (i128::from(spent) * 100 + i128::from(total) / 2) / i128::from(total);
287 u16::try_from(rounded).unwrap_or(100).min(100)
288 };
289 let mut caption = format!(
290 "{} of {} used ({pct}%)",
291 crate::usage::fmt_minor(spent, 2, Some("USD")),
292 crate::usage::fmt_minor(total, 2, Some("USD"))
293 );
294 if let Some(clause) = credit_expiry_clause(expires_at, now) {
295 caption.push_str(" · ");
296 caption.push_str(&clause);
297 }
298 (
299 pct,
300 crate::usage::fmt_minor(remaining, 2, Some("USD")),
301 caption,
302 )
303}
304
305fn credit_expiry_clause(expires_at: Option<DateTime<Utc>>, now: DateTime<Utc>) -> Option<String> {
306 match expires_at {
307 Some(expires) if expires <= now => Some(format!("expired {}", local_date_hm(expires))),
308 Some(expires) => Some(format!(
309 "expires {} ({})",
310 local_date_hm(expires),
311 crate::countdown::format(Some(expires), now)
312 )),
313 None => None,
314 }
315}
316
317pub fn cursor_credit_label(display_name: &str) -> String {
326 let name = display_name.trim();
327 if name.to_lowercase().contains("credit") {
328 name.to_string()
329 } else {
330 "Credits".to_string()
331 }
332}
333
334pub fn updated_at_hm(now: DateTime<Utc>, cache_age: Option<Duration>) -> String {
335 match cache_age {
336 Some(age) => local_time_hm(now - chrono::Duration::from_std(age).unwrap_or_default()),
337 None => "—".to_string(),
338 }
339}
340
341pub fn substitute(template: &str, values: &HashMap<&str, String>) -> String {
348 let mut out = String::with_capacity(template.len());
349 let mut rest = template;
350 while !rest.is_empty() {
351 match rest.find('{') {
352 None => {
353 out.push_str(rest);
354 break;
355 }
356 Some(open) => {
357 out.push_str(&rest[..open]);
359 let after_open = &rest[open + 1..];
360 if let Some(close) = after_open.find('}') {
361 let key = &after_open[..close];
362 if let Some(val) = values.get(key) {
363 out.push_str(val);
364 rest = &after_open[close + 1..];
365 continue;
366 }
367 }
368 out.push('{');
370 rest = after_open;
371 }
372 }
373 }
374 out
375}
376
377pub fn placeholders<I, V>(pairs: I) -> HashMap<&'static str, String>
379where
380 I: IntoIterator<Item = (&'static str, V)>,
381 V: Into<String>,
382{
383 pairs.into_iter().map(|(k, v)| (k, v.into())).collect()
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 fn pm(pairs: &[(&'static str, &str)]) -> HashMap<&'static str, String> {
391 placeholders(pairs.iter().map(|(k, v)| (*k, v.to_string())))
392 }
393
394 #[test]
398 fn a_grant_is_titled_the_way_cursor_titles_it() {
399 assert_eq!(cursor_credit_label(""), "Credits");
400 assert_eq!(cursor_credit_label("Power user grant"), "Credits");
401 assert_eq!(cursor_credit_label("promo"), "Credits");
402 assert_eq!(
403 cursor_credit_label("Cursor Grok 4.6 Credit"),
404 "Cursor Grok 4.6 Credit"
405 );
406 assert_eq!(
407 cursor_credit_label("Cloud Agent Credits"),
408 "Cloud Agent Credits"
409 );
410 }
411
412 #[test]
413 fn a_spent_grant_stays_a_full_meter() {
414 let now = DateTime::from_timestamp(1_700_000_000, 0).unwrap();
415 let (pct, value, caption) = cursor_credit_meter(0, 2500, None, now);
416 assert_eq!(pct, 100);
417 assert_eq!(value, "$0.00");
418 assert_eq!(caption, "$25.00 of $25.00 used (100%)");
419 }
420
421 #[test]
422 fn a_percentage_is_rounded_held_to_the_meter_and_nan_safe() {
423 assert_eq!(clamp_pct(0.0), 0);
424 assert_eq!(clamp_pct(100.0), 100);
425 assert_eq!(clamp_pct(24.5), 25);
426 assert_eq!(clamp_pct(24.4), 24);
427 assert_eq!(clamp_pct(-0.4), 0);
430 assert_eq!(clamp_pct(-9_999.0), 0);
431 assert_eq!(clamp_pct(140.0), 100);
432 assert_eq!(clamp_pct(f64::INFINITY), 100);
433 assert_eq!(clamp_pct(f64::NEG_INFINITY), 0);
434 assert_eq!(clamp_pct(f64::NAN), 0);
437 }
438
439 fn offer(title: Option<&str>, expires: &str) -> ResetCredit {
440 ResetCredit {
441 title: title.map(str::to_string),
442 expires_at: Some(expires.parse().unwrap()),
443 }
444 }
445
446 #[test]
447 fn a_reset_inventory_lists_each_credit_soonest_first() {
448 let now = "2026-07-04T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
449 let credits = ResetCredits {
450 available: 2,
451 credits: vec![
452 offer(Some("Full reset (Weekly + 5 hr)"), "2026-08-01T00:00:00Z"),
453 offer(Some("Full reset (Weekly + 5 hr)"), "2026-07-17T00:00:00Z"),
454 ],
455 };
456 assert_eq!(reset_credits(&credits), "2 resets available");
457 let lines = reset_credit_lines(&credits, now);
458 assert_eq!(lines.len(), 2, "{lines:?}");
459 assert!(lines[0].contains("Full reset (Weekly + 5 hr)"), "{lines:?}");
460 assert!(lines[0].contains("(13d 0h)"), "{lines:?}");
461 assert!(lines[1].contains("(28d 0h)"), "{lines:?}");
462 }
463
464 #[test]
465 fn a_lapsed_deadline_reads_as_expired_rather_than_now() {
466 let now = "2026-07-04T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
467 let lines = reset_credit_lines(
468 &ResetCredits {
469 available: 1,
470 credits: vec![offer(None, "2026-07-01T00:00:00Z")],
471 },
472 now,
473 );
474 assert_eq!(lines.len(), 1);
475 assert!(lines[0].starts_with("Expired "), "{lines:?}");
476 assert!(!lines[0].contains("(now)"), "{lines:?}");
477 }
478
479 #[test]
480 fn a_count_without_detail_still_renders_the_inventory() {
481 let now = "2026-07-04T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
482 assert_eq!(
483 reset_credit_lines(
484 &ResetCredits {
485 available: 2,
486 credits: vec![]
487 },
488 now
489 ),
490 vec!["2 resets available"]
491 );
492 }
493
494 #[test]
495 fn single_substitution() {
496 let v = pm(&[("session_pct", "42")]);
497 assert_eq!(substitute("{session_pct}%", &v), "42%");
498 }
499
500 #[test]
501 fn usd_keeps_the_sign_outside_the_symbol() {
502 assert_eq!(usd(74.5), "$74.50");
503 assert_eq!(usd(0.0), "$0.00");
504 assert_eq!(usd(-5.71), "-$5.71");
505 assert_eq!(usd(-0.0), "$0.00");
508 assert_eq!(usd(-0.001), "$0.00");
510 assert_eq!(usd(-0.006), "-$0.01");
512 }
513
514 #[test]
515 fn money_places_the_sign_ahead_of_every_currency() {
516 assert_eq!(money(20.0, "CNY"), "¥20.00");
517 assert_eq!(money(-20.0, "CNY"), "-¥20.00");
518 assert_eq!(money(3.5, "SEK"), "3.50 SEK");
521 assert_eq!(money(-3.5, "SEK"), "-3.50 SEK");
522 assert_eq!(money(-5.71, "USD"), usd(-5.71));
524 assert_eq!(money(-0.0, "CNY"), "¥0.00");
525 }
526
527 #[test]
533 fn the_two_money_formatters_agree_on_every_symbol() {
534 for (code, symbol) in [
535 ("USD", "$"),
536 ("BRL", "R$"),
537 ("EUR", "€"),
538 ("GBP", "£"),
539 ("JPY", "¥"),
540 ("CNY", "¥"),
541 ] {
542 assert_eq!(money(3.5, code), format!("{symbol}3.50"), "money {code}");
543 assert_eq!(
544 crate::usage::fmt_minor(350, 2, Some(code)),
545 format!("{symbol}3.50"),
546 "fmt_minor {code}"
547 );
548 }
549 assert_eq!(money(3.5, "SEK"), "3.50 SEK");
551 assert_eq!(crate::usage::fmt_minor(350, 2, Some("SEK")), "3.50 SEK");
552 assert_eq!(crate::usage::fmt_minor(350, 0, Some("JPY")), "¥350");
554 }
555
556 #[test]
557 fn multiple_substitutions() {
558 let v = pm(&[("a", "1"), ("b", "2")]);
559 assert_eq!(substitute("{a}-{b}-{a}", &v), "1-2-1");
560 }
561
562 #[test]
563 fn tokens_compact_every_allowance_scale() {
564 assert_eq!(tokens(0), "0");
565 assert_eq!(tokens(999), "999");
566 assert_eq!(tokens(1_000), "1K");
567 assert_eq!(tokens(12_400), "12.4K");
568 assert_eq!(tokens(812_400_000), "812.4M");
569 assert_eq!(tokens(2_500_000_000), "2.5B");
570 assert_eq!(tokens(625_000_000), "625M");
571 assert_eq!(tokens(-2_500), "-2.5K");
572 }
573
574 #[test]
575 fn unknown_placeholder_passes_through() {
576 let v = pm(&[("a", "1")]);
577 assert_eq!(substitute("{a} {unknown}", &v), "1 {unknown}");
578 }
579
580 #[test]
581 fn no_re_substitution_in_replacement_text() {
582 let v = pm(&[("a", "{a}"), ("b", "X")]);
584 assert_eq!(substitute("{b}{a}{b}", &v), "X{a}X");
585 }
586
587 #[test]
588 fn empty_template() {
589 let v = pm(&[("a", "1")]);
590 assert_eq!(substitute("", &v), "");
591 }
592
593 #[test]
594 fn template_without_braces() {
595 let v = pm(&[("a", "1")]);
596 assert_eq!(substitute("hello world", &v), "hello world");
597 }
598
599 #[test]
600 fn unmatched_open_brace_is_literal() {
601 let v = pm(&[("a", "1")]);
602 assert_eq!(substitute("{a {x", &v), "{a {x");
603 }
604
605 #[test]
606 fn placeholders_with_underscores_and_digits() {
607 let v = pm(&[("session_pct_2", "x")]);
608 assert_eq!(substitute("{session_pct_2}", &v), "x");
609 }
610
611 #[test]
612 fn utf8_around_braces() {
613 let v = pm(&[("x", "→")]);
614 assert_eq!(substitute("α{x}β", &v), "α→β");
615 }
616}