Skip to main content

ai_usagebar/
recommendation.rs

1//! Quota priority with a bounded, local history of fresh observations.
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5use std::{
6    path::{Path, PathBuf},
7    time::Duration,
8};
9
10pub const DEFAULT_RECENT_HOURS: u8 = 3;
11pub const MAX_RECENT_HOURS: u8 = 24;
12const HISTORY_SECONDS: i64 = (MAX_RECENT_HOURS as i64 + 1) * 3600;
13const MAX_SAMPLES: usize = 16_384;
14const MIN_UNUSED_PERCENT: f64 = 5.0;
15const DAY_SECONDS: f64 = 86_400.0;
16// A cached reading (served during a backoff) still describes a weekly quota
17// well for a few hours; older than this it is treated as unavailable.
18const MAX_STALE_SECONDS: i64 = 6 * 3600;
19const WEEK_SECONDS: f64 = 7.0 * DAY_SECONDS;
20pub const DEFAULT_ALERT_UNUSED_PERCENT: u8 = 50;
21pub const DEFAULT_ALERT_HOURS: u8 = 24;
22pub const MAX_ALERT_HOURS: u8 = 168;
23
24/// When a weekly quota counts as urgent: at least `unused_percent` projected
25/// unused and a reset within `hours`.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub struct Alert {
28    pub unused_percent: u8,
29    pub hours: u8,
30}
31
32impl Default for Alert {
33    fn default() -> Self {
34        Self {
35            unused_percent: DEFAULT_ALERT_UNUSED_PERCENT,
36            hours: DEFAULT_ALERT_HOURS,
37        }
38    }
39}
40
41/// What `usage --json --priorities` was asked for.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub struct Options {
44    pub recent_hours: u8,
45    pub alert: Alert,
46}
47
48impl Alert {
49    fn clamped(self) -> Self {
50        Self {
51            unused_percent: self.unused_percent.clamp(1, 100),
52            hours: self.hours.clamp(1, MAX_ALERT_HOURS),
53        }
54    }
55}
56
57#[derive(Clone, Serialize, Deserialize)]
58pub struct Sample {
59    entry: String,
60    label: String,
61    at: i64,
62    reset: i64,
63    percent: f64,
64}
65
66fn timestamp(v: &Value) -> Option<i64> {
67    DateTime::parse_from_rfc3339(v.as_str()?)
68        .ok()
69        .map(|t| t.timestamp())
70}
71
72/// Tests inject storage. No credentials or conversations are recorded.
73pub fn update_history(path: &Path, entries: &[Value], now: i64) -> std::io::Result<Vec<Sample>> {
74    std::fs::create_dir_all(path)?;
75    let _lock = crate::cache::acquire_lock(&path.join("history.lock"), Duration::from_secs(1))
76        .map_err(std::io::Error::other)?;
77    let file = path.join("history.json");
78    let mut samples: Vec<Sample> = std::fs::read(&file)
79        .ok()
80        .and_then(|b| serde_json::from_slice(&b).ok())
81        .unwrap_or_default();
82    samples.retain(|s| s.at >= now - HISTORY_SECONDS && s.at <= now);
83    for e in entries {
84        if e["status"] != "ready" {
85            continue;
86        }
87        let Some(at) = timestamp(&e["fetched_at"]).filter(|t| *t <= now && now - *t <= 1800) else {
88            continue;
89        };
90        for m in e["metrics"].as_array().into_iter().flatten() {
91            let (Some(percent), Some(reset), Some(id), Some(label)) = (
92                m["percent"].as_f64(),
93                timestamp(&m["reset_at"]),
94                e["id"].as_str(),
95                m["label"].as_str(),
96            ) else {
97                continue;
98            };
99            if m["headline"] != "percent" || !(0.0..=100.0).contains(&percent) {
100                continue;
101            }
102            if samples
103                .iter()
104                .any(|s| s.entry == id && s.label == label && s.at == at && s.reset == reset)
105            {
106                continue;
107            }
108            samples.push(Sample {
109                entry: id.into(),
110                label: label.into(),
111                at,
112                reset,
113                percent,
114            });
115        }
116    }
117    samples.sort_by_key(|s| s.at);
118    if samples.len() > MAX_SAMPLES {
119        samples.drain(..samples.len() - MAX_SAMPLES);
120    }
121    crate::cache::atomic_write(&file, &serde_json::to_vec(&samples)?)
122        .map_err(std::io::Error::other)?;
123    Ok(samples)
124}
125
126fn recent_rate(
127    samples: &[Sample],
128    entry: &Value,
129    metric: &Value,
130    now: i64,
131    recent_hours: u8,
132) -> Option<(f64, f64)> {
133    let reset = timestamp(&metric["reset_at"])?;
134    let at = timestamp(&entry["fetched_at"])?;
135    if now - at > 1800 || at > now {
136        return None;
137    }
138    let used = metric["percent"].as_f64()?;
139    let mut chain: Vec<&Sample> = Vec::new();
140    for s in samples.iter().filter(|s| {
141        Some(s.entry.as_str()) == entry["id"].as_str()
142            && Some(s.label.as_str()) == metric["label"].as_str()
143            && (s.reset - reset).abs() <= 120
144            && s.at >= at - i64::from(recent_hours.clamp(1, MAX_RECENT_HOURS)) * 3600
145            && s.at <= at
146    }) {
147        if chain
148            .last()
149            .is_some_and(|last| s.percent < last.percent || s.at - last.at > 1800)
150        {
151            chain.clear();
152        }
153        chain.push(s);
154    }
155    let last = chain.last()?;
156    let first = chain.first()?;
157    // A decrease means a reset/correction: don't turn it into negative consumption.
158    if at - last.at > 1800 || used < last.percent || at - first.at < 1800 {
159        return None;
160    }
161    let hours = (at - first.at) as f64 / 3600.0;
162    Some(((used - first.percent) / hours, hours))
163}
164
165#[cfg(test)]
166fn rank(entries: &[Value], samples: &[Sample], now: DateTime<Utc>) -> Vec<Value> {
167    rank_with_recent_hours(
168        entries,
169        samples,
170        now,
171        DEFAULT_RECENT_HOURS,
172        Alert::default(),
173    )
174}
175
176pub fn rank_with_recent_hours(
177    entries: &[Value],
178    samples: &[Sample],
179    now: DateTime<Utc>,
180    recent_hours: u8,
181    alert: Alert,
182) -> Vec<Value> {
183    let recent_hours = recent_hours.clamp(1, MAX_RECENT_HOURS);
184    let alert = alert.clamped();
185    let horizon = f64::from(alert.hours) * 3600.0;
186    let now_ts = now.timestamp();
187    let mut ranking = Vec::new();
188    for e in entries {
189        let mut row = json!({"entry_id":e["id"],"name":e["display_name"],"status":"unknown", "reason":"No reset window with a known duration", "score":-1.0, "urgent":false, "reason_code":"unknown"});
190        let fetched = timestamp(&e["fetched_at"]);
191        let too_old = fetched.is_none_or(|t| now_ts - t > MAX_STALE_SECONDS);
192        if e["status"] != "ready" || (e["stale"] == true && too_old) {
193            row["status"] = json!("unavailable");
194            row["reason_code"] = json!("unavailable");
195            row["reason"] = json!("Usage data is unavailable or outdated");
196            ranking.push(row);
197            continue;
198        }
199        let metrics = e["metrics"].as_array().map(Vec::as_slice).unwrap_or(&[]);
200        let shared = |m: &&Value| {
201            m["headline"] == "percent"
202                && m["model_pool"] != true
203                && m.get("group").is_none_or(Value::is_null)
204        };
205        let session_exhausted = metrics.iter().filter(shared).any(|m| {
206            m["window_secs"]
207                .as_f64()
208                .is_some_and(|w| w < WEEK_SECONDS - DAY_SECONDS)
209                && timestamp(&m["reset_at"]).is_none_or(|reset| reset > now_ts)
210                && m["percent"].as_f64().unwrap_or(0.0) >= 100.0
211        });
212        // Quota is lost at the weekly reset, so the weekly window is the
213        // measure; a session/rolling window refills within hours. With several
214        // weekly windows (Claude's shared week and a per-model week) the most
215        // used one binds what can still be spent. Providers without a weekly
216        // window fall back to their longest known window of at least a day.
217        // Expired windows cannot bind the current quota, even if a recent
218        // cached report still shows them at 100%.
219        let windowed: Vec<(&Value, f64, i64, f64)> = metrics
220            .iter()
221            .filter(shared)
222            .filter_map(|m| {
223                Some((
224                    m,
225                    m["window_secs"].as_f64().filter(|w| *w >= DAY_SECONDS)?,
226                    timestamp(&m["reset_at"]).filter(|reset| *reset > now_ts)?,
227                    m["percent"].as_f64()?,
228                ))
229            })
230            .collect();
231        let weekly = |w: f64| (w - WEEK_SECONDS).abs() <= DAY_SECONDS;
232        let pool: Vec<_> = if windowed.iter().any(|c| weekly(c.1)) {
233            windowed.into_iter().filter(|c| weekly(c.1)).collect()
234        } else {
235            let longest = windowed.iter().map(|c| c.1).fold(0.0, f64::max);
236            windowed.into_iter().filter(|c| c.1 == longest).collect()
237        };
238        // `max_by` keeps the last of equals; reversing keeps the first listed.
239        let Some(&(m, window, reset, used)) = pool
240            .iter()
241            .rev()
242            .max_by(|a, b| a.3.total_cmp(&b.3).then(b.2.cmp(&a.2)))
243        else {
244            ranking.push(row);
245            continue;
246        };
247        row["window"] = m["label"].clone();
248        row["reset_at"] = m["reset_at"].clone();
249        if used >= 100.0 {
250            row["status"] = json!("blocked");
251            row["reason_code"] = json!("blocked");
252            row["reason"] = json!("The weekly usage window is exhausted");
253            ranking.push(row);
254            continue;
255        }
256        let remaining = (reset - now_ts) as f64;
257        if used < 0.0 {
258            ranking.push(row);
259            continue;
260        }
261        let remaining_h = remaining / 3600.0;
262        // Usage follows a daily rhythm: the first hours of a cycle are averaged
263        // over a whole day instead of being extrapolated around the clock.
264        let elapsed = (window - remaining).max(0.0);
265        let cycle = used / (elapsed.max(DAY_SECONDS) / 3600.0);
266        let recent = recent_rate(samples, e, m, now_ts, recent_hours);
267        let weight = recent
268            .map(|(_, h)| (h / f64::from(recent_hours) * 0.75).min(0.75))
269            .unwrap_or(0.0);
270        let blended = recent
271            .map(|(r, _)| r * weight + cycle * (1.0 - weight))
272            .unwrap_or(cycle);
273        // The recent pace speaks for the next day; the cycle average for the rest.
274        let near = remaining_h.min(24.0);
275        let consumed = blended * near + cycle * (remaining_h - near);
276        let left = 100.0 - used;
277        let unused = (left - consumed).clamp(0.0, left);
278        let score = unused / remaining_h;
279        let urgent = remaining <= horizon
280            && unused >= f64::from(alert.unused_percent).max(MIN_UNUSED_PERCENT);
281        let priority = unused >= MIN_UNUSED_PERCENT;
282        row = json!({"entry_id":e["id"],"name":e["display_name"],"window":m["label"],"reset_at":m["reset_at"],
283            "unused_percent":unused.round(),"remaining_percent":left.round(),"required_percent_per_hour":left/remaining_h,
284            "pace_percent_per_hour":consumed/remaining_h,"cycle_percent_per_hour":cycle,"recent_percent_per_hour":recent.map(|r|r.0),
285            "recent_hours":recent.map(|r|r.1),
286            "session_exhausted":session_exhausted, "stale":e["stale"] == true, "fetched_at":e["fetched_at"],
287            "urgent":urgent, "alert_unused_percent":alert.unused_percent, "alert_hours":alert.hours,
288            "reason_code":if priority{"priority"}else{"on_track"},"score":score,"status":if priority{"priority"}else{"on_track"},
289            "reason":if priority{"Quota is projected to reset with unused capacity"}else{"The estimated pace already uses the quota before reset"}});
290        ranking.push(row);
291    }
292    ranking.sort_by(|a, b| {
293        let tier = |v: &Value| match v["status"].as_str().unwrap_or("") {
294            "priority" if v["urgent"] == true => 0,
295            "priority" => 1,
296            "on_track" => 2,
297            "unknown" => 3,
298            "blocked" => 4,
299            _ => 5,
300        };
301        tier(a)
302            .cmp(&tier(b))
303            .then_with(|| {
304                b["score"]
305                    .as_f64()
306                    .unwrap_or(-1.0)
307                    .total_cmp(&a["score"].as_f64().unwrap_or(-1.0))
308            })
309            .then_with(|| a["entry_id"].as_str().cmp(&b["entry_id"].as_str()))
310    });
311    // The score only orders the rows; it is not part of the document.
312    for row in &mut ranking {
313        if let Some(fields) = row.as_object_mut() {
314            fields.remove("score");
315        }
316    }
317    ranking
318}
319
320/// Where `usage --json --priorities` keeps its history of recent readings.
321pub fn default_history_dir() -> Option<PathBuf> {
322    crate::cache::xdg_cache_dir()
323        .ok()
324        .map(|dir| dir.join("ai-usagebar/priority"))
325}
326
327/// Adds the ranking to an in-memory `usage --json` document. Readings are
328/// recorded in `history_dir`; with `None` (or an unwritable directory) the
329/// ranking falls back to each window's cycle average.
330pub fn with_priorities(
331    mut document: Value,
332    options: Options,
333    history_dir: Option<&Path>,
334    now: DateTime<Utc>,
335) -> Value {
336    let recent_hours = options.recent_hours.clamp(1, MAX_RECENT_HOURS);
337    let alert = options.alert.clamped();
338    let (ranking, history_available) = {
339        let entries = document["entries"]
340            .as_array()
341            .map(Vec::as_slice)
342            .unwrap_or(&[]);
343        let history =
344            history_dir.and_then(|dir| update_history(dir, entries, now.timestamp()).ok());
345        let ranking = rank_with_recent_hours(
346            entries,
347            history.as_deref().unwrap_or(&[]),
348            now,
349            recent_hours,
350            alert,
351        );
352        (ranking, history.is_some())
353    };
354    document["recommendation"] = ranking
355        .iter()
356        .find(|r| r["status"] == "priority")
357        .cloned()
358        .unwrap_or(Value::Null);
359    document["ranking"] = json!(ranking);
360    document["priority_history_available"] = json!(history_available);
361    document["priority_recent_hours"] = json!(recent_hours);
362    document["priority_alert"] =
363        json!({"unused_percent": alert.unused_percent, "hours": alert.hours});
364    document
365}
366
367/// `with_priorities` off the async report's runtime: the history write takes
368/// an inter-process lock and touches disk.
369pub async fn attach_priorities(
370    document: Value,
371    options: Options,
372    history_dir: Option<PathBuf>,
373    now: DateTime<Utc>,
374) -> Value {
375    let fallback = document.clone();
376    tokio::task::spawn_blocking(move || {
377        with_priorities(document, options, history_dir.as_deref(), now)
378    })
379    .await
380    .unwrap_or(fallback)
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    const NOW: i64 = 1800000000;
387    fn now() -> DateTime<Utc> {
388        DateTime::from_timestamp(NOW, 0).unwrap()
389    }
390    fn entry(id: &str, used: u16) -> Value {
391        json!({"id":id,"display_name":id,"status":"ready","stale":false,"fetched_at":now().to_rfc3339(),"metrics":[{"label":"Weekly","headline":"percent","percent":used,"window_secs":604800,"reset_at":DateTime::from_timestamp(NOW+86400,0).unwrap().to_rfc3339()}]})
392    }
393    fn sample(at: i64, p: f64) -> Sample {
394        Sample {
395            entry: "a".into(),
396            label: "Weekly".into(),
397            at,
398            reset: NOW + 86400,
399            percent: p,
400        }
401    }
402    #[test]
403    fn recent_acceleration_changes_order() {
404        let a = entry("a", 20);
405        let b = entry("b", 30);
406        assert_eq!(
407            rank(&[a.clone(), b.clone()], &[], now())[0]["entry_id"],
408            "a"
409        );
410        let samples: Vec<_> = (0..=6)
411            .map(|i| sample(NOW - 10800 + i * 1800, i as f64 * 20.0 / 6.0))
412            .collect();
413        let rows = rank(&[a, b], &samples, now());
414        assert_eq!(rows[0]["entry_id"], "b");
415        assert!(rows[1]["recent_percent_per_hour"].is_number());
416    }
417    #[test]
418    fn reset_and_gap_do_not_create_a_recent_rate() {
419        let e = entry("a", 20);
420        let m = &e["metrics"][0];
421        assert!(
422            recent_rate(
423                &[sample(NOW - 3600, 30.0), sample(NOW, 20.0)],
424                &e,
425                m,
426                NOW,
427                DEFAULT_RECENT_HOURS
428            )
429            .is_none()
430        );
431        assert!(
432            recent_rate(
433                &[sample(NOW - 10800, 0.0), sample(NOW, 20.0)],
434                &e,
435                m,
436                NOW,
437                DEFAULT_RECENT_HOURS
438            )
439            .is_none()
440        );
441        let mut wrong = sample(NOW - 3600, 0.0);
442        wrong.reset -= 604800;
443        assert!(
444            recent_rate(
445                &[wrong, sample(NOW, 20.0)],
446                &e,
447                m,
448                NOW,
449                DEFAULT_RECENT_HOURS
450            )
451            .is_none()
452        );
453    }
454    #[test]
455    fn all_entries_remain_visible() {
456        let mut stale = entry("stale", 20);
457        stale["stale"] = json!(true);
458        stale["fetched_at"] = json!(
459            DateTime::from_timestamp(NOW - MAX_STALE_SECONDS - 1, 0)
460                .unwrap()
461                .to_rfc3339()
462        );
463        let mut unknown = entry("unknown", 20);
464        unknown["metrics"][0]["window_secs"] = Value::Null;
465        let r = rank(
466            &[
467                entry("a", 20),
468                entry("blocked", 100),
469                stale,
470                unknown,
471                entry("ontrack", 99),
472            ],
473            &[],
474            now(),
475        );
476        assert_eq!(r.len(), 5);
477        assert_eq!(r[0]["status"], "priority");
478        assert_eq!(r[1]["status"], "on_track");
479        assert_eq!(r[4]["status"], "unavailable");
480    }
481    #[test]
482    fn history_deduplicates_fetches_and_survives_restart() {
483        let dir = tempfile::tempdir().unwrap();
484        let e = entry("a", 20);
485        assert_eq!(
486            update_history(dir.path(), std::slice::from_ref(&e), NOW)
487                .unwrap()
488                .len(),
489            1
490        );
491        assert_eq!(
492            update_history(dir.path(), &[e], NOW + 300).unwrap().len(),
493            1
494        );
495        assert!(
496            update_history(dir.path(), &[], NOW + HISTORY_SECONDS + 1)
497                .unwrap()
498                .is_empty()
499        );
500    }
501    #[test]
502    fn zero_recent_usage_is_measured_not_missing() {
503        let e = entry("a", 20);
504        let samples: Vec<_> = (0..=6)
505            .map(|i| sample(NOW - 10800 + i * 1800, 20.0))
506            .collect();
507        let r = rank(&[e], &samples, now());
508        assert_eq!(r[0]["recent_percent_per_hour"], 0.0);
509        assert_eq!(r[0]["recent_hours"], 3.0);
510    }
511    #[test]
512    fn configured_window_changes_the_rate_and_priority() {
513        // Four hours of inactivity, followed by one hour of fast consumption.
514        let samples: Vec<_> = (0..=10)
515            .map(|i| {
516                sample(
517                    NOW - 18000 + i * 1800,
518                    if i <= 8 { 0.0 } else { (i - 8) as f64 * 10.0 },
519                )
520            })
521            .collect();
522        let entries = [entry("a", 20), entry("b", 75)];
523        let short = rank_with_recent_hours(&entries, &samples, now(), 1, Alert::default());
524        let long = rank_with_recent_hours(&entries, &samples, now(), 6, Alert::default());
525        assert_eq!(short[0]["entry_id"], "b");
526        assert_eq!(long[0]["entry_id"], "a");
527        assert_eq!(short[1]["recent_percent_per_hour"], 20.0);
528        assert_eq!(long[0]["recent_percent_per_hour"], 4.0);
529        assert!(long[0].get("recent_window_hours").is_none());
530    }
531    #[test]
532    fn urgency_requires_a_real_unused_quota_before_a_near_reset() {
533        let mut tomorrow = entry("tomorrow", 20);
534        tomorrow["metrics"][0]["reset_at"] = json!(
535            DateTime::from_timestamp(NOW + 86401, 0)
536                .unwrap()
537                .to_rfc3339()
538        );
539        let rows = rank(
540            &[
541                entry("urgent", 20),
542                entry("ontrack", 99),
543                entry("blocked", 100),
544                tomorrow,
545            ],
546            &[],
547            now(),
548        );
549        assert_eq!(rows[0]["entry_id"], "urgent");
550        assert!(
551            rows.iter()
552                .filter(|r| r["entry_id"] != "urgent")
553                .all(|r| r["urgent"] == false)
554        );
555    }
556    #[test]
557    fn short_windows_are_never_urgent_and_do_not_mask_a_weekly_reset() {
558        let reset = |secs: i64| {
559            DateTime::from_timestamp(NOW + secs, 0)
560                .unwrap()
561                .to_rfc3339()
562        };
563        let mut session = entry("session", 10);
564        session["metrics"][0]["window_secs"] = json!(18000);
565        session["metrics"][0]["reset_at"] = json!(reset(3600));
566        assert_eq!(rank(&[session], &[], now())[0]["urgent"], false);
567
568        let mut both = entry("both", 30);
569        both["metrics"]
570            .as_array_mut()
571            .unwrap()
572            .insert(0, json!({"label":"Session (5h)","headline":"percent","percent":5,"window_secs":18000,"reset_at":reset(3600)}));
573        let row = &rank(&[both], &[], now())[0];
574        assert_eq!(row["urgent"], true);
575        assert_eq!(row["window"], "Weekly");
576    }
577    fn metric(label: &str, percent: f64, window: Option<i64>, reset_in: i64) -> Value {
578        json!({"label":label,"headline":"percent","percent":percent,"window_secs":window,
579            "reset_at":DateTime::from_timestamp(NOW+reset_in,0).unwrap().to_rfc3339()})
580    }
581    fn provider(id: &str, metrics: Vec<Value>) -> Value {
582        json!({"id":id,"display_name":id,"status":"ready","stale":false,"fetched_at":now().to_rfc3339(),"metrics":metrics})
583    }
584    #[test]
585    fn the_binding_weekly_window_is_the_measure() {
586        // Claude: session, shared week and a lighter per-model week.
587        let claude = provider(
588            "anthropic",
589            vec![
590                metric("Session (5h)", 8.0, Some(18000), 3600),
591                metric("Weekly (7d)", 31.0, Some(604800), 23 * 3600),
592                metric("Fable (7d)", 4.0, Some(604800), 23 * 3600),
593            ],
594        );
595        let row = &rank(&[claude], &[], now())[0];
596        assert_eq!(row["window"], "Weekly (7d)");
597        assert_eq!(row["remaining_percent"], 69.0);
598        let unused = row["unused_percent"].as_f64().unwrap();
599        assert!(
600            unused > 0.0 && unused <= 69.0,
601            "never more than what is left: {unused}"
602        );
603    }
604    #[test]
605    fn expired_windows_do_not_mask_a_current_weekly_quota() {
606        let mut cached = provider(
607            "anthropic",
608            vec![
609                metric("Session (5h)", 100.0, Some(18000), -60),
610                metric("Weekly (7d)", 31.0, Some(604800), 23 * 3600),
611                metric("Fable (7d)", 100.0, Some(604800), -60),
612            ],
613        );
614        cached["stale"] = json!(true);
615        cached["fetched_at"] = json!(DateTime::from_timestamp(NOW - 120, 0).unwrap().to_rfc3339());
616        let document = with_priorities(
617            json!({"entries": [cached]}),
618            Options {
619                recent_hours: DEFAULT_RECENT_HOURS,
620                alert: Alert::default(),
621            },
622            None,
623            now(),
624        );
625        let row = &document["ranking"][0];
626        assert_eq!(row["status"], "priority");
627        assert_eq!(row["window"], "Weekly (7d)");
628        assert_eq!(row["session_exhausted"], false);
629        assert_eq!(document["recommendation"]["entry_id"], "anthropic");
630    }
631
632    #[test]
633    fn exhaustion_at_the_reset_instant_is_no_forecast() {
634        let spent = provider("a", vec![metric("Weekly", 100.0, Some(604800), 0)]);
635        let row = &rank(&[spent], &[], now())[0];
636        assert_eq!(row["status"], "unknown");
637        assert_eq!(row["urgent"], false);
638    }
639    #[test]
640    fn early_cycle_usage_is_not_extrapolated_around_the_clock() {
641        // 2% in the first two hours of a week is not 1%/h for seven days.
642        let codex = provider(
643            "openai",
644            vec![metric("Codex weekly", 2.0, Some(604800), 604800 - 7200)],
645        );
646        let row = &rank(&[codex], &[], now())[0];
647        assert_eq!(row["status"], "priority");
648        assert!(row["unused_percent"].as_f64().unwrap() > 80.0);
649    }
650    #[test]
651    fn a_fresh_untouched_week_is_a_priority_not_unknown() {
652        // Antigravity repeats pool labels per window and starts a week at 0%.
653        let agy = provider(
654            "antigravity",
655            vec![
656                metric("Gemini", 0.0, Some(18000), 18000),
657                metric("Gemini", 0.0, Some(604800), 4 * 86400),
658            ],
659        );
660        let row = &rank(std::slice::from_ref(&agy), &[], now())[0];
661        assert_eq!(row["status"], "priority");
662        assert_eq!(row["window"], "Gemini", "an even tie keeps the first pool");
663        assert_eq!(row["unused_percent"], 100.0);
664        let dir = tempfile::tempdir().unwrap();
665        assert_eq!(update_history(dir.path(), &[agy], NOW).unwrap().len(), 2);
666    }
667    #[test]
668    fn a_recent_cached_reading_is_still_ranked() {
669        let mut cached = entry("cached", 20);
670        cached["stale"] = json!(true);
671        let row = &rank(std::slice::from_ref(&cached), &[], now())[0];
672        assert_eq!(row["status"], "priority");
673        assert_eq!(row["stale"], true);
674        cached["fetched_at"] = json!(
675            DateTime::from_timestamp(NOW - MAX_STALE_SECONDS - 1, 0)
676                .unwrap()
677                .to_rfc3339()
678        );
679        assert_eq!(rank(&[cached], &[], now())[0]["status"], "unavailable");
680    }
681    #[test]
682    fn session_limits_inform_but_do_not_block_the_week() {
683        let p = provider(
684            "a",
685            vec![
686                metric("Session (5h)", 100.0, Some(18000), 3600),
687                metric("Weekly", 40.0, Some(604800), 3 * 86400),
688            ],
689        );
690        let row = &rank(&[p], &[], now())[0];
691        assert_eq!(row["status"], "priority");
692        assert_eq!(row["session_exhausted"], true);
693        let spent = provider("b", vec![metric("Weekly", 100.0, Some(604800), 86400)]);
694        assert_eq!(rank(&[spent], &[], now())[0]["status"], "blocked");
695    }
696    #[test]
697    fn without_a_week_the_longest_known_window_is_used() {
698        let p = provider(
699            "a",
700            vec![
701                metric("Rolling (5h)", 10.0, Some(18000), 3600),
702                metric("Monthly", 20.0, Some(30 * 86400), 20 * 86400),
703                metric("Unknown", 50.0, None, 86400),
704            ],
705        );
706        assert_eq!(rank(&[p], &[], now())[0]["window"], "Monthly");
707        let session_only = provider("s", vec![metric("Session", 10.0, Some(18000), 3600)]);
708        assert_eq!(rank(&[session_only], &[], now())[0]["status"], "unknown");
709    }
710    #[test]
711    fn alert_thresholds_are_configurable() {
712        // 60% used, resets in 24h: ~37% projected unused at the cycle pace.
713        let rows = |unused_percent, hours| {
714            rank_with_recent_hours(
715                &[entry("a", 60)],
716                &[],
717                now(),
718                DEFAULT_RECENT_HOURS,
719                Alert {
720                    unused_percent,
721                    hours,
722                },
723            )[0]["urgent"]
724                .clone()
725        };
726        assert_eq!(rows(50, 24), false);
727        assert_eq!(rows(30, 24), true);
728        assert_eq!(rows(30, 12), false, "reset is outside a 12h horizon");
729        // The weekly window is the measure, so a full-week horizon still alerts.
730        assert_eq!(rows(30, 168), true);
731    }
732    #[test]
733    fn history_retains_a_full_day_without_counting_old_fetches_again() {
734        let dir = tempfile::tempdir().unwrap();
735        let mut old = entry("a", 20);
736        old["fetched_at"] = json!(
737            DateTime::from_timestamp(NOW - 86400, 0)
738                .unwrap()
739                .to_rfc3339()
740        );
741        update_history(dir.path(), &[old], NOW - 86400).unwrap();
742        assert_eq!(update_history(dir.path(), &[], NOW).unwrap().len(), 1);
743        assert!(
744            update_history(dir.path(), &[], NOW + 3601)
745                .unwrap()
746                .is_empty()
747        );
748    }
749}