use crate::types::AgentKind;
use chrono::{Datelike, Duration, Local, NaiveDateTime};
pub(crate) struct QuotaSignature {
pub(crate) agent: AgentKind,
pub(crate) needle: &'static str,
pub(crate) fallback_minutes: i64,
}
pub(crate) const QUOTA_SIGNATURES: &[QuotaSignature] = &[
QuotaSignature { agent: AgentKind::Qwen, needle: "quota has been exhausted", fallback_minutes: 300 },
QuotaSignature { agent: AgentKind::Qwen, needle: "quota exhausted", fallback_minutes: 300 },
QuotaSignature { agent: AgentKind::Droid, needle: "weekly standard usage limit", fallback_minutes: 1440 },
QuotaSignature { agent: AgentKind::Codex, needle: "hit your usage limit", fallback_minutes: 300 },
QuotaSignature { agent: AgentKind::Oz, needle: "quota limit reached", fallback_minutes: 60 },
QuotaSignature { agent: AgentKind::Antigravity, needle: "individual quota reached", fallback_minutes: 60 },
];
pub(crate) fn match_quota_signature(message: &str) -> Option<(AgentKind, i64)> {
let lower = message.to_lowercase();
QUOTA_SIGNATURES
.iter()
.find(|signature| lower.contains(signature.needle))
.map(|signature| (signature.agent, signature.fallback_minutes))
}
pub(crate) fn parse_relative_recovery(message: &str) -> Option<NaiveDateTime> {
let lower = message.to_lowercase();
let now = Local::now().naive_local();
if let Some(duration) = parse_compact_duration(&lower) {
return Some(now + duration);
}
if let Some(duration) = parse_resets_in(&lower) {
return Some(now + duration);
}
if let Some(at) = parse_reset_at_utc(&lower) {
return Some(at);
}
if let Some(hours) = parse_hyphenated_hours(&lower) {
return Some(now + Duration::hours(hours));
}
None
}
fn parse_reset_at_utc(lower: &str) -> Option<NaiveDateTime> {
let idx = lower.find("reset at ")?;
let rest = lower[idx + "reset at ".len()..].trim();
let stamp: String = rest.chars().take(14).collect();
let year = Local::now().naive_local().year();
let candidate =
NaiveDateTime::parse_from_str(&format!("{year}-{stamp}"), "%Y-%m-%d %H:%M:%S").ok()?;
let utc_offset = Local::now().offset().local_minus_utc();
Some(candidate + Duration::seconds(i64::from(utc_offset)))
}
fn parse_compact_duration(lower: &str) -> Option<Duration> {
let idx = lower.find("resets in ").or_else(|| lower.find("try again in "))?;
let rest = lower[idx..].split_once(" in ")?.1.trim();
let token: String = rest
.chars()
.take_while(|c| c.is_ascii_digit() || matches!(c, 'd' | 'h' | 'm' | 's'))
.collect();
if token.is_empty() || !token.chars().any(|c| c.is_ascii_alphabetic()) {
return None;
}
let mut total = Duration::zero();
let mut number = String::new();
for ch in token.chars() {
if ch.is_ascii_digit() {
number.push(ch);
continue;
}
let amount: i64 = number.parse().ok()?;
number.clear();
total = total
+ match ch {
'd' => Duration::days(amount),
'h' => Duration::hours(amount),
'm' => Duration::minutes(amount),
's' => Duration::seconds(amount),
_ => return None,
};
}
(total > Duration::zero()).then_some(total)
}
fn parse_resets_in(lower: &str) -> Option<Duration> {
let idx = lower.find("resets in ").or_else(|| lower.find("try again in "))?;
let rest = &lower[idx..];
let rest = rest.split_once(" in ")?.1;
let mut parts = rest.split_whitespace();
let amount: i64 = parts.next()?.parse().ok()?;
let unit = parts.next()?.trim_end_matches([',', '.', ')']);
unit_to_duration(unit, amount)
}
fn parse_hyphenated_hours(lower: &str) -> Option<i64> {
let idx = lower.find("-hour")?;
let head = &lower[..idx];
let digits: String = head.chars().rev().take_while(char::is_ascii_digit).collect();
if digits.is_empty() {
return None;
}
digits.chars().rev().collect::<String>().parse().ok()
}
fn unit_to_duration(unit: &str, amount: i64) -> Option<Duration> {
match unit {
u if u.starts_with("minute") || u == "min" || u == "mins" => Some(Duration::minutes(amount)),
u if u.starts_with("hour") || u == "hr" || u == "hrs" => Some(Duration::hours(amount)),
u if u.starts_with("day") => Some(Duration::days(amount)),
u if u.starts_with("week") => Some(Duration::weeks(amount)),
_ => None,
}
}
#[cfg(test)]
#[path = "rate_limit_signatures_tests.rs"]
mod tests;