use std::collections::HashMap;
use std::env;
use crate::account::reset::resets_to_epoch;
use crate::usage::{FetchError, UsageData};
pub const LIMIT_PCT: i64 = 99;
pub const SATURATION_PCT: i64 = 95;
pub const ABSENT_SESSION_PCT: i64 = -1;
fn limit_pct() -> i64 {
env::var("CLAUDE_LIMIT_PCT")
.ok()
.and_then(|v| v.trim().parse::<i64>().ok())
.unwrap_or(LIMIT_PCT)
}
fn saturation_pct() -> i64 {
env::var("CLAUDE_PICK_SATURATION_PCT")
.ok()
.and_then(|v| v.trim().parse::<i64>().ok())
.unwrap_or(SATURATION_PCT)
}
#[derive(Debug, thiserror::Error)]
pub enum ScoringError {
#[error("all profiles are saturated or at session limit")]
AllSaturated,
#[error("no usable usage data for any profile")]
NoUsableData,
#[error("usage fetch failed: {0}")]
FetchFailed(#[from] FetchError),
}
pub type ScoringResult = Result<Option<String>, ScoringError>;
pub fn pick_best(data: &UsageData, current_profile: &str, include_current: bool) -> ScoringResult {
let lim = limit_pct();
let sat = saturation_pct();
let empty_errors: HashMap<String, String> = HashMap::new();
let errors: &HashMap<String, String> = data
.errors
.as_ref()
.map(|m| m as &HashMap<String, String>)
.unwrap_or(&empty_errors);
struct Candidate<'a> {
name: &'a str,
week_pct: i64,
session_pct: i64,
resets: Option<&'a str>,
}
let mut candidates: Vec<Candidate<'_>> = data
.profiles
.iter()
.filter_map(|(name, pu)| {
if errors.contains_key(name.as_str()) {
return None;
}
let week_pct = pu.week_all.as_ref()?.pct;
let session_pct = pu
.session
.as_ref()
.map(|s| s.pct)
.unwrap_or(ABSENT_SESSION_PCT);
let resets = pu.week_all.as_ref().and_then(|wa| wa.resets.as_deref());
Some(Candidate {
name: name.as_str(),
week_pct,
session_pct,
resets,
})
})
.collect();
let mut best_name: Option<&str> = None;
let mut best_pct: i64 = i64::MIN;
let mut best_epoch: Option<i64> = None;
candidates.sort_by(|a, b| a.name.cmp(b.name));
for c in &candidates {
if !include_current && !current_profile.is_empty() && c.name == current_profile {
continue;
}
if c.session_pct >= lim {
continue;
}
if c.week_pct >= sat {
continue;
}
let epoch: Option<i64> = c
.resets
.and_then(|r| resets_to_epoch(r).ok())
.map(|dt| dt.timestamp());
if best_name.is_none() {
best_name = Some(c.name);
best_pct = c.week_pct;
best_epoch = epoch;
continue;
}
if c.week_pct > best_pct {
best_name = Some(c.name);
best_pct = c.week_pct;
best_epoch = epoch;
} else if c.week_pct == best_pct {
let new_wins = match (epoch, best_epoch) {
(Some(_), None) => true, (Some(e), Some(be)) => e < be, _ => false, };
if new_wins {
best_name = Some(c.name);
best_pct = c.week_pct;
best_epoch = epoch;
}
}
}
match best_name {
None if candidates.is_empty() => Err(ScoringError::NoUsableData),
None => Err(ScoringError::AllSaturated),
Some(name) => {
if include_current && !current_profile.is_empty() && name == current_profile {
Ok(None)
} else {
Ok(Some(name.to_owned()))
}
}
}
}
#[allow(dead_code)]
pub fn current_usage_pcts(data: &UsageData, profile: &str) -> Option<(i64, i64)> {
data.current_usage(profile)
}
#[allow(dead_code)]
pub fn is_excluded(data: &UsageData, profile: &str) -> bool {
let lim = limit_pct();
let sat = saturation_pct();
if let Some(errors) = &data.errors {
if errors.contains_key(profile) {
return true;
}
}
if let Some(pu) = data.profiles.get(profile) {
let session_pct = pu
.session
.as_ref()
.map(|s| s.pct)
.unwrap_or(ABSENT_SESSION_PCT);
if session_pct >= lim {
return true;
}
if let Some(wa) = &pu.week_all {
if wa.pct >= sat {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use crate::usage::model::{ProfileUsage, UsageData, UsageSection};
use super::*;
fn make_section(pct: i64, resets: Option<&str>) -> UsageSection {
UsageSection {
pct,
resets: resets.map(String::from),
}
}
fn make_profile(session_pct: Option<i64>, week_pct: i64, resets: Option<&str>) -> ProfileUsage {
ProfileUsage {
captured_at: None,
session: session_pct.map(|p| make_section(p, None)),
week_all: Some(make_section(week_pct, resets)),
week_sonnet: None,
session_stats: vec![],
}
}
fn make_data(profiles: HashMap<String, ProfileUsage>) -> UsageData {
UsageData {
captured_at: None,
profiles,
errors: None,
}
}
fn make_data_with_errors(
profiles: HashMap<String, ProfileUsage>,
errors: HashMap<String, String>,
) -> UsageData {
UsageData {
captured_at: None,
profiles,
errors: Some(errors),
}
}
#[test]
fn constants_are_sane() {
const {
assert!(
LIMIT_PCT > SATURATION_PCT,
"LIMIT_PCT must be > SATURATION_PCT"
)
};
const { assert!(ABSENT_SESSION_PCT < 0, "absent sentinel must be negative") };
}
#[test]
fn one_saturated_one_healthy_picks_healthy() {
let mut profiles = HashMap::new();
profiles.insert("saturated".to_string(), make_profile(Some(10), 96, None));
profiles.insert("healthy".to_string(), make_profile(Some(5), 60, None));
let data = make_data(profiles);
let result = pick_best(&data, "other", false).unwrap();
assert_eq!(result.as_deref(), Some("healthy"));
}
#[test]
fn picks_highest_week_pct() {
let mut profiles = HashMap::new();
profiles.insert("low".to_string(), make_profile(Some(5), 30, None));
profiles.insert("high".to_string(), make_profile(Some(5), 70, None));
let data = make_data(profiles);
let result = pick_best(&data, "", false).unwrap();
assert_eq!(result.as_deref(), Some("high"));
}
#[test]
fn tiebreak_no_resets_alphabetical_first_wins() {
let mut profiles = HashMap::new();
profiles.insert("alpha".to_string(), make_profile(Some(5), 50, None));
profiles.insert("beta".to_string(), make_profile(Some(5), 50, None));
let data = make_data(profiles);
let result = pick_best(&data, "", false).unwrap();
assert!(result.is_some(), "tie-break must return Some");
assert_eq!(
result.as_deref(),
Some("alpha"),
"with equal pct and no epochs, first alphabetical candidate wins"
);
}
#[test]
fn tiebreak_equal_pct_and_no_epoch_first_alphabetical_wins() {
let mut profiles = HashMap::new();
profiles.insert("early".to_string(), make_profile(Some(5), 50, None));
profiles.insert("zeta".to_string(), make_profile(Some(5), 50, None));
let data = make_data(profiles);
let result = pick_best(&data, "", false).unwrap();
assert_eq!(result.as_deref(), Some("early"));
}
#[test]
fn include_current_no_op_when_winner_is_current() {
let mut profiles = HashMap::new();
profiles.insert("current".to_string(), make_profile(Some(10), 70, None));
let data = make_data(profiles);
let result = pick_best(&data, "current", true).unwrap();
assert_eq!(
result, None,
"winner == current with include_current=true must be None"
);
}
#[test]
fn include_current_returns_better_profile() {
let mut profiles = HashMap::new();
profiles.insert("current".to_string(), make_profile(Some(10), 30, None));
profiles.insert("better".to_string(), make_profile(Some(5), 70, None));
let data = make_data(profiles);
let result = pick_best(&data, "current", true).unwrap();
assert_eq!(result.as_deref(), Some("better"));
}
#[test]
fn include_current_sole_saturated_current_is_all_saturated_not_noop() {
let mut profiles = HashMap::new();
profiles.insert("current".to_string(), make_profile(Some(10), 97, None)); let data = make_data(profiles);
let err = pick_best(&data, "current", true).unwrap_err();
assert!(
matches!(err, ScoringError::AllSaturated),
"a saturated sole current must be AllSaturated, not a no-op Ok(None)"
);
}
fn make_profile_no_week(session_pct: Option<i64>) -> ProfileUsage {
ProfileUsage {
captured_at: None,
session: session_pct.map(|p| make_section(p, None)),
week_all: None,
week_sonnet: None,
session_stats: vec![],
}
}
#[test]
fn no_week_data_anywhere_is_no_usable_data_not_saturated() {
let mut profiles = HashMap::new();
profiles.insert("a".to_string(), make_profile_no_week(Some(10)));
profiles.insert("b".to_string(), make_profile_no_week(None));
let data = make_data(profiles);
let err = pick_best(&data, "", true).unwrap_err();
assert!(
matches!(err, ScoringError::NoUsableData),
"all-no-week must be NoUsableData (open picker), got {err:?}"
);
}
#[test]
fn real_saturation_stays_all_saturated_not_no_usable_data() {
let mut profiles = HashMap::new();
profiles.insert("a".to_string(), make_profile(Some(10), 96, None)); profiles.insert("b".to_string(), make_profile(Some(10), 98, None));
let data = make_data(profiles);
let err = pick_best(&data, "", true).unwrap_err();
assert!(
matches!(err, ScoringError::AllSaturated),
"real all-saturated must stay AllSaturated, got {err:?}"
);
}
#[test]
fn exclude_current_in_reactive_mode() {
let mut profiles = HashMap::new();
profiles.insert("current".to_string(), make_profile(Some(10), 80, None));
profiles.insert("alt".to_string(), make_profile(Some(5), 40, None));
let data = make_data(profiles);
let result = pick_best(&data, "current", false).unwrap();
assert_eq!(
result.as_deref(),
Some("alt"),
"reactive mode must not return current"
);
}
#[test]
fn all_saturated_returns_error() {
let mut profiles = HashMap::new();
profiles.insert("p1".to_string(), make_profile(Some(10), 95, None)); profiles.insert("p2".to_string(), make_profile(Some(10), 98, None));
let data = make_data(profiles);
let err = pick_best(&data, "other", false).unwrap_err();
assert!(
matches!(err, ScoringError::AllSaturated),
"all saturated must return AllSaturated"
);
}
#[test]
fn empty_profiles_is_no_usable_data() {
let data = make_data(HashMap::new());
let err = pick_best(&data, "", false).unwrap_err();
assert!(matches!(err, ScoringError::NoUsableData));
}
#[test]
fn errored_profile_excluded() {
let mut profiles = HashMap::new();
profiles.insert("errored".to_string(), make_profile(Some(5), 80, None));
profiles.insert("healthy".to_string(), make_profile(Some(5), 50, None));
let mut errors = HashMap::new();
errors.insert(
"errored".to_string(),
"HTTP 401: no credentials".to_string(),
);
let data = make_data_with_errors(profiles, errors);
let result = pick_best(&data, "", false).unwrap();
assert_eq!(
result.as_deref(),
Some("healthy"),
"errored profile must be excluded"
);
}
#[test]
fn all_errored_is_no_usable_data() {
let mut profiles = HashMap::new();
profiles.insert("p1".to_string(), make_profile(Some(5), 50, None));
let mut errors = HashMap::new();
errors.insert("p1".to_string(), "error".to_string());
let data = make_data_with_errors(profiles, errors);
let err = pick_best(&data, "", false).unwrap_err();
assert!(matches!(err, ScoringError::NoUsableData));
}
#[test]
fn session_limited_excluded() {
let mut profiles = HashMap::new();
profiles.insert("session_hit".to_string(), make_profile(Some(99), 20, None));
profiles.insert("healthy".to_string(), make_profile(Some(5), 10, None));
let data = make_data(profiles);
let result = pick_best(&data, "other", false).unwrap();
assert_eq!(result.as_deref(), Some("healthy"));
}
#[test]
fn absent_session_pct_not_excluded() {
let mut profiles = HashMap::new();
profiles.insert("no_session".to_string(), make_profile(None, 50, None));
let data = make_data(profiles);
let result = pick_best(&data, "", false).unwrap();
assert_eq!(result.as_deref(), Some("no_session"));
}
#[test]
fn is_excluded_for_error_profile() {
let mut profiles = HashMap::new();
profiles.insert("p".to_string(), make_profile(Some(5), 50, None));
let mut errors = HashMap::new();
errors.insert("p".to_string(), "err".to_string());
let data = make_data_with_errors(profiles, errors);
assert!(is_excluded(&data, "p"));
}
#[test]
fn is_excluded_for_session_limited() {
let mut profiles = HashMap::new();
profiles.insert("p".to_string(), make_profile(Some(LIMIT_PCT), 50, None));
let data = make_data(profiles);
assert!(is_excluded(&data, "p"));
}
#[test]
fn is_excluded_for_saturated() {
let mut profiles = HashMap::new();
profiles.insert("p".to_string(), make_profile(Some(5), SATURATION_PCT, None));
let data = make_data(profiles);
assert!(is_excluded(&data, "p"));
}
#[test]
fn is_excluded_healthy_is_false() {
let mut profiles = HashMap::new();
profiles.insert("p".to_string(), make_profile(Some(5), 50, None));
let data = make_data(profiles);
assert!(!is_excluded(&data, "p"));
}
#[test]
fn current_usage_pcts_present_profile() {
let mut profiles = HashMap::new();
profiles.insert("p".to_string(), make_profile(Some(42), 31, None));
let data = make_data(profiles);
let (sess, week) = current_usage_pcts(&data, "p").unwrap();
assert_eq!(sess, 42);
assert_eq!(week, 31);
}
#[test]
fn current_usage_pcts_absent_session_encodes_minus_one() {
let mut profiles = HashMap::new();
profiles.insert("p".to_string(), make_profile(None, 55, None));
let data = make_data(profiles);
let (sess, week) = current_usage_pcts(&data, "p").unwrap();
assert_eq!(sess, ABSENT_SESSION_PCT);
assert_eq!(week, 55);
}
#[test]
fn current_usage_pcts_errored_is_none() {
let mut profiles = HashMap::new();
profiles.insert("p".to_string(), make_profile(Some(5), 50, None));
let mut errors = HashMap::new();
errors.insert("p".to_string(), "err".to_string());
let data = make_data_with_errors(profiles, errors);
assert!(current_usage_pcts(&data, "p").is_none());
}
#[test]
fn current_usage_pcts_absent_is_none() {
let data = make_data(HashMap::new());
assert!(current_usage_pcts(&data, "nonexistent").is_none());
}
#[test]
fn profile_without_week_all_is_excluded() {
let mut profiles = HashMap::new();
let pu = ProfileUsage {
captured_at: None,
session: Some(make_section(5, None)),
week_all: None,
week_sonnet: None,
session_stats: vec![],
};
profiles.insert("no_week_all".to_string(), pu);
profiles.insert("has_week_all".to_string(), make_profile(Some(5), 40, None));
let data = make_data(profiles);
let result = pick_best(&data, "", false).unwrap();
assert_eq!(result.as_deref(), Some("has_week_all"));
}
#[test]
fn reactive_mode_empty_current_includes_all() {
let mut profiles = HashMap::new();
profiles.insert("p".to_string(), make_profile(Some(5), 60, None));
let data = make_data(profiles);
let result = pick_best(&data, "", false).unwrap();
assert_eq!(result.as_deref(), Some("p"));
}
#[test]
fn reactive_mode_picks_non_current_profile() {
let mut profiles = HashMap::new();
profiles.insert("alt".to_string(), make_profile(Some(10), 50, None));
let data = make_data(profiles);
let result = pick_best(&data, "main", false).unwrap();
assert_eq!(result.as_deref(), Some("alt"));
}
}