Skip to main content

cmduse_core/
lib.rs

1pub mod dates;
2pub use dates::parse_iso_utc;
3
4/// Plan display name from the API's planId (e.g. "individual-goat" → "GOAT").
5pub fn plan_name(plan_id: &str) -> &'static str {
6    let id = plan_id.to_lowercase();
7    if id.contains("enterprise") {
8        "Enterprise"
9    } else if id.contains("provider") {
10        "Provider"
11    } else if id.contains("team") {
12        "Team Pro"
13    } else if id.contains("max") {
14        if id.contains("20") { "Max 20x" } else { "Max 10x" }
15    } else if id.contains("goat") {
16        "GOAT"
17    } else if id.contains("pro") {
18        "Pro"
19    } else if id.contains("go") {
20        "Go"
21    } else {
22        "Free"
23    }
24}
25
26/// Monthly credit pool per plan. One shared pool per plan (verified — the
27/// docs' per-model allowances are not what the API meters). None = PAYG.
28pub fn plan_monthly_cap(plan_id: &str) -> Option<f64> {
29    let id = plan_id.to_lowercase();
30    if id.contains("enterprise") || id.contains("provider") {
31        None
32    } else if id.contains("team") {
33        Some(40.0)
34    } else if id.contains("max") {
35        if id.contains("20") { Some(300.0) } else { Some(150.0) }
36    } else if id.contains("goat") {
37        Some(70.0)
38    } else if id.contains("pro") {
39        Some(80.0)
40    } else if id.contains("go") {
41        Some(10.0)
42    } else {
43        None
44    }
45}
46
47pub fn money(v: f64) -> String {
48    format!("${v:.2}")
49}
50
51pub fn compact(n: u64) -> String {
52    if n >= 1_000_000 {
53        format!("{:.1}M", n as f64 / 1_000_000.0)
54    } else if n >= 1_000 {
55        format!("{:.1}K", n as f64 / 1_000.0)
56    } else {
57        format!("{n}")
58    }
59}
60
61/// Percent string of used/cap: "34%" or "—" when cap unknown.
62pub fn pct(used: f64, cap: f64) -> String {
63    if cap > 0.0 {
64        format!("{:.0}%", (used / cap) * 100.0)
65    } else {
66        "—".into()
67    }
68}
69
70/// Compact "Xh Ym" human time until `reset_at` (epoch ms). `now` may be None
71/// (zed ext without a clock) → falls back to a raw epoch stamp.
72pub fn rel_time(reset_at: Option<f64>, now: Option<u64>) -> String {
73    let Some(reset_ms) = reset_at else {
74        return "unknown".into();
75    };
76    let Some(now_s) = now else {
77        return format!("epoch {}", reset_ms as u64 / 1000);
78    };
79    let reset_s = reset_ms as u64 / 1000;
80    if reset_s <= now_s {
81        // resetAt already passed (clock skew or window rolling over);
82        // next fetch will pick up the fresh window
83        return "resetting…".into();
84    }
85    let diff = reset_s - now_s;
86    let d = diff / 86400;
87    let h = (diff % 86400) / 3600;
88    let m = (diff % 3600) / 60;
89    if d > 0 {
90        format!("{d}d {h}h")
91    } else if h > 0 {
92        format!("{h}h {m}m")
93    } else if m > 0 {
94        format!("{m}m")
95    } else {
96        "<1m".into()
97    }
98}
99
100/// Elapsed % of a rolling window: window length = dur_secs, ends at reset_at.
101pub fn elapsed_pct(reset_at: Option<f64>, dur_secs: u64, now: Option<u64>) -> Option<u8> {
102    let reset_ms = reset_at?;
103    let now_s = now?;
104    let reset_s = reset_ms as u64 / 1000;
105    let start = reset_s.checked_sub(dur_secs)?;
106    if now_s < start {
107        return None; // window hasn't started
108    }
109    let elapsed = now_s - start;
110    let pct = (elapsed as f64 / dur_secs as f64 * 100.0).clamp(0.0, 100.0);
111    Some(pct.round() as u8)
112}
113
114/// Seconds until spend hits cap at the current rate, if that lands before the
115/// window resets. None = no warning. Suppressed before 10% of the window has
116/// elapsed — the flat-rate projection is unreliable that early.
117pub fn pace_eta(
118    reset_at: Option<f64>,
119    dur_secs: u64,
120    used: f64,
121    cap: f64,
122    now: u64,
123) -> Option<f64> {
124    let reset_ms = reset_at?;
125    let reset_s = reset_ms as u64 / 1000;
126    let start = reset_s.checked_sub(dur_secs)?;
127    if now <= start || now >= reset_s {
128        return None; // window not started or already rolling over
129    }
130    let elapsed = (now - start) as f64;
131    if elapsed / (dur_secs as f64) < 0.10 {
132        return None; // too early in window: flat-rate ETA unreliable
133    }
134    let rate = used / elapsed; // $/sec
135    if rate <= 0.0 {
136        return None;
137    }
138    let secs_to_cap = (cap - used) / rate;
139    if secs_to_cap >= (reset_s - now) as f64 {
140        return None; // won't hit cap before reset
141    }
142    Some(secs_to_cap)
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn plan_names() {
151        assert_eq!(plan_name("individual-goat"), "GOAT");
152        assert_eq!(plan_name("individual-max-20"), "Max 20x");
153        assert_eq!(plan_name("individual-max-10"), "Max 10x");
154        assert_eq!(plan_name("individual-go"), "Go");
155        assert_eq!(plan_name("teams-pro"), "Team Pro");
156        assert_eq!(plan_name("individual-provider"), "Provider");
157        assert_eq!(plan_name("bogus"), "Free");
158    }
159
160    #[test]
161    fn monthly_caps() {
162        assert_eq!(plan_monthly_cap("individual-goat"), Some(70.0));
163        assert_eq!(plan_monthly_cap("individual-go"), Some(10.0));
164        assert_eq!(plan_monthly_cap("individual-pro"), Some(80.0));
165        assert_eq!(plan_monthly_cap("individual-max-10"), Some(150.0));
166        assert_eq!(plan_monthly_cap("individual-max-20"), Some(300.0));
167        assert_eq!(plan_monthly_cap("teams-pro"), Some(40.0));
168        assert_eq!(plan_monthly_cap("individual-provider"), None);
169    }
170
171    #[test]
172    fn iso_hour_start_roundtrips() {
173        use crate::dates::iso_hour_start;
174        for epoch in [1_700_000_000u64, 1_767_225_600, 1_000_000_000] {
175            let s = iso_hour_start(epoch);
176            assert!(s.contains('T'), "must be a parseable ISO string: {s}");
177            let ms = parse_iso_utc(&s).unwrap() as u64;
178            assert_eq!(ms, (epoch - epoch % 3600) * 1000);
179        }
180    }
181
182    #[test]
183    fn money_and_compact() {
184        assert_eq!(money(0.0), "$0.00");
185        assert_eq!(money(45.689), "$45.69");
186        assert_eq!(compact(999), "999");
187        assert_eq!(compact(2_300), "2.3K");
188        assert_eq!(compact(316_700_000), "316.7M");
189    }
190
191    #[test]
192    fn rel_time_human() {
193        assert_eq!(rel_time(None, Some(0)), "unknown");
194        assert_eq!(rel_time(Some(59_000.0), Some(0)), "<1m");
195        assert_eq!(rel_time(Some(120_000.0), Some(60)), "1m");
196        assert_eq!(rel_time(Some(7_200_000.0), Some(0)), "2h 0m");
197        assert_eq!(rel_time(Some(136_800_000.0), Some(0)), "1d 14h");
198        // reset already passed
199        assert_eq!(rel_time(Some(1_000.0), Some(100)), "resetting…");
200        // no clock → raw epoch
201        assert_eq!(rel_time(Some(1_000.0), None), "epoch 1");
202    }
203
204    #[test]
205    fn elapsed_pct_windows() {
206        let now = 1_000_000u64;
207        let reset = (now as f64 + 2.5 * 3600.0) * 1000.0;
208        assert_eq!(elapsed_pct(Some(reset), 5 * 3600, Some(now)), Some(50));
209        assert_eq!(elapsed_pct(Some(1.0), 3600, Some(now)), None);
210        assert_eq!(elapsed_pct(None, 3600, Some(now)), None);
211    }
212
213    #[test]
214    fn pace_warns_only_after_10pct_elapsed() {
215        let now = 1_000_000u64;
216        let d = 5 * 3600;
217        // 5% elapsed, spend rate would hit cap → suppressed (too early).
218        let start5 = now - d / 20;
219        let early = pace_eta(Some((start5 + d) as f64 * 1000.0), d, 5.0, 10.0, now);
220        assert!(early.is_none(), "must not warn at 5% elapsed: {early:?}");
221        // 10% elapsed, same spend rate → pace warning shown.
222        let start10 = now - d / 10;
223        let at10 = pace_eta(Some((start10 + d) as f64 * 1000.0), d, 5.0, 10.0, now);
224        assert_eq!(at10, Some(1800.0));
225        // won't hit cap before reset → suppressed.
226        let start50 = now - d / 2;
227        let fine = pace_eta(Some((start50 + d) as f64 * 1000.0), d, 1.0, 10.0, now);
228        assert!(fine.is_none());
229    }
230}