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 clamp_pct(v: f64) -> u16 {
149 if v.is_nan() {
150 0
151 } else {
152 v.round().clamp(0.0, 100.0) as u16
153 }
154}
155
156pub fn local_time_hm(when: DateTime<Utc>) -> String {
157 when.with_timezone(&Local).format("%H:%M").to_string()
158}
159
160pub fn local_time_hms(when: DateTime<Utc>) -> String {
161 when.with_timezone(&Local).format("%H:%M:%S").to_string()
162}
163
164pub fn local_date_hm(when: DateTime<Utc>) -> String {
165 when.with_timezone(&Local)
166 .format("%b %-d %H:%M")
167 .to_string()
168}
169
170pub fn reset_credits(credits: &ResetCredits) -> String {
172 let noun = if credits.available == 1 {
173 "reset"
174 } else {
175 "resets"
176 };
177 format!("{} {noun} available", credits.available)
178}
179
180pub fn reset_credit_lines(credits: &ResetCredits, now: DateTime<Utc>) -> Vec<String> {
184 let mut items = credits.credits.clone();
185 items.sort_by_key(|credit| credit.expires_at);
186 let mut lines: Vec<String> = items
187 .iter()
188 .map(|credit| reset_credit_line(credit, now))
189 .collect();
190 if lines.is_empty() && credits.available > 0 {
191 lines.push(reset_credits(credits));
192 }
193 lines
194}
195
196fn reset_credit_line(credit: &ResetCredit, now: DateTime<Utc>) -> String {
197 let expiry = match credit.expires_at {
198 Some(expires) if expires <= now => format!("expired {}", local_date_hm(expires)),
199 Some(expires) => format!(
200 "expires {} ({})",
201 local_date_hm(expires),
202 crate::countdown::format(Some(expires), now)
203 ),
204 None => "no expiry reported".into(),
205 };
206 match credit
207 .title
208 .as_deref()
209 .map(str::trim)
210 .filter(|title| !title.is_empty())
211 {
212 Some(title) => format!("{title} · {expiry}"),
213 None => capitalize(&expiry),
214 }
215}
216
217pub fn cursor_credit_line(
224 remaining_cents: i64,
225 total_cents: i64,
226 expires_at: Option<DateTime<Utc>>,
227 now: DateTime<Utc>,
228) -> String {
229 let pair = format!(
230 "{}/{}",
231 crate::usage::fmt_minor(remaining_cents, 2, Some("USD")),
232 crate::usage::fmt_minor(total_cents, 2, Some("USD"))
233 );
234 let balance = format!("{pair} remaining");
235 match credit_expiry_clause(expires_at, now) {
236 Some(clause) => format!("{balance} · {clause}"),
237 None => balance,
238 }
239}
240
241pub fn cursor_credit_meter(
248 remaining_cents: i64,
249 total_cents: i64,
250 expires_at: Option<DateTime<Utc>>,
251 now: DateTime<Utc>,
252) -> (u16, String, String) {
253 let total = total_cents.max(0);
254 let remaining = remaining_cents.max(0).min(total);
255 let spent = total.saturating_sub(remaining);
256 let pct = if total <= 0 {
257 0
258 } else {
259 let rounded = (i128::from(spent) * 100 + i128::from(total) / 2) / i128::from(total);
260 u16::try_from(rounded).unwrap_or(100).min(100)
261 };
262 let mut caption = format!(
263 "{} of {} used ({pct}%)",
264 crate::usage::fmt_minor(spent, 2, Some("USD")),
265 crate::usage::fmt_minor(total, 2, Some("USD"))
266 );
267 if let Some(clause) = credit_expiry_clause(expires_at, now) {
268 caption.push_str(" · ");
269 caption.push_str(&clause);
270 }
271 (
272 pct,
273 crate::usage::fmt_minor(remaining, 2, Some("USD")),
274 caption,
275 )
276}
277
278fn credit_expiry_clause(expires_at: Option<DateTime<Utc>>, now: DateTime<Utc>) -> Option<String> {
279 match expires_at {
280 Some(expires) if expires <= now => Some(format!("expired {}", local_date_hm(expires))),
281 Some(expires) => Some(format!(
282 "expires {} ({})",
283 local_date_hm(expires),
284 crate::countdown::format(Some(expires), now)
285 )),
286 None => None,
287 }
288}
289
290pub fn cursor_credit_label(display_name: &str) -> String {
299 let name = display_name.trim();
300 if name.to_lowercase().contains("credit") {
301 name.to_string()
302 } else {
303 "Credits".to_string()
304 }
305}
306
307pub fn updated_at_hm(now: DateTime<Utc>, cache_age: Option<Duration>) -> String {
308 match cache_age {
309 Some(age) => local_time_hm(now - chrono::Duration::from_std(age).unwrap_or_default()),
310 None => "—".to_string(),
311 }
312}
313
314pub fn substitute(template: &str, values: &HashMap<&str, String>) -> String {
321 let mut out = String::with_capacity(template.len());
322 let mut rest = template;
323 while !rest.is_empty() {
324 match rest.find('{') {
325 None => {
326 out.push_str(rest);
327 break;
328 }
329 Some(open) => {
330 out.push_str(&rest[..open]);
332 let after_open = &rest[open + 1..];
333 if let Some(close) = after_open.find('}') {
334 let key = &after_open[..close];
335 if let Some(val) = values.get(key) {
336 out.push_str(val);
337 rest = &after_open[close + 1..];
338 continue;
339 }
340 }
341 out.push('{');
343 rest = after_open;
344 }
345 }
346 }
347 out
348}
349
350pub fn placeholders<I, V>(pairs: I) -> HashMap<&'static str, String>
352where
353 I: IntoIterator<Item = (&'static str, V)>,
354 V: Into<String>,
355{
356 pairs.into_iter().map(|(k, v)| (k, v.into())).collect()
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 fn pm(pairs: &[(&'static str, &str)]) -> HashMap<&'static str, String> {
364 placeholders(pairs.iter().map(|(k, v)| (*k, v.to_string())))
365 }
366
367 #[test]
371 fn a_grant_is_titled_the_way_cursor_titles_it() {
372 assert_eq!(cursor_credit_label(""), "Credits");
373 assert_eq!(cursor_credit_label("Power user grant"), "Credits");
374 assert_eq!(cursor_credit_label("promo"), "Credits");
375 assert_eq!(
376 cursor_credit_label("Cursor Grok 4.6 Credit"),
377 "Cursor Grok 4.6 Credit"
378 );
379 assert_eq!(
380 cursor_credit_label("Cloud Agent Credits"),
381 "Cloud Agent Credits"
382 );
383 }
384
385 #[test]
386 fn a_spent_grant_stays_a_full_meter() {
387 let now = DateTime::from_timestamp(1_700_000_000, 0).unwrap();
388 let (pct, value, caption) = cursor_credit_meter(0, 2500, None, now);
389 assert_eq!(pct, 100);
390 assert_eq!(value, "$0.00");
391 assert_eq!(caption, "$25.00 of $25.00 used (100%)");
392 }
393
394 #[test]
395 fn a_percentage_is_rounded_held_to_the_meter_and_nan_safe() {
396 assert_eq!(clamp_pct(0.0), 0);
397 assert_eq!(clamp_pct(100.0), 100);
398 assert_eq!(clamp_pct(24.5), 25);
399 assert_eq!(clamp_pct(24.4), 24);
400 assert_eq!(clamp_pct(-0.4), 0);
403 assert_eq!(clamp_pct(-9_999.0), 0);
404 assert_eq!(clamp_pct(140.0), 100);
405 assert_eq!(clamp_pct(f64::INFINITY), 100);
406 assert_eq!(clamp_pct(f64::NEG_INFINITY), 0);
407 assert_eq!(clamp_pct(f64::NAN), 0);
410 }
411
412 fn offer(title: Option<&str>, expires: &str) -> ResetCredit {
413 ResetCredit {
414 title: title.map(str::to_string),
415 expires_at: Some(expires.parse().unwrap()),
416 }
417 }
418
419 #[test]
420 fn a_reset_inventory_lists_each_credit_soonest_first() {
421 let now = "2026-07-04T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
422 let credits = ResetCredits {
423 available: 2,
424 credits: vec![
425 offer(Some("Full reset (Weekly + 5 hr)"), "2026-08-01T00:00:00Z"),
426 offer(Some("Full reset (Weekly + 5 hr)"), "2026-07-17T00:00:00Z"),
427 ],
428 };
429 assert_eq!(reset_credits(&credits), "2 resets available");
430 let lines = reset_credit_lines(&credits, now);
431 assert_eq!(lines.len(), 2, "{lines:?}");
432 assert!(lines[0].contains("Full reset (Weekly + 5 hr)"), "{lines:?}");
433 assert!(lines[0].contains("(13d 0h)"), "{lines:?}");
434 assert!(lines[1].contains("(28d 0h)"), "{lines:?}");
435 }
436
437 #[test]
438 fn a_lapsed_deadline_reads_as_expired_rather_than_now() {
439 let now = "2026-07-04T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
440 let lines = reset_credit_lines(
441 &ResetCredits {
442 available: 1,
443 credits: vec![offer(None, "2026-07-01T00:00:00Z")],
444 },
445 now,
446 );
447 assert_eq!(lines.len(), 1);
448 assert!(lines[0].starts_with("Expired "), "{lines:?}");
449 assert!(!lines[0].contains("(now)"), "{lines:?}");
450 }
451
452 #[test]
453 fn a_count_without_detail_still_renders_the_inventory() {
454 let now = "2026-07-04T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
455 assert_eq!(
456 reset_credit_lines(
457 &ResetCredits {
458 available: 2,
459 credits: vec![]
460 },
461 now
462 ),
463 vec!["2 resets available"]
464 );
465 }
466
467 #[test]
468 fn single_substitution() {
469 let v = pm(&[("session_pct", "42")]);
470 assert_eq!(substitute("{session_pct}%", &v), "42%");
471 }
472
473 #[test]
474 fn usd_keeps_the_sign_outside_the_symbol() {
475 assert_eq!(usd(74.5), "$74.50");
476 assert_eq!(usd(0.0), "$0.00");
477 assert_eq!(usd(-5.71), "-$5.71");
478 assert_eq!(usd(-0.0), "$0.00");
481 assert_eq!(usd(-0.001), "$0.00");
483 assert_eq!(usd(-0.006), "-$0.01");
485 }
486
487 #[test]
488 fn money_places_the_sign_ahead_of_every_currency() {
489 assert_eq!(money(20.0, "CNY"), "¥20.00");
490 assert_eq!(money(-20.0, "CNY"), "-¥20.00");
491 assert_eq!(money(3.5, "SEK"), "3.50 SEK");
494 assert_eq!(money(-3.5, "SEK"), "-3.50 SEK");
495 assert_eq!(money(-5.71, "USD"), usd(-5.71));
497 assert_eq!(money(-0.0, "CNY"), "¥0.00");
498 }
499
500 #[test]
506 fn the_two_money_formatters_agree_on_every_symbol() {
507 for (code, symbol) in [
508 ("USD", "$"),
509 ("BRL", "R$"),
510 ("EUR", "€"),
511 ("GBP", "£"),
512 ("JPY", "¥"),
513 ("CNY", "¥"),
514 ] {
515 assert_eq!(money(3.5, code), format!("{symbol}3.50"), "money {code}");
516 assert_eq!(
517 crate::usage::fmt_minor(350, 2, Some(code)),
518 format!("{symbol}3.50"),
519 "fmt_minor {code}"
520 );
521 }
522 assert_eq!(money(3.5, "SEK"), "3.50 SEK");
524 assert_eq!(crate::usage::fmt_minor(350, 2, Some("SEK")), "3.50 SEK");
525 assert_eq!(crate::usage::fmt_minor(350, 0, Some("JPY")), "¥350");
527 }
528
529 #[test]
530 fn multiple_substitutions() {
531 let v = pm(&[("a", "1"), ("b", "2")]);
532 assert_eq!(substitute("{a}-{b}-{a}", &v), "1-2-1");
533 }
534
535 #[test]
536 fn unknown_placeholder_passes_through() {
537 let v = pm(&[("a", "1")]);
538 assert_eq!(substitute("{a} {unknown}", &v), "1 {unknown}");
539 }
540
541 #[test]
542 fn no_re_substitution_in_replacement_text() {
543 let v = pm(&[("a", "{a}"), ("b", "X")]);
545 assert_eq!(substitute("{b}{a}{b}", &v), "X{a}X");
546 }
547
548 #[test]
549 fn empty_template() {
550 let v = pm(&[("a", "1")]);
551 assert_eq!(substitute("", &v), "");
552 }
553
554 #[test]
555 fn template_without_braces() {
556 let v = pm(&[("a", "1")]);
557 assert_eq!(substitute("hello world", &v), "hello world");
558 }
559
560 #[test]
561 fn unmatched_open_brace_is_literal() {
562 let v = pm(&[("a", "1")]);
563 assert_eq!(substitute("{a {x", &v), "{a {x");
564 }
565
566 #[test]
567 fn placeholders_with_underscores_and_digits() {
568 let v = pm(&[("session_pct_2", "x")]);
569 assert_eq!(substitute("{session_pct_2}", &v), "x");
570 }
571
572 #[test]
573 fn utf8_around_braces() {
574 let v = pm(&[("x", "→")]);
575 assert_eq!(substitute("α{x}β", &v), "α→β");
576 }
577}