use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
const TIER_KEYS: [&str; 3] = [
"organizationRateLimitTier",
"userRateLimitTier",
"seatTier",
];
pub fn detect() -> Option<String> {
let src = dirs::home_dir()?.join(".claude.json");
let mtime = std::fs::metadata(&src)
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs());
if let (Some(mt), Some((cached_mt, label))) = (mtime, read_cache()) {
if cached_mt == mt {
return if label.is_empty() { None } else { Some(label) };
}
}
let label = detect_from_file(&src);
if let Some(mt) = mtime {
write_cache(mt, label.as_deref().unwrap_or(""));
}
label
}
pub fn detect_from_file(path: &Path) -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
for key in TIER_KEYS {
if let Some(raw) = json_string_value(&text, key) {
if let Some(label) = pretty_tier(&raw) {
return Some(label);
}
}
}
None
}
fn json_string_value(text: &str, key: &str) -> Option<String> {
let needle = format!("\"{key}\"");
let mut from = 0;
while let Some(i) = text[from..].find(&needle) {
let after = from + i + needle.len();
from = after; let rest = text[after..].trim_start();
let Some(rest) = rest.strip_prefix(':') else {
continue;
};
let rest = rest.trim_start();
if let Some(rest) = rest.strip_prefix('"') {
if let Some(end) = rest.find('"') {
let val = &rest[..end];
if !val.is_empty() && !val.contains('\\') {
return Some(val.to_string());
}
}
}
}
None
}
pub fn pretty_tier(raw: &str) -> Option<String> {
let s = raw.trim().to_lowercase();
if s.is_empty() {
return None;
}
let s = s
.strip_prefix("default_claude_")
.or_else(|| s.strip_prefix("claude_"))
.unwrap_or(&s);
if let Some(rest) = s.strip_prefix("max_") {
if !rest.is_empty() {
return Some(format!("Max ({rest})")); }
}
let label = match s {
"max" => "Max".to_string(),
"pro" => "Pro".to_string(),
"free" => "Free".to_string(),
"team" => "Team".to_string(),
"enterprise" => "Enterprise".to_string(),
other => {
let spaced = other.trim_matches('_').replace('_', " ");
let mut chars = spaced.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => return None,
}
}
};
Some(label)
}
fn cache_path() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".cache")))?;
Some(base.join("ccstatus").join("plan"))
}
fn read_cache() -> Option<(u64, String)> {
let text = std::fs::read_to_string(cache_path()?).ok()?;
let (first, rest) = text.split_once('\n')?;
let mt = first.trim().parse::<u64>().ok()?;
Some((mt, rest.trim_end_matches('\n').to_string()))
}
fn write_cache(mtime: u64, label: &str) {
let Some(path) = cache_path() else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, format!("{mtime}\n{label}"));
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn maps_tier_strings() {
assert_eq!(pretty_tier("default_claude_max_20x").as_deref(), Some("Max (20x)"));
assert_eq!(pretty_tier("default_claude_max_5x").as_deref(), Some("Max (5x)"));
assert_eq!(pretty_tier("claude_pro").as_deref(), Some("Pro"));
assert_eq!(pretty_tier("default_claude_pro").as_deref(), Some("Pro"));
assert_eq!(pretty_tier("team").as_deref(), Some("Team"));
assert_eq!(pretty_tier("max_").as_deref(), Some("Max")); assert_eq!(pretty_tier("").as_deref(), None);
}
#[test]
fn extracts_json_string_skipping_null_and_escapes() {
let json = r#"{ "userRateLimitTier": null, "oauthAccount": { "organizationRateLimitTier": "default_claude_max_20x" } }"#;
assert_eq!(
json_string_value(json, "organizationRateLimitTier").as_deref(),
Some("default_claude_max_20x")
);
assert_eq!(json_string_value(json, "userRateLimitTier"), None); assert_eq!(json_string_value(json, "nope"), None); let tricky = r#"{"organizationRateLimitTier": "a\"b", "userRateLimitTier": "claude_pro"}"#;
assert_eq!(json_string_value(tricky, "organizationRateLimitTier"), None);
}
#[test]
fn detect_from_file_reads_tier() {
let dir = std::env::temp_dir().join("ccstatus_plan_test");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("claude.json");
let mut f = std::fs::File::create(&path).unwrap();
write!(
f,
r#"{{"organizationRateLimitTier": null, "userRateLimitTier": "default_claude_max_5x"}}"#
)
.unwrap();
assert_eq!(detect_from_file(&path).as_deref(), Some("Max (5x)"));
assert_eq!(detect_from_file(&dir.join("missing.json")), None);
}
}