1use std::path::Path;
31use std::sync::{Mutex, PoisonError};
32
33use anyhow::{bail, Context, Result};
34use chrono::{DateTime, Utc};
35use serde::{Deserialize, Serialize};
36use serde_json::Value;
37
38pub const WARN_PERCENT: f64 = 80.0;
44
45const RESOURCE_KEYS: &[&str] = &["graphql", "core", "search"];
50
51#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
54pub struct RateLimitResource {
55 pub used: u64,
57 pub limit: u64,
59 pub remaining: u64,
61 pub percent: f64,
64 pub reset: i64,
67}
68
69impl RateLimitResource {
70 fn from_value(v: &Value) -> Option<Self> {
74 let used = v.get("used").and_then(Value::as_u64)?;
75 let limit = v.get("limit").and_then(Value::as_u64)?;
76 let remaining = v
78 .get("remaining")
79 .and_then(Value::as_u64)
80 .unwrap_or_else(|| limit.saturating_sub(used));
81 let reset = v.get("reset").and_then(Value::as_i64).unwrap_or(0);
82 Some(Self {
83 used,
84 limit,
85 remaining,
86 percent: percent_of(used, limit),
87 reset,
88 })
89 }
90
91 #[must_use]
93 pub fn over_warn(&self) -> bool {
94 self.percent >= WARN_PERCENT
95 }
96
97 #[must_use]
101 pub fn new(used: u64, limit: u64, remaining: u64, reset: i64) -> Self {
102 Self {
103 used,
104 limit,
105 remaining,
106 percent: percent_of(used, limit),
107 reset,
108 }
109 }
110}
111
112fn percent_of(used: u64, limit: u64) -> f64 {
114 if limit == 0 {
115 return 0.0;
116 }
117 let raw = used as f64 / limit as f64 * 100.0;
120 (raw * 10.0).round() / 10.0
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
129pub struct RateLimitSnapshot {
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub graphql: Option<RateLimitResource>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub core: Option<RateLimitResource>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub search: Option<RateLimitResource>,
139}
140
141impl RateLimitSnapshot {
142 fn resources(&self) -> Vec<(&'static str, RateLimitResource)> {
145 [
146 ("graphql", self.graphql),
147 ("core", self.core),
148 ("search", self.search),
149 ]
150 .into_iter()
151 .filter_map(|(name, res)| res.map(|r| (name, r)))
152 .collect()
153 }
154
155 #[must_use]
157 pub fn max_percent(&self) -> f64 {
158 self.resources()
159 .iter()
160 .map(|(_, r)| r.percent)
161 .fold(0.0_f64, f64::max)
162 }
163
164 #[must_use]
166 pub fn over_warn(&self) -> bool {
167 self.resources().iter().any(|(_, r)| r.over_warn())
168 }
169
170 #[must_use]
175 pub fn summary_line(&self) -> String {
176 let body = self
177 .resources()
178 .iter()
179 .map(|(name, r)| format_resource(name, r))
180 .collect::<Vec<_>>()
181 .join(" · ");
182 if body.is_empty() {
183 return body;
184 }
185 if self.over_warn() {
186 format!("⚠ {body}")
187 } else {
188 body
189 }
190 }
191
192 #[must_use]
195 pub fn tray_label(&self) -> String {
196 let resources = self.resources();
197 if resources.is_empty() {
198 return String::new();
199 }
200 let body = resources
201 .iter()
202 .map(|(name, r)| format!("{name} {}%", trim_percent(r.percent)))
203 .collect::<Vec<_>>()
204 .join(" · ");
205 if self.over_warn() {
206 format!("github: {body} ⚠")
207 } else {
208 format!("github: {body}")
209 }
210 }
211}
212
213fn format_resource(name: &str, r: &RateLimitResource) -> String {
215 format!(
216 "{name} {}% ({}/{}, resets {})",
217 trim_percent(r.percent),
218 r.used,
219 r.limit,
220 format_reset_utc(r.reset)
221 )
222}
223
224fn trim_percent(percent: f64) -> String {
228 if (percent.fract()).abs() < f64::EPSILON {
229 format!("{}", percent as i64)
230 } else {
231 format!("{percent}")
232 }
233}
234
235#[must_use]
238pub fn format_reset_utc(epoch: i64) -> String {
239 match DateTime::<Utc>::from_timestamp(epoch, 0) {
240 Some(dt) if epoch > 0 => dt.format("%H:%MZ").to_string(),
241 _ => "??:??Z".to_string(),
242 }
243}
244
245fn parse_rate_limit(body: &Value) -> RateLimitSnapshot {
249 let resources = body.get("resources");
250 let read = |key: &str| -> Option<RateLimitResource> {
251 resources
252 .and_then(|r| r.get(key))
253 .and_then(RateLimitResource::from_value)
254 };
255 debug_assert!(RESOURCE_KEYS.contains(&"graphql"));
256 RateLimitSnapshot {
257 graphql: read("graphql"),
258 core: read("core"),
259 search: read("search"),
260 }
261}
262
263fn run_gh_rate_limit(bin: &Path) -> Result<Value> {
267 let output = crate::github_metrics::run_gh(bin, ["api", "rate_limit"], "api rate_limit", None)
268 .with_context(|| {
269 format!(
270 "failed to run {} (is the GitHub CLI installed?)",
271 bin.display()
272 )
273 })?;
274 if !output.status.success() {
275 let stderr = String::from_utf8_lossy(&output.stderr);
276 bail!("gh api rate_limit failed: {}", stderr.trim());
277 }
278 serde_json::from_slice(&output.stdout).context("gh api rate_limit returned invalid JSON")
279}
280
281pub fn resolve_rate_limit_with(bin: &Path) -> Result<RateLimitSnapshot> {
295 let body = run_gh_rate_limit(bin)?;
296 Ok(parse_rate_limit(&body))
297}
298
299#[derive(Debug, Default)]
306pub struct RateLimitCache {
307 snapshot: Mutex<Option<RateLimitSnapshot>>,
308}
309
310impl RateLimitCache {
311 #[must_use]
313 pub fn new() -> Self {
314 Self::default()
315 }
316
317 #[must_use]
319 pub fn get(&self) -> Option<RateLimitSnapshot> {
320 *self.lock()
321 }
322
323 pub fn replace(&self, next: RateLimitSnapshot) -> bool {
327 let mut guard = self.lock();
328 let changed = *guard != Some(next);
329 *guard = Some(next);
330 changed
331 }
332
333 pub fn observe_graphql(&self, graphql: RateLimitResource) -> bool {
343 let mut guard = self.lock();
344 let mut snap = (*guard).unwrap_or_default();
345 let changed = snap.graphql != Some(graphql);
346 snap.graphql = Some(graphql);
347 *guard = Some(snap);
348 changed
349 }
350
351 fn lock(&self) -> std::sync::MutexGuard<'_, Option<RateLimitSnapshot>> {
354 self.snapshot.lock().unwrap_or_else(PoisonError::into_inner)
355 }
356}
357
358#[cfg(test)]
359#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
363mod tests {
364 use super::*;
365 use crate::test_support::shim::{retry_on_etxtbsy, shim_lock, write_exec_script};
366 use serde_json::json;
367 use std::path::PathBuf;
368 use std::sync::MutexGuard;
369
370 fn sample_body() -> Value {
372 json!({
373 "resources": {
374 "core": {"limit": 5000, "used": 27, "remaining": 4973, "reset": 1_700_000_000_i64},
375 "graphql": {"limit": 5000, "used": 4100, "remaining": 900, "reset": 1_700_003_000_i64},
376 "search": {"limit": 30, "used": 3, "remaining": 27, "reset": 1_700_000_060_i64},
377 },
378 "rate": {"limit": 5000, "used": 27, "remaining": 4973, "reset": 1_700_000_000_i64}
380 })
381 }
382
383 #[test]
386 fn parse_reads_every_resource_and_computes_percent() {
387 let snap = parse_rate_limit(&sample_body());
388 let graphql = snap.graphql.expect("graphql present");
389 assert_eq!(graphql.used, 4100);
390 assert_eq!(graphql.limit, 5000);
391 assert_eq!(graphql.remaining, 900);
392 assert_eq!(graphql.percent, 82.0);
393 assert_eq!(graphql.reset, 1_700_003_000);
394 let core = snap.core.expect("core present");
395 assert_eq!(core.percent, 0.5);
396 assert!(snap.search.is_some());
397 }
398
399 #[test]
400 fn parse_tolerates_a_missing_search_resource() {
401 let body = json!({"resources": {
402 "core": {"limit": 5000, "used": 27, "remaining": 4973, "reset": 1},
403 "graphql": {"limit": 5000, "used": 10, "remaining": 4990, "reset": 1},
404 }});
405 let snap = parse_rate_limit(&body);
406 assert!(snap.graphql.is_some());
407 assert!(snap.core.is_some());
408 assert!(snap.search.is_none());
409 }
410
411 #[test]
412 fn parse_tolerates_a_missing_resources_block() {
413 let snap = parse_rate_limit(&json!({}));
414 assert_eq!(snap, RateLimitSnapshot::default());
415 }
416
417 #[test]
418 fn parse_derives_remaining_when_absent() {
419 let body = json!({"resources": {"core": {"limit": 100, "used": 40}}});
420 let core = parse_rate_limit(&body).core.expect("core present");
421 assert_eq!(core.remaining, 60);
422 assert_eq!(core.percent, 40.0);
423 }
424
425 #[test]
426 fn percent_guards_a_zero_limit() {
427 assert_eq!(percent_of(0, 0), 0.0);
428 assert_eq!(percent_of(5, 0), 0.0);
429 assert_eq!(percent_of(1, 3), 33.3);
430 }
431
432 #[test]
435 fn over_warn_fires_only_at_or_above_the_threshold() {
436 let res = |used: u64| RateLimitResource {
437 used,
438 limit: 100,
439 remaining: 100 - used,
440 percent: percent_of(used, 100),
441 reset: 0,
442 };
443 assert!(!res(79).over_warn());
444 assert!(res(80).over_warn());
445 assert!(res(95).over_warn());
446
447 let snap = RateLimitSnapshot {
448 core: Some(res(10)),
449 graphql: Some(res(85)),
450 search: None,
451 };
452 assert!(snap.over_warn());
453 assert_eq!(snap.max_percent(), 85.0);
454 }
455
456 #[test]
459 fn format_reset_utc_renders_clock_time_or_a_placeholder() {
460 assert_eq!(format_reset_utc(1_700_000_000), "22:13Z");
462 assert_eq!(format_reset_utc(0), "??:??Z");
463 assert_eq!(format_reset_utc(-5), "??:??Z");
464 }
465
466 #[test]
467 fn summary_line_lists_resources_and_marks_the_warn_case() {
468 let snap = parse_rate_limit(&sample_body());
469 let line = snap.summary_line();
470 assert!(line.contains("graphql 82% (4100/5000, resets"), "{line}");
471 assert!(line.contains("core 0.5% (27/5000"), "{line}");
472 assert!(line.starts_with("⚠ "), "{line}");
474 }
475
476 #[test]
477 fn summary_line_omits_the_marker_below_threshold() {
478 let body = json!({"resources": {
479 "graphql": {"limit": 5000, "used": 10, "remaining": 4990, "reset": 1_700_000_000_i64},
480 "core": {"limit": 5000, "used": 27, "remaining": 4973, "reset": 1_700_000_000_i64},
481 }});
482 let line = parse_rate_limit(&body).summary_line();
483 assert!(!line.starts_with('⚠'), "{line}");
484 assert!(line.starts_with("graphql 0.2%"), "{line}");
485 }
486
487 #[test]
488 fn summary_line_is_empty_without_resources() {
489 assert!(RateLimitSnapshot::default().summary_line().is_empty());
490 assert!(RateLimitSnapshot::default().tray_label().is_empty());
491 }
492
493 #[test]
494 fn tray_label_is_compact_and_marks_the_warn_case() {
495 let snap = parse_rate_limit(&sample_body());
496 let label = snap.tray_label();
497 assert!(label.starts_with("github: graphql 82%"), "{label}");
498 assert!(label.ends_with('⚠'), "{label}");
499 }
500
501 #[test]
502 fn tray_label_omits_the_marker_below_threshold() {
503 let body = json!({"resources": {
504 "graphql": {"limit": 5000, "used": 10, "remaining": 4990, "reset": 1},
505 "core": {"limit": 5000, "used": 27, "remaining": 4973, "reset": 1},
506 }});
507 let label = parse_rate_limit(&body).tray_label();
508 assert_eq!(label, "github: graphql 0.2% · core 0.5%", "{label}");
509 assert!(!label.contains('⚠'), "{label}");
510 }
511
512 #[test]
515 fn cache_get_and_replace_report_changes() {
516 let cache = RateLimitCache::new();
517 assert!(cache.get().is_none());
518 let a = parse_rate_limit(&sample_body());
519 assert!(cache.replace(a));
521 assert_eq!(cache.get(), Some(a));
522 assert!(!cache.replace(a));
524 let b = parse_rate_limit(&json!({"resources": {
526 "graphql": {"limit": 5000, "used": 4200, "remaining": 800, "reset": 1}
527 }}));
528 assert!(cache.replace(b));
529 }
530
531 #[test]
532 fn observe_graphql_updates_only_graphql_and_preserves_core_search() {
533 let cache = RateLimitCache::new();
536 cache.replace(RateLimitSnapshot {
537 graphql: Some(RateLimitResource::new(10, 5000, 4990, 0)),
538 core: Some(RateLimitResource::new(3, 5000, 4997, 0)),
539 search: Some(RateLimitResource::new(0, 30, 30, 0)),
540 });
541 assert!(cache.observe_graphql(RateLimitResource::new(45, 5000, 4955, 0)));
542 let snap = cache.get().unwrap();
543 assert_eq!(snap.graphql.unwrap().used, 45, "graphql updated");
544 assert_eq!(snap.core.unwrap().used, 3, "core preserved");
545 assert_eq!(snap.search.unwrap().used, 0, "search preserved");
546 assert!(!cache.observe_graphql(RateLimitResource::new(45, 5000, 4955, 0)));
548 let empty = RateLimitCache::new();
550 assert!(empty.observe_graphql(RateLimitResource::new(1, 5000, 4999, 0)));
551 assert!(empty.get().unwrap().core.is_none());
552 }
553
554 fn fake_gh(dir: &Path, stdout: &str, code: i32) -> (PathBuf, MutexGuard<'static, ()>) {
564 let guard = shim_lock();
565 let path = dir.join("fake-gh");
566 write_exec_script(
567 &path,
568 &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\nexit {code}\n"),
569 );
570 (path, guard)
571 }
572
573 #[test]
574 fn resolve_errors_when_gh_is_missing() {
575 let err = resolve_rate_limit_with(Path::new("/no/such/gh/xyzzy")).unwrap_err();
576 let msg = format!("{err:#}");
577 assert!(msg.contains("failed to run"), "{msg}");
578 assert!(msg.contains("GitHub CLI"), "{msg}");
579 }
580
581 #[test]
582 fn resolve_errors_on_a_nonzero_exit() {
583 let dir = tempfile::tempdir().unwrap();
584 let (bin, _shim) = fake_gh(dir.path(), "", 1);
585 let err = retry_on_etxtbsy(|| resolve_rate_limit_with(&bin)).unwrap_err();
586 assert!(
587 format!("{err:#}").contains("gh api rate_limit failed"),
588 "{err:#}"
589 );
590 }
591
592 #[test]
593 fn resolve_errors_on_unparseable_output() {
594 let dir = tempfile::tempdir().unwrap();
595 let (bin, _shim) = fake_gh(dir.path(), "not json at all", 0);
596 let err = retry_on_etxtbsy(|| resolve_rate_limit_with(&bin)).unwrap_err();
597 assert!(format!("{err:#}").contains("invalid JSON"), "{err:#}");
598 }
599
600 #[test]
601 fn resolve_reads_a_real_reply_end_to_end() {
602 let dir = tempfile::tempdir().unwrap();
603 let (bin, _shim) = fake_gh(dir.path(), &sample_body().to_string(), 0);
604 let snap = retry_on_etxtbsy(|| resolve_rate_limit_with(&bin)).unwrap();
605 assert_eq!(snap.graphql.unwrap().percent, 82.0);
606 assert_eq!(snap.core.unwrap().used, 27);
607 }
608}