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    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; // window hasn't started
171    }
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
177/// Seconds until spend hits cap at the current rate, if that lands before the
178/// window resets. None = no warning. Suppressed before 10% of the window has
179/// elapsed — the flat-rate projection is unreliable that early.
180pub 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; // window not started or already rolling over
192    }
193    let elapsed = (now - start) as f64;
194    if elapsed / (dur_secs as f64) < 0.10 {
195        return None; // too early in window: flat-rate ETA unreliable
196    }
197    let rate = used / elapsed; // $/sec
198    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; // won't hit cap before reset
204    }
205    Some(secs_to_cap)
206}
207
208// ---- plan-based model gating (tables from gating.json) ----
209// Mirrors the opencode plugin's evaluateModelAccess; conformance vectors pin
210// the two ports together. Unknown plan/model default to allowed — the API
211// enforces the real gate.
212
213fn 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
224/// Exact known id (case-insensitive), else alias target, else date-stripped.
225pub 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
245/// Table category, else the longest known id that prefixes it (a version bump
246/// of a known model inherits its category; otherwise None).
247fn 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
268/// Bare model id with any provider qualifier stripped: everything after the
269/// FIRST colon. blockedModels entries are provider-qualified
270/// ("anthropic:claude-opus-5"); we only serve via command-code lanes, so match
271/// on the id portion. A model id that itself contains ':' keeps it.
272pub fn bare_model(blocked: &str) -> &str {
273    blocked
274        .split_once(':')
275        .map(|(_, rest)| rest)
276        .unwrap_or(blocked)
277}
278
279/// Access decision plus a short human reason (for `models --gated --json`).
280pub 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    // Strip any provider qualifier first: a caller may pass
288    // "anthropic:claude-opus-5" while the block tables store both forms.
289    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
311/// Age of the gating snapshot in days, from `gating.json`'s `extractedAt`.
312/// None when the metadata is absent/unparseable (pre-metadata snapshots).
313pub 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        // The ocuse bin includes this from the published crate, so a broken
325        // path or a missing file must fail here, not at `cargo publish`.
326        assert!(ZEN_JSON.contains("\"go\""), "zen.json looks wrong");
327        // The plan dimension is generated, not hand-written: assert both plan
328        // ids so a stale single-plan snapshot fails the build instead of
329        // silently dropping Go Plus.
330        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        // Same guard as zen.json: the published crate must carry it, and the
343        // four personal tiers plus a coefficient table are the invariants the
344        // docs state — a stale half-snapshot should fail the build.
345        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"); // half-away, not half-even
399        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        // reset already passed
411        assert_eq!(rel_time(Some(1_000.0), Some(100)), "resetting…");
412        // no clock → raw epoch
413        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        // same civil instant expressed with explicit offsets == Z epoch.
428        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        // bad offsets → None
434        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        // 5% elapsed, spend rate would hit cap → suppressed (too early).
442        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        // 10% elapsed, same spend rate → pace warning shown.
446        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        // won't hit cap before reset → suppressed.
450        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        // already over cap → no negative ETA, no warning next to LIMIT EXCEEDED.
454        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    /// Shared vectors (conformance.json) that the opencode TypeScript port must
459    /// also satisfy — keeps the two implementations from drifting.
460    #[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}