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 let reset_ms = reset_at?;
166 let now_s = now?;
167 let reset_s = reset_ms as u64 / 1000;
168 let start = reset_s.checked_sub(dur_secs)?;
169 if now_s < start {
170 return None; }
172 let elapsed = now_s - start;
173 let pct = (elapsed as f64 / dur_secs as f64 * 100.0).clamp(0.0, 100.0);
174 Some((pct * 10.0).round() / 10.0)
175}
176
177pub fn pace_eta(
181 reset_at: Option<f64>,
182 dur_secs: u64,
183 used: f64,
184 cap: f64,
185 now: u64,
186) -> Option<f64> {
187 let reset_ms = reset_at?;
188 let reset_s = reset_ms as u64 / 1000;
189 let start = reset_s.checked_sub(dur_secs)?;
190 if now <= start || now >= reset_s {
191 return None; }
193 let elapsed = (now - start) as f64;
194 if elapsed / (dur_secs as f64) < 0.10 {
195 return None; }
197 let rate = used / elapsed; if rate <= 0.0 || used >= cap {
199 return None;
200 }
201 let secs_to_cap = (cap - used) / rate;
202 if secs_to_cap >= (reset_s - now) as f64 {
203 return None; }
205 Some(secs_to_cap)
206}
207
208fn strip_date(s: &str) -> String {
214 let b = s.as_bytes();
215 if b.len() > 9 {
216 let sep = b[b.len() - 9];
217 if (sep == b'-' || sep == b'@') && b[b.len() - 8..].iter().all(u8::is_ascii_digit) {
218 return s[..s.len() - 9].to_string();
219 }
220 }
221 s.to_string()
222}
223
224pub fn canonical_model(model: &str) -> String {
226 let lower = model.to_lowercase();
227 if let Some(k) = GATE_KNOWN.iter().find(|m| m.to_lowercase() == lower) {
228 return (*k).to_string();
229 }
230 if let Some((_, to)) = GATE_ALIASES.iter().find(|(from, _)| *from == lower) {
231 return GATE_KNOWN
232 .iter()
233 .find(|m| m.to_lowercase() == to.to_lowercase())
234 .map(|m| (*m).to_string())
235 .unwrap_or_else(|| model.to_string());
236 }
237 let stripped = strip_date(model).to_lowercase();
238 GATE_KNOWN
239 .iter()
240 .find(|m| m.to_lowercase() == stripped)
241 .map(|m| (*m).to_string())
242 .unwrap_or_else(|| model.to_string())
243}
244
245fn model_category(model: &str) -> Option<&'static str> {
248 let lower = canonical_model(bare_model(model)).to_lowercase();
249 if let Some((_, c)) = GATE_CATEGORIES
250 .iter()
251 .find(|(m, _)| m.to_lowercase() == lower)
252 {
253 return Some(c);
254 }
255 let mut best: Option<(&str, &str)> = None;
256 for (m, c) in GATE_CATEGORIES {
257 if lower.starts_with(&m.to_lowercase()) && best.is_none_or(|(bm, _)| m.len() > bm.len()) {
258 best = Some((m, c));
259 }
260 }
261 best.map(|(_, c)| c)
262}
263
264pub fn gate_allowed(model: &str, plan_id: &str, unlocked: bool) -> bool {
265 gate(model, plan_id, unlocked).0
266}
267
268pub fn bare_model(blocked: &str) -> &str {
273 blocked
274 .split_once(':')
275 .map(|(_, rest)| rest)
276 .unwrap_or(blocked)
277}
278
279pub fn gate(model: &str, plan_id: &str, unlocked: bool) -> (bool, &'static str) {
281 if unlocked {
282 return (true, "credits unlock all models");
283 }
284 if plan_id.is_empty() {
285 return (true, "unknown plan");
286 }
287 let canonical = canonical_model(bare_model(model));
290 let cl = canonical.to_lowercase();
291 if let Some((_, list)) = GATE_HARD_BLOCKED.iter().find(|(p, _)| *p == plan_id) {
292 if list.iter().any(|m| m.to_lowercase() == cl) {
293 return (false, "blocked for this plan");
294 }
295 }
296 let Some((_, allowed, blocked)) = GATE_PLANS.iter().find(|(p, _, _)| *p == plan_id) else {
297 return (true, "plan has no restrictions");
298 };
299 let Some(cat) = model_category(model) else {
300 return (true, "unknown model category");
301 };
302 if blocked.iter().any(|b| bare_model(b).to_lowercase() == cl) {
303 return (false, "blocked for this plan");
304 }
305 if !allowed.contains(&cat) {
306 return (false, "premium model, plan is open-models-only");
307 }
308 (true, "allowed")
309}
310
311pub fn gate_age_days(now_secs: u64) -> Option<u64> {
314 let ms = dates::parse_iso_utc(GATE_EXTRACTED_AT)?;
315 Some(now_secs.saturating_sub((ms / 1000.0) as u64) / 86400)
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn zen_catalogue_is_embedded() {
324 assert!(ZEN_JSON.contains("\"go\""), "zen.json looks wrong");
327 assert!(
331 ZEN_JSON.contains("\"go-plus\""),
332 "zen.json is missing the Go Plus plan"
333 );
334 assert!(
335 ZEN_JSON.contains("\"allowanceByModel\""),
336 "zen.json is missing per-plan allowances"
337 );
338 }
339
340 #[test]
341 fn ark_catalogue_is_embedded() {
342 assert!(ARK_JSON.contains("\"medium\""), "ark.json looks wrong");
346 for tier in ["\"small\"", "\"large\"", "\"max\""] {
347 assert!(ARK_JSON.contains(tier), "ark.json is missing tier {tier}");
348 }
349 assert!(
350 ARK_JSON.contains("\"promoCoef\""),
351 "ark.json is missing the promo coefficients"
352 );
353 assert!(
354 ARK_JSON.contains("\"afpYuan\""),
355 "ark.json is missing the AFP unit price"
356 );
357 }
358
359 #[test]
360 fn plan_names() {
361 assert_eq!(plan_name("individual-goat"), "GOAT");
362 assert_eq!(plan_name("individual-max-20"), "Max 20x");
363 assert_eq!(plan_name("individual-max-10"), "Max 10x");
364 assert_eq!(plan_name("individual-go"), "Go");
365 assert_eq!(plan_name("teams-pro"), "Team Pro");
366 assert_eq!(plan_name("individual-provider"), "Provider");
367 assert_eq!(plan_name("bogus"), "Free");
368 }
369
370 #[test]
371 fn plan_rule_match_and_gate_age() {
372 assert!(plan_rule_matched("individual-goat"));
373 assert!(plan_rule_matched("teams-pro"));
374 assert!(!plan_rule_matched("free"));
375 assert!(!plan_rule_matched("bogus"));
376 let secs = (parse_iso_utc(GATE_EXTRACTED_AT).expect("extractedAt ISO") / 1000.0) as u64;
377 assert_eq!(gate_age_days(secs), Some(0));
378 assert_eq!(gate_age_days(secs + 40 * 86400), Some(40));
379 }
380
381 #[test]
382 fn monthly_caps() {
383 assert_eq!(plan_monthly_cap("individual-goat"), Some(70.0));
384 assert_eq!(plan_monthly_cap("individual-go"), Some(10.0));
385 assert_eq!(plan_monthly_cap("individual-pro"), Some(80.0));
386 assert_eq!(plan_monthly_cap("individual-max-10"), Some(150.0));
387 assert_eq!(plan_monthly_cap("individual-max-20"), Some(300.0));
388 assert_eq!(plan_monthly_cap("teams-pro"), Some(40.0));
389 assert_eq!(plan_monthly_cap("individual-provider"), None);
390 }
391
392 #[test]
393 fn money_and_compact() {
394 assert_eq!(money(0.0), "$0.00");
395 assert_eq!(money(45.689), "$45.69");
396 assert_eq!(compact(999), "999");
397 assert_eq!(compact(2_300), "2.3K");
398 assert_eq!(compact(1_250), "1.3K"); assert_eq!(compact(1_250_000), "1.3M");
400 assert_eq!(compact(316_700_000), "316.7M");
401 }
402
403 #[test]
404 fn rel_time_human() {
405 assert_eq!(rel_time(None, Some(0)), "unknown");
406 assert_eq!(rel_time(Some(59_000.0), Some(0)), "<1m");
407 assert_eq!(rel_time(Some(120_000.0), Some(60)), "1m");
408 assert_eq!(rel_time(Some(7_200_000.0), Some(0)), "2h 0m");
409 assert_eq!(rel_time(Some(136_800_000.0), Some(0)), "1d 14h");
410 assert_eq!(rel_time(Some(1_000.0), Some(100)), "resetting…");
412 assert_eq!(rel_time(Some(1_000.0), None), "epoch 1");
414 }
415
416 #[test]
417 fn elapsed_pct_windows() {
418 let now = 1_000_000u64;
419 let reset = (now as f64 + 2.5 * 3600.0) * 1000.0;
420 assert_eq!(elapsed_pct(Some(reset), 5 * 3600, Some(now)), Some(50.0));
421 assert_eq!(elapsed_pct(Some(1.0), 3600, Some(now)), None);
422 assert_eq!(elapsed_pct(None, 3600, Some(now)), None);
423 }
424
425 #[test]
426 fn iso_offsets_convert_to_utc() {
427 let base = parse_iso_utc("2026-09-27T12:00:00.000Z").unwrap();
429 assert_eq!(parse_iso_utc("2026-09-27T12:00:00+00:00").unwrap(), base);
430 assert_eq!(parse_iso_utc("2026-09-27T07:00:00-05:00").unwrap(), base);
431 assert_eq!(parse_iso_utc("2026-09-27T19:30:00+07:30").unwrap(), base);
432 assert_eq!(parse_iso_utc("2026-09-27T07:00:00-0500").unwrap(), base);
433 assert!(parse_iso_utc("2026-09-27T12:00:00X").is_none());
435 }
436
437 #[test]
438 fn pace_warns_only_after_10pct_elapsed() {
439 let now = 1_000_000u64;
440 let d = 5 * 3600;
441 let start5 = now - d / 20;
443 let early = pace_eta(Some((start5 + d) as f64 * 1000.0), d, 5.0, 10.0, now);
444 assert!(early.is_none(), "must not warn at 5% elapsed: {early:?}");
445 let start10 = now - d / 10;
447 let at10 = pace_eta(Some((start10 + d) as f64 * 1000.0), d, 5.0, 10.0, now);
448 assert_eq!(at10, Some(1800.0));
449 let start50 = now - d / 2;
451 let fine = pace_eta(Some((start50 + d) as f64 * 1000.0), d, 1.0, 10.0, now);
452 assert!(fine.is_none());
453 let over = pace_eta(Some((start50 + d) as f64 * 1000.0), d, 12.0, 10.0, now);
455 assert!(over.is_none(), "over-cap window must not project: {over:?}");
456 }
457
458 #[test]
461 fn conformance_vectors() {
462 let v: serde_json::Value =
463 serde_json::from_str(include_str!("../conformance.json")).unwrap();
464 for c in v["money"].as_array().unwrap() {
465 assert_eq!(money(c["in"].as_f64().unwrap()), c["out"].as_str().unwrap());
466 }
467 for c in v["compact"].as_array().unwrap() {
468 assert_eq!(
469 compact(c["in"].as_u64().unwrap()),
470 c["out"].as_str().unwrap()
471 );
472 }
473 for c in v["pct"].as_array().unwrap() {
474 assert_eq!(
475 pct(c["used"].as_f64().unwrap(), c["cap"].as_f64().unwrap()),
476 c["out"].as_str().unwrap(),
477 "pct {}/{}",
478 c["used"],
479 c["cap"]
480 );
481 }
482 for c in v["bareModel"].as_array().unwrap() {
483 assert_eq!(
484 bare_model(c["in"].as_str().unwrap()),
485 c["out"].as_str().unwrap(),
486 "bareModel {}",
487 c["in"]
488 );
489 }
490 for c in v["canonicalize"].as_array().unwrap() {
491 assert_eq!(
492 canonical_model(c["in"].as_str().unwrap()),
493 c["out"].as_str().unwrap(),
494 "canonicalize {}",
495 c["in"]
496 );
497 }
498 for c in v["elapsedPct"].as_array().unwrap() {
499 let reset = if c["resetAtMs"].is_null() {
500 None
501 } else {
502 Some(c["resetAtMs"].as_f64().unwrap())
503 };
504 let got = elapsed_pct(
505 reset,
506 c["durSecs"].as_u64().unwrap(),
507 Some(c["now"].as_u64().unwrap()),
508 );
509 let want = if c["out"].is_null() {
510 None
511 } else {
512 Some(c["out"].as_f64().unwrap())
513 };
514 assert_eq!(got, want, "elapsedPct {}", c["durSecs"]);
515 }
516 for c in v["paceEta"].as_array().unwrap() {
517 let reset = if c["resetAtMs"].is_null() {
518 None
519 } else {
520 Some(c["resetAtMs"].as_f64().unwrap())
521 };
522 let got = pace_eta(
523 reset,
524 c["durSecs"].as_u64().unwrap(),
525 c["used"].as_f64().unwrap(),
526 c["cap"].as_f64().unwrap(),
527 c["now"].as_u64().unwrap(),
528 );
529 let want = c["outSecs"].as_f64();
530 assert_eq!(got, want, "paceEta used={} cap={}", c["used"], c["cap"]);
531 }
532 for c in v["relTime"].as_array().unwrap() {
533 let reset = if c["resetAtMs"].is_null() {
534 None
535 } else {
536 Some(c["resetAtMs"].as_f64().unwrap())
537 };
538 assert_eq!(
539 rel_time(reset, Some(c["now"].as_u64().unwrap())),
540 c["out"].as_str().unwrap()
541 );
542 }
543 for c in v["parseIso"].as_array().unwrap() {
544 let got = parse_iso_utc(c["in"].as_str().unwrap());
545 let want = if c["outMs"].is_null() {
546 None
547 } else {
548 Some(c["outMs"].as_f64().unwrap())
549 };
550 assert_eq!(got, want, "parse {}", c["in"]);
551 }
552 for c in v["duration"].as_array().unwrap() {
553 assert_eq!(
554 duration(c["in"].as_u64().unwrap()),
555 c["out"].as_str().unwrap()
556 );
557 }
558 for c in v["monthlyWindow"].as_array().unwrap() {
559 let (w, dur) = monthly_window(
560 c["cap"].as_f64().unwrap(),
561 c["remaining"].as_f64().unwrap(),
562 c["periodStart"].as_str(),
563 c["periodEnd"].as_str(),
564 );
565 assert_eq!(w.used, c["used"].as_f64().unwrap(), "monthlyWindow used");
566 let reset = if c["resetAtMs"].is_null() {
567 None
568 } else {
569 Some(c["resetAtMs"].as_f64().unwrap())
570 };
571 assert_eq!(w.reset_at, reset, "monthlyWindow resetAtMs");
572 let want_dur = if c["durSecs"].is_null() {
573 None
574 } else {
575 Some(c["durSecs"].as_u64().unwrap())
576 };
577 assert_eq!(dur, want_dur, "monthlyWindow durSecs");
578 }
579 for c in v["plan"].as_array().unwrap() {
580 let id = c["id"].as_str().unwrap();
581 assert_eq!(plan_name(id), c["name"].as_str().unwrap(), "name {id}");
582 assert_eq!(plan_monthly_cap(id), c["cap"].as_f64(), "cap {id}");
583 assert_eq!(
584 plan_rule_matched(id),
585 c["matched"].as_bool().unwrap(),
586 "matched {id}"
587 );
588 }
589 for c in v["gating"].as_array().unwrap() {
590 let model = c["model"].as_str().unwrap();
591 let plan = c["plan"].as_str().unwrap();
592 let unlocked = c["unlocked"].as_bool().unwrap();
593 assert_eq!(
594 gate_allowed(model, plan, unlocked),
595 c["allowed"].as_bool().unwrap(),
596 "gate {model} / {plan} / unlocked={unlocked}"
597 );
598 }
599 }
600}