207 lines
7.7 KiB
Rust
207 lines
7.7 KiB
Rust
//! 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);
|
||
}
|
||
}
|