claude-usage-widget/src-tauri/src/tier.rs

207 lines
7.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Read the user's plan tier from `~/.claude/` and map it to approximate
//! cap values.
//!
//! These caps are *approximations* based on community-shared observations —
//! Anthropic does not publish the exact 5-hour / weekly token caps, and they
//! shift over time. The widget shows a percentage off these caps, but
//! anything within ±20% of "the real number" is fine for a desktop heads-up
//! display. Users tune them in Settings once they hit a real limit.
use crate::settings::Caps;
use serde::Serialize;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub enum PlanTier {
Pro,
Max5x,
Max20x,
/// Tier string we recognized as Max but couldn't sub-classify.
MaxOther(String),
/// The on-disk string was something we don't have a mapping for.
Unknown(String),
/// No `.claude.json` (or backup) found, or the field was missing.
NotFound,
}
impl PlanTier {
pub fn caps(&self) -> Caps {
match self {
// Rough numbers, deliberately on the generous side so the ring
// doesn't peg red on day one. Tune in Settings when reality hits.
PlanTier::Max20x => Caps { block_tokens: 200_000_000, weekly_tokens: 1_000_000_000 },
PlanTier::Max5x | PlanTier::MaxOther(_) => Caps {
block_tokens: 50_000_000,
weekly_tokens: 250_000_000,
},
PlanTier::Pro => Caps { block_tokens: 10_000_000, weekly_tokens: 50_000_000 },
// Fall back to the safe (and visibly wrong) placeholders so the
// user knows they need to tune. Better than guessing.
PlanTier::Unknown(_) | PlanTier::NotFound => Caps::default(),
}
}
pub fn label(&self) -> String {
match self {
PlanTier::Pro => "Pro".to_string(),
PlanTier::Max5x => "Max 5×".to_string(),
PlanTier::Max20x => "Max 20×".to_string(),
PlanTier::MaxOther(s) => format!("Max ({s})"),
PlanTier::Unknown(s) => format!("Unknown ({s})"),
PlanTier::NotFound => "Unknown".to_string(),
}
}
}
/// Parse a raw tier string (the value of `organizationRateLimitTier` in
/// `.claude.json`) into a known tier.
fn classify(raw: &str) -> PlanTier {
let l = raw.to_lowercase();
if l.contains("max_20x") || l.contains("max20x") {
PlanTier::Max20x
} else if l.contains("max_5x") || l.contains("max5x") {
PlanTier::Max5x
} else if l.contains("max") {
PlanTier::MaxOther(raw.to_string())
} else if l.contains("pro") {
PlanTier::Pro
} else {
PlanTier::Unknown(raw.to_string())
}
}
/// Read the tier from a single `.claude.json`-shaped file. None if the file
/// doesn't exist, isn't valid JSON, or is missing the field.
fn read_tier_from(path: &std::path::Path) -> Option<PlanTier> {
let bytes = std::fs::read(path).ok()?;
let json: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
let raw = json.get("organizationRateLimitTier").and_then(|v| v.as_str())?;
Some(classify(raw))
}
/// Same as [`read_tier_from`] but returns a string describing what happened.
/// Used for the diagnostic `searched` list in TierInfo.
fn probe_tier_file(path: &std::path::Path) -> String {
match std::fs::read(path) {
Err(e) => format!("read_err: {}", e.kind()),
Ok(bytes) => match serde_json::from_slice::<serde_json::Value>(&bytes) {
Err(_) => format!("not_json (size={})", bytes.len()),
Ok(json) => match json.get("organizationRateLimitTier").and_then(|v| v.as_str()) {
None => format!("no_tier_field (size={})", bytes.len()),
Some(raw) => format!("OK: {}", raw),
},
},
}
}
/// Try each `.claude/` candidate directory in order. The first hit wins.
///
/// For each candidate `<dir>` we look at:
/// 1. `<dir>/.claude.json` (e.g. ~/.claude/.claude.json)
/// 2. `<dir>/backups/.claude.json.backup.*` (newest)
/// 3. `<parent of dir>/.claude.json` (some setups put it at ~/.claude.json)
pub fn detect_in(claude_dirs: &[PathBuf]) -> PlanTier {
let (tier, _) = detect_in_with_diagnostics(claude_dirs);
tier
}
/// Like [`detect_in`] but also returns one diagnostic line per probed path
/// describing what happened (for surfacing in the Settings UI).
pub fn detect_in_with_diagnostics(
claude_dirs: &[PathBuf],
) -> (PlanTier, Vec<String>) {
let mut diag: Vec<String> = Vec::new();
let mut found: Option<PlanTier> = None;
for dir in claude_dirs {
let primary = dir.join(".claude.json");
let result = probe_tier_file(&primary);
diag.push(format!("{}{}", primary.display(), result));
if found.is_none() {
if let Some(t) = read_tier_from(&primary) {
found = Some(t);
}
}
let backups = dir.join("backups");
match newest_backup(&backups) {
Err(e) => diag.push(format!("{} → readdir_err: {}", backups.display(), e)),
Ok(None) => diag.push(format!("{} → no_backups", backups.display())),
Ok(Some(p)) => {
let result = probe_tier_file(&p);
diag.push(format!("{}{}", p.display(), result));
if found.is_none() {
if let Some(t) = read_tier_from(&p) {
found = Some(t);
}
}
}
}
if let Some(parent) = dir.parent() {
let alt = parent.join(".claude.json");
let result = probe_tier_file(&alt);
diag.push(format!("{}{}", alt.display(), result));
if found.is_none() {
if let Some(t) = read_tier_from(&alt) {
found = Some(t);
}
}
}
}
(found.unwrap_or(PlanTier::NotFound), diag)
}
fn newest_backup(dir: &std::path::Path) -> std::io::Result<Option<PathBuf>> {
let entries = std::fs::read_dir(dir)?;
let mut latest: Option<(std::time::SystemTime, PathBuf)> = None;
for entry in entries.flatten() {
let path = entry.path();
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if !name.starts_with(".claude.json.backup.") {
continue;
}
if let Ok(meta) = entry.metadata() {
if let Ok(mtime) = meta.modified() {
match &latest {
None => latest = Some((mtime, path)),
Some((m, _)) if mtime > *m => latest = Some((mtime, path)),
_ => {}
}
}
}
}
Ok(latest.map(|(_, p)| p))
}
/// Native-only fallback path. Most callers should prefer `detect_in` with
/// the resolved-roots' parents so the WSL case works.
#[allow(dead_code)]
pub fn detect() -> PlanTier {
let Some(home) = dirs::home_dir() else {
return PlanTier::NotFound;
};
detect_in(&[home.join(".claude")])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_known_tiers() {
assert_eq!(classify("default_claude_max_5x"), PlanTier::Max5x);
assert_eq!(classify("default_claude_max_20x"), PlanTier::Max20x);
assert_eq!(classify("default_claude_pro"), PlanTier::Pro);
assert!(matches!(classify("default_claude_max_99x"), PlanTier::MaxOther(_)));
assert!(matches!(classify("anything-else"), PlanTier::Unknown(_)));
}
#[test]
fn caps_scale_by_tier() {
assert!(PlanTier::Max20x.caps().block_tokens > PlanTier::Max5x.caps().block_tokens);
assert!(PlanTier::Max5x.caps().block_tokens > PlanTier::Pro.caps().block_tokens);
assert!(PlanTier::Pro.caps().block_tokens > PlanTier::NotFound.caps().block_tokens);
}
}