tokr 0.1.0

Persistent token-usage ledger for AI coding agents. Captures on write, queries forever.
use anyhow::Result;
use clap::{Args, Parser, Subcommand};

use crate::{backfill, blocks, db, hook, paths, pricing, recost, report, ui, update};

#[derive(Parser)]
#[command(
    name = "tokr",
    version,
    about = "Persistent token-usage ledger for Claude Code + Codex."
)]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    #[command(about = "Dashboard: cost, tokens, sources, top models")]
    Stats(StatsArgs),

    #[command(about = "Tabular usage report, bucketed daily/weekly/monthly")]
    Report(report::ReportArgs),

    #[command(about = "5-hour billing windows with an active-block hero panel")]
    Blocks(blocks::BlocksArgs),

    #[command(
        alias = "backfill",
        about = "Walk transcripts on disk and ingest missing rows"
    )]
    Sync(SyncArgs),

    #[command(about = "Harness-facing ingest; reads JSON on stdin")]
    Hook(HookArgs),

    #[command(subcommand, about = "List, update, or re-apply pricing")]
    Prices(PricesCmd),

    #[command(subcommand, about = "Ledger database operations")]
    Db(DbCmd),
}

#[derive(Args)]
struct StatsArgs {
    #[arg(long)]
    global: bool,
}

#[derive(Args)]
struct HookArgs {
    #[arg(long)]
    r#final: bool,
}

#[derive(Args)]
struct SyncArgs {
    #[arg(long, value_enum, default_value_t = backfill::Source::All)]
    source: backfill::Source,
    #[arg(long, value_name = "DAYS")]
    archive_older_than: Option<u32>,
}

#[derive(Subcommand)]
enum PricesCmd {
    #[command(about = "Print every loaded pricing entry")]
    List,

    #[command(about = "Fetch the latest pricing table and append new entries")]
    Update {
        #[arg(long, value_name = "URL")]
        from: Option<String>,
    },

    #[command(about = "Recompute cost_usd on existing rows using current pricing")]
    Recost {
        #[arg(long)]
        model: Option<String>,
        #[arg(long, value_name = "ISO")]
        since: Option<String>,
        #[arg(long)]
        dry_run: bool,
    },
}

#[derive(Subcommand)]
enum DbCmd {
    #[command(about = "Print the path to the SQLite database")]
    Path,

    #[command(about = "VACUUM and prune cursors for vanished transcripts")]
    Vacuum,
}

pub fn run() -> Result<()> {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::Stats(a) => report::stats(a.global),
        Cmd::Report(a) => report::run(a),
        Cmd::Blocks(a) => blocks::run(a),
        Cmd::Sync(a) => backfill::run(a.source, a.archive_older_than),
        Cmd::Hook(a) => hook::run(a.r#final),
        Cmd::Prices(sub) => run_prices(sub),
        Cmd::Db(sub) => run_db(sub),
    }
}

fn run_prices(cmd: PricesCmd) -> Result<()> {
    match cmd {
        PricesCmd::List => {
            let p = pricing::Pricing::load()?;
            print_prices(&p.list_all());
            println!();
            println!(
                "  {} {}",
                ui::dim("Overrides file:"),
                ui::cyan(&pricing::overrides_path()?.display().to_string()),
            );
            Ok(())
        }
        PricesCmd::Update { from } => update::run(from),
        PricesCmd::Recost {
            model,
            since,
            dry_run,
        } => {
            let stats = recost::run(recost::RecostFilters {
                model_substr: model.as_deref(),
                since_iso: since.as_deref(),
                dry_run,
            })?;
            print_recost(&stats, dry_run);
            Ok(())
        }
    }
}

fn run_db(cmd: DbCmd) -> Result<()> {
    match cmd {
        DbCmd::Path => {
            println!("{}", paths::db_path()?.display());
            Ok(())
        }
        DbCmd::Vacuum => {
            let handle = db::Db::open()?;
            handle.vacuum()?;
            println!(
                "{} {}",
                ui::green("✓ Vacuumed"),
                ui::dim(&paths::db_path()?.display().to_string()),
            );
            Ok(())
        }
    }
}

fn print_prices(prices: &[&pricing::ModelPrice]) {
    println!(
        "  {} {}",
        ui::bold_cyan("Pricing table"),
        ui::dim(&format!("· {} entries", prices.len())),
    );
    println!();

    let mut t = ui::Table::new(
        vec![
            "Model",
            "Effective",
            "Input",
            "Output",
            "CW 5m",
            "CW 1h",
            "CR",
        ],
        vec![
            ui::Align::Left,
            ui::Align::Left,
            ui::Align::Right,
            ui::Align::Right,
            ui::Align::Right,
            ui::Align::Right,
            ui::Align::Right,
        ],
    );
    for p in prices {
        let eff = p
            .effective_from
            .split('T')
            .next()
            .unwrap_or(&p.effective_from);
        t.push(vec![
            ui::magenta(&p.name),
            ui::dim(eff),
            ui::green(&format!("${:.3}", p.input_per_mtok)),
            ui::green(&format!("${:.2}", p.output_per_mtok)),
            ui::dim(&format!("${:.2}", p.cache_write_5m_per_mtok)),
            ui::dim(&format!("${:.2}", p.cache_write_1h_per_mtok)),
            ui::dim(&format!("${:.3}", p.cache_read_per_mtok)),
        ]);
    }
    for line in t.render().lines() {
        println!("  {line}");
    }
    println!(
        "  {}",
        ui::dim("Prices are USD per million tokens (MTok). CW=cache write, CR=cache read."),
    );
}

fn print_recost(stats: &recost::RecostStats, dry_run: bool) {
    let inner = 70_usize;
    let title = if dry_run {
        "Recost · dry-run"
    } else {
        "Recost"
    };
    println!("{}", ui::box_top(title, inner));
    println!("{}", ui::box_blank(inner));

    let col_w = 20;
    let labels = format!(
        "  {}  {}  {}",
        ui::pad_right(&ui::dim("Examined"), col_w),
        ui::pad_right(
            &ui::dim(if dry_run { "Would change" } else { "Changed" }),
            col_w,
        ),
        ui::pad_right(&ui::dim("Δ cost"), col_w),
    );
    let delta = stats.new_total_usd - stats.old_total_usd;
    let delta_s = if delta.abs() < 0.005 {
        ui::dim(&format!("{delta:+.2}"))
    } else if delta < 0.0 {
        ui::bold_green(&format!("-${:.2}", delta.abs()))
    } else {
        ui::bold_red(&format!("+${delta:.2}"))
    };
    let values = format!(
        "  {}  {}  {}",
        ui::pad_right(&ui::bold_white(&ui::fmt_int(stats.rows_examined)), col_w),
        ui::pad_right(&ui::bold_white(&ui::fmt_int(stats.rows_changed)), col_w),
        ui::pad_right(&delta_s, col_w),
    );
    println!("{}", ui::box_row(&labels, inner));
    println!("{}", ui::box_row(&values, inner));
    println!("{}", ui::box_blank(inner));

    let totals = format!(
        "  {}   old {}   →   new {}",
        ui::dim("Totals"),
        ui::dim(&ui::fmt_cost(stats.old_total_usd)),
        ui::bold_green(&ui::fmt_cost(stats.new_total_usd)),
    );
    println!("{}", ui::box_row(&totals, inner));
    println!("{}", ui::box_blank(inner));
    println!("{}", ui::box_bottom(inner));
}