use crate::colors::SectionColors;
use crate::config::Config;
use crate::render::get_details_and_fg_codes;
use crate::types::{CachedQuota, KeychainCredentials, QuotaData, QuotaResponse};
use chrono::{DateTime, Duration, Utc};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
fn get_oauth_token() -> Option<String> {
if let Ok(token) = std::env::var("CLAUDE_OAUTH_TOKEN") {
if token.contains('\r') || token.contains('\n') {
return None;
}
return Some(token);
}
#[cfg(target_os = "macos")]
{
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 creds: KeychainCredentials = serde_json::from_str(&creds_json).ok()?;
let token = creds.claude_ai_oauth?.access_token;
if token.contains('\r') || token.contains('\n') {
return None;
}
Some(token)
}
#[cfg(not(target_os = "macos"))]
{
#[cfg(unix)]
let home_dir = std::env::var_os("HOME");
#[cfg(windows)]
let home_dir = std::env::var_os("USERPROFILE");
if let Some(home) = home_dir {
let creds_path = std::path::Path::new(&home)
.join(".claude")
.join(".credentials.json");
if let Ok(creds_json) = fs::read_to_string(creds_path) {
if let Ok(creds) = serde_json::from_str::<KeychainCredentials>(&creds_json) {
if let Some(oauth) = creds.claude_ai_oauth {
let token = oauth.access_token;
if !token.contains('\r') && !token.contains('\n') {
return Some(token);
}
}
}
}
}
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 creds: KeychainCredentials = serde_json::from_str(&creds_json).ok()?;
let token = creds.claude_ai_oauth?.access_token;
if token.contains('\r') || token.contains('\n') {
return None;
}
Some(token)
}
}
fn fetch_quota_from_api_safe(token: &str) -> Option<QuotaData> {
let output = match Command::new("curl")
.args([
"-s", "-f", "-m",
"1", "-H",
"Accept: application/json",
"-H",
"Content-Type: application/json",
"-H",
"User-Agent: claude-code/2.0.31",
"-H",
&format!("Authorization: Bearer {}", token),
"-H",
"anthropic-beta: oauth-2025-04-20",
"https://api.anthropic.com/api/oauth/usage",
])
.output()
{
Ok(o) => o,
Err(e) => {
if std::env::var("STATUSLINE_DEBUG").is_ok() {
eprintln!("statusline warning: curl not available: {}", e);
}
return None;
}
};
if !output.status.success() {
if std::env::var("STATUSLINE_DEBUG").is_ok() {
eprintln!(
"statusline warning: quota fetch failed with status {:?}",
output.status
);
}
return None;
}
let q: QuotaResponse = serde_json::from_slice(&output.stdout).ok()?;
Some(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),
})
}
fn get_cache_path() -> PathBuf {
let temp_dir = std::env::temp_dir();
#[cfg(unix)]
let uid = unsafe { libc::getuid() };
#[cfg(not(unix))]
let uid = std::env::var("USERNAME")
.or_else(|_| std::env::var("USER"))
.unwrap_or_else(|_| "default".to_string());
temp_dir.join(format!("claude-statusline-quota-{}.json", uid))
}
fn write_cache_atomic(path: &Path, content: &str) -> std::io::Result<()> {
use std::io::Write;
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut f = tempfile::NamedTempFile::new_in(dir)?;
f.write_all(content.as_bytes())?;
f.persist(path).map(|_| ()).map_err(|e| e.error)
}
#[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();
let cached_full = fs::read_to_string(&cache_path)
.ok()
.and_then(|content| serde_json::from_str::<CachedQuota>(&content).ok());
let is_fresh = if config.sections.quota.cache_ttl == 0 {
false
} else if let Some(cache) = &cached_full {
now.saturating_sub(cache.timestamp) < config.sections.quota.cache_ttl
} else {
false
};
if is_fresh {
return cached_full.map(|c| c.data);
}
if let Some(token) = get_oauth_token() {
if let Some(fresh_data) = fetch_quota_from_api_safe(&token) {
let cache = CachedQuota {
timestamp: now,
data: fresh_data.clone(),
};
if let Ok(json) = serde_json::to_string(&cache) {
let _ = write_cache_atomic(&cache_path, &json);
}
return Some(fresh_data);
}
}
cached_full.map(|c| c.data)
}
pub fn format_quota_display(
label: &str,
pct: Option<f64>,
reset: &str,
colors: &SectionColors,
config: &Config,
) -> String {
let pct_str = match pct {
Some(p) => format!("{}%", p),
None => "-".to_string(),
};
if reset.is_empty() {
format!("{}: {}", label, pct_str)
} else {
let (details, fg) = get_details_and_fg_codes(colors, config);
format!("{}: {} {}({}){}", label, pct_str, details, reset, fg)
}
}
pub fn format_quota_compact(label: &str, pct: Option<f64>) -> String {
match pct {
Some(p) => format!("{}: {}%", label, p),
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(t) => t.with_timezone(&Utc),
Err(_) => return String::new(),
};
let duration = reset_time.signed_duration_since(now);
if duration <= Duration::zero() {
return "now".to_string();
}
let total_minutes = duration.num_minutes();
let total_hours = duration.num_hours();
let total_days = duration.num_days();
if total_days >= 1 {
let hours = total_hours % 24;
format!("{}d {}h", total_days, hours)
} else if total_hours >= 1 {
let minutes = total_minutes % 60;
format!("{}h {}m", total_hours, minutes)
} else {
format!("{}m", total_minutes.max(1))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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'));
}
}