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 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 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 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 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 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 for row in &mut ranking {
309 if let Some(fields) = row.as_object_mut() {
310 fields.remove("score");
311 }
312 }
313 ranking
314}
315
316pub fn default_history_dir() -> Option<PathBuf> {
318 crate::cache::xdg_cache_dir()
319 .ok()
320 .map(|dir| dir.join("ai-usagebar/priority"))
321}
322
323pub 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
363pub 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 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 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 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 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 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 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}