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 mut ranking = Vec::new();
187    for e in entries {
188        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"});
189        let fetched = timestamp(&e["fetched_at"]);
190        let too_old = fetched.is_none_or(|t| now.timestamp() - t > MAX_STALE_SECONDS);
191        if e["status"] != "ready" || (e["stale"] == true && too_old) {
192            row["status"] = json!("unavailable");
193            row["reason_code"] = json!("unavailable");
194            row["reason"] = json!("Usage data is unavailable or outdated");
195            ranking.push(row);
196            continue;
197        }
198        let metrics = e["metrics"].as_array().map(Vec::as_slice).unwrap_or(&[]);
199        let shared = |m: &&Value| {
200            m["headline"] == "percent"
201                && m["model_pool"] != true
202                && m.get("group").is_none_or(Value::is_null)
203        };
204        let session_exhausted = metrics.iter().filter(shared).any(|m| {
205            m["window_secs"]
206                .as_f64()
207                .is_some_and(|w| w < WEEK_SECONDS - DAY_SECONDS)
208                && m["percent"].as_f64().unwrap_or(0.0) >= 100.0
209        });
210        // Quota is lost at the weekly reset, so the weekly window is the
211        // measure; a session/rolling window refills within hours. With several
212        // weekly windows (Claude's shared week and a per-model week) the most
213        // used one binds what can still be spent. Providers without a weekly
214        // window fall back to their longest known window of at least a day.
215        let windowed: Vec<(&Value, f64, i64, f64)> = metrics
216            .iter()
217            .filter(shared)
218            .filter_map(|m| {
219                Some((
220                    m,
221                    m["window_secs"].as_f64().filter(|w| *w >= DAY_SECONDS)?,
222                    timestamp(&m["reset_at"])?,
223                    m["percent"].as_f64()?,
224                ))
225            })
226            .collect();
227        let weekly = |w: f64| (w - WEEK_SECONDS).abs() <= DAY_SECONDS;
228        let pool: Vec<_> = if windowed.iter().any(|c| weekly(c.1)) {
229            windowed.into_iter().filter(|c| weekly(c.1)).collect()
230        } else {
231            let longest = windowed.iter().map(|c| c.1).fold(0.0, f64::max);
232            windowed.into_iter().filter(|c| c.1 == longest).collect()
233        };
234        // `max_by` keeps the last of equals; reversing keeps the first listed.
235        let Some(&(m, window, reset, used)) = pool
236            .iter()
237            .rev()
238            .max_by(|a, b| a.3.total_cmp(&b.3).then(b.2.cmp(&a.2)))
239        else {
240            ranking.push(row);
241            continue;
242        };
243        row["window"] = m["label"].clone();
244        row["reset_at"] = m["reset_at"].clone();
245        if used >= 100.0 {
246            row["status"] = json!("blocked");
247            row["reason_code"] = json!("blocked");
248            row["reason"] = json!("The weekly usage window is exhausted");
249            ranking.push(row);
250            continue;
251        }
252        let remaining = (reset - now.timestamp()) as f64;
253        if remaining <= 0.0 || window <= 0.0 || used < 0.0 {
254            ranking.push(row);
255            continue;
256        }
257        let remaining_h = remaining / 3600.0;
258        // Usage follows a daily rhythm: the first hours of a cycle are averaged
259        // over a whole day instead of being extrapolated around the clock.
260        let elapsed = (window - remaining).max(0.0);
261        let cycle = used / (elapsed.max(DAY_SECONDS) / 3600.0);
262        let recent = recent_rate(samples, e, m, now.timestamp(), recent_hours);
263        let weight = recent
264            .map(|(_, h)| (h / f64::from(recent_hours) * 0.75).min(0.75))
265            .unwrap_or(0.0);
266        let blended = recent
267            .map(|(r, _)| r * weight + cycle * (1.0 - weight))
268            .unwrap_or(cycle);
269        // The recent pace speaks for the next day; the cycle average for the rest.
270        let near = remaining_h.min(24.0);
271        let consumed = blended * near + cycle * (remaining_h - near);
272        let left = 100.0 - used;
273        let unused = (left - consumed).clamp(0.0, left);
274        let score = unused / remaining_h;
275        let urgent = remaining <= horizon
276            && unused >= f64::from(alert.unused_percent).max(MIN_UNUSED_PERCENT);
277        let priority = unused >= MIN_UNUSED_PERCENT;
278        row = json!({"entry_id":e["id"],"name":e["display_name"],"window":m["label"],"reset_at":m["reset_at"],
279            "unused_percent":unused.round(),"remaining_percent":left.round(),"required_percent_per_hour":left/remaining_h,
280            "pace_percent_per_hour":consumed/remaining_h,"cycle_percent_per_hour":cycle,"recent_percent_per_hour":recent.map(|r|r.0),
281            "recent_hours":recent.map(|r|r.1),
282            "session_exhausted":session_exhausted, "stale":e["stale"] == true, "fetched_at":e["fetched_at"],
283            "urgent":urgent, "alert_unused_percent":alert.unused_percent, "alert_hours":alert.hours,
284            "reason_code":if priority{"priority"}else{"on_track"},"score":score,"status":if priority{"priority"}else{"on_track"},
285            "reason":if priority{"Quota is projected to reset with unused capacity"}else{"The estimated pace already uses the quota before reset"}});
286        ranking.push(row);
287    }
288    ranking.sort_by(|a, b| {
289        let tier = |v: &Value| match v["status"].as_str().unwrap_or("") {
290            "priority" if v["urgent"] == true => 0,
291            "priority" => 1,
292            "on_track" => 2,
293            "unknown" => 3,
294            "blocked" => 4,
295            _ => 5,
296        };
297        tier(a)
298            .cmp(&tier(b))
299            .then_with(|| {
300                b["score"]
301                    .as_f64()
302                    .unwrap_or(-1.0)
303                    .total_cmp(&a["score"].as_f64().unwrap_or(-1.0))
304            })
305            .then_with(|| a["entry_id"].as_str().cmp(&b["entry_id"].as_str()))
306    });
307    // The score only orders the rows; it is not part of the document.
308    for row in &mut ranking {
309        if let Some(fields) = row.as_object_mut() {
310            fields.remove("score");
311        }
312    }
313    ranking
314}
315
316/// Where `usage --json --priorities` keeps its history of recent readings.
317pub fn default_history_dir() -> Option<PathBuf> {
318    crate::cache::xdg_cache_dir()
319        .ok()
320        .map(|dir| dir.join("ai-usagebar/priority"))
321}
322
323/// Adds the ranking to an in-memory `usage --json` document. Readings are
324/// recorded in `history_dir`; with `None` (or an unwritable directory) the
325/// ranking falls back to each window's cycle average.
326pub fn with_priorities(
327    mut document: Value,
328    options: Options,
329    history_dir: Option<&Path>,
330    now: DateTime<Utc>,
331) -> Value {
332    let recent_hours = options.recent_hours.clamp(1, MAX_RECENT_HOURS);
333    let alert = options.alert.clamped();
334    let (ranking, history_available) = {
335        let entries = document["entries"]
336            .as_array()
337            .map(Vec::as_slice)
338            .unwrap_or(&[]);
339        let history =
340            history_dir.and_then(|dir| update_history(dir, entries, now.timestamp()).ok());
341        let ranking = rank_with_recent_hours(
342            entries,
343            history.as_deref().unwrap_or(&[]),
344            now,
345            recent_hours,
346            alert,
347        );
348        (ranking, history.is_some())
349    };
350    document["recommendation"] = ranking
351        .iter()
352        .find(|r| r["status"] == "priority")
353        .cloned()
354        .unwrap_or(Value::Null);
355    document["ranking"] = json!(ranking);
356    document["priority_history_available"] = json!(history_available);
357    document["priority_recent_hours"] = json!(recent_hours);
358    document["priority_alert"] =
359        json!({"unused_percent": alert.unused_percent, "hours": alert.hours});
360    document
361}
362
363/// `with_priorities` off the async report's runtime: the history write takes
364/// an inter-process lock and touches disk.
365pub async fn attach_priorities(
366    document: Value,
367    options: Options,
368    history_dir: Option<PathBuf>,
369    now: DateTime<Utc>,
370) -> Value {
371    let fallback = document.clone();
372    tokio::task::spawn_blocking(move || {
373        with_priorities(document, options, history_dir.as_deref(), now)
374    })
375    .await
376    .unwrap_or(fallback)
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    const NOW: i64 = 1800000000;
383    fn now() -> DateTime<Utc> {
384        DateTime::from_timestamp(NOW, 0).unwrap()
385    }
386    fn entry(id: &str, used: u16) -> Value {
387        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()}]})
388    }
389    fn sample(at: i64, p: f64) -> Sample {
390        Sample {
391            entry: "a".into(),
392            label: "Weekly".into(),
393            at,
394            reset: NOW + 86400,
395            percent: p,
396        }
397    }
398    #[test]
399    fn recent_acceleration_changes_order() {
400        let a = entry("a", 20);
401        let b = entry("b", 30);
402        assert_eq!(
403            rank(&[a.clone(), b.clone()], &[], now())[0]["entry_id"],
404            "a"
405        );
406        let samples: Vec<_> = (0..=6)
407            .map(|i| sample(NOW - 10800 + i * 1800, i as f64 * 20.0 / 6.0))
408            .collect();
409        let rows = rank(&[a, b], &samples, now());
410        assert_eq!(rows[0]["entry_id"], "b");
411        assert!(rows[1]["recent_percent_per_hour"].is_number());
412    }
413    #[test]
414    fn reset_and_gap_do_not_create_a_recent_rate() {
415        let e = entry("a", 20);
416        let m = &e["metrics"][0];
417        assert!(
418            recent_rate(
419                &[sample(NOW - 3600, 30.0), sample(NOW, 20.0)],
420                &e,
421                m,
422                NOW,
423                DEFAULT_RECENT_HOURS
424            )
425            .is_none()
426        );
427        assert!(
428            recent_rate(
429                &[sample(NOW - 10800, 0.0), sample(NOW, 20.0)],
430                &e,
431                m,
432                NOW,
433                DEFAULT_RECENT_HOURS
434            )
435            .is_none()
436        );
437        let mut wrong = sample(NOW - 3600, 0.0);
438        wrong.reset -= 604800;
439        assert!(
440            recent_rate(
441                &[wrong, sample(NOW, 20.0)],
442                &e,
443                m,
444                NOW,
445                DEFAULT_RECENT_HOURS
446            )
447            .is_none()
448        );
449    }
450    #[test]
451    fn all_entries_remain_visible() {
452        let mut stale = entry("stale", 20);
453        stale["stale"] = json!(true);
454        stale["fetched_at"] = json!(
455            DateTime::from_timestamp(NOW - MAX_STALE_SECONDS - 1, 0)
456                .unwrap()
457                .to_rfc3339()
458        );
459        let mut unknown = entry("unknown", 20);
460        unknown["metrics"][0]["window_secs"] = Value::Null;
461        let r = rank(
462            &[
463                entry("a", 20),
464                entry("blocked", 100),
465                stale,
466                unknown,
467                entry("ontrack", 99),
468            ],
469            &[],
470            now(),
471        );
472        assert_eq!(r.len(), 5);
473        assert_eq!(r[0]["status"], "priority");
474        assert_eq!(r[1]["status"], "on_track");
475        assert_eq!(r[4]["status"], "unavailable");
476    }
477    #[test]
478    fn history_deduplicates_fetches_and_survives_restart() {
479        let dir = tempfile::tempdir().unwrap();
480        let e = entry("a", 20);
481        assert_eq!(
482            update_history(dir.path(), std::slice::from_ref(&e), NOW)
483                .unwrap()
484                .len(),
485            1
486        );
487        assert_eq!(
488            update_history(dir.path(), &[e], NOW + 300).unwrap().len(),
489            1
490        );
491        assert!(
492            update_history(dir.path(), &[], NOW + HISTORY_SECONDS + 1)
493                .unwrap()
494                .is_empty()
495        );
496    }
497    #[test]
498    fn zero_recent_usage_is_measured_not_missing() {
499        let e = entry("a", 20);
500        let samples: Vec<_> = (0..=6)
501            .map(|i| sample(NOW - 10800 + i * 1800, 20.0))
502            .collect();
503        let r = rank(&[e], &samples, now());
504        assert_eq!(r[0]["recent_percent_per_hour"], 0.0);
505        assert_eq!(r[0]["recent_hours"], 3.0);
506    }
507    #[test]
508    fn configured_window_changes_the_rate_and_priority() {
509        // Four hours of inactivity, followed by one hour of fast consumption.
510        let samples: Vec<_> = (0..=10)
511            .map(|i| {
512                sample(
513                    NOW - 18000 + i * 1800,
514                    if i <= 8 { 0.0 } else { (i - 8) as f64 * 10.0 },
515                )
516            })
517            .collect();
518        let entries = [entry("a", 20), entry("b", 75)];
519        let short = rank_with_recent_hours(&entries, &samples, now(), 1, Alert::default());
520        let long = rank_with_recent_hours(&entries, &samples, now(), 6, Alert::default());
521        assert_eq!(short[0]["entry_id"], "b");
522        assert_eq!(long[0]["entry_id"], "a");
523        assert_eq!(short[1]["recent_percent_per_hour"], 20.0);
524        assert_eq!(long[0]["recent_percent_per_hour"], 4.0);
525        assert!(long[0].get("recent_window_hours").is_none());
526    }
527    #[test]
528    fn urgency_requires_a_real_unused_quota_before_a_near_reset() {
529        let mut tomorrow = entry("tomorrow", 20);
530        tomorrow["metrics"][0]["reset_at"] = json!(
531            DateTime::from_timestamp(NOW + 86401, 0)
532                .unwrap()
533                .to_rfc3339()
534        );
535        let rows = rank(
536            &[
537                entry("urgent", 20),
538                entry("ontrack", 99),
539                entry("blocked", 100),
540                tomorrow,
541            ],
542            &[],
543            now(),
544        );
545        assert_eq!(rows[0]["entry_id"], "urgent");
546        assert!(
547            rows.iter()
548                .filter(|r| r["entry_id"] != "urgent")
549                .all(|r| r["urgent"] == false)
550        );
551    }
552    #[test]
553    fn short_windows_are_never_urgent_and_do_not_mask_a_weekly_reset() {
554        let reset = |secs: i64| {
555            DateTime::from_timestamp(NOW + secs, 0)
556                .unwrap()
557                .to_rfc3339()
558        };
559        let mut session = entry("session", 10);
560        session["metrics"][0]["window_secs"] = json!(18000);
561        session["metrics"][0]["reset_at"] = json!(reset(3600));
562        assert_eq!(rank(&[session], &[], now())[0]["urgent"], false);
563
564        let mut both = entry("both", 30);
565        both["metrics"]
566            .as_array_mut()
567            .unwrap()
568            .insert(0, json!({"label":"Session (5h)","headline":"percent","percent":5,"window_secs":18000,"reset_at":reset(3600)}));
569        let row = &rank(&[both], &[], now())[0];
570        assert_eq!(row["urgent"], true);
571        assert_eq!(row["window"], "Weekly");
572    }
573    fn metric(label: &str, percent: f64, window: Option<i64>, reset_in: i64) -> Value {
574        json!({"label":label,"headline":"percent","percent":percent,"window_secs":window,
575            "reset_at":DateTime::from_timestamp(NOW+reset_in,0).unwrap().to_rfc3339()})
576    }
577    fn provider(id: &str, metrics: Vec<Value>) -> Value {
578        json!({"id":id,"display_name":id,"status":"ready","stale":false,"fetched_at":now().to_rfc3339(),"metrics":metrics})
579    }
580    #[test]
581    fn the_binding_weekly_window_is_the_measure() {
582        // Claude: session, shared week and a lighter per-model week.
583        let claude = provider(
584            "anthropic",
585            vec![
586                metric("Session (5h)", 8.0, Some(18000), 3600),
587                metric("Weekly (7d)", 31.0, Some(604800), 23 * 3600),
588                metric("Fable (7d)", 4.0, Some(604800), 23 * 3600),
589            ],
590        );
591        let row = &rank(&[claude], &[], now())[0];
592        assert_eq!(row["window"], "Weekly (7d)");
593        assert_eq!(row["remaining_percent"], 69.0);
594        let unused = row["unused_percent"].as_f64().unwrap();
595        assert!(
596            unused > 0.0 && unused <= 69.0,
597            "never more than what is left: {unused}"
598        );
599    }
600    #[test]
601    fn early_cycle_usage_is_not_extrapolated_around_the_clock() {
602        // 2% in the first two hours of a week is not 1%/h for seven days.
603        let codex = provider(
604            "openai",
605            vec![metric("Codex weekly", 2.0, Some(604800), 604800 - 7200)],
606        );
607        let row = &rank(&[codex], &[], now())[0];
608        assert_eq!(row["status"], "priority");
609        assert!(row["unused_percent"].as_f64().unwrap() > 80.0);
610    }
611    #[test]
612    fn a_fresh_untouched_week_is_a_priority_not_unknown() {
613        // Antigravity repeats pool labels per window and starts a week at 0%.
614        let agy = provider(
615            "antigravity",
616            vec![
617                metric("Gemini", 0.0, Some(18000), 18000),
618                metric("Gemini", 0.0, Some(604800), 4 * 86400),
619            ],
620        );
621        let row = &rank(std::slice::from_ref(&agy), &[], now())[0];
622        assert_eq!(row["status"], "priority");
623        assert_eq!(row["window"], "Gemini", "an even tie keeps the first pool");
624        assert_eq!(row["unused_percent"], 100.0);
625        let dir = tempfile::tempdir().unwrap();
626        assert_eq!(update_history(dir.path(), &[agy], NOW).unwrap().len(), 2);
627    }
628    #[test]
629    fn a_recent_cached_reading_is_still_ranked() {
630        let mut cached = entry("cached", 20);
631        cached["stale"] = json!(true);
632        let row = &rank(std::slice::from_ref(&cached), &[], now())[0];
633        assert_eq!(row["status"], "priority");
634        assert_eq!(row["stale"], true);
635        cached["fetched_at"] = json!(
636            DateTime::from_timestamp(NOW - MAX_STALE_SECONDS - 1, 0)
637                .unwrap()
638                .to_rfc3339()
639        );
640        assert_eq!(rank(&[cached], &[], now())[0]["status"], "unavailable");
641    }
642    #[test]
643    fn session_limits_inform_but_do_not_block_the_week() {
644        let p = provider(
645            "a",
646            vec![
647                metric("Session (5h)", 100.0, Some(18000), 3600),
648                metric("Weekly", 40.0, Some(604800), 3 * 86400),
649            ],
650        );
651        let row = &rank(&[p], &[], now())[0];
652        assert_eq!(row["status"], "priority");
653        assert_eq!(row["session_exhausted"], true);
654        let spent = provider("b", vec![metric("Weekly", 100.0, Some(604800), 86400)]);
655        assert_eq!(rank(&[spent], &[], now())[0]["status"], "blocked");
656    }
657    #[test]
658    fn without_a_week_the_longest_known_window_is_used() {
659        let p = provider(
660            "a",
661            vec![
662                metric("Rolling (5h)", 10.0, Some(18000), 3600),
663                metric("Monthly", 20.0, Some(30 * 86400), 20 * 86400),
664                metric("Unknown", 50.0, None, 86400),
665            ],
666        );
667        assert_eq!(rank(&[p], &[], now())[0]["window"], "Monthly");
668        let session_only = provider("s", vec![metric("Session", 10.0, Some(18000), 3600)]);
669        assert_eq!(rank(&[session_only], &[], now())[0]["status"], "unknown");
670    }
671    #[test]
672    fn alert_thresholds_are_configurable() {
673        // 60% used, resets in 24h: ~37% projected unused at the cycle pace.
674        let rows = |unused_percent, hours| {
675            rank_with_recent_hours(
676                &[entry("a", 60)],
677                &[],
678                now(),
679                DEFAULT_RECENT_HOURS,
680                Alert {
681                    unused_percent,
682                    hours,
683                },
684            )[0]["urgent"]
685                .clone()
686        };
687        assert_eq!(rows(50, 24), false);
688        assert_eq!(rows(30, 24), true);
689        assert_eq!(rows(30, 12), false, "reset is outside a 12h horizon");
690        // The weekly window is the measure, so a full-week horizon still alerts.
691        assert_eq!(rows(30, 168), true);
692    }
693    #[test]
694    fn history_retains_a_full_day_without_counting_old_fetches_again() {
695        let dir = tempfile::tempdir().unwrap();
696        let mut old = entry("a", 20);
697        old["fetched_at"] = json!(
698            DateTime::from_timestamp(NOW - 86400, 0)
699                .unwrap()
700                .to_rfc3339()
701        );
702        update_history(dir.path(), &[old], NOW - 86400).unwrap();
703        assert_eq!(update_history(dir.path(), &[], NOW).unwrap().len(), 1);
704        assert!(
705            update_history(dir.path(), &[], NOW + 3601)
706                .unwrap()
707                .is_empty()
708        );
709    }
710}