Skip to main content

cmduse_core/
lib.rs

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
9// Plan table, name rules, and monthly caps generated from plans.json
10// (single source shared with the TypeScript opencode plugin).
11include!(concat!(env!("OUT_DIR"), "/plans.rs"));
12
13/// Plan display name from the API's planId (e.g. "individual-goat" → "GOAT").
14/// First rule whose needles are all contained in the lowercased id wins.
15pub 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
25/// True when `plan_id` matched a NAME_RULES entry rather than falling through
26/// to DEFAULT_NAME. Callers warn on false: the API returned a plan we don't
27/// know, so its name/cap may be wrong until `plans.json` is updated.
28pub 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
35/// Monthly credit pool per plan. One shared pool per plan (verified — the
36/// docs' per-model allowances are not what the API meters). None = PAYG.
37pub 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
42/// The OpenCode Go/Zen catalogue, as generated by `scripts/extract-zen.ts` into
43/// `core/zen.json`. Exposed here so the `ocuse` bin can include it without
44/// reaching outside its own crate (which cargo refuses at package time).
45pub const ZEN_JSON: &str = include_str!("../zen.json");
46
47/// The Volcengine ARK Agent Plan catalogue, hand-maintained in `core/ark.json`
48/// from the docs (tiers, AFP quota, coefficients, model gates). Exposed here for
49/// the same reason as `ZEN_JSON`: the `arkuse` bin includes it from the
50/// published crate.
51pub const ARK_JSON: &str = include_str!("../ark.json");
52
53pub fn money(v: f64) -> String {
54    // Round to cents half-away-from-zero before formatting: `{:.2}` alone is
55    // round-half-to-even, JS `toFixed` is half-away, so a .x5 tie (0.125)
56    // disagreed ("$0.12" vs "$0.13"). Matches the opencode port.
57    format!("${:.2}", (v * 100.0).round() / 100.0)
58}
59
60/// Rolling-window lengths — the API serves only `resetAt`, the length is
61/// implied by the window name.
62pub const FIVE_HOUR_SECS: u64 = 5 * 3600;
63pub const WEEKLY_SECS: u64 = 7 * 86400;
64
65/// Assemble the monthly usage window from the plan cap and subscription
66/// period: used = cap − remaining (clamped to [0, cap]), reset = period end,
67/// duration = period length. Returns (window, duration secs). Single source
68/// for cli + zed; the opencode TS port mirrors it via conformance vectors.
69pub 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            // A period that ends before it starts (API glitch) must not wrap
80            // the u64 cast; equal timestamps keep the 1s floor the vectors pin.
81            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
100/// Compact thousands/millions. Rounds half-away-from-zero explicitly before
101/// formatting: `{:.1}` alone is round-half-to-even, which disagrees with JS
102/// `Math.round` at exact `.x5` ties (1_250_000 → "1.2M" vs "1.3M").
103pub fn compact(n: u64) -> String {
104    let round1 = |v: f64| (v * 10.0).round() / 10.0;
105    // >=999_950 rounds to 1000.0K under the K bucket, so promote to M first.
106    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
115/// Percent string of used/cap at one decimal: "34.2%" or "—" when cap unknown.
116/// Multiply-rounds to one decimal (`(x*10).round()/10`) before formatting,
117/// matching JS `toFixed(1)`/`Math.round` in the opencode port; `format!("{:.1}")`
118/// alone is half-to-even and drifts at exact .x5 ties.
119pub 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
127/// Compact "Xh Ym" / "Xd Yh" for a span of seconds. Pure duration — no
128/// absolute time involved; use this (not `rel_time`) for ETAs/countdowns.
129pub 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
144/// Compact "Xh Ym" human time until `reset_at` (epoch ms). `now` may be None
145/// (zed ext without a clock) → falls back to a raw epoch stamp.
146pub 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        // resetAt already passed (clock skew or window rolling over);
156        // next fetch will pick up the fresh window
157        return "resetting…".into();
158    }
159    duration(reset_s - now_s)
160}
161
162/// Elapsed % of a rolling window, one decimal: window length = dur_secs, ends
163/// at reset_at. Multiplied-rounded like `pct` so both ports agree at .x5.
164pub 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; // window hasn't started
174    }
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
180/// Seconds until spend hits cap at the current rate, if that lands before the
181/// window resets. None = no warning. Suppressed before 10% of the window has
182/// elapsed — the flat-rate projection is unreliable that early.
183pub 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; // window not started or already rolling over
195    }
196    let elapsed = (now - start) as f64;
197    if elapsed / (dur_secs as f64) < 0.10 {
198        return None; // too early in window: flat-rate ETA unreliable
199    }
200    let rate = used / elapsed; // $/sec
201    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; // won't hit cap before reset
207    }
208    Some(secs_to_cap)
209}
210
211// ---- plan-based model gating (tables from gating.json) ----
212// Mirrors the opencode plugin's evaluateModelAccess; conformance vectors pin
213// the two ports together. Unknown plan/model default to allowed — the API
214// enforces the real gate.
215
216fn 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
227/// Exact known id (case-insensitive), else alias target, else date-stripped.
228pub 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
251/// Table category, else the longest known id that prefixes it (a version bump
252/// of a known model inherits its category; otherwise None).
253fn 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
274/// Bare model id with any provider qualifier stripped: everything after the
275/// FIRST colon. blockedModels entries are provider-qualified
276/// ("anthropic:claude-opus-5"); we only serve via command-code lanes, so match
277/// on the id portion. A model id that itself contains ':' keeps it.
278pub fn bare_model(blocked: &str) -> &str {
279    blocked
280        .split_once(':')
281        .map(|(_, rest)| rest)
282        .unwrap_or(blocked)
283}
284
285/// Access decision plus a short human reason (for `models --gated --json`).
286pub 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    // Strip any provider qualifier first: a caller may pass
294    // "anthropic:claude-opus-5" while the block tables store both forms.
295    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
317/// Age of the gating snapshot in days, from `gating.json`'s `extractedAt`.
318/// None when the metadata is absent/unparseable (pre-metadata snapshots).
319pub 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        // The ocuse bin includes this from the published crate, so a broken
331        // path or a missing file must fail here, not at `cargo publish`.
332        assert!(ZEN_JSON.contains("\"go\""), "zen.json looks wrong");
333        // The plan dimension is generated, not hand-written: assert both plan
334        // ids so a stale single-plan snapshot fails the build instead of
335        // silently dropping Go Plus.
336        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        // Same guard as zen.json: the published crate must carry it, and the
349        // four personal tiers plus a coefficient table are the invariants the
350        // docs state — a stale half-snapshot should fail the build.
351        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"); // half-away, not half-even
405        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        // reset already passed
417        assert_eq!(rel_time(Some(1_000.0), Some(100)), "resetting…");
418        // no clock → raw epoch
419        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        // same civil instant expressed with explicit offsets == Z epoch.
435        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        // bad offsets → None
441        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        // 5% elapsed, spend rate would hit cap → suppressed (too early).
449        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        // 10% elapsed, same spend rate → pace warning shown.
453        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        // won't hit cap before reset → suppressed.
457        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        // already over cap → no negative ETA, no warning next to LIMIT EXCEEDED.
461        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    /// Shared vectors (conformance.json) that the opencode TypeScript port must
466    /// also satisfy — keeps the two implementations from drifting.
467    #[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}