1use 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;
16const 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#[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#[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
72pub 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 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 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 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 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 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 for row in &mut ranking {
313 if let Some(fields) = row.as_object_mut() {
314 fields.remove("score");
315 }
316 }
317 ranking
318}
319
320pub fn default_history_dir() -> Option<PathBuf> {
322 crate::cache::xdg_cache_dir()
323 .ok()
324 .map(|dir| dir.join("ai-usagebar/priority"))
325}
326
327pub 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
367pub 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 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 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 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 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 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 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}