1pub mod dates;
2pub mod wire;
3pub use dates::parse_iso_utc;
4pub use wire::{
5 Credits, CreditsResp, SubData, SubscriptionsResp, UsageSummary, Window, WindowLimits,
6};
7
8include!(concat!(env!("OUT_DIR"), "/plans.rs"));
11
12pub fn plan_name(plan_id: &str) -> &'static str {
15 let id = plan_id.to_lowercase();
16 for (needles, name) in NAME_RULES {
17 if needles.iter().all(|n| id.contains(n)) {
18 return name;
19 }
20 }
21 DEFAULT_NAME
22}
23
24pub fn plan_rule_matched(plan_id: &str) -> bool {
28 let id = plan_id.to_lowercase();
29 NAME_RULES
30 .iter()
31 .any(|(needles, _)| needles.iter().all(|n| id.contains(n)))
32}
33
34pub fn plan_monthly_cap(plan_id: &str) -> Option<f64> {
37 let name = plan_name(plan_id);
38 CAPS.iter().find(|(n, _)| *n == name).and_then(|(_, c)| *c)
39}
40
41pub fn money(v: f64) -> String {
42 format!("${:.2}", (v * 100.0).round() / 100.0)
46}
47
48pub const FIVE_HOUR_SECS: u64 = 5 * 3600;
51pub const WEEKLY_SECS: u64 = 7 * 86400;
52
53pub fn monthly_window(
58 cap: f64,
59 remaining: f64,
60 period_start: Option<&str>,
61 period_end: Option<&str>,
62) -> (Window, Option<u64>) {
63 let used = (cap - remaining).clamp(0.0, cap);
64 let reset_at = period_end.and_then(parse_iso_utc);
65 let dur = match (period_start, period_end) {
66 (Some(st), Some(en)) => parse_iso_utc(st).zip(parse_iso_utc(en)).and_then(|(a, b)| {
67 if b < a {
70 None
71 } else {
72 Some(((b - a) as u64 / 1000).max(1))
73 }
74 }),
75 _ => None,
76 };
77 (
78 Window {
79 used,
80 cap,
81 exceeded: false,
82 reset_at,
83 },
84 dur,
85 )
86}
87
88pub fn compact(n: u64) -> String {
92 let round1 = |v: f64| (v * 10.0).round() / 10.0;
93 if n >= 999_950 {
95 format!("{:.1}M", round1(n as f64 / 1_000_000.0))
96 } else if n >= 1_000 {
97 format!("{:.1}K", round1(n as f64 / 1_000.0))
98 } else {
99 format!("{n}")
100 }
101}
102
103pub fn pct(used: f64, cap: f64) -> String {
107 if cap > 0.0 {
108 format!("{:.0}%", ((used / cap) * 100.0).round())
109 } else {
110 "—".into()
111 }
112}
113
114pub fn duration(secs: u64) -> String {
117 let d = secs / 86400;
118 let h = (secs % 86400) / 3600;
119 let m = (secs % 3600) / 60;
120 if d > 0 {
121 format!("{d}d {h}h")
122 } else if h > 0 {
123 format!("{h}h {m}m")
124 } else if m > 0 {
125 format!("{m}m")
126 } else {
127 "<1m".into()
128 }
129}
130
131pub fn rel_time(reset_at: Option<f64>, now: Option<u64>) -> String {
134 let Some(reset_ms) = reset_at else {
135 return "unknown".into();
136 };
137 let Some(now_s) = now else {
138 return format!("epoch {}", reset_ms as u64 / 1000);
139 };
140 let reset_s = reset_ms as u64 / 1000;
141 if reset_s <= now_s {
142 return "resetting…".into();
145 }
146 duration(reset_s - now_s)
147}
148
149pub fn elapsed_pct(reset_at: Option<f64>, dur_secs: u64, now: Option<u64>) -> Option<u8> {
151 let reset_ms = reset_at?;
152 let now_s = now?;
153 let reset_s = reset_ms as u64 / 1000;
154 let start = reset_s.checked_sub(dur_secs)?;
155 if now_s < start {
156 return None; }
158 let elapsed = now_s - start;
159 let pct = (elapsed as f64 / dur_secs as f64 * 100.0).clamp(0.0, 100.0);
160 Some(pct.round() as u8)
161}
162
163pub fn pace_eta(
167 reset_at: Option<f64>,
168 dur_secs: u64,
169 used: f64,
170 cap: f64,
171 now: u64,
172) -> Option<f64> {
173 let reset_ms = reset_at?;
174 let reset_s = reset_ms as u64 / 1000;
175 let start = reset_s.checked_sub(dur_secs)?;
176 if now <= start || now >= reset_s {
177 return None; }
179 let elapsed = (now - start) as f64;
180 if elapsed / (dur_secs as f64) < 0.10 {
181 return None; }
183 let rate = used / elapsed; if rate <= 0.0 || used >= cap {
185 return None;
186 }
187 let secs_to_cap = (cap - used) / rate;
188 if secs_to_cap >= (reset_s - now) as f64 {
189 return None; }
191 Some(secs_to_cap)
192}
193
194fn strip_date(s: &str) -> String {
200 let b = s.as_bytes();
201 if b.len() > 9 {
202 let sep = b[b.len() - 9];
203 if (sep == b'-' || sep == b'@') && b[b.len() - 8..].iter().all(u8::is_ascii_digit) {
204 return s[..s.len() - 9].to_string();
205 }
206 }
207 s.to_string()
208}
209
210pub fn canonical_model(model: &str) -> String {
212 let lower = model.to_lowercase();
213 if let Some(k) = GATE_KNOWN.iter().find(|m| m.to_lowercase() == lower) {
214 return (*k).to_string();
215 }
216 if let Some((_, to)) = GATE_ALIASES.iter().find(|(from, _)| *from == lower) {
217 return GATE_KNOWN
218 .iter()
219 .find(|m| m.to_lowercase() == to.to_lowercase())
220 .map(|m| (*m).to_string())
221 .unwrap_or_else(|| model.to_string());
222 }
223 let stripped = strip_date(model).to_lowercase();
224 GATE_KNOWN
225 .iter()
226 .find(|m| m.to_lowercase() == stripped)
227 .map(|m| (*m).to_string())
228 .unwrap_or_else(|| model.to_string())
229}
230
231fn model_category(model: &str) -> Option<&'static str> {
234 let lower = canonical_model(bare_model(model)).to_lowercase();
235 if let Some((_, c)) = GATE_CATEGORIES
236 .iter()
237 .find(|(m, _)| m.to_lowercase() == lower)
238 {
239 return Some(c);
240 }
241 let mut best: Option<(&str, &str)> = None;
242 for (m, c) in GATE_CATEGORIES {
243 if lower.starts_with(&m.to_lowercase()) && best.is_none_or(|(bm, _)| m.len() > bm.len()) {
244 best = Some((m, c));
245 }
246 }
247 best.map(|(_, c)| c)
248}
249
250pub fn gate_allowed(model: &str, plan_id: &str, unlocked: bool) -> bool {
251 gate(model, plan_id, unlocked).0
252}
253
254pub fn bare_model(blocked: &str) -> &str {
259 blocked
260 .split_once(':')
261 .map(|(_, rest)| rest)
262 .unwrap_or(blocked)
263}
264
265pub fn gate(model: &str, plan_id: &str, unlocked: bool) -> (bool, &'static str) {
267 if unlocked {
268 return (true, "credits unlock all models");
269 }
270 if plan_id.is_empty() {
271 return (true, "unknown plan");
272 }
273 let canonical = canonical_model(bare_model(model));
276 let cl = canonical.to_lowercase();
277 if let Some((_, list)) = GATE_HARD_BLOCKED.iter().find(|(p, _)| *p == plan_id) {
278 if list.iter().any(|m| m.to_lowercase() == cl) {
279 return (false, "blocked for this plan");
280 }
281 }
282 let Some((_, allowed, blocked)) = GATE_PLANS.iter().find(|(p, _, _)| *p == plan_id) else {
283 return (true, "plan has no restrictions");
284 };
285 let Some(cat) = model_category(model) else {
286 return (true, "unknown model category");
287 };
288 if blocked.iter().any(|b| bare_model(b).to_lowercase() == cl) {
289 return (false, "blocked for this plan");
290 }
291 if !allowed.contains(&cat) {
292 return (false, "premium model, plan is open-models-only");
293 }
294 (true, "allowed")
295}
296
297pub fn gate_age_days(now_secs: u64) -> Option<u64> {
300 let ms = dates::parse_iso_utc(GATE_EXTRACTED_AT)?;
301 Some(now_secs.saturating_sub((ms / 1000.0) as u64) / 86400)
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn plan_names() {
310 assert_eq!(plan_name("individual-goat"), "GOAT");
311 assert_eq!(plan_name("individual-max-20"), "Max 20x");
312 assert_eq!(plan_name("individual-max-10"), "Max 10x");
313 assert_eq!(plan_name("individual-go"), "Go");
314 assert_eq!(plan_name("teams-pro"), "Team Pro");
315 assert_eq!(plan_name("individual-provider"), "Provider");
316 assert_eq!(plan_name("bogus"), "Free");
317 }
318
319 #[test]
320 fn plan_rule_match_and_gate_age() {
321 assert!(plan_rule_matched("individual-goat"));
322 assert!(plan_rule_matched("teams-pro"));
323 assert!(!plan_rule_matched("free"));
324 assert!(!plan_rule_matched("bogus"));
325 let secs = (parse_iso_utc(GATE_EXTRACTED_AT).expect("extractedAt ISO") / 1000.0) as u64;
326 assert_eq!(gate_age_days(secs), Some(0));
327 assert_eq!(gate_age_days(secs + 40 * 86400), Some(40));
328 }
329
330 #[test]
331 fn monthly_caps() {
332 assert_eq!(plan_monthly_cap("individual-goat"), Some(70.0));
333 assert_eq!(plan_monthly_cap("individual-go"), Some(10.0));
334 assert_eq!(plan_monthly_cap("individual-pro"), Some(80.0));
335 assert_eq!(plan_monthly_cap("individual-max-10"), Some(150.0));
336 assert_eq!(plan_monthly_cap("individual-max-20"), Some(300.0));
337 assert_eq!(plan_monthly_cap("teams-pro"), Some(40.0));
338 assert_eq!(plan_monthly_cap("individual-provider"), None);
339 }
340
341 #[test]
342 fn money_and_compact() {
343 assert_eq!(money(0.0), "$0.00");
344 assert_eq!(money(45.689), "$45.69");
345 assert_eq!(compact(999), "999");
346 assert_eq!(compact(2_300), "2.3K");
347 assert_eq!(compact(1_250), "1.3K"); assert_eq!(compact(1_250_000), "1.3M");
349 assert_eq!(compact(316_700_000), "316.7M");
350 }
351
352 #[test]
353 fn rel_time_human() {
354 assert_eq!(rel_time(None, Some(0)), "unknown");
355 assert_eq!(rel_time(Some(59_000.0), Some(0)), "<1m");
356 assert_eq!(rel_time(Some(120_000.0), Some(60)), "1m");
357 assert_eq!(rel_time(Some(7_200_000.0), Some(0)), "2h 0m");
358 assert_eq!(rel_time(Some(136_800_000.0), Some(0)), "1d 14h");
359 assert_eq!(rel_time(Some(1_000.0), Some(100)), "resetting…");
361 assert_eq!(rel_time(Some(1_000.0), None), "epoch 1");
363 }
364
365 #[test]
366 fn elapsed_pct_windows() {
367 let now = 1_000_000u64;
368 let reset = (now as f64 + 2.5 * 3600.0) * 1000.0;
369 assert_eq!(elapsed_pct(Some(reset), 5 * 3600, Some(now)), Some(50));
370 assert_eq!(elapsed_pct(Some(1.0), 3600, Some(now)), None);
371 assert_eq!(elapsed_pct(None, 3600, Some(now)), None);
372 }
373
374 #[test]
375 fn iso_offsets_convert_to_utc() {
376 let base = parse_iso_utc("2026-09-27T12:00:00.000Z").unwrap();
378 assert_eq!(parse_iso_utc("2026-09-27T12:00:00+00:00").unwrap(), base);
379 assert_eq!(parse_iso_utc("2026-09-27T07:00:00-05:00").unwrap(), base);
380 assert_eq!(parse_iso_utc("2026-09-27T19:30:00+07:30").unwrap(), base);
381 assert_eq!(parse_iso_utc("2026-09-27T07:00:00-0500").unwrap(), base);
382 assert!(parse_iso_utc("2026-09-27T12:00:00X").is_none());
384 }
385
386 #[test]
387 fn pace_warns_only_after_10pct_elapsed() {
388 let now = 1_000_000u64;
389 let d = 5 * 3600;
390 let start5 = now - d / 20;
392 let early = pace_eta(Some((start5 + d) as f64 * 1000.0), d, 5.0, 10.0, now);
393 assert!(early.is_none(), "must not warn at 5% elapsed: {early:?}");
394 let start10 = now - d / 10;
396 let at10 = pace_eta(Some((start10 + d) as f64 * 1000.0), d, 5.0, 10.0, now);
397 assert_eq!(at10, Some(1800.0));
398 let start50 = now - d / 2;
400 let fine = pace_eta(Some((start50 + d) as f64 * 1000.0), d, 1.0, 10.0, now);
401 assert!(fine.is_none());
402 let over = pace_eta(Some((start50 + d) as f64 * 1000.0), d, 12.0, 10.0, now);
404 assert!(over.is_none(), "over-cap window must not project: {over:?}");
405 }
406
407 #[test]
410 fn conformance_vectors() {
411 let v: serde_json::Value =
412 serde_json::from_str(include_str!("../conformance.json")).unwrap();
413 for c in v["money"].as_array().unwrap() {
414 assert_eq!(money(c["in"].as_f64().unwrap()), c["out"].as_str().unwrap());
415 }
416 for c in v["compact"].as_array().unwrap() {
417 assert_eq!(
418 compact(c["in"].as_u64().unwrap()),
419 c["out"].as_str().unwrap()
420 );
421 }
422 for c in v["pct"].as_array().unwrap() {
423 assert_eq!(
424 pct(c["used"].as_f64().unwrap(), c["cap"].as_f64().unwrap()),
425 c["out"].as_str().unwrap(),
426 "pct {}/{}",
427 c["used"],
428 c["cap"]
429 );
430 }
431 for c in v["bareModel"].as_array().unwrap() {
432 assert_eq!(
433 bare_model(c["in"].as_str().unwrap()),
434 c["out"].as_str().unwrap(),
435 "bareModel {}",
436 c["in"]
437 );
438 }
439 for c in v["canonicalize"].as_array().unwrap() {
440 assert_eq!(
441 canonical_model(c["in"].as_str().unwrap()),
442 c["out"].as_str().unwrap(),
443 "canonicalize {}",
444 c["in"]
445 );
446 }
447 for c in v["elapsedPct"].as_array().unwrap() {
448 let reset = if c["resetAtMs"].is_null() {
449 None
450 } else {
451 Some(c["resetAtMs"].as_f64().unwrap())
452 };
453 let got = elapsed_pct(
454 reset,
455 c["durSecs"].as_u64().unwrap(),
456 Some(c["now"].as_u64().unwrap()),
457 );
458 let want = if c["out"].is_null() {
459 None
460 } else {
461 Some(c["out"].as_u64().unwrap() as u8)
462 };
463 assert_eq!(got, want, "elapsedPct {}", c["durSecs"]);
464 }
465 for c in v["paceEta"].as_array().unwrap() {
466 let reset = if c["resetAtMs"].is_null() {
467 None
468 } else {
469 Some(c["resetAtMs"].as_f64().unwrap())
470 };
471 let got = pace_eta(
472 reset,
473 c["durSecs"].as_u64().unwrap(),
474 c["used"].as_f64().unwrap(),
475 c["cap"].as_f64().unwrap(),
476 c["now"].as_u64().unwrap(),
477 );
478 let want = c["outSecs"].as_f64();
479 assert_eq!(got, want, "paceEta used={} cap={}", c["used"], c["cap"]);
480 }
481 for c in v["relTime"].as_array().unwrap() {
482 let reset = if c["resetAtMs"].is_null() {
483 None
484 } else {
485 Some(c["resetAtMs"].as_f64().unwrap())
486 };
487 assert_eq!(
488 rel_time(reset, Some(c["now"].as_u64().unwrap())),
489 c["out"].as_str().unwrap()
490 );
491 }
492 for c in v["parseIso"].as_array().unwrap() {
493 let got = parse_iso_utc(c["in"].as_str().unwrap());
494 let want = if c["outMs"].is_null() {
495 None
496 } else {
497 Some(c["outMs"].as_f64().unwrap())
498 };
499 assert_eq!(got, want, "parse {}", c["in"]);
500 }
501 for c in v["duration"].as_array().unwrap() {
502 assert_eq!(
503 duration(c["in"].as_u64().unwrap()),
504 c["out"].as_str().unwrap()
505 );
506 }
507 for c in v["monthlyWindow"].as_array().unwrap() {
508 let (w, dur) = monthly_window(
509 c["cap"].as_f64().unwrap(),
510 c["remaining"].as_f64().unwrap(),
511 c["periodStart"].as_str(),
512 c["periodEnd"].as_str(),
513 );
514 assert_eq!(w.used, c["used"].as_f64().unwrap(), "monthlyWindow used");
515 let reset = if c["resetAtMs"].is_null() {
516 None
517 } else {
518 Some(c["resetAtMs"].as_f64().unwrap())
519 };
520 assert_eq!(w.reset_at, reset, "monthlyWindow resetAtMs");
521 let want_dur = if c["durSecs"].is_null() {
522 None
523 } else {
524 Some(c["durSecs"].as_u64().unwrap())
525 };
526 assert_eq!(dur, want_dur, "monthlyWindow durSecs");
527 }
528 for c in v["plan"].as_array().unwrap() {
529 let id = c["id"].as_str().unwrap();
530 assert_eq!(plan_name(id), c["name"].as_str().unwrap(), "name {id}");
531 assert_eq!(plan_monthly_cap(id), c["cap"].as_f64(), "cap {id}");
532 assert_eq!(
533 plan_rule_matched(id),
534 c["matched"].as_bool().unwrap(),
535 "matched {id}"
536 );
537 }
538 for c in v["gating"].as_array().unwrap() {
539 let model = c["model"].as_str().unwrap();
540 let plan = c["plan"].as_str().unwrap();
541 let unlocked = c["unlocked"].as_bool().unwrap();
542 assert_eq!(
543 gate_allowed(model, plan, unlocked),
544 c["allowed"].as_bool().unwrap(),
545 "gate {model} / {plan} / unlocked={unlocked}"
546 );
547 }
548 }
549}