aura-gui 0.3.5

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Uso restante de la suscripción de Claude (ventana de 5 horas y semanal), el mismo dato que muestra `/usage` en
//! Claude Code. Es un endpoint no documentado por Anthropic: si cambia o falla, el selector simplemente no lo muestra.

use crate::ai::oauth::{self, Service};
use crate::ai::types::AiError;
use serde_json::Value;
use std::io::Read;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

const URL: &str = "https://api.anthropic.com/api/oauth/usage";

/// Una ventana de límite: porcentaje gastado y cuándo se renueva (segundos desde 1970).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Window {
    pub used_percent: f32,
    pub resets_at: Option<u64>,
}

impl Window {
    /// Tiempo que falta para la renovación, si se conoce y aún no pasó.
    pub fn resets_in(&self, now: u64) -> Option<Duration> {
        self.resets_at.filter(|at| *at > now).map(|at| Duration::from_secs(at - now))
    }
}

#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct SubscriptionUsage {
    pub five_hour: Option<Window>,
    pub weekly: Option<Window>,
}

pub fn now_secs() -> u64 {
    SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |elapsed| elapsed.as_secs())
}

/// Segundos desde 1970 de una fecha ISO 8601 con zona (`2025-11-04T04:59:59.943648+00:00` o `...Z`).
pub fn parse_iso8601(text: &str) -> Option<u64> {
    let (date, rest) = text.split_once('T')?;
    let mut parts = date.split('-').map(|part| part.parse::<i64>().ok());
    let (year, month, day) = (parts.next()??, parts.next()??, parts.next()??);
    let clock: String = rest.chars().take_while(|c| c.is_ascii_digit() || *c == ':').collect();
    let mut hms = clock.split(':').map(|part| part.parse::<i64>().ok());
    let (hour, minute, second) = (hms.next()??, hms.next()??, hms.next().flatten().unwrap_or(0));
    let zone = &rest[clock.len()..];
    let zone = zone.trim_start_matches(|c: char| c == '.' || c.is_ascii_digit());
    let offset = match zone.as_bytes().first() {
        Some(b'+' | b'-') => {
            let sign = if zone.starts_with('-') { -1 } else { 1 };
            let mut pieces = zone[1..].split(':').map(|part| part.parse::<i64>().ok());
            sign * (pieces.next().flatten().unwrap_or(0) * 3600 + pieces.next().flatten().unwrap_or(0) * 60)
        }
        _ => 0,
    };
    // Días desde 1970 (algoritmo de Howard Hinnant).
    let year = if month <= 2 { year - 1 } else { year };
    let era = year.div_euclid(400);
    let year_of_era = year - era * 400;
    let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
    let days = era * 146_097 + day_of_era - 719_468;
    u64::try_from(days * 86_400 + hour * 3600 + minute * 60 + second - offset).ok()
}

fn window(value: &Value) -> Option<Window> {
    let used = value["utilization"].as_f64()? as f32;
    Some(Window {
        used_percent: used.clamp(0.0, 100.0),
        resets_at: value["resets_at"].as_str().and_then(parse_iso8601),
    })
}

pub fn parse(body: &str) -> Result<SubscriptionUsage, AiError> {
    let value: Value = serde_json::from_str(body).map_err(|error| AiError::Decode(error.to_string()))?;
    let usage = SubscriptionUsage { five_hour: window(&value["five_hour"]), weekly: window(&value["seven_day"]) };
    if usage == SubscriptionUsage::default() {
        return Err(AiError::Decode("la respuesta no trae los límites de uso".into()));
    }
    Ok(usage)
}

/// Consulta el uso de la sesión de Claude guardada (bloqueante: llamar desde un hilo de trabajo).
pub fn fetch_claude() -> Result<SubscriptionUsage, AiError> {
    let session = oauth::fresh(Service::Claude)?;
    let agent: ureq::Agent = ureq::Agent::config_builder()
        .timeout_global(Some(Duration::from_secs(10)))
        .http_status_as_error(false)
        .build()
        .into();
    let mut request = agent.get(URL);
    for (name, value) in crate::ai::anthropic::subscription_headers(&session.access) {
        request = request.header(name, value);
    }
    let response = request.call().map_err(|error| AiError::Transport(error.to_string()))?;
    let code = response.status().as_u16();
    let mut text = String::new();
    response
        .into_body()
        .into_reader()
        .take(200_000)
        .read_to_string(&mut text)
        .map_err(|error| AiError::Transport(error.to_string()))?;
    if code >= 400 {
        return Err(AiError::Status { code, body: text.chars().take(300).collect() });
    }
    parse(&text)
}

fn window_json(window: Option<Window>) -> Value {
    window.map_or(Value::Null, |w| serde_json::json!({ "used": w.used_percent, "resets_at": w.resets_at }))
}

fn window_from(value: &Value) -> Option<Window> {
    Some(Window { used_percent: value["used"].as_f64()? as f32, resets_at: value["resets_at"].as_u64() })
}

impl SubscriptionUsage {
    /// Copia en disco del último uso leído, para mostrarlo al arrancar sin volver a consultar el servidor.
    pub fn to_cache(&self, fetched_at: u64) -> String {
        serde_json::json!({
            "fetched_at": fetched_at,
            "five_hour": window_json(self.five_hour),
            "weekly": window_json(self.weekly),
        })
        .to_string()
    }

    pub fn from_cache(text: &str) -> Option<(Self, u64)> {
        let value: Value = serde_json::from_str(text).ok()?;
        let usage = Self { five_hour: window_from(&value["five_hour"]), weekly: window_from(&value["weekly"]) };
        Some((usage, value["fetched_at"].as_u64()?))
    }
}

/// «2 h 10 min», «45 min» o «3 d 4 h»: lo bastante corto para una fila del selector.
pub fn format_duration(duration: Duration) -> String {
    let minutes = duration.as_secs() / 60;
    match (minutes / 1440, minutes / 60 % 24, minutes % 60) {
        (0, 0, minutes) => format!("{minutes} min"),
        (0, hours, minutes) => format!("{hours} h {minutes} min"),
        (days, hours, _) => format!("{days} d {hours} h"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn fechas_iso_con_y_sin_zona() {
        assert_eq!(parse_iso8601("1970-01-01T00:00:00Z"), Some(0));
        assert_eq!(parse_iso8601("2025-11-04T04:59:59.943648+00:00"), Some(1_762_232_399));
        assert_eq!(parse_iso8601("2025-11-04T00:59:59-04:00"), Some(1_762_232_399));
        assert_eq!(parse_iso8601("2024-02-29T12:00:00Z"), Some(1_709_208_000));
        assert_eq!(parse_iso8601("no es fecha"), None);
    }

    #[test]
    fn lee_las_dos_ventanas_y_calcula_lo_que_falta() {
        let body = r#"{"five_hour":{"utilization":22.0,"resets_at":"2025-11-04T04:59:59+00:00"},
                       "seven_day":{"utilization":49.5,"resets_at":null},"seven_day_opus":null}"#;
        let usage = parse(body).unwrap();
        let five = usage.five_hour.unwrap();
        assert_eq!(five.used_percent, 22.0);
        assert_eq!(five.resets_in(1_762_232_399 - 7500), Some(Duration::from_secs(7500)));
        assert_eq!(five.resets_in(1_762_232_400), None);
        assert_eq!(usage.weekly, Some(Window { used_percent: 49.5, resets_at: None }));
        assert!(parse(r#"{"error":"x"}"#).is_err());
    }

    #[test]
    fn la_copia_en_disco_ida_y_vuelta() {
        let usage = SubscriptionUsage {
            five_hour: Some(Window { used_percent: 41.0, resets_at: Some(100) }),
            weekly: Some(Window { used_percent: 36.0, resets_at: None }),
        };
        assert_eq!(SubscriptionUsage::from_cache(&usage.to_cache(7)), Some((usage, 7)));
        assert_eq!(SubscriptionUsage::from_cache("{}"), None);
    }

    #[test]
    fn duraciones_cortas() {
        assert_eq!(format_duration(Duration::from_secs(45 * 60)), "45 min");
        assert_eq!(format_duration(Duration::from_secs(7500)), "2 h 5 min");
        assert_eq!(format_duration(Duration::from_secs(3 * 86_400 + 4 * 3600)), "3 d 4 h");
    }
}