use super::{rate_limit::RateLimitState, stream::RateLimitInfo, window_kind::WindowKind};
const HEADER_SCAN_LINES: usize = 6;
struct HeaderHit {
key: String,
line: usize,
start: usize,
end: usize,
}
pub(crate) fn parse_usage_screen(text: &str, now_unix: i64) -> Option<RateLimitState> {
let lines: Vec<String> = text.lines().map(strip_box_drawing).collect();
let line_refs: Vec<&str> = lines.iter().map(String::as_str).collect();
let hits = find_headers(&line_refs);
let mut state = RateLimitState::default();
for (index, hit) in hits.iter().enumerate() {
let columns = hits
.iter()
.any(|other| other.line == hit.line && other.start != hit.start);
let region = region_text(&line_refs, hit, hits.get(index + 1), columns);
if let Some(info) = parse_window_block(&hit.key, ®ion, now_unix) {
state.merge_window(super::rate_limit::RateLimitObservation::capture(info));
}
}
if state.is_empty() {
None
} else {
Some(state)
}
}
pub(crate) fn named_window_keys(text: &str) -> Vec<String> {
let lines: Vec<String> = text.lines().map(strip_box_drawing).collect();
let line_refs: Vec<&str> = lines.iter().map(String::as_str).collect();
find_headers(&line_refs)
.into_iter()
.map(|hit| hit.key)
.collect()
}
fn find_headers(lines: &[&str]) -> Vec<HeaderHit> {
let mut hits = Vec::new();
for (line, text) in lines.iter().enumerate() {
let lower = text.to_ascii_lowercase();
push_ascii_hit(
&mut hits,
line,
&lower,
"current session",
WindowKind::FiveHour.key(),
);
push_ascii_hit(
&mut hits,
line,
&lower,
"extra usage",
WindowKind::ExtraUsage.key(),
);
hits.extend(week_hits(line, &lower));
}
hits.sort_by_key(|hit| (hit.line, hit.start));
hits
}
fn push_ascii_hit(hits: &mut Vec<HeaderHit>, line: usize, lower: &str, needle: &str, key: &str) {
let mut from = 0;
while let Some(offset) = lower[from..].find(needle) {
let byte = from + offset;
hits.push(HeaderHit {
key: key.to_owned(),
line,
start: char_count(lower, byte),
end: char_count(lower, byte + needle.len()),
});
from = byte + needle.len();
}
}
fn week_hits(line: usize, lower: &str) -> Vec<HeaderHit> {
let needle = "current week (";
let mut hits = Vec::new();
let mut from = 0;
while let Some(offset) = lower[from..].find(needle) {
let start_byte = from + offset;
let inner_byte = start_byte + needle.len();
let Some(rel_end) = lower[inner_byte..].find(')') else {
break;
};
let inner = &lower[inner_byte..inner_byte + rel_end];
let end_byte = inner_byte + rel_end + 1;
hits.push(HeaderHit {
key: WindowKind::from_week_inner(inner).key().to_owned(),
line,
start: char_count(lower, start_byte),
end: char_count(lower, end_byte),
});
from = end_byte;
}
hits
}
fn char_count(text: &str, byte: usize) -> usize {
text.get(..byte.min(text.len()))
.map_or(0, |prefix| prefix.chars().count())
}
fn region_text(lines: &[&str], hit: &HeaderHit, next: Option<&HeaderHit>, columns: bool) -> String {
let mut out = String::new();
let header_chars: Vec<char> = lines[hit.line].chars().collect();
let same_line_end = match next {
Some(next) if next.line == hit.line => next.start,
_ => header_chars.len(),
};
let rest_start = hit.end.min(header_chars.len());
let rest_end = same_line_end.min(header_chars.len()).max(rest_start);
out.extend(header_chars[rest_start..rest_end].iter().copied());
out.push('\n');
for offset in 1..=HEADER_SCAN_LINES {
let index = hit.line + offset;
if index >= lines.len() {
break;
}
if !columns && next.is_some_and(|next| next.line == index) {
break;
}
let chars: Vec<char> = lines[index].chars().collect();
if columns {
let end = match next {
Some(next) if next.line == hit.line => next.start,
_ => chars.len(),
};
let start = hit.start.min(chars.len());
let end = end.min(chars.len()).max(start);
out.extend(chars[start..end].iter().copied());
} else {
out.extend(chars);
}
out.push('\n');
}
out
}
fn parse_window_block(window: &str, region: &str, now_unix: i64) -> Option<RateLimitInfo> {
let mut used_percent = None;
let mut resets_at = None;
for line in region.lines() {
if used_percent.is_none() {
used_percent = parse_used_percent(line);
}
if resets_at.is_none() {
resets_at = parse_resets_at(line, now_unix);
}
if used_percent.is_some() && resets_at.is_some() {
break;
}
}
let used_percent = used_percent?;
Some(RateLimitInfo {
status: None,
rate_limit_type: Some(window.to_owned()),
resets_at,
utilization: Some(used_percent / 100.0),
overage_status: None,
overage_resets_at: None,
is_using_overage: None,
})
}
fn strip_box_drawing(line: &str) -> String {
line.trim()
.trim_start_matches(['│', '|', 'â•', 'â•°', 'â•®', '╯'])
.trim_end_matches(['│', '|', 'â•', 'â•°', 'â•®', '╯'])
.trim()
.to_string()
}
fn parse_used_percent(line: &str) -> Option<f64> {
let lower = line.to_ascii_lowercase();
let idx = lower.find("% used").or_else(|| lower.find("%used"))?;
let before = line[..idx].trim_end();
let token = before.split_whitespace().last()?;
let value: f64 = token.parse().ok()?;
if !value.is_finite() {
return None;
}
Some(value.clamp(0.0, 100.0))
}
fn parse_resets_at(line: &str, now_unix: i64) -> Option<i64> {
let lower = line.to_ascii_lowercase();
if !lower.contains("reset") {
return None;
}
if let Some(seconds) = parse_relative_seconds(&lower) {
return Some(now_unix.saturating_add(seconds));
}
parse_local_clock(&lower, now_unix)
}
fn parse_relative_seconds(text: &str) -> Option<i64> {
let start = text.find(" in ")?;
let mut seconds = 0_i64;
let mut saw_unit = false;
for token in text[start + 4..].split(|ch: char| !ch.is_ascii_alphanumeric()) {
if token.is_empty() {
continue;
}
let (digits, unit) = split_trailing_unit(token)?;
let value: i64 = digits.parse().ok()?;
seconds = seconds.saturating_add(match unit {
"d" => value.saturating_mul(86_400),
"h" => value.saturating_mul(3_600),
"m" => value.saturating_mul(60),
"s" => value,
_ => return None,
});
saw_unit = true;
}
saw_unit.then_some(seconds)
}
fn split_trailing_unit(token: &str) -> Option<(&str, &str)> {
let split = token.find(|ch: char| ch.is_ascii_alphabetic())?;
Some((&token[..split], &token[split..]))
}
fn parse_local_clock(text: &str, now_unix: i64) -> Option<i64> {
use chrono::Datelike;
let (hour, minute) = parse_12h_time(text)?;
let now = chrono::DateTime::from_timestamp(now_unix, 0)?
.with_timezone(&chrono::Local)
.naive_local();
let today = now.date();
let naive_time = chrono::NaiveTime::from_hms_opt(hour, minute, 0)?;
let day = parse_day_of_month(text);
let range = if day.is_some() { 0..=8 } else { 0..=1 };
range
.map(|offset| today + chrono::TimeDelta::days(offset))
.filter(|date| day.is_none_or(|day| date.day() == day))
.map(|date| date.and_time(naive_time))
.find(|candidate| *candidate > now)
.and_then(local_timestamp)
}
fn local_timestamp(candidate: chrono::NaiveDateTime) -> Option<i64> {
Some(
candidate
.and_local_timezone(chrono::Local)
.single()?
.timestamp(),
)
}
fn parse_day_of_month(text: &str) -> Option<u32> {
const MONTHS: [&str; 12] = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
];
let (index, month) = MONTHS
.iter()
.filter_map(|month| Some((text.find(month)?, month)))
.min()?;
let rest = text[index + month.len()..].trim_start();
let digits = &rest[..rest
.find(|ch: char| !ch.is_ascii_digit())
.unwrap_or(rest.len())];
let day: u32 = digits.parse().ok()?;
(1..=31).contains(&day).then_some(day)
}
fn parse_12h_time(text: &str) -> Option<(u32, u32)> {
let bytes = text.as_bytes();
let mut index = 0;
while index < bytes.len() {
if !bytes[index].is_ascii_digit() {
index += 1;
continue;
}
let start = index;
while index < bytes.len() && bytes[index].is_ascii_digit() {
index += 1;
}
let hour: u32 = text[start..index].parse().ok()?;
let mut minute = 0;
if index < bytes.len() && bytes[index] == b':' {
index += 1;
let min_start = index;
while index < bytes.len() && bytes[index].is_ascii_digit() {
index += 1;
}
if index - min_start != 2 {
continue;
}
minute = text[min_start..index].parse().ok()?;
}
while index < bytes.len() && bytes[index].is_ascii_whitespace() {
index += 1;
}
let rest = text.get(index..)?;
let meridiem = if rest.starts_with("am") {
"am"
} else if rest.starts_with("pm") {
"pm"
} else {
continue;
};
if hour == 0 || hour > 12 || minute > 59 {
return None;
}
let hour24 = match (hour, meridiem) {
(12, "am") => 0,
(12, "pm") => 12,
(h, "pm") => h + 12,
(h, _) => h,
};
return Some((hour24, minute));
}
None
}
#[cfg(test)]
#[path = "usage_parse_tests.rs"]
mod tests;