use crate::utils::time;
use serde_json::Value;
pub(super) fn as_f64(value: &Value) -> Option<f64> {
value
.as_f64()
.or_else(|| value.as_str().and_then(|text| text.trim().parse().ok()))
}
pub(super) fn value_text(value: &Value) -> Option<String> {
if let Some(text) = value.as_str() {
return Some(text.to_string());
}
value.as_f64().map(|number| {
if number.fract() == 0.0 {
format!("{}", number as i64)
} else {
format!("{number}")
}
})
}
pub(super) fn clamp_percent(percent: f64) -> f32 {
if percent.is_finite() {
percent.clamp(0.0, 100.0) as f32
} else {
0.0
}
}
pub(super) fn ratio_percent(value: f64) -> f32 {
if value <= 1.0 {
clamp_percent(value * 100.0)
} else {
clamp_percent(value)
}
}
pub(super) fn used_limit_percent(used: f64, limit: f64) -> Option<f32> {
if !limit.is_finite() || limit <= 0.0 {
return None;
}
Some(clamp_percent((used.max(0.0) / limit) * 100.0))
}
pub(super) fn percent_from_object(object: &serde_json::Map<String, Value>) -> Option<f32> {
for key in ["usage_percent", "used_percent", "percent_used"] {
if let Some(value) = object.get(key).and_then(as_f64) {
return Some(clamp_percent(value));
}
}
for key in ["usage", "utilization", "usage_ratio", "used_ratio"] {
if let Some(value) = object.get(key).and_then(as_f64) {
return Some(ratio_percent(value));
}
}
let used = object.get("used").and_then(as_f64);
let limit = object
.get("limit")
.or_else(|| object.get("max"))
.and_then(as_f64);
if let (Some(used), Some(limit)) = (used, limit) {
return used_limit_percent(used, limit);
}
let remaining = object.get("remaining").and_then(as_f64);
if let (Some(remaining), Some(limit)) = (remaining, limit) {
return used_limit_percent(limit - remaining, limit);
}
None
}
pub(super) fn resets_at_from_object(object: &serde_json::Map<String, Value>) -> Option<u64> {
[
"resets_at",
"reset_at",
"resetsAt",
"resetAt",
"reset_time",
"resetTime",
"nextResetTime",
]
.iter()
.find_map(|key| object.get(*key).and_then(parse_reset_ms))
}
pub(super) fn parse_reset_ms(value: &Value) -> Option<u64> {
if let Some(text) = value.as_str() {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
if let Some(epoch) = trimmed.parse::<f64>().ok().and_then(epoch_to_ms) {
return Some(epoch);
}
if let Some(ms) = time::parse_timestamp_ms(trimmed) {
return Some(ms);
}
let date_only = format!("{trimmed}T00:00:00Z");
return time::parse_timestamp_ms(&date_only);
}
value.as_f64().and_then(epoch_to_ms)
}
pub(super) fn epoch_to_ms(epoch: f64) -> Option<u64> {
if !epoch.is_finite() || epoch <= 0.0 {
return None;
}
let ms = if epoch > 1.0e12 {
epoch
} else {
epoch * 1000.0
};
Some(ms as u64)
}
pub(super) fn window_label(seconds: f64) -> String {
let minutes = seconds / 60.0;
match minutes {
300.0 => "5-hour".to_string(),
10080.0 => "Weekly".to_string(),
m if (43200.0..=44640.0).contains(&m) => "Monthly".to_string(),
m if m >= 1440.0 && m % 1440.0 == 0.0 => format!("{}d", m / 1440.0),
m if m >= 60.0 && m % 60.0 == 0.0 => format!("{}h", m / 60.0),
m if m >= 1.0 => format!("{}m", m),
_ => format!("{}s", seconds),
}
}
pub(super) fn humanize_key(key: &str) -> String {
let mut out = String::with_capacity(key.len());
let mut upper = true;
for ch in key.chars() {
if ch == '_' || ch == '-' {
out.push(' ');
upper = true;
} else if upper {
out.extend(ch.to_uppercase());
upper = false;
} else {
out.push(ch);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn percent_helpers_clamp_and_normalize() {
assert_eq!(clamp_percent(150.0), 100.0);
assert_eq!(clamp_percent(-5.0), 0.0);
assert_eq!(clamp_percent(f64::NAN), 0.0);
assert_eq!(ratio_percent(0.42), 42.0);
assert_eq!(ratio_percent(42.0), 42.0);
assert_eq!(ratio_percent(1.0), 100.0);
assert_eq!(used_limit_percent(5.0, 10.0), Some(50.0));
assert_eq!(used_limit_percent(5.0, 0.0), None, "无上限画不出进度条");
assert_eq!(used_limit_percent(-1.0, 10.0), Some(0.0));
}
#[test]
fn epoch_and_reset_times_normalize_to_ms() {
assert_eq!(
parse_reset_ms(&serde_json::json!(1_700_000_000)),
Some(1_700_000_000_000)
);
assert_eq!(
parse_reset_ms(&serde_json::json!(1_700_000_000_000u64)),
Some(1_700_000_000_000),
"大于 1e12 按毫秒算"
);
assert_eq!(
parse_reset_ms(&serde_json::json!("1700000000")),
Some(1_700_000_000_000),
"数字字符串按 epoch 秒"
);
assert_eq!(
parse_reset_ms(&serde_json::json!("2026-11-01T00:00:00Z")),
Some(1_793_491_200_000)
);
assert_eq!(
parse_reset_ms(&serde_json::json!("2026-11-01T08:00:00+08:00")),
Some(1_793_491_200_000),
"带时区偏移的写法按偏移换算,不能当成本地钟点"
);
assert_eq!(
parse_reset_ms(&serde_json::json!("2026-11-01")),
Some(1_793_491_200_000),
"纯日期补成当天零点"
);
assert_eq!(parse_reset_ms(&serde_json::json!(0)), None);
assert_eq!(parse_reset_ms(&serde_json::json!("")), None);
assert_eq!(parse_reset_ms(&serde_json::json!("not a time")), None);
assert_eq!(parse_reset_ms(&serde_json::Value::Null), None);
}
#[test]
fn window_labels_cover_common_durations() {
assert_eq!(window_label(18_000.0), "5-hour");
assert_eq!(window_label(604_800.0), "Weekly");
assert_eq!(window_label(2_592_000.0), "Monthly");
assert_eq!(window_label(86_400.0), "1d");
assert_eq!(window_label(7_200.0), "2h");
assert_eq!(window_label(90.0), "1.5m");
assert_eq!(window_label(30.0), "30s");
}
#[test]
fn percent_from_object_prefers_explicit_percent_fields() {
let object = serde_json::json!({"usage_percent": 12.0, "used_ratio": 0.5});
assert_eq!(percent_from_object(object.as_object().unwrap()), Some(12.0));
let object = serde_json::json!({"used_ratio": 0.5});
assert_eq!(percent_from_object(object.as_object().unwrap()), Some(50.0));
let object = serde_json::json!({"used": 3, "limit": 4});
assert_eq!(percent_from_object(object.as_object().unwrap()), Some(75.0));
let object = serde_json::json!({"remaining": 1, "limit": 4});
assert_eq!(percent_from_object(object.as_object().unwrap()), Some(75.0));
let object = serde_json::json!({"nothing": 1});
assert_eq!(percent_from_object(object.as_object().unwrap()), None);
}
#[test]
fn humanize_key_splits_separators() {
assert_eq!(humanize_key("premium_requests"), "Premium Requests");
assert_eq!(humanize_key("chat-messages"), "Chat Messages");
assert_eq!(humanize_key("sku"), "Sku");
}
}