1pub mod dates;
2pub mod reports;
3pub mod wire;
4pub use dates::parse_iso_utc;
5pub use wire::{
6 Credits, CreditsResp, SubData, SubscriptionsResp, UsageSummary, Window, WindowLimits,
7};
8
9include!(concat!(env!("OUT_DIR"), "/plans.rs"));
12
13pub fn plan_name(plan_id: &str) -> &'static str {
16 let id = plan_id.to_lowercase();
17 for (needles, name) in NAME_RULES {
18 if needles.iter().all(|n| id.contains(n)) {
19 return name;
20 }
21 }
22 DEFAULT_NAME
23}
24
25pub fn plan_rule_matched(plan_id: &str) -> bool {
29 let id = plan_id.to_lowercase();
30 NAME_RULES
31 .iter()
32 .any(|(needles, _)| needles.iter().all(|n| id.contains(n)))
33}
34
35pub fn plan_monthly_cap(plan_id: &str) -> Option<f64> {
38 let name = plan_name(plan_id);
39 CAPS.iter().find(|(n, _)| *n == name).and_then(|(_, c)| *c)
40}
41
42pub const ZEN_JSON: &str = include_str!("../zen.json");
46
47pub const ARK_JSON: &str = include_str!("../ark.json");
52
53pub fn money(v: f64) -> String {
54 format!("${:.2}", (v * 100.0).round() / 100.0)
58}
59
60pub const FIVE_HOUR_SECS: u64 = 5 * 3600;
63pub const WEEKLY_SECS: u64 = 7 * 86400;
64
65pub fn monthly_window(
70 cap: f64,
71 remaining: f64,
72 period_start: Option<&str>,
73 period_end: Option<&str>,
74) -> (Window, Option<u64>) {
75 let used = (cap - remaining).clamp(0.0, cap);
76 let reset_at = period_end.and_then(parse_iso_utc);
77 let dur = match (period_start, period_end) {
78 (Some(st), Some(en)) => parse_iso_utc(st).zip(parse_iso_utc(en)).and_then(|(a, b)| {
79 if b < a {
82 None
83 } else {
84 Some(((b - a) as u64 / 1000).max(1))
85 }
86 }),
87 _ => None,
88 };
89 (
90 Window {
91 used,
92 cap,
93 exceeded: false,
94 reset_at,
95 },
96 dur,
97 )
98}
99
100pub fn compact(n: u64) -> String {
104 let round1 = |v: f64| (v * 10.0).round() / 10.0;
105 if n >= 999_950 {
107 format!("{:.1}M", round1(n as f64 / 1_000_000.0))
108 } else if n >= 1_000 {
109 format!("{:.1}K", round1(n as f64 / 1_000.0))
110 } else {
111 format!("{n}")
112 }
113}
114
115pub fn pct(used: f64, cap: f64) -> String {
120 if cap > 0.0 {
121 format!("{:.1}%", (used / cap * 1000.0).round() / 10.0)
122 } else {
123 "—".into()
124 }
125}
126
127pub fn duration(secs: u64) -> String {
130 let d = secs / 86400;
131 let h = (secs % 86400) / 3600;
132 let m = (secs % 3600) / 60;
133 if d > 0 {
134 format!("{d}d {h}h")
135 } else if h > 0 {
136 format!("{h}h {m}m")
137 } else if m > 0 {
138 format!("{m}m")
139 } else {
140 "<1m".into()
141 }
142}
143
144pub fn rel_time(reset_at: Option<f64>, now: Option<u64>) -> String {
147 let Some(reset_ms) = reset_at else {
148 return "unknown".into();
149 };
150 let Some(now_s) = now else {
151 return format!("epoch {}", reset_ms as u64 / 1000);
152 };
153 let reset_s = reset_ms as u64 / 1000;
154 if reset_s <= now_s {
155 return "resetting…".into();
158 }
159 duration(reset_s - now_s)
160}
161
162pub fn elapsed_pct(reset_at: Option<f64>, dur_secs: u64, now: Option<u64>) -> Option<f64> {
165 if dur_secs == 0 {
166 return None;
167 }
168 let reset_ms = reset_at?;
169 let now_s = now?;
170 let reset_s = reset_ms as u64 / 1000;
171 let start = reset_s.checked_sub(dur_secs)?;
172 if now_s < start {
173 return None; }
175 let elapsed = now_s - start;
176 let pct = (elapsed as f64 / dur_secs as f64 * 100.0).clamp(0.0, 100.0);
177 Some((pct * 10.0).round() / 10.0)
178}
179
180pub fn pace_eta(
184 reset_at: Option<f64>,
185 dur_secs: u64,
186 used: f64,
187 cap: f64,
188 now: u64,
189) -> Option<f64> {
190 let reset_ms = reset_at?;
191 let reset_s = reset_ms as u64 / 1000;
192 let start = reset_s.checked_sub(dur_secs)?;
193 if now <= start || now >= reset_s {
194 return None; }
196 let elapsed = (now - start) as f64;
197 if elapsed / (dur_secs as f64) < 0.10 {
198 return None; }
200 let rate = used / elapsed; if rate <= 0.0 || used >= cap {
202 return None;
203 }
204 let secs_to_cap = (cap - used) / rate;
205 if secs_to_cap >= (reset_s - now) as f64 {
206 return None; }
208 Some(secs_to_cap)
209}
210
211fn strip_date(s: &str) -> String {
217 let b = s.as_bytes();
218 if b.len() > 9 {
219 let sep = b[b.len() - 9];
220 if (sep == b'-' || sep == b'@') && b[b.len() - 8..].iter().all(u8::is_ascii_digit) {
221 return s[..s.len() - 9].to_string();
222 }
223 }
224 s.to_string()
225}
226
227pub fn canonical_model(model: &str) -> String {
229 let lower = model.to_lowercase();
230 if let Some(k) = GATE_KNOWN.iter().find(|m| m.to_lowercase() == lower) {
231 return (*k).to_string();
232 }
233 if let Some((_, to)) = GATE_ALIASES
234 .iter()
235 .find(|(from, _)| from.to_lowercase() == lower)
236 {
237 return GATE_KNOWN
238 .iter()
239 .find(|m| m.to_lowercase() == to.to_lowercase())
240 .map(|m| (*m).to_string())
241 .unwrap_or_else(|| model.to_string());
242 }
243 let stripped = strip_date(model).to_lowercase();
244 GATE_KNOWN
245 .iter()
246 .find(|m| m.to_lowercase() == stripped)
247 .map(|m| (*m).to_string())
248 .unwrap_or_else(|| model.to_string())
249}
250
251fn model_category(model: &str) -> Option<&'static str> {
254 let lower = canonical_model(bare_model(model)).to_lowercase();
255 if let Some((_, c)) = GATE_CATEGORIES
256 .iter()
257 .find(|(m, _)| m.to_lowercase() == lower)
258 {
259 return Some(c);
260 }
261 let mut best: Option<(&str, &str)> = None;
262 for (m, c) in GATE_CATEGORIES {
263 if lower.starts_with(&m.to_lowercase()) && best.is_none_or(|(bm, _)| m.len() > bm.len()) {
264 best = Some((m, c));
265 }
266 }
267 best.map(|(_, c)| c)
268}
269
270pub fn gate_allowed(model: &str, plan_id: &str, unlocked: bool) -> bool {
271 gate(model, plan_id, unlocked).0
272}
273
274pub fn bare_model(blocked: &str) -> &str {
279 blocked
280 .split_once(':')
281 .map(|(_, rest)| rest)
282 .unwrap_or(blocked)
283}
284
285pub fn gate(model: &str, plan_id: &str, unlocked: bool) -> (bool, &'static str) {
287 if unlocked {
288 return (true, "credits unlock all models");
289 }
290 if plan_id.is_empty() {
291 return (true, "unknown plan");
292 }
293 let canonical = canonical_model(bare_model(model));
296 let cl = canonical.to_lowercase();
297 if let Some((_, list)) = GATE_HARD_BLOCKED.iter().find(|(p, _)| *p == plan_id) {
298 if list.iter().any(|m| bare_model(m).to_lowercase() == cl) {
299 return (false, "blocked for this plan");
300 }
301 }
302 let Some((_, allowed, blocked)) = GATE_PLANS.iter().find(|(p, _, _)| *p == plan_id) else {
303 return (true, "plan has no restrictions");
304 };
305 let Some(cat) = model_category(model) else {
306 return (true, "unknown model category");
307 };
308 if blocked.iter().any(|b| bare_model(b).to_lowercase() == cl) {
309 return (false, "blocked for this plan");
310 }
311 if !allowed.contains(&cat) {
312 return (false, "premium model, plan is open-models-only");
313 }
314 (true, "allowed")
315}
316
317pub fn gate_age_days(now_secs: u64) -> Option<u64> {
320 let ms = dates::parse_iso_utc(GATE_EXTRACTED_AT)?;
321 Some(now_secs.saturating_sub((ms / 1000.0) as u64) / 86400)
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn zen_catalogue_is_embedded() {
330 assert!(ZEN_JSON.contains("\"go\""), "zen.json looks wrong");
333 assert!(
337 ZEN_JSON.contains("\"go-plus\""),
338 "zen.json is missing the Go Plus plan"
339 );
340 assert!(
341 ZEN_JSON.contains("\"allowanceByModel\""),
342 "zen.json is missing per-plan allowances"
343 );
344 }
345
346 #[test]
347 fn ark_catalogue_is_embedded() {
348 assert!(ARK_JSON.contains("\"medium\""), "ark.json looks wrong");
352 for tier in ["\"small\"", "\"large\"", "\"max\""] {
353 assert!(ARK_JSON.contains(tier), "ark.json is missing tier {tier}");
354 }
355 assert!(
356 ARK_JSON.contains("\"promoCoef\""),
357 "ark.json is missing the promo coefficients"
358 );
359 assert!(
360 ARK_JSON.contains("\"afpYuan\""),
361 "ark.json is missing the AFP unit price"
362 );
363 }
364
365 #[test]
366 fn plan_names() {
367 assert_eq!(plan_name("individual-goat"), "GOAT");
368 assert_eq!(plan_name("individual-max-20"), "Max 20x");
369 assert_eq!(plan_name("individual-max-10"), "Max 10x");
370 assert_eq!(plan_name("individual-go"), "Go");
371 assert_eq!(plan_name("teams-pro"), "Team Pro");
372 assert_eq!(plan_name("individual-provider"), "Provider");
373 assert_eq!(plan_name("bogus"), "Free");
374 }
375
376 #[test]
377 fn plan_rule_match_and_gate_age() {
378 assert!(plan_rule_matched("individual-goat"));
379 assert!(plan_rule_matched("teams-pro"));
380 assert!(!plan_rule_matched("free"));
381 assert!(!plan_rule_matched("bogus"));
382 let secs = (parse_iso_utc(GATE_EXTRACTED_AT).expect("extractedAt ISO") / 1000.0) as u64;
383 assert_eq!(gate_age_days(secs), Some(0));
384 assert_eq!(gate_age_days(secs + 40 * 86400), Some(40));
385 }
386
387 #[test]
388 fn monthly_caps() {
389 assert_eq!(plan_monthly_cap("individual-goat"), Some(70.0));
390 assert_eq!(plan_monthly_cap("individual-go"), Some(10.0));
391 assert_eq!(plan_monthly_cap("individual-pro"), Some(80.0));
392 assert_eq!(plan_monthly_cap("individual-max-10"), Some(150.0));
393 assert_eq!(plan_monthly_cap("individual-max-20"), Some(300.0));
394 assert_eq!(plan_monthly_cap("teams-pro"), Some(40.0));
395 assert_eq!(plan_monthly_cap("individual-provider"), None);
396 }
397
398 #[test]
399 fn money_and_compact() {
400 assert_eq!(money(0.0), "$0.00");
401 assert_eq!(money(45.689), "$45.69");
402 assert_eq!(compact(999), "999");
403 assert_eq!(compact(2_300), "2.3K");
404 assert_eq!(compact(1_250), "1.3K"); assert_eq!(compact(1_250_000), "1.3M");
406 assert_eq!(compact(316_700_000), "316.7M");
407 }
408
409 #[test]
410 fn rel_time_human() {
411 assert_eq!(rel_time(None, Some(0)), "unknown");
412 assert_eq!(rel_time(Some(59_000.0), Some(0)), "<1m");
413 assert_eq!(rel_time(Some(120_000.0), Some(60)), "1m");
414 assert_eq!(rel_time(Some(7_200_000.0), Some(0)), "2h 0m");
415 assert_eq!(rel_time(Some(136_800_000.0), Some(0)), "1d 14h");
416 assert_eq!(rel_time(Some(1_000.0), Some(100)), "resetting…");
418 assert_eq!(rel_time(Some(1_000.0), None), "epoch 1");
420 }
421
422 #[test]
423 fn elapsed_pct_windows() {
424 let now = 1_000_000u64;
425 let reset = (now as f64 + 2.5 * 3600.0) * 1000.0;
426 assert_eq!(elapsed_pct(Some(reset), 5 * 3600, Some(now)), Some(50.0));
427 assert_eq!(elapsed_pct(Some(1.0), 3600, Some(now)), None);
428 assert_eq!(elapsed_pct(None, 3600, Some(now)), None);
429 assert_eq!(elapsed_pct(Some(reset), 0, Some(now)), None);
430 }
431
432 #[test]
433 fn iso_offsets_convert_to_utc() {
434 let base = parse_iso_utc("2026-09-27T12:00:00.000Z").unwrap();
436 assert_eq!(parse_iso_utc("2026-09-27T12:00:00+00:00").unwrap(), base);
437 assert_eq!(parse_iso_utc("2026-09-27T07:00:00-05:00").unwrap(), base);
438 assert_eq!(parse_iso_utc("2026-09-27T19:30:00+07:30").unwrap(), base);
439 assert_eq!(parse_iso_utc("2026-09-27T07:00:00-0500").unwrap(), base);
440 assert!(parse_iso_utc("2026-09-27T12:00:00X").is_none());
442 }
443
444 #[test]
445 fn pace_warns_only_after_10pct_elapsed() {
446 let now = 1_000_000u64;
447 let d = 5 * 3600;
448 let start5 = now - d / 20;
450 let early = pace_eta(Some((start5 + d) as f64 * 1000.0), d, 5.0, 10.0, now);
451 assert!(early.is_none(), "must not warn at 5% elapsed: {early:?}");
452 let start10 = now - d / 10;
454 let at10 = pace_eta(Some((start10 + d) as f64 * 1000.0), d, 5.0, 10.0, now);
455 assert_eq!(at10, Some(1800.0));
456 let start50 = now - d / 2;
458 let fine = pace_eta(Some((start50 + d) as f64 * 1000.0), d, 1.0, 10.0, now);
459 assert!(fine.is_none());
460 let over = pace_eta(Some((start50 + d) as f64 * 1000.0), d, 12.0, 10.0, now);
462 assert!(over.is_none(), "over-cap window must not project: {over:?}");
463 }
464
465 #[test]
468 fn conformance_vectors() {
469 let v: serde_json::Value =
470 serde_json::from_str(include_str!("../conformance.json")).unwrap();
471 for c in v["money"].as_array().unwrap() {
472 assert_eq!(money(c["in"].as_f64().unwrap()), c["out"].as_str().unwrap());
473 }
474 for c in v["compact"].as_array().unwrap() {
475 assert_eq!(
476 compact(c["in"].as_u64().unwrap()),
477 c["out"].as_str().unwrap()
478 );
479 }
480 for c in v["pct"].as_array().unwrap() {
481 assert_eq!(
482 pct(c["used"].as_f64().unwrap(), c["cap"].as_f64().unwrap()),
483 c["out"].as_str().unwrap(),
484 "pct {}/{}",
485 c["used"],
486 c["cap"]
487 );
488 }
489 for c in v["bareModel"].as_array().unwrap() {
490 assert_eq!(
491 bare_model(c["in"].as_str().unwrap()),
492 c["out"].as_str().unwrap(),
493 "bareModel {}",
494 c["in"]
495 );
496 }
497 for c in v["canonicalize"].as_array().unwrap() {
498 assert_eq!(
499 canonical_model(c["in"].as_str().unwrap()),
500 c["out"].as_str().unwrap(),
501 "canonicalize {}",
502 c["in"]
503 );
504 }
505 for c in v["elapsedPct"].as_array().unwrap() {
506 let reset = if c["resetAtMs"].is_null() {
507 None
508 } else {
509 Some(c["resetAtMs"].as_f64().unwrap())
510 };
511 let got = elapsed_pct(
512 reset,
513 c["durSecs"].as_u64().unwrap(),
514 Some(c["now"].as_u64().unwrap()),
515 );
516 let want = if c["out"].is_null() {
517 None
518 } else {
519 Some(c["out"].as_f64().unwrap())
520 };
521 assert_eq!(got, want, "elapsedPct {}", c["durSecs"]);
522 }
523 for c in v["paceEta"].as_array().unwrap() {
524 let reset = if c["resetAtMs"].is_null() {
525 None
526 } else {
527 Some(c["resetAtMs"].as_f64().unwrap())
528 };
529 let got = pace_eta(
530 reset,
531 c["durSecs"].as_u64().unwrap(),
532 c["used"].as_f64().unwrap(),
533 c["cap"].as_f64().unwrap(),
534 c["now"].as_u64().unwrap(),
535 );
536 let want = c["outSecs"].as_f64();
537 assert_eq!(got, want, "paceEta used={} cap={}", c["used"], c["cap"]);
538 }
539 for c in v["relTime"].as_array().unwrap() {
540 let reset = if c["resetAtMs"].is_null() {
541 None
542 } else {
543 Some(c["resetAtMs"].as_f64().unwrap())
544 };
545 assert_eq!(
546 rel_time(reset, Some(c["now"].as_u64().unwrap())),
547 c["out"].as_str().unwrap()
548 );
549 }
550 for c in v["parseIso"].as_array().unwrap() {
551 let got = parse_iso_utc(c["in"].as_str().unwrap());
552 let want = if c["outMs"].is_null() {
553 None
554 } else {
555 Some(c["outMs"].as_f64().unwrap())
556 };
557 assert_eq!(got, want, "parse {}", c["in"]);
558 }
559 for c in v["duration"].as_array().unwrap() {
560 assert_eq!(
561 duration(c["in"].as_u64().unwrap()),
562 c["out"].as_str().unwrap()
563 );
564 }
565 for c in v["monthlyWindow"].as_array().unwrap() {
566 let (w, dur) = monthly_window(
567 c["cap"].as_f64().unwrap(),
568 c["remaining"].as_f64().unwrap(),
569 c["periodStart"].as_str(),
570 c["periodEnd"].as_str(),
571 );
572 assert_eq!(w.used, c["used"].as_f64().unwrap(), "monthlyWindow used");
573 let reset = if c["resetAtMs"].is_null() {
574 None
575 } else {
576 Some(c["resetAtMs"].as_f64().unwrap())
577 };
578 assert_eq!(w.reset_at, reset, "monthlyWindow resetAtMs");
579 let want_dur = if c["durSecs"].is_null() {
580 None
581 } else {
582 Some(c["durSecs"].as_u64().unwrap())
583 };
584 assert_eq!(dur, want_dur, "monthlyWindow durSecs");
585 }
586 for c in v["plan"].as_array().unwrap() {
587 let id = c["id"].as_str().unwrap();
588 assert_eq!(plan_name(id), c["name"].as_str().unwrap(), "name {id}");
589 assert_eq!(plan_monthly_cap(id), c["cap"].as_f64(), "cap {id}");
590 assert_eq!(
591 plan_rule_matched(id),
592 c["matched"].as_bool().unwrap(),
593 "matched {id}"
594 );
595 }
596 for c in v["gating"].as_array().unwrap() {
597 let model = c["model"].as_str().unwrap();
598 let plan = c["plan"].as_str().unwrap();
599 let unlocked = c["unlocked"].as_bool().unwrap();
600 assert_eq!(
601 gate_allowed(model, plan, unlocked),
602 c["allowed"].as_bool().unwrap(),
603 "gate {model} / {plan} / unlocked={unlocked}"
604 );
605 }
606 }
607}