use crate::colors::SectionColors;
use crate::config::Config;
use crate::render::get_details_and_fg_codes;
use crate::types::{
ApiErrorResponse, CachedQuota, KeychainCredentials, QuotaCacheState, QuotaData, QuotaResponse,
};
use chrono::{DateTime, Duration, Utc};
use std::fs;
use std::fs::OpenOptions;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
const QUOTA_ENDPOINT: &str = "https://api.anthropic.com/api/oauth/usage";
const QUOTA_BETA_HEADER: &str = "oauth-2025-04-20";
const QUOTA_CONNECT_TIMEOUT_SECS: &str = "1";
const QUOTA_TIMEOUT_SECS: &str = "2";
const DEFAULT_RATE_LIMIT_COOLDOWN_SECS: u64 = 120;
const DEFAULT_FAILURE_COOLDOWN_SECS: u64 = 30;
const DEFAULT_AUTH_COOLDOWN_SECS: u64 = 60;
const REFRESH_LEASE_SECS: u64 = 5;
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TokenSource {
Env,
MacosKeychain,
CredentialsFile,
SystemKeyring,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum QuotaFetchErrorKind {
RateLimited,
Auth,
Network,
Parse,
Http,
}
impl QuotaFetchErrorKind {
fn as_str(self) -> &'static str {
match self {
Self::RateLimited => "rate_limited",
Self::Auth => "auth_failed",
Self::Network => "network_failed",
Self::Parse => "parse_failed",
Self::Http => "http_failed",
}
}
fn default_cooldown_secs(self) -> u64 {
match self {
Self::RateLimited => DEFAULT_RATE_LIMIT_COOLDOWN_SECS,
Self::Auth => DEFAULT_AUTH_COOLDOWN_SECS,
Self::Network | Self::Parse | Self::Http => DEFAULT_FAILURE_COOLDOWN_SECS,
}
}
}
#[derive(Debug)]
struct QuotaFetchError {
kind: QuotaFetchErrorKind,
status_code: Option<u16>,
retry_after_secs: Option<u64>,
message: Option<String>,
}
struct RefreshLock {
path: PathBuf,
}
impl Drop for RefreshLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
impl QuotaFetchError {
fn cooldown_secs(&self) -> u64 {
self.retry_after_secs
.unwrap_or_else(|| self.kind.default_cooldown_secs())
}
}
fn debug_log(message: &str) {
if std::env::var("STATUSLINE_DEBUG").is_ok() {
eprintln!("statusline debug: {}", message);
}
}
fn sanitize_oauth_token(token: String) -> Option<String> {
let trimmed = token.trim().to_string();
if trimmed.is_empty() || trimmed.contains('\r') || trimmed.contains('\n') {
return None;
}
Some(trimmed)
}
fn extract_oauth_token_from_credentials_json(creds_json: &str) -> Option<String> {
let creds: KeychainCredentials = serde_json::from_str(creds_json).ok()?;
sanitize_oauth_token(creds.claude_ai_oauth?.access_token)
}
fn quota_user_agent() -> String {
format!("claude-code-status-line/{}", env!("CARGO_PKG_VERSION"))
}
fn quota_beta_header() -> Option<String> {
if std::env::var("STATUSLINE_DISABLE_QUOTA_BETA_HEADER")
.map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
.unwrap_or(false)
{
return None;
}
Some(
std::env::var("STATUSLINE_QUOTA_BETA_HEADER")
.unwrap_or_else(|_| QUOTA_BETA_HEADER.to_string()),
)
}
fn get_oauth_token() -> Option<String> {
let (token, source) = get_oauth_token_with_source()?;
debug_log(&format!("using oauth token from {:?}", source));
Some(token)
}
fn select_oauth_token(
env_token: Option<String>,
primary_source: Option<(String, TokenSource)>,
credentials_file_source: Option<(String, TokenSource)>,
keyring_source: Option<(String, TokenSource)>,
) -> Option<(String, TokenSource)> {
env_token
.and_then(|token| sanitize_oauth_token(token).map(|token| (token, TokenSource::Env)))
.or(primary_source)
.or(credentials_file_source)
.or(keyring_source)
}
#[cfg(target_os = "macos")]
fn get_macos_keychain_token() -> Option<(String, TokenSource)> {
let output = Command::new("security")
.args([
"find-generic-password",
"-s",
"Claude Code-credentials",
"-w",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let creds_json = String::from_utf8(output.stdout).ok()?;
let token = extract_oauth_token_from_credentials_json(&creds_json)?;
Some((token, TokenSource::MacosKeychain))
}
fn get_credentials_file_token() -> Option<(String, TokenSource)> {
let home = crate::utils::get_home_dir()?;
let creds_path = home.join(".claude").join(".credentials.json");
let creds_json = fs::read_to_string(creds_path).ok()?;
let token = extract_oauth_token_from_credentials_json(&creds_json)?;
Some((token, TokenSource::CredentialsFile))
}
fn get_system_keyring_token() -> Option<(String, TokenSource)> {
let username = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "default".to_string());
let entry = keyring::Entry::new("Claude Code-credentials", &username).ok()?;
let creds_json = entry.get_password().ok()?;
let token = extract_oauth_token_from_credentials_json(&creds_json)?;
Some((token, TokenSource::SystemKeyring))
}
fn get_oauth_token_with_source() -> Option<(String, TokenSource)> {
let env_token = std::env::var("CLAUDE_OAUTH_TOKEN").ok();
if env_token.is_some()
&& env_token
.as_ref()
.and_then(|token| sanitize_oauth_token(token.clone()))
.is_none()
{
debug_log("rejected CLAUDE_OAUTH_TOKEN because it was empty or malformed");
}
#[cfg(target_os = "macos")]
let primary_source = get_macos_keychain_token();
#[cfg(not(target_os = "macos"))]
let primary_source = None;
if cfg!(target_os = "macos") && primary_source.is_none() {
debug_log("failed to extract oauth token from macOS keychain credentials");
}
let credentials_file_source = get_credentials_file_token();
if credentials_file_source.is_none() {
debug_log("failed to extract oauth token from ~/.claude/.credentials.json");
}
let keyring_source = get_system_keyring_token();
if keyring_source.is_none() {
debug_log("failed to extract oauth token from system keyring credentials");
}
select_oauth_token(
env_token,
primary_source,
credentials_file_source,
keyring_source,
)
}
fn parse_retry_after_secs(headers: &str, now: DateTime<Utc>) -> Option<u64> {
for line in headers.lines().rev() {
let Some((name, value)) = line.split_once(':') else {
continue;
};
if !name.trim().eq_ignore_ascii_case("retry-after") {
continue;
}
let value = value.trim();
if let Ok(seconds) = value.parse::<u64>() {
return Some(seconds);
}
if let Ok(retry_at) = DateTime::parse_from_rfc2822(value) {
let retry_at = retry_at.with_timezone(&Utc);
let duration = retry_at.signed_duration_since(now);
if duration > Duration::zero() {
return Some(duration.num_seconds() as u64);
}
}
}
None
}
fn parse_api_error_message(body: &str) -> Option<String> {
let parsed: ApiErrorResponse = serde_json::from_str(body).ok()?;
let error_type = parsed
.error
.error_type
.unwrap_or_else(|| "unknown".to_string());
let message = parsed
.error
.message
.unwrap_or_else(|| "Unknown error".to_string());
Some(format!("{}: {}", error_type, message))
}
fn fetch_quota_from_api(token: &str) -> Result<QuotaData, QuotaFetchError> {
let headers_file = tempfile::NamedTempFile::new().map_err(|err| QuotaFetchError {
kind: QuotaFetchErrorKind::Network,
status_code: None,
retry_after_secs: None,
message: Some(format!("failed to create header temp file: {}", err)),
})?;
let body_file = tempfile::NamedTempFile::new().map_err(|err| QuotaFetchError {
kind: QuotaFetchErrorKind::Network,
status_code: None,
retry_after_secs: None,
message: Some(format!("failed to create body temp file: {}", err)),
})?;
let headers_path = headers_file.path().to_string_lossy().into_owned();
let body_path = body_file.path().to_string_lossy().into_owned();
let mut args = vec![
"-sS".to_string(),
"--connect-timeout".to_string(),
QUOTA_CONNECT_TIMEOUT_SECS.to_string(),
"-m".to_string(),
QUOTA_TIMEOUT_SECS.to_string(),
"-D".to_string(),
headers_path,
"-o".to_string(),
body_path,
"-w".to_string(),
"%{http_code}".to_string(),
"-H".to_string(),
"Accept: application/json".to_string(),
"-H".to_string(),
"Content-Type: application/json".to_string(),
"-H".to_string(),
format!("User-Agent: {}", quota_user_agent()),
"-H".to_string(),
format!("Authorization: Bearer {}", token),
];
if let Some(beta_header) = quota_beta_header() {
args.push("-H".to_string());
args.push(format!("anthropic-beta: {}", beta_header));
}
args.push(QUOTA_ENDPOINT.to_string());
let output = Command::new("curl")
.args(&args)
.output()
.map_err(|err| QuotaFetchError {
kind: QuotaFetchErrorKind::Network,
status_code: None,
retry_after_secs: None,
message: Some(format!("curl unavailable: {}", err)),
})?;
if !output.status.success() {
return Err(QuotaFetchError {
kind: QuotaFetchErrorKind::Network,
status_code: None,
retry_after_secs: None,
message: Some(String::from_utf8_lossy(&output.stderr).trim().to_string()),
});
}
let status_code = String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u16>()
.map_err(|_| QuotaFetchError {
kind: QuotaFetchErrorKind::Parse,
status_code: None,
retry_after_secs: None,
message: Some("failed to parse HTTP status code from curl output".to_string()),
})?;
let headers = fs::read_to_string(headers_file.path()).unwrap_or_default();
let body = fs::read_to_string(body_file.path()).unwrap_or_default();
if status_code == 200 {
let q: QuotaResponse = serde_json::from_str(&body).map_err(|err| QuotaFetchError {
kind: QuotaFetchErrorKind::Parse,
status_code: Some(status_code),
retry_after_secs: None,
message: Some(format!("failed to parse quota response JSON: {}", err)),
})?;
return Ok(QuotaData {
five_hour_pct: q.five_hour.as_ref().map(|x| x.utilization),
five_hour_resets_at: q.five_hour.and_then(|x| x.resets_at),
seven_day_pct: q.seven_day.as_ref().map(|x| x.utilization),
seven_day_resets_at: q.seven_day.and_then(|x| x.resets_at),
});
}
let retry_after_secs = parse_retry_after_secs(&headers, Utc::now());
let message = parse_api_error_message(&body).filter(|message| !message.is_empty());
let kind = match status_code {
401 | 403 => QuotaFetchErrorKind::Auth,
429 => QuotaFetchErrorKind::RateLimited,
_ => QuotaFetchErrorKind::Http,
};
Err(QuotaFetchError {
kind,
status_code: Some(status_code),
retry_after_secs,
message,
})
}
fn get_cache_path() -> PathBuf {
if let Some(home) = crate::utils::get_home_dir() {
return home
.join(".claude")
.join("statusline")
.join("quota_cache.json");
}
std::env::temp_dir().join("claude-statusline-quota-cache.json")
}
fn get_refresh_lock_path(cache_path: &Path) -> PathBuf {
let file_name = cache_path
.file_name()
.map(|name| {
let mut owned = name.to_os_string();
owned.push(".lock");
owned
})
.unwrap_or_else(|| "quota_cache.json.lock".into());
cache_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(file_name)
}
fn try_acquire_refresh_lock(cache_path: &Path, now: u64) -> Option<RefreshLock> {
let lock_path = get_refresh_lock_path(cache_path);
let parent = lock_path.parent().unwrap_or_else(|| Path::new("."));
if let Err(err) = fs::create_dir_all(parent) {
debug_log(&format!("failed to prepare quota lock directory: {}", err));
return None;
}
for _ in 0..2 {
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(mut file) => {
use std::io::Write;
let expires_at = now + REFRESH_LEASE_SECS;
if let Err(err) = write!(file, "{}", expires_at) {
debug_log(&format!("failed to write quota refresh lock: {}", err));
let _ = fs::remove_file(&lock_path);
return None;
}
return Some(RefreshLock { path: lock_path });
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
let expires_at = fs::read_to_string(&lock_path)
.ok()
.and_then(|content| content.trim().parse::<u64>().ok());
if expires_at.is_some_and(|expires_at| expires_at <= now) {
let _ = fs::remove_file(&lock_path);
continue;
}
debug_log("quota refresh lock already held by another process");
return None;
}
Err(err) => {
debug_log(&format!("failed to acquire quota refresh lock: {}", err));
return None;
}
}
}
None
}
fn write_cache_atomic(path: &Path, content: &str) -> std::io::Result<()> {
use std::io::Write;
let dir = path.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(dir)?;
let mut file = tempfile::NamedTempFile::new_in(dir)?;
file.write_all(content.as_bytes())?;
match file.persist(path) {
Ok(_) => Ok(()),
Err(err) => {
let io_error = err.error;
match io_error.kind() {
std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied => {
fs::write(path, content)
}
_ => Err(io_error),
}
}
}
}
fn read_cache_state(path: &Path) -> Option<QuotaCacheState> {
let content = fs::read_to_string(path).ok()?;
if let Ok(legacy) = serde_json::from_str::<CachedQuota>(&content) {
return Some(QuotaCacheState {
last_success_at: Some(legacy.timestamp),
last_success_data: Some(legacy.data),
..QuotaCacheState::default()
});
}
serde_json::from_str::<QuotaCacheState>(&content).ok()
}
fn write_cache_state(path: &Path, state: &QuotaCacheState) {
match serde_json::to_string(state) {
Ok(json) => {
if let Err(err) = write_cache_atomic(path, &json) {
debug_log(&format!("failed to write quota cache state: {}", err));
}
}
Err(err) => debug_log(&format!("failed to serialize quota cache state: {}", err)),
}
}
fn is_fresh_quota(state: &QuotaCacheState, cache_ttl: u64, now: u64) -> bool {
if cache_ttl == 0 {
return false;
}
state.last_success_data.is_some()
&& state
.last_success_at
.is_some_and(|timestamp| now.saturating_sub(timestamp) < cache_ttl)
}
fn cooldown_is_active(state: &QuotaCacheState, now: u64) -> bool {
state
.next_retry_at
.is_some_and(|next_retry_at| next_retry_at > now)
}
fn refresh_is_in_progress(state: &QuotaCacheState, now: u64) -> bool {
state
.refresh_in_progress_until
.is_some_and(|until| until > now)
}
fn get_quota_with_fetcher<F>(
config: &Config,
cache_path: &Path,
now: u64,
token: Option<String>,
fetcher: F,
) -> Option<QuotaData>
where
F: FnOnce(&str) -> Result<QuotaData, QuotaFetchError>,
{
let mut cache_state = read_cache_state(cache_path).unwrap_or_default();
if is_fresh_quota(&cache_state, config.sections.quota.cache_ttl, now) {
debug_log("using fresh quota cache");
return cache_state.last_success_data.clone();
}
if cooldown_is_active(&cache_state, now) {
debug_log("quota fetch cooldown active; serving cached quota if available");
return cache_state.last_success_data.clone();
}
if refresh_is_in_progress(&cache_state, now) {
debug_log("quota refresh already in progress; serving cached quota if available");
return cache_state.last_success_data.clone();
}
let token = match token {
Some(token) => token,
None => {
debug_log("no oauth token available for quota fetch");
return cache_state.last_success_data.clone();
}
};
let _refresh_lock = match try_acquire_refresh_lock(cache_path, now) {
Some(lock) => lock,
None => return cache_state.last_success_data.clone(),
};
cache_state.refresh_in_progress_until = Some(now + REFRESH_LEASE_SECS);
write_cache_state(cache_path, &cache_state);
match fetcher(&token) {
Ok(fresh_data) => {
cache_state.last_success_at = Some(now);
cache_state.last_success_data = Some(fresh_data.clone());
cache_state.next_retry_at = None;
cache_state.last_error_kind = None;
cache_state.retry_after_secs = None;
cache_state.refresh_in_progress_until = None;
write_cache_state(cache_path, &cache_state);
Some(fresh_data)
}
Err(error) => {
let cooldown_secs = error.cooldown_secs();
cache_state.next_retry_at = Some(now + cooldown_secs);
cache_state.last_error_kind = Some(error.kind.as_str().to_string());
cache_state.retry_after_secs = Some(cooldown_secs);
cache_state.refresh_in_progress_until = None;
write_cache_state(cache_path, &cache_state);
debug_log(&format!(
"quota fetch failed: kind={}, status={:?}, retry_after={}s{}",
error.kind.as_str(),
error.status_code,
cooldown_secs,
error
.message
.as_ref()
.map(|message| format!(", message={}", message))
.unwrap_or_default()
));
cache_state.last_success_data.clone()
}
}
}
#[allow(clippy::absurd_extreme_comparisons)]
pub fn get_quota(config: &Config) -> Option<QuotaData> {
let cache_path = get_cache_path();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
get_quota_with_fetcher(
config,
&cache_path,
now,
get_oauth_token(),
fetch_quota_from_api,
)
}
pub fn format_quota_display(
label: &str,
pct: Option<f64>,
reset: &str,
colors: &SectionColors,
) -> String {
let pct_str = match pct {
Some(pct) => format!("{}%", pct),
None => "-".to_string(),
};
if reset.is_empty() {
format!("{}: {}", label, pct_str)
} else {
let (details, fg) = get_details_and_fg_codes(colors);
format!("{}: {} {}({}){}", label, pct_str, details, reset, fg)
}
}
pub fn format_quota_compact(label: &str, pct: Option<f64>) -> String {
match pct {
Some(pct) => format!("{}: {}%", label, pct),
None => format!("{}: -", label),
}
}
pub fn format_time_remaining(resets_at: &str, now: DateTime<Utc>) -> String {
let reset_time = match DateTime::parse_from_rfc3339(resets_at) {
Ok(time) => time.with_timezone(&Utc),
Err(_) => return String::new(),
};
let duration = reset_time.signed_duration_since(now);
if duration <= Duration::zero() {
return "now".to_string();
}
crate::utils::format_duration_secs(duration.num_seconds() as u64, false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
fn temp_cache_path() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("quota_cache.json");
(dir, path)
}
fn sample_quota() -> QuotaData {
QuotaData {
five_hour_pct: Some(42.0),
five_hour_resets_at: Some("2026-03-17T01:00:00Z".to_string()),
seven_day_pct: Some(12.0),
seven_day_resets_at: Some("2026-03-20T01:00:00Z".to_string()),
}
}
#[test]
fn test_extract_oauth_token_from_credentials_json() {
let token = extract_oauth_token_from_credentials_json(
r#"{"claudeAiOauth":{"accessToken":"test-token"}}"#,
);
assert_eq!(token, Some("test-token".to_string()));
}
#[test]
fn test_extract_oauth_token_rejects_newlines() {
let token = extract_oauth_token_from_credentials_json(
r#"{"claudeAiOauth":{"accessToken":"bad\nvalue"}}"#,
);
assert_eq!(token, None);
}
#[test]
fn test_extract_oauth_token_from_missing_oauth_credentials() {
let token = extract_oauth_token_from_credentials_json(r#"{}"#);
assert_eq!(token, None);
}
#[test]
fn test_select_oauth_token_prefers_env() {
let selected = select_oauth_token(
Some("env-token".to_string()),
Some(("keychain-token".to_string(), TokenSource::MacosKeychain)),
Some(("file-token".to_string(), TokenSource::CredentialsFile)),
Some(("keyring-token".to_string(), TokenSource::SystemKeyring)),
);
assert_eq!(selected, Some(("env-token".to_string(), TokenSource::Env)));
}
#[test]
fn test_select_oauth_token_falls_back_through_sources() {
let selected = select_oauth_token(
None,
None,
Some(("file-token".to_string(), TokenSource::CredentialsFile)),
Some(("keyring-token".to_string(), TokenSource::SystemKeyring)),
);
assert_eq!(
selected,
Some(("file-token".to_string(), TokenSource::CredentialsFile))
);
}
#[test]
fn test_sanitize_oauth_token_trims_and_validates() {
assert_eq!(
sanitize_oauth_token(" token ".to_string()),
Some("token".to_string())
);
assert_eq!(sanitize_oauth_token("\n".to_string()), None);
}
#[test]
fn test_parse_retry_after_secs_numeric_header() {
let headers = "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 90\r\n";
let now = Utc::now();
assert_eq!(parse_retry_after_secs(headers, now), Some(90));
}
#[test]
fn test_read_cache_state_supports_legacy_cache_shape() {
let (_temp_dir, cache_path) = temp_cache_path();
let legacy = CachedQuota {
timestamp: 123,
data: sample_quota(),
};
fs::write(&cache_path, serde_json::to_string(&legacy).unwrap()).unwrap();
let state = read_cache_state(&cache_path).unwrap();
assert_eq!(state.last_success_at, Some(123));
assert_eq!(state.last_success_data, Some(sample_quota()));
}
#[test]
fn test_get_quota_with_fetcher_uses_fresh_cache() {
let (_temp_dir, cache_path) = temp_cache_path();
let now = 10_000;
let state = QuotaCacheState {
last_success_at: Some(now - 10),
last_success_data: Some(sample_quota()),
..QuotaCacheState::default()
};
write_cache_state(&cache_path, &state);
let mut config = Config::default();
config.sections.quota.cache_ttl = 60;
let calls = Cell::new(0);
let result =
get_quota_with_fetcher(&config, &cache_path, now, Some("token".to_string()), |_| {
calls.set(calls.get() + 1);
Ok(sample_quota())
});
assert_eq!(result, Some(sample_quota()));
assert_eq!(calls.get(), 0);
}
#[test]
fn test_get_quota_with_fetcher_serves_stale_data_during_rate_limit_cooldown() {
let (_temp_dir, cache_path) = temp_cache_path();
let now = 20_000;
let state = QuotaCacheState {
last_success_at: Some(now - 3600),
last_success_data: Some(sample_quota()),
next_retry_at: Some(now + 45),
last_error_kind: Some("rate_limited".to_string()),
retry_after_secs: Some(45),
..QuotaCacheState::default()
};
write_cache_state(&cache_path, &state);
let mut config = Config::default();
config.sections.quota.cache_ttl = 60;
let calls = Cell::new(0);
let result =
get_quota_with_fetcher(&config, &cache_path, now, Some("token".to_string()), |_| {
calls.set(calls.get() + 1);
Ok(sample_quota())
});
assert_eq!(result, Some(sample_quota()));
assert_eq!(calls.get(), 0);
}
#[test]
fn test_get_quota_with_fetcher_records_rate_limit_cooldown_without_cache() {
let (_temp_dir, cache_path) = temp_cache_path();
let now = 30_000;
let mut config = Config::default();
config.sections.quota.cache_ttl = 60;
let result =
get_quota_with_fetcher(&config, &cache_path, now, Some("token".to_string()), |_| {
Err(QuotaFetchError {
kind: QuotaFetchErrorKind::RateLimited,
status_code: Some(429),
retry_after_secs: Some(90),
message: Some("rate_limit_error: try later".to_string()),
})
});
assert_eq!(result, None);
let state = read_cache_state(&cache_path).unwrap();
assert_eq!(state.last_error_kind.as_deref(), Some("rate_limited"));
assert_eq!(state.retry_after_secs, Some(90));
assert_eq!(state.next_retry_at, Some(now + 90));
assert!(state.last_success_data.is_none());
}
#[test]
fn test_get_quota_with_fetcher_honors_refresh_after_cooldown_expiry() {
let (_temp_dir, cache_path) = temp_cache_path();
let now = 40_000;
let state = QuotaCacheState {
last_success_at: Some(now - 3600),
last_success_data: Some(sample_quota()),
next_retry_at: Some(now - 1),
last_error_kind: Some("rate_limited".to_string()),
retry_after_secs: Some(90),
..QuotaCacheState::default()
};
write_cache_state(&cache_path, &state);
let mut config = Config::default();
config.sections.quota.cache_ttl = 60;
let calls = Cell::new(0);
let refreshed = QuotaData {
five_hour_pct: Some(55.0),
seven_day_pct: Some(20.0),
..sample_quota()
};
let result =
get_quota_with_fetcher(&config, &cache_path, now, Some("token".to_string()), |_| {
calls.set(calls.get() + 1);
Ok(refreshed.clone())
});
assert_eq!(calls.get(), 1);
assert_eq!(result, Some(refreshed.clone()));
let state = read_cache_state(&cache_path).unwrap();
assert_eq!(state.last_success_data, Some(refreshed));
assert!(state.next_retry_at.is_none());
}
#[test]
fn test_get_quota_with_fetcher_respects_refresh_lease() {
let (_temp_dir, cache_path) = temp_cache_path();
let now = 50_000;
let state = QuotaCacheState {
last_success_at: Some(now - 3600),
last_success_data: Some(sample_quota()),
refresh_in_progress_until: Some(now + 3),
..QuotaCacheState::default()
};
write_cache_state(&cache_path, &state);
let mut config = Config::default();
config.sections.quota.cache_ttl = 0;
let calls = Cell::new(0);
let result =
get_quota_with_fetcher(&config, &cache_path, now, Some("token".to_string()), |_| {
calls.set(calls.get() + 1);
Ok(sample_quota())
});
assert_eq!(result, Some(sample_quota()));
assert_eq!(calls.get(), 0);
}
#[test]
fn test_try_acquire_refresh_lock_blocks_second_refresh() {
let temp_dir = tempfile::tempdir().unwrap();
let cache_path = temp_dir.path().join("quota_cache.json");
let now = 60_000;
let first_lock = try_acquire_refresh_lock(&cache_path, now);
assert!(first_lock.is_some());
let second_lock = try_acquire_refresh_lock(&cache_path, now);
assert!(second_lock.is_none());
drop(first_lock);
let third_lock = try_acquire_refresh_lock(&cache_path, now);
assert!(third_lock.is_some());
}
#[test]
fn test_format_time_remaining_now() {
let now = Utc::now();
let past = now - Duration::minutes(5);
assert_eq!(format_time_remaining(&past.to_rfc3339(), now), "now");
}
#[test]
fn test_format_time_remaining_minutes() {
let now = Utc::now();
let future = now + Duration::minutes(45);
let result = format_time_remaining(&future.to_rfc3339(), now);
assert!(result.ends_with('m'));
assert!(!result.contains('h'));
assert!(!result.contains('d'));
}
#[test]
fn test_format_time_remaining_hours() {
let now = Utc::now();
let future = now + Duration::hours(5) + Duration::minutes(30);
let result = format_time_remaining(&future.to_rfc3339(), now);
assert!(result.contains('h'));
assert!(result.contains('m'));
assert!(!result.contains('d'));
}
#[test]
fn test_format_time_remaining_days() {
let now = Utc::now();
let future = now + Duration::days(3) + Duration::hours(12);
let result = format_time_remaining(&future.to_rfc3339(), now);
assert!(result.contains('d'));
assert!(result.contains('h'));
}
}