use anyhow::Result;
use crate::actions::{switch_off, switch_profile};
use crate::lock::with_state_lock;
use crate::profile::{AppConfig, Profile};
use crate::usage::{UsageStore, UsageWindow, five_hour_live, iso_to_epoch_secs, now_epoch_secs};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SwitchAction {
To(String),
Off,
}
pub(crate) const DEFAULT_THRESHOLD: f64 = 95.0;
pub(crate) fn threshold_for(profile: &Profile) -> f64 {
profile.fallback_threshold.unwrap_or(DEFAULT_THRESHOLD)
}
fn live_five_hour(profile: &Profile) -> Option<&UsageWindow> {
let usage = profile.usage.as_ref()?;
if !five_hour_live(usage, now_epoch_secs()) {
return None;
}
usage.five_hour.as_ref()
}
pub(crate) fn is_exhausted(profile: &Profile) -> bool {
live_five_hour(profile).is_some_and(|w| w.utilization >= threshold_for(profile))
}
fn burn_rate_for_profile(name: &str, window: &UsageWindow) -> Option<f64> {
let history = crate::profile::load_usage_history(name);
let pair = ("5h", window);
crate::usage::compute_burn_rates_from_history(
&history,
std::slice::from_ref(&pair),
crate::usage::BURN_LOOKBACK_MS,
crate::usage::BURN_MIN_SAMPLES,
crate::usage::BURN_GAP_CUT_MS,
)
.remove("5h")
.flatten()
}
fn is_exhausted_projected(
util_pct: f64,
threshold: f64,
burn_pct_per_hour: Option<f64>,
interval_ms: u64,
) -> bool {
match burn_pct_per_hour {
Some(rate) => crate::usage::project_utilization(util_pct, rate, interval_ms) >= 100.0,
None => util_pct >= threshold,
}
}
pub(crate) fn is_exhausted_active(
profile: &Profile,
burn_aware: bool,
interval_ms: u64,
active_burn_pct_per_hour: Option<f64>,
) -> bool {
let Some(window) = live_five_hour(profile) else {
return false;
};
let threshold = threshold_for(profile);
if !burn_aware {
return window.utilization >= threshold;
}
is_exhausted_projected(
window.utilization,
threshold,
active_burn_pct_per_hour,
interval_ms,
)
}
pub(crate) fn soonest_resume(config: &AppConfig) -> Option<(String, i64)> {
let chain = &config.state.fallback_chain;
if chain.is_empty() {
return None;
}
let now = now_epoch_secs();
let mut best: Option<(&str, i64)> = None;
for name in chain {
let profile = config.find(name)?;
if !is_exhausted(profile) {
return None;
}
let resets_at = profile
.usage
.as_ref()?
.five_hour
.as_ref()?
.resets_at
.as_deref()
.and_then(iso_to_epoch_secs)?;
if best.is_none_or(|(_, cur)| resets_at < cur) {
best = Some((name.as_str(), resets_at));
}
}
let (name, resets_at) = best?;
Some((name.to_string(), (resets_at - now).max(0)))
}
#[derive(Debug, Clone)]
pub(crate) struct ChainMember {
pub(crate) name: String,
pub(crate) threshold: f64,
pub(crate) last_resort: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct ChainSnapshot {
pub(crate) active: String,
pub(crate) chain: Vec<ChainMember>,
pub(crate) wrap_off: bool,
pub(crate) burn_aware: bool,
pub(crate) interval_ms: u64,
}
pub(crate) fn snapshot_chain(config: &AppConfig) -> Option<ChainSnapshot> {
let active = config.state.active_profile.as_deref()?.to_string();
let chain = &config.state.fallback_chain;
if !chain.iter().any(|n| n == &active) {
return None;
}
let chain = chain
.iter()
.map(|name| {
let profile = config.find(name);
ChainMember {
name: name.to_string(),
threshold: profile.map(threshold_for).unwrap_or(DEFAULT_THRESHOLD),
last_resort: profile.is_some_and(|p| p.last_resort),
}
})
.collect();
Some(ChainSnapshot {
active,
chain,
wrap_off: config.state.wrap_off,
burn_aware: config.state.burn_aware_switching,
interval_ms: config.state.refresh_interval_ms,
})
}
fn is_exhausted_from_store(name: &str, threshold: f64, store: &UsageStore) -> bool {
let now = now_epoch_secs();
match store.lock() {
Ok(s) => s.get(name).is_some_and(|info| {
five_hour_live(info, now)
&& info
.five_hour
.as_ref()
.is_some_and(|w| w.utilization >= threshold)
}),
Err(_) => false,
}
}
fn is_exhausted_active_from_store(
name: &str,
threshold: f64,
burn_aware: bool,
interval_ms: u64,
store: &UsageStore,
) -> bool {
let now = now_epoch_secs();
let window: Option<UsageWindow> = match store.lock() {
Ok(s) => s.get(name).and_then(|info| {
if five_hour_live(info, now) {
info.five_hour.clone()
} else {
None
}
}),
Err(_) => None,
};
let Some(window) = window else {
return false;
};
if !burn_aware {
return window.utilization >= threshold;
}
let rate = burn_rate_for_profile(name, &window);
is_exhausted_projected(window.utilization, threshold, rate, interval_ms)
}
fn walk_chain(
idx: usize,
len: usize,
skip_pred: &dyn Fn(usize) -> bool,
accept_pred: &dyn Fn(usize) -> bool,
) -> Option<usize> {
for offset in 1..=len {
let i = (idx + offset) % len;
if skip_pred(i) {
continue;
}
if accept_pred(i) {
return Some(i);
}
}
None
}
pub(crate) fn next_target(
config: &AppConfig,
active_burn_pct_per_hour: Option<f64>,
) -> Option<SwitchAction> {
let active = config.state.active_profile.as_deref()?;
let chain = &config.state.fallback_chain;
let active_idx = chain.iter().position(|n| n == active)?;
let len = chain.len();
let skip = |i: usize| chain[i] == active || config.find(&chain[i]).is_none();
let walk = |accept: &dyn Fn(&Profile) -> bool| -> Option<String> {
let pick = walk_chain(active_idx, len, &skip, &|i| {
config.find(&chain[i]).is_some_and(&accept)
});
pick.map(|i| chain[i].to_string())
};
if let Some(name) = walk(&|p| !is_exhausted(p)) {
return Some(SwitchAction::To(name));
}
let active_is_last_resort = config.find(active).is_some_and(|p| p.last_resort);
if active_is_last_resort {
return None;
}
if let Some(name) = walk(&|p| p.last_resort) {
return Some(SwitchAction::To(name));
}
if config.state.wrap_off
&& config.find(active).is_some_and(|p| {
is_exhausted_active(
p,
config.state.burn_aware_switching,
config.state.refresh_interval_ms,
active_burn_pct_per_hour,
)
})
{
return Some(SwitchAction::Off);
}
None
}
pub(crate) fn next_auto_switch_target(
snapshot: &ChainSnapshot,
store: &UsageStore,
) -> Option<SwitchAction> {
let active_idx = snapshot
.chain
.iter()
.position(|m| m.name == snapshot.active)?;
let len = snapshot.chain.len();
let active = &snapshot.chain[active_idx];
if !is_exhausted_active_from_store(
&active.name,
active.threshold,
snapshot.burn_aware,
snapshot.interval_ms,
store,
) {
return None;
}
let skip = |i: usize| snapshot.chain[i].name == active.name;
let walk = |accept: &dyn Fn(&ChainMember) -> bool| -> Option<String> {
let pick = walk_chain(active_idx, len, &skip, &|i| accept(&snapshot.chain[i]));
pick.map(|i| snapshot.chain[i].name.clone())
};
if let Some(name) = walk(&|m| !is_exhausted_from_store(&m.name, m.threshold, store)) {
return Some(SwitchAction::To(name));
}
let active_is_last_resort = active.last_resort;
if active_is_last_resort {
return None;
}
if let Some(name) = walk(&|m| m.last_resort) {
return Some(SwitchAction::To(name));
}
if snapshot.wrap_off {
return Some(SwitchAction::Off);
}
None
}
pub(crate) fn find_recovered_member(chain: &[ChainMember], store: &UsageStore) -> Option<String> {
let now = now_epoch_secs();
for member in chain {
let recovered = match store.lock() {
Ok(s) => s.get(&member.name).map(|info| {
!five_hour_live(info, now)
|| info
.five_hour
.as_ref()
.is_none_or(|w| w.utilization < member.threshold)
}),
Err(_) => None,
};
if recovered == Some(true) {
return Some(member.name.clone());
}
}
None
}
pub(crate) fn auto_switch_if_needed(
config: &mut AppConfig,
active_burn_pct_per_hour: Option<f64>,
) -> Result<Option<SwitchAction>> {
with_state_lock(|| {
let Some(active_name) = config.state.active_profile.as_deref() else {
return Ok(None);
};
if !config.state.fallback_chain.iter().any(|n| n == active_name) {
return Ok(None);
}
let Some(active) = config.find(active_name) else {
return Ok(None);
};
if !is_exhausted_active(
active,
config.state.burn_aware_switching,
config.state.refresh_interval_ms,
active_burn_pct_per_hour,
) {
return Ok(None);
}
let Some(action) = next_target(config, active_burn_pct_per_hour) else {
return Ok(None);
};
match &action {
SwitchAction::To(target) => switch_profile(config, target)?,
SwitchAction::Off => switch_off(config)?,
}
Ok(Some(action))
})
}
#[cfg(test)]
#[path = "../tests/inline/fallback.rs"]
mod tests;