clauth 0.6.1

Simple Claude Code account switcher and usage monitor
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};

use crate::usage::{LABEL_5H, LABEL_7D, LABEL_7D_OPUS, LABEL_7D_SONNET, UsageInfo, UsageWindow};

fn window_util(usage: &UsageInfo, label: &str) -> Option<f64> {
    match label {
        LABEL_5H => usage.five_hour.as_ref().map(|w| w.utilization),
        LABEL_7D => usage.seven_day.as_ref().map(|w| w.utilization),
        LABEL_7D_SONNET => usage.seven_day_sonnet.as_ref().map(|w| w.utilization),
        LABEL_7D_OPUS => usage.seven_day_opus.as_ref().map(|w| w.utilization),
        _ => None,
    }
}

/// Walk `entries` (chronological) to find the index of the first entry after
/// the most recent utilization drop (window reset). Returns 0 when no reset is
/// detected.
fn reset_boundary(entries: &[(u64, f64)]) -> usize {
    for i in (1..entries.len()).rev() {
        if entries[i].1 < entries[i - 1].1 {
            return i;
        }
    }
    0
}

/// Walk `entries` (chronological) to find the most recent pair of consecutive
/// entries with identical utilization whose time gap exceeds `max_gap_ms`.
/// Returns the index of the later entry (start of the active period after an
/// idle stretch), or 0 when no idle gap is detected.
fn gap_boundary(entries: &[(u64, f64)], max_gap_ms: u64) -> usize {
    for i in (1..entries.len()).rev() {
        if entries[i].1 == entries[i - 1].1
            && entries[i].0.saturating_sub(entries[i - 1].0) > max_gap_ms
        {
            return i;
        }
    }
    0
}

/// Compute recency-weighted burn rates (%/h) per usage window from cached
/// history plus the latest current usage.
///
/// `windows` is a slice of `(label, &UsageWindow)` pairs — typically a subset
/// of [`UsageInfo::windows`].
///
/// For each window the samples are filtered to that window's utilization, the
/// current value is appended, idle plateaus are gap-cut, and flat runs are
/// deduplicated to distinct utilization points. The rate is the slope of a
/// recency-weighted least-squares fit over the samples falling within the last
/// `lookback_ms` (and after the most recent window reset). Sample weights decay
/// exponentially with age — half-life `lookback_ms / 4` — so the newest samples
/// dominate. `None` is returned until at least `min_samples` distinct samples
/// sit inside that window, so a rate is never shown from too little data.
///
/// `lookback_ms` is a hard cap: samples older than `now - lookback_ms` are
/// dropped (1 h for the 5-hour window → `%/h`).
///
/// `gap_cut_ms` controls idle-gap detection: when two consecutive entries share
/// the same utilization and their timestamps differ by more than `gap_cut_ms`,
/// the history is sliced from the later entry onward. Pass 0 to disable gap-cut
/// entirely (for windows where idle stretches should count toward the rate).
///
/// Returns rates in %/h. Drives the 5-hour window's `%/h` rate and the overview
/// burn-ETA. The 7-day windows show a window-anchored average pace instead
/// (`window_avg_pace_per_day`), which a history slope can't give: a per-profile
/// history jumps to another account's utilization on every rotation.
pub(crate) fn compute_burn_rates_from_history(
    history: &[(u64, UsageInfo)],
    windows: &[(&str, &UsageWindow)],
    lookback_ms: u64,
    min_samples: usize,
    gap_cut_ms: u64,
) -> HashMap<String, Option<f64>> {
    let now_ms = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as u64;
    let half_life_ms = (lookback_ms as f64 / 4.0).max(1.0);

    let mut rates = HashMap::new();
    for (label, window) in windows {
        let mut entries: Vec<(u64, f64)> = history
            .iter()
            .filter_map(|(ts, u)| window_util(u, label).map(|p| (*ts, p)))
            .collect();

        entries.push((now_ms, window.utilization));

        // Gap-cut: slice off any idle stretch before the current activity.
        // Must run before dedup — the equal-util pair marks the gap.
        if gap_cut_ms > 0 && entries.len() >= 2 {
            let gap = gap_boundary(&entries, gap_cut_ms);
            if gap > 0 {
                entries = entries[gap..].to_vec();
            }
        }

        // Collapse flat runs to distinct utilization points, keeping the newest
        // timestamp of each run.
        if entries.len() >= 2 {
            entries.reverse();
            entries.dedup_by(|a, b| a.1 == b.1);
            entries.reverse();
        }

        // Start no earlier than the most recent window reset, and no earlier
        // than `lookback_ms` before now (the hard sample-window cap).
        let cutoff = now_ms.saturating_sub(lookback_ms);
        let cap = entries
            .iter()
            .position(|&(ts, _)| ts >= cutoff)
            .unwrap_or(0);
        let start = reset_boundary(&entries).max(cap);
        let recent = &entries[start..];

        // Require enough distinct samples in the window before trusting a rate.
        let rate = if recent.len() >= min_samples {
            weighted_rate_per_hour(recent, half_life_ms)
        } else {
            None
        };
        rates.insert(label.to_string(), rate);
    }
    rates
}

/// Slope of a recency-weighted least-squares fit of utilization over time, in
/// %/h. Weights decay exponentially with age relative to the newest sample:
/// `w = 0.5^(age / half_life_ms)`, so recent samples drive the rate. Returns
/// `None` when the weighted time variance is zero (samples all simultaneous).
fn weighted_rate_per_hour(entries: &[(u64, f64)], half_life_ms: f64) -> Option<f64> {
    let last_ts = entries[entries.len() - 1].0;
    let base = entries[0].0 as f64; // rebase time for numeric stability

    let (mut sw, mut swx, mut swy, mut swxx, mut swxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
    for &(ts, util) in entries {
        let age = last_ts.saturating_sub(ts) as f64;
        let w = 0.5_f64.powf(age / half_life_ms);
        let x = ts as f64 - base;
        sw += w;
        swx += w * x;
        swy += w * util;
        swxx += w * x * x;
        swxy += w * x * util;
    }

    let denom = sw * swxx - swx * swx;
    if denom.abs() < f64::EPSILON {
        return None;
    }
    let slope_per_ms = (sw * swxy - swx * swy) / denom;
    Some(slope_per_ms * 3_600_000.0)
}

#[cfg(test)]
#[path = "../../tests/inline/burn.rs"]
mod tests;