quotch 0.5.3

Fast cross-platform CLI for AI coding-agent usage limits
use crate::model::{Snapshot, Status, Window};
use chrono::{DateTime, Utc};
use std::collections::HashMap;

const BAR_WIDTH: usize = 20;
const GREEN: &str = "\x1b[32m";
const YELLOW: &str = "\x1b[33m";
const RED: &str = "\x1b[31m";
const RESET: &str = "\x1b[0m";

/// A snapshot resolves to either a run of aligned window rows (Ok/Stale with
/// windows), or a single standalone line (AuthMissing/Error/no-usage-data).
enum Entry {
    Table {
        header: String,
        windows: Vec<Window>,
    },
    Line(String),
}

pub fn render(snapshots: &[Snapshot], color: bool) -> String {
    let now = Utc::now();

    // Providers with more than one visible account get the account/label
    // suffix in their header cell.
    let mut provider_counts: HashMap<&str, usize> = HashMap::new();
    for s in snapshots {
        if s.status != Status::NotDetected {
            *provider_counts.entry(s.provider.as_str()).or_insert(0) += 1;
        }
    }

    let mut entries: Vec<Entry> = Vec::new();
    for s in snapshots {
        match s.status {
            Status::NotDetected => continue,
            Status::AuthMissing => {
                entries.push(Entry::Line(format!(
                    "{}  \u{26A0} not logged in (run: {})",
                    s.provider, s.provider
                )));
            }
            Status::Error => {
                let msg = s.error.as_deref().unwrap_or("error");
                entries.push(Entry::Line(format!("{}  \u{2717} {}", s.provider, msg)));
            }
            Status::Ok | Status::Stale => {
                if s.windows.is_empty() {
                    entries.push(Entry::Line(format!("{}  (no usage data)", s.provider)));
                    continue;
                }

                let multi = provider_counts
                    .get(s.provider.as_str())
                    .copied()
                    .unwrap_or(0)
                    > 1;
                let mut header = s.provider.clone();
                if let Some(plan) = &s.plan {
                    header.push_str(" · ");
                    header.push_str(plan);
                }
                if multi {
                    let label = s.label.as_deref().unwrap_or(s.account.as_str());
                    header.push(' ');
                    header.push_str(label);
                }
                if s.status == Status::Stale {
                    header.push_str(&format!(" (stale {})", format_duration(now - s.fetched_at)));
                }

                entries.push(Entry::Table {
                    header,
                    windows: s.windows.clone(),
                });
            }
        }
    }

    // Column widths, computed from the data — only table entries participate.
    let mut header_width = 0usize;
    let mut key_width = 0usize;
    let mut pct_width = 0usize;
    // Count column (used/limit) width, derived from data. Stays 0 when no window
    // carries both figures, so percent-only output is byte-identical to before.
    let mut count_width = 0usize;
    for e in &entries {
        if let Entry::Table { header, windows } = e {
            header_width = header_width.max(header.chars().count());
            for w in windows {
                key_width = key_width.max(w.key.chars().count());
                pct_width = pct_width.max(format_pct(w.used_pct).chars().count());
                if let Some(count) = format_count(w) {
                    count_width = count_width.max(count.chars().count());
                }
            }
        }
    }

    let mut lines: Vec<String> = Vec::new();
    for e in entries {
        match e {
            Entry::Line(l) => lines.push(l),
            Entry::Table { header, windows } => {
                let mut first = true;
                for w in windows {
                    let header_cell = if first {
                        format!("{:<hw$}", header, hw = header_width)
                    } else {
                        format!("{:<hw$}", "", hw = header_width)
                    };
                    first = false;

                    let key_cell = format!("{:<kw$}", w.key, kw = key_width);
                    let bar = render_bar(w.used_pct);
                    let pct_cell = format!("{:>pw$}", format_pct(w.used_pct), pw = pct_width);

                    let (bar_out, pct_out) = if color {
                        let c = color_for(w.used_pct);
                        (format!("{c}{bar}{RESET}"), format!("{c}{pct_cell}{RESET}"))
                    } else {
                        (bar, pct_cell)
                    };

                    let mut line = format!("{header_cell}  {key_cell}  {bar_out}  {pct_out}");
                    if count_width > 0 {
                        // Left-align (empty for percent-only rows) so the resets
                        // column stays aligned across mixed providers.
                        let count = format_count(&w).unwrap_or_default();
                        line.push_str(&format!("  {count:<count_width$}"));
                    }
                    if let Some(resets) = format_resets(w.resets_at, now) {
                        line.push_str("  ");
                        line.push_str(&resets);
                    }
                    // Trim so percent-only rows (empty count cell, no resets) never
                    // gain trailing whitespace; a no-op for rows ending in data.
                    lines.push(line.trim_end().to_string());
                }
            }
        }
    }

    lines.join("\n")
}

fn render_bar(used_pct: f64) -> String {
    let clamped = used_pct.clamp(0.0, 100.0);
    let filled = ((clamped / 100.0) * BAR_WIDTH as f64).round() as usize;
    let filled = filled.min(BAR_WIDTH);
    let empty = BAR_WIDTH - filled;
    format!("{}{}", "".repeat(filled), "".repeat(empty))
}

/// `used/limit` as integers, only when a window carries both figures (count-based
/// windows like copilot). Percent-only windows (claude) return None.
fn format_count(w: &Window) -> Option<String> {
    match (w.used, w.limit) {
        (Some(u), Some(l)) => Some(format!("{}/{}", u as i64, l as i64)),
        _ => None,
    }
}

fn format_pct(pct: f64) -> String {
    let rounded = (pct * 10.0).round() / 10.0;
    if rounded.fract().abs() < 1e-9 {
        format!("{}%", rounded as i64)
    } else {
        format!("{rounded:.1}%")
    }
}

fn color_for(pct: f64) -> &'static str {
    if pct >= 90.0 {
        RED
    } else if pct >= 70.0 {
        YELLOW
    } else {
        GREEN
    }
}

fn format_resets(resets_at: Option<DateTime<Utc>>, now: DateTime<Utc>) -> Option<String> {
    let at = resets_at?;
    let dur = at - now;
    if dur <= chrono::Duration::zero() {
        return None;
    }
    Some(format!("resets {}", format_duration(dur)))
}

fn format_duration(dur: chrono::Duration) -> String {
    let total_secs = dur.num_seconds().max(0);
    let days = total_secs / 86400;
    let hours = (total_secs % 86400) / 3600;
    let minutes = (total_secs % 3600) / 60;
    if days >= 1 {
        format!("{days}d {hours}h")
    } else if hours >= 1 {
        format!("{hours}h {minutes}m")
    } else if minutes >= 1 {
        format!("{minutes}m")
    } else {
        "<1m".to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{Unit, WindowKind};

    fn window(key: &str, used_pct: f64, resets_at: Option<DateTime<Utc>>) -> Window {
        Window {
            key: key.to_string(),
            kind: WindowKind::Rolling,
            unit: Unit::Percent,
            used_pct,
            used: None,
            limit: None,
            unlimited: false,
            resets_at,
        }
    }

    fn ok_snapshot() -> Snapshot {
        Snapshot {
            provider: "claude".to_string(),
            account: "default".to_string(),
            label: None,
            plan: Some("max".to_string()),
            windows: vec![
                window("5h", 19.0, Some(Utc::now() + chrono::Duration::hours(2))),
                window("7d", 95.0, None),
            ],
            fetched_at: Utc::now(),
            status: Status::Ok,
            error: None,
            raw: None,
        }
    }

    fn count_snapshot() -> Snapshot {
        Snapshot {
            provider: "copilot".to_string(),
            account: "default".to_string(),
            label: None,
            plan: Some("enterprise".to_string()),
            windows: vec![Window {
                key: "monthly".to_string(),
                kind: WindowKind::Calendar,
                unit: Unit::Requests,
                used_pct: 11.0,
                used: Some(2729.0),
                limit: Some(25000.0),
                unlimited: false,
                resets_at: None,
            }],
            fetched_at: Utc::now(),
            status: Status::Ok,
            error: None,
            raw: None,
        }
    }

    fn auth_missing_snapshot() -> Snapshot {
        Snapshot {
            provider: "codex".to_string(),
            account: "default".to_string(),
            label: None,
            plan: None,
            windows: vec![],
            fetched_at: Utc::now(),
            status: Status::AuthMissing,
            error: None,
            raw: None,
        }
    }

    #[test]
    fn render_plain_contains_expected_fragments() {
        let snapshots = vec![ok_snapshot(), auth_missing_snapshot()];
        let out = render(&snapshots, false);
        assert!(out.contains("5h"), "missing window key: {out}");
        assert!(out.contains('%'), "missing percent figure: {out}");
        assert!(
            out.contains("not logged in (run: codex)"),
            "missing auth line: {out}"
        );
        assert!(!out.contains('\u{1b}'), "unexpected ANSI escape: {out}");
    }

    #[test]
    fn render_color_emits_ansi_for_high_usage() {
        let snapshots = vec![ok_snapshot(), auth_missing_snapshot()];
        let out = render(&snapshots, true);
        assert!(out.contains("\u{1b}[3"), "missing ANSI color escape: {out}");
    }

    #[test]
    fn count_window_shows_used_over_limit() {
        let out = render(&[count_snapshot()], false);
        assert!(out.contains("2729/25000"), "missing count column: {out}");
    }

    #[test]
    fn percent_only_rows_have_no_trailing_whitespace_with_counts() {
        // A count-based (copilot) window and percent-only (claude) windows share
        // one render pass, so the count column exists globally. Percent-only rows
        // must still not gain trailing whitespace.
        let out = render(&[ok_snapshot(), count_snapshot()], false);
        for line in out.lines() {
            assert_eq!(line, line.trim_end(), "trailing whitespace: {line:?}");
        }
    }
}