tokr 0.1.0

Persistent token-usage ledger for AI coding agents. Captures on write, queries forever.
use std::io::IsTerminal;
use std::sync::LazyLock;

static COLOR_ENABLED: LazyLock<bool> = LazyLock::new(|| {
    if std::env::var_os("NO_COLOR").is_some() {
        return false;
    }
    if std::env::var("TERM").is_ok_and(|t| t == "dumb") {
        return false;
    }
    std::io::stdout().is_terminal()
});

fn color_enabled() -> bool {
    *COLOR_ENABLED
}

fn wrap(s: &str, code: &str) -> String {
    if color_enabled() {
        format!("\x1b[{code}m{s}\x1b[0m")
    } else {
        s.to_string()
    }
}

pub fn bold(s: &str) -> String {
    wrap(s, "1")
}
pub fn dim(s: &str) -> String {
    wrap(s, "2")
}
pub fn cyan(s: &str) -> String {
    wrap(s, "36")
}
pub fn green(s: &str) -> String {
    wrap(s, "32")
}
pub fn yellow(s: &str) -> String {
    wrap(s, "33")
}
pub fn magenta(s: &str) -> String {
    wrap(s, "35")
}
pub fn blue(s: &str) -> String {
    wrap(s, "34")
}
pub fn red(s: &str) -> String {
    wrap(s, "31")
}
pub fn bold_cyan(s: &str) -> String {
    wrap(s, "1;36")
}
pub fn bold_green(s: &str) -> String {
    wrap(s, "1;32")
}
pub fn bold_white(s: &str) -> String {
    wrap(s, "1;37")
}
pub fn bold_red(s: &str) -> String {
    wrap(s, "1;31")
}

fn ansi_byte_count(s: &str) -> usize {
    let bytes = s.as_bytes();
    let mut i = 0;
    let mut n = 0;
    while i < bytes.len() {
        if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
            let start = i;
            i += 2;
            while i < bytes.len() && bytes[i] != b'm' {
                i += 1;
            }
            if i < bytes.len() {
                i += 1;
            }
            n += i - start;
        } else {
            i += 1;
        }
    }
    n
}

pub fn visible_len(s: &str) -> usize {
    s.chars().count() - ansi_byte_count(s)
}

pub fn pad_right(s: &str, width: usize) -> String {
    let vl = visible_len(s);
    if vl >= width {
        s.to_string()
    } else {
        format!("{}{}", s, " ".repeat(width - vl))
    }
}

pub fn pad_left(s: &str, width: usize) -> String {
    let vl = visible_len(s);
    if vl >= width {
        s.to_string()
    } else {
        format!("{}{}", " ".repeat(width - vl), s)
    }
}

pub fn bar(pct: f64, width: usize) -> String {
    let p = pct.clamp(0.0, 1.0);
    let filled = ((p * width as f64).round() as usize).min(width);
    let empty = width - filled;
    format!("{}{}", cyan(&"â–ˆ".repeat(filled)), dim(&"â–‘".repeat(empty)))
}

pub fn bar_threshold(pct: f64, width: usize) -> String {
    let p = pct.clamp(0.0, 1.0);
    let filled = ((p * width as f64).round() as usize).min(width);
    let empty = width - filled;
    let fill = "â–ˆ".repeat(filled);
    let painted = if p >= 0.85 {
        red(&fill)
    } else if p >= 0.60 {
        yellow(&fill)
    } else {
        green(&fill)
    };
    format!("{}{}", painted, dim(&"â–‘".repeat(empty)))
}

pub fn box_top(title: &str, inner_width: usize) -> String {
    let total = inner_width + 4;
    let dashes = total.saturating_sub(visible_len(title) + 5);
    format!(
        "{}{}{}",
        dim("╭─ "),
        bold_cyan(title),
        dim(&format!(" {}╮", "─".repeat(dashes)))
    )
}

pub fn box_mid(title: &str, inner_width: usize) -> String {
    let total = inner_width + 4;
    let dashes = total.saturating_sub(visible_len(title) + 5);
    format!(
        "{}{}{}",
        dim("├─ "),
        bold_cyan(title),
        dim(&format!(" {}┤", "─".repeat(dashes)))
    )
}

pub fn box_bottom(inner_width: usize) -> String {
    dim(&format!("╰{}╯", "─".repeat(inner_width + 2)))
}

pub fn box_row(content: &str, inner_width: usize) -> String {
    format!(
        "{} {} {}",
        dim("│"),
        pad_right(content, inner_width),
        dim("│")
    )
}

pub fn box_blank(inner_width: usize) -> String {
    box_row("", inner_width)
}

pub enum Align {
    Left,
    Right,
}

pub struct Table {
    pub headers: Vec<String>,
    pub aligns: Vec<Align>,
    pub rows: Vec<Vec<String>>,
    pub totals: Option<Vec<String>>,
}

impl Table {
    pub fn new(headers: Vec<&str>, aligns: Vec<Align>) -> Self {
        Self {
            headers: headers.into_iter().map(String::from).collect(),
            aligns,
            rows: Vec::new(),
            totals: None,
        }
    }

    pub fn push(&mut self, row: Vec<String>) {
        self.rows.push(row);
    }

    pub fn with_totals(&mut self, totals: Vec<String>) {
        self.totals = Some(totals);
    }

    pub fn render(&self) -> String {
        let cols = self.headers.len();
        let mut widths = vec![0usize; cols];
        for (i, h) in self.headers.iter().enumerate() {
            widths[i] = widths[i].max(visible_len(h));
        }
        for row in &self.rows {
            for (i, cell) in row.iter().enumerate() {
                widths[i] = widths[i].max(visible_len(cell));
            }
        }
        if let Some(t) = &self.totals {
            for (i, cell) in t.iter().enumerate() {
                if i < widths.len() {
                    widths[i] = widths[i].max(visible_len(cell));
                }
            }
        }

        let render_row = |cells: &[String], stylize: bool| -> String {
            let mut out = String::new();
            for (i, cell) in cells.iter().enumerate() {
                let styled = if stylize {
                    bold_white(cell)
                } else {
                    cell.clone()
                };
                let padded = match self.aligns.get(i).unwrap_or(&Align::Left) {
                    Align::Left => pad_right(&styled, widths[i]),
                    Align::Right => pad_left(&styled, widths[i]),
                };
                out.push_str(&padded);
                if i + 1 < cells.len() {
                    out.push_str("  ");
                }
            }
            out
        };

        let rule: Vec<String> = widths.iter().map(|w| dim(&"─".repeat(*w))).collect();

        let mut out = String::new();
        out.push_str(&render_row(&self.headers, true));
        out.push('\n');
        out.push_str(&render_row(&rule, false));
        out.push('\n');
        for row in &self.rows {
            out.push_str(&render_row(row, false));
            out.push('\n');
        }
        if let Some(t) = &self.totals {
            out.push_str(&render_row(&rule, false));
            out.push('\n');
            let styled: Vec<String> = t.iter().map(|c| bold_green(c)).collect();
            out.push_str(&render_row(&styled, false));
            out.push('\n');
        }
        out
    }
}

pub fn fmt_int(n: u64) -> String {
    let s = n.to_string();
    let mut out = String::with_capacity(s.len() + s.len() / 3);
    for (i, c) in s.chars().rev().enumerate() {
        if i > 0 && i % 3 == 0 {
            out.push(',');
        }
        out.push(c);
    }
    out.chars().rev().collect()
}

pub fn fmt_compact(n: u64) -> String {
    let f = n as f64;
    if f < 1e3 {
        n.to_string()
    } else if f < 1e6 {
        format!("{:.1}K", f / 1e3)
    } else if f < 1e9 {
        format!("{:.1}M", f / 1e6)
    } else if f < 1e12 {
        format!("{:.2}B", f / 1e9)
    } else {
        format!("{:.2}T", f / 1e12)
    }
}

pub fn fmt_cost(v: f64) -> String {
    let negative = v < 0.0;
    let cents = (v.abs() * 100.0).round() as u64;
    let whole = fmt_int(cents / 100);
    let core = format!("${}.{:02}", whole, cents % 100);
    if negative { format!("-{core}") } else { core }
}

pub fn truncate(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        s.to_string()
    } else {
        let head: String = s.chars().take(max.saturating_sub(1)).collect();
        format!("{head}…")
    }
}

pub fn short_model(s: &str) -> String {
    let s = s.strip_prefix("claude-").unwrap_or(s);

    let without_date = match s.rsplit_once('-') {
        Some((head, tail)) if tail.len() == 8 && tail.chars().all(|c| c.is_ascii_digit()) => head,
        _ => s,
    };

    if let Some((head, tail)) = without_date.rsplit_once('-') {
        let digit =
            |x: &str| !x.is_empty() && x.len() <= 2 && x.chars().all(|c| c.is_ascii_digit());
        if digit(tail) {
            if let Some((_, major)) = head.rsplit_once('-') {
                if digit(major) {
                    return format!("{head}.{tail}");
                }
            }
        }
    }
    without_date.to_string()
}

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

    #[test]
    fn short_model_anthropic_modern() {
        assert_eq!(short_model("claude-opus-4-7"), "opus-4.7");
        assert_eq!(short_model("claude-haiku-4-5"), "haiku-4.5");
        assert_eq!(short_model("claude-sonnet-4-6"), "sonnet-4.6");
    }

    #[test]
    fn short_model_strips_date_suffix() {
        assert_eq!(short_model("claude-haiku-4-5-20251001"), "haiku-4.5");
        assert_eq!(short_model("claude-sonnet-4-5-20250929"), "sonnet-4.5");
    }

    #[test]
    fn short_model_leaves_non_versioned_ids_alone() {
        assert_eq!(short_model("gpt-5.3-codex"), "gpt-5.3-codex");
        assert_eq!(short_model("gpt-5.4"), "gpt-5.4");
        assert_eq!(short_model("<synthetic>"), "<synthetic>");
        assert_eq!(short_model("__unknown__"), "__unknown__");
    }

    #[test]
    fn fmt_int_adds_thousands_separators() {
        assert_eq!(fmt_int(0), "0");
        assert_eq!(fmt_int(42), "42");
        assert_eq!(fmt_int(1_000), "1,000");
        assert_eq!(fmt_int(1_234_567), "1,234,567");
    }

    #[test]
    fn fmt_compact_buckets() {
        assert_eq!(fmt_compact(0), "0");
        assert_eq!(fmt_compact(999), "999");
        assert_eq!(fmt_compact(1_500), "1.5K");
        assert_eq!(fmt_compact(2_500_000), "2.5M");
        assert_eq!(fmt_compact(8_030_000_000), "8.03B");
    }

    #[test]
    fn fmt_cost_rounds_and_separates() {
        assert_eq!(fmt_cost(0.0), "$0.00");
        assert_eq!(fmt_cost(0.005), "$0.01");
        assert_eq!(fmt_cost(1_234.5), "$1,234.50");
        assert_eq!(fmt_cost(-42.1), "-$42.10");
    }

    #[test]
    fn visible_len_ignores_ansi() {
        let plain = "hello";
        let painted = "\x1b[1;32mhello\x1b[0m";
        assert_eq!(visible_len(plain), 5);
        assert_eq!(visible_len(painted), 5);
    }
}