use crate::cache::CacheFile;
use crate::providers::{ProviderKind, ProviderResult, ProviderStatus, QuotaWindow};
use crate::update::UpdateInfo;
use std::collections::BTreeSet;
const ICON_AVAILABLE: &str = "\u{f05d}";
const DEFAULT_FORMAT: &str = "%provider %remaining/%limit";
pub struct StatusLineConfig {
pub icons: bool,
pub providers: Vec<ProviderKind>,
pub format: Option<String>,
pub update_info: Option<UpdateInfo>,
}
impl Default for StatusLineConfig {
fn default() -> Self {
Self {
icons: true,
providers: vec![],
format: None,
update_info: None,
}
}
}
pub fn render(cache: &CacheFile, config: &StatusLineConfig) -> String {
let format = config.format.as_deref().unwrap_or(DEFAULT_FORMAT);
let filter: Option<BTreeSet<ProviderKind>> = if config.providers.is_empty() {
None
} else {
Some(config.providers.iter().copied().collect())
};
let mut parts: Vec<String> = Vec::new();
for entry in cache.entries.values() {
if let Some(ref f) = filter {
if !f.contains(&entry.result.kind) {
continue;
}
}
if let Some(segment) = format_result(&entry.result, format, config.icons) {
parts.push(segment);
}
}
if let Some(update) = &config.update_info {
if update.is_update_available() {
parts.push(format!("update {}", update.latest_version));
}
}
parts.join(" | ")
}
fn format_result(result: &ProviderResult, format: &str, icons: bool) -> Option<String> {
match &result.status {
ProviderStatus::Available { quota } => {
let primary = primary_window("a.windows);
let mut out = if icons {
format!("{ICON_AVAILABLE} ")
} else {
String::new()
};
let remaining_str = if quota.unlimited {
"\u{221E}".to_string() } else {
primary.map_or("?".into(), |w| w.remaining.to_string())
};
let limit_str = if quota.unlimited {
"\u{221E}".to_string()
} else {
primary.map_or("?".into(), |w| w.limit.to_string())
};
let used_str = primary.map_or("?".into(), |w| w.used.to_string());
let window_str = primary.map_or("?".into(), |w| w.window_type.clone());
let reset_str = primary
.and_then(|w| w.reset_at)
.map(|t| format_timestamp(&t))
.unwrap_or_else(|| "?".into());
let mut s = format.to_string();
s = s.replace("%provider", result.kind.display_name());
s = s.replace("%remaining", &remaining_str);
s = s.replace("%limit", &limit_str);
s = s.replace("%used", &used_str);
s = s.replace("%window", &window_str);
s = s.replace("%reset", &reset_str);
out.push_str(&s);
Some(out)
}
ProviderStatus::Unavailable { .. }
| ProviderStatus::AuthRequired
| ProviderStatus::NetworkError { .. } => None,
}
}
fn primary_window(windows: &[QuotaWindow]) -> Option<&QuotaWindow> {
if windows.is_empty() {
return None;
}
windows
.iter()
.find(|w| w.limit > 0)
.or_else(|| windows.first())
}
fn format_timestamp(ts: &chrono::DateTime<chrono::Utc>) -> String {
let now = chrono::Utc::now();
let diff = *ts - now;
let mins = diff.num_minutes();
if mins < 0 {
return "now".into();
}
if mins < 60 {
format!("{mins}m")
} else {
format!("{}h{}m", mins / 60, mins % 60)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::{ProviderKind, ProviderQuota, ProviderStatus};
use chrono::Utc;
fn available_result(kind: ProviderKind, remaining: i64, limit: i64) -> ProviderResult {
ProviderResult {
kind,
status: ProviderStatus::Available {
quota: ProviderQuota {
plan_name: "test".into(),
windows: vec![QuotaWindow {
window_type: "5h".into(),
used: limit - remaining,
limit,
remaining,
reset_at: None,
period_seconds: Some(18000),
}],
unlimited: false,
},
},
fetched_at: Utc::now(),
raw_response: None,
auth_source: None,
cached_at: None,
}
}
fn unlimited_result(kind: ProviderKind) -> ProviderResult {
ProviderResult {
kind,
status: ProviderStatus::Available {
quota: ProviderQuota {
plan_name: "unlimited".into(),
windows: vec![],
unlimited: true,
},
},
fetched_at: Utc::now(),
raw_response: None,
auth_source: None,
cached_at: None,
}
}
fn auth_required_result(kind: ProviderKind) -> ProviderResult {
ProviderResult {
kind,
status: ProviderStatus::AuthRequired,
fetched_at: Utc::now(),
raw_response: None,
auth_source: None,
cached_at: None,
}
}
#[test]
fn default_format_available() {
let mut cache = CacheFile::default();
cache.entries.insert(
"claude".into(),
crate::cache::CacheEntry {
result: available_result(ProviderKind::Claude, 42, 50),
cached_at: Utc::now(),
},
);
let config = StatusLineConfig {
icons: false,
..Default::default()
};
let out = render(&cache, &config);
assert_eq!(out, "Claude 42/50");
}
#[test]
fn default_format_unlimited() {
let mut cache = CacheFile::default();
cache.entries.insert(
"codex".into(),
crate::cache::CacheEntry {
result: unlimited_result(ProviderKind::Codex),
cached_at: Utc::now(),
},
);
let config = StatusLineConfig {
icons: false,
..Default::default()
};
let out = render(&cache, &config);
assert!(out.contains("\u{221E}"), "expected ∞ in: {out}");
}
#[test]
fn auth_required_omitted() {
let mut cache = CacheFile::default();
cache.entries.insert(
"claude".into(),
crate::cache::CacheEntry {
result: auth_required_result(ProviderKind::Claude),
cached_at: Utc::now(),
},
);
let config = StatusLineConfig::default();
let out = render(&cache, &config);
assert!(out.is_empty());
}
#[test]
fn appends_update_hint_when_newer_version_is_cached() {
let cache = CacheFile::default();
let config = StatusLineConfig {
icons: false,
update_info: Some(UpdateInfo {
current_version: "0.8.1".into(),
latest_version: "0.8.2".into(),
checked_at: Utc::now(),
}),
..Default::default()
};
let out = render(&cache, &config);
assert_eq!(out, "update 0.8.2");
}
#[test]
fn provider_filter() {
let mut cache = CacheFile::default();
cache.entries.insert(
"claude".into(),
crate::cache::CacheEntry {
result: available_result(ProviderKind::Claude, 42, 50),
cached_at: Utc::now(),
},
);
cache.entries.insert(
"codex".into(),
crate::cache::CacheEntry {
result: available_result(ProviderKind::Codex, 10, 20),
cached_at: Utc::now(),
},
);
let config = StatusLineConfig {
icons: false,
providers: vec![ProviderKind::Claude],
format: None,
update_info: None,
};
let out = render(&cache, &config);
assert!(out.contains("Claude"));
assert!(!out.contains("Codex"));
}
}