//! 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 { 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::(&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 `` we look at: /// 1. `/.claude.json` (e.g. ~/.claude/.claude.json) /// 2. `/backups/.claude.json.backup.*` (newest) /// 3. `/.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) { let mut diag: Vec = Vec::new(); let mut found: Option = 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> { 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); } }