use anyhow::Result;
use clap::Args;
use crate::db::Db;
use crate::ui;
#[derive(Args, Debug)]
pub struct ReportArgs {
#[arg(long, value_enum, default_value_t = Bucket::Monthly)]
pub bucket: Bucket,
#[arg(long, conflicts_with_all = ["bucket", "weekly", "daily"])]
pub monthly: bool,
#[arg(long, conflicts_with_all = ["bucket", "monthly", "daily"])]
pub weekly: bool,
#[arg(long, conflicts_with_all = ["bucket", "monthly", "weekly"])]
pub daily: bool,
#[arg(long, value_name = "WINDOW")]
pub last: Option<String>,
#[arg(long)]
pub model: Option<String>,
#[arg(long)]
pub project: Option<String>,
#[arg(long)]
pub session: Option<String>,
#[arg(long, value_enum)]
pub source: Option<SourceFilter>,
#[arg(long)]
pub no_subagents: bool,
#[arg(long)]
pub include_synthetic: bool,
#[arg(long)]
pub json: bool,
#[arg(long, value_enum)]
pub by: Option<By>,
#[arg(long, hide = true)]
pub by_model: bool,
#[arg(long, default_value = "local")]
pub tz: String,
}
#[derive(clap::ValueEnum, Clone, Debug)]
pub enum Bucket {
Daily,
Weekly,
Monthly,
Session,
Total,
}
#[derive(clap::ValueEnum, Clone, Copy, Debug)]
pub enum By {
Model,
Project,
Source,
}
#[derive(clap::ValueEnum, Clone, Copy, Debug)]
pub enum SourceFilter {
ClaudeCode,
Codex,
}
pub fn run(mut args: ReportArgs) -> Result<()> {
if args.monthly {
args.bucket = Bucket::Monthly;
} else if args.weekly {
args.bucket = Bucket::Weekly;
} else if args.daily {
args.bucket = Bucket::Daily;
}
if args.session.is_some() {
args.bucket = Bucket::Session;
}
if args.by_model && args.by.is_none() {
args.by = Some(By::Model);
}
let db = Db::open()?;
let (where_sql, params_vec) = build_where(&args);
let bucket_expr = bucket_expression(&args.bucket, &args.tz);
let extra_col = match args.by {
Some(By::Model) => "model",
Some(By::Project) => "project_path",
Some(By::Source) => "source",
None => "",
};
let (extra_select, group_extra, order_extra) = if extra_col.is_empty() {
(
"GROUP_CONCAT(DISTINCT model) AS models".to_string(),
String::new(),
String::new(),
)
} else {
(
extra_col.to_string(),
format!(", {extra_col}"),
format!(", {extra_col} ASC"),
)
};
let sql = format!(
"SELECT
{bucket_expr} AS bucket,
{extra_select},
SUM(input_tokens) AS input_tokens,
SUM(output_tokens) AS output_tokens,
SUM(reasoning_tokens) AS reasoning_tokens,
SUM(cache_creation_5m) AS cw5,
SUM(cache_creation_1h) AS cw1,
SUM(cache_read_tokens) AS cread,
SUM(cost_usd) AS cost
FROM usage_events
{where_sql}
GROUP BY bucket{group_extra}
ORDER BY bucket ASC{order_extra}",
);
let mut stmt = db.conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params_vec.iter()), |r| {
Ok(Row {
bucket: r.get(0)?,
dim: r.get::<_, String>(1).unwrap_or_default(),
input: r.get::<_, i64>(2)? as u64,
output: r.get::<_, i64>(3)? as u64,
reasoning: r.get::<_, i64>(4)? as u64,
cw5: r.get::<_, i64>(5)? as u64,
cw1: r.get::<_, i64>(6)? as u64,
cread: r.get::<_, i64>(7)? as u64,
cost: r.get::<_, f64>(8)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
let mut per_bucket_models: std::collections::HashMap<String, Vec<BucketModel>> =
std::collections::HashMap::new();
if args.by.is_none() {
let model_sql = format!(
"SELECT {bucket_expr} AS bucket, model,
SUM(cost_usd) AS cost,
SUM(input_tokens + output_tokens + cache_creation_5m
+ cache_creation_1h + cache_read_tokens) AS tokens
FROM usage_events
{where_sql}
GROUP BY bucket, model
ORDER BY bucket ASC, cost DESC",
);
let mut stmt = db.conn.prepare(&model_sql)?;
let mut it = stmt.query(rusqlite::params_from_iter(params_vec.iter()))?;
while let Some(r) = it.next()? {
let bucket: String = r.get(0)?;
let model: String = r.get(1)?;
let cost: f64 = r.get::<_, f64>(2).unwrap_or(0.0);
let tokens: u64 = r.get::<_, i64>(3).unwrap_or(0).max(0) as u64;
per_bucket_models
.entry(bucket)
.or_default()
.push(BucketModel {
name: model,
cost,
tokens,
});
}
}
if args.json {
print_json(&args, &rows)?;
} else {
print_table(&args, &rows, &per_bucket_models);
}
Ok(())
}
#[derive(Debug)]
struct Row {
bucket: String,
dim: String,
input: u64,
output: u64,
reasoning: u64,
cw5: u64,
cw1: u64,
cread: u64,
cost: f64,
}
#[derive(Debug)]
struct BucketModel {
name: String,
cost: f64,
#[allow(dead_code)]
tokens: u64,
}
fn build_where(args: &ReportArgs) -> (String, Vec<rusqlite::types::Value>) {
use rusqlite::types::Value;
let mut clauses: Vec<String> = Vec::new();
let mut p: Vec<Value> = Vec::new();
if args.no_subagents {
clauses.push("is_sidechain = 0".into());
}
if !args.include_synthetic {
clauses.push("model != '<synthetic>'".into());
}
if let Some(s) = args.source {
let v = match s {
SourceFilter::ClaudeCode => "claude_code",
SourceFilter::Codex => "codex",
};
clauses.push(format!("source = ?{}", p.len() + 1));
p.push(Value::Text(v.into()));
}
if let Some(m) = &args.model {
clauses.push(format!("model LIKE ?{}", p.len() + 1));
p.push(Value::Text(format!("%{m}%")));
}
if let Some(pr) = &args.project {
clauses.push(format!("project_path LIKE ?{}", p.len() + 1));
p.push(Value::Text(format!("%{pr}%")));
}
if let Some(s) = &args.session {
clauses.push(format!("session_id = ?{}", p.len() + 1));
p.push(Value::Text(s.clone()));
}
if let Some(window) = &args.last {
if let Some(spec) = parse_window(window) {
clauses.push(format!("timestamp >= ?{}", p.len() + 1));
p.push(Value::Text(spec));
}
}
if clauses.is_empty() {
(String::new(), p)
} else {
(format!("WHERE {}", clauses.join(" AND ")), p)
}
}
fn parse_window(s: &str) -> Option<String> {
use chrono::{Duration, Utc};
let s = s.trim();
let (num_str, unit) = if let Some(stripped) = s.strip_suffix('d') {
(stripped, 'd')
} else if let Some(stripped) = s.strip_suffix('h') {
(stripped, 'h')
} else if let Some(stripped) = s.strip_suffix('w') {
(stripped, 'w')
} else if let Some(stripped) = s.strip_suffix('m') {
(stripped, 'm')
} else {
(s, 'd')
};
let n: i64 = num_str.parse().ok()?;
let dur = match unit {
'h' => Duration::hours(n),
'w' => Duration::weeks(n),
'm' => Duration::days(n * 30),
_ => Duration::days(n),
};
Some((Utc::now() - dur).to_rfc3339())
}
fn bucket_expression(b: &Bucket, tz: &str) -> String {
let modifier = tz_modifier(tz);
let ts_expr = if modifier.is_empty() {
"timestamp".to_string()
} else {
format!("datetime(timestamp, '{modifier}')")
};
match b {
Bucket::Daily => format!("substr({ts_expr}, 1, 10)"),
Bucket::Weekly => format!("strftime('%Y-W%W', {ts_expr})"),
Bucket::Monthly => format!("substr({ts_expr}, 1, 7)"),
Bucket::Session => "session_id".into(),
Bucket::Total => "'total'".into(),
}
}
fn tz_modifier(tz: &str) -> String {
match tz {
"" | "utc" | "UTC" => String::new(),
"local" | "localtime" => "localtime".into(),
s if is_fixed_offset(s) => s.into(),
_ => "localtime".into(),
}
}
fn is_fixed_offset(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 6
&& (b[0] == b'+' || b[0] == b'-')
&& b[1].is_ascii_digit()
&& b[2].is_ascii_digit()
&& b[3] == b':'
&& b[4].is_ascii_digit()
&& b[5].is_ascii_digit()
}
fn print_table(
args: &ReportArgs,
rows: &[Row],
per_bucket_models: &std::collections::HashMap<String, Vec<BucketModel>>,
) {
if rows.is_empty() {
println!(
"{} Run {} to import existing transcripts.",
ui::yellow("No usage recorded yet."),
ui::bold_cyan("tokr sync"),
);
return;
}
let bucket_name = match args.bucket {
Bucket::Daily => "Daily",
Bucket::Weekly => "Weekly",
Bucket::Monthly => "Monthly",
Bucket::Session => "Session",
Bucket::Total => "Total",
};
let mut filters: Vec<String> = Vec::new();
if let Some(w) = &args.last {
filters.push(format!("last {w}"));
}
if let Some(m) = &args.model {
filters.push(format!("model~{m}"));
}
if let Some(p) = &args.project {
filters.push(format!("project~{p}"));
}
if let Some(s) = &args.source {
let v = match s {
SourceFilter::ClaudeCode => "claude_code",
SourceFilter::Codex => "codex",
};
filters.push(format!("source={v}"));
}
if let Some(by) = args.by {
let v = match by {
By::Model => "model",
By::Project => "project",
By::Source => "source",
};
filters.push(format!("by={v}"));
}
let filter_str = if filters.is_empty() {
String::from("all")
} else {
filters.join(" · ")
};
println!(
" {} {} {} {}",
ui::bold_cyan(bucket_name),
ui::bold("usage"),
ui::dim(&format!("· {filter_str}")),
ui::dim(&format!("· tz {}", args.tz)),
);
println!();
if args.by.is_some() {
print_grouped_table(args, rows);
} else {
print_default_table(args, rows, per_bucket_models);
}
}
fn print_grouped_table(args: &ReportArgs, rows: &[Row]) {
let dim_label = match args.by {
Some(By::Model) => "Model",
Some(By::Project) => "Project",
Some(By::Source) => "Source",
None => "Models",
};
let mut t = ui::Table::new(
vec![
"Bucket", dim_label, "Input", "Output", "Reason", "Cache W", "Cache R", "Total", "Cost",
],
vec![
ui::Align::Left,
ui::Align::Left,
ui::Align::Right,
ui::Align::Right,
ui::Align::Right,
ui::Align::Right,
ui::Align::Right,
ui::Align::Right,
ui::Align::Right,
],
);
for r in rows {
let dim_cell_raw = match args.by {
Some(By::Project) => ui::truncate(&r.dim, 40),
Some(By::Model) => ui::short_model(&r.dim),
_ => ui::truncate(&r.dim, 28),
};
let dim_cell = match args.by {
Some(By::Model) => ui::magenta(&dim_cell_raw),
Some(By::Project) => ui::blue(&dim_cell_raw),
Some(By::Source) => ui::cyan(&dim_cell_raw),
None => ui::dim(&dim_cell_raw),
};
t.push(vec![
ui::cyan(&r.bucket),
dim_cell,
fmt_cell(r.input),
fmt_cell(r.output),
fmt_cell(r.reasoning),
fmt_cell(r.cw5 + r.cw1),
fmt_cell(r.cread),
ui::bold_white(&ui::fmt_compact(
r.input + r.output + r.cw5 + r.cw1 + r.cread,
)),
ui::green(&ui::fmt_cost(r.cost)),
]);
}
add_totals(&mut t, rows, true);
for line in t.render().lines() {
println!(" {line}");
}
}
fn print_default_table(
_args: &ReportArgs,
rows: &[Row],
per_bucket_models: &std::collections::HashMap<String, Vec<BucketModel>>,
) {
let mut t = ui::Table::new(
vec![
"Bucket", "Input", "Output", "Reason", "Cache W", "Cache R", "Total", "Cost",
],
vec![
ui::Align::Left,
ui::Align::Right,
ui::Align::Right,
ui::Align::Right,
ui::Align::Right,
ui::Align::Right,
ui::Align::Right,
ui::Align::Right,
],
);
for r in rows {
t.push(vec![
ui::cyan(&r.bucket),
fmt_cell(r.input),
fmt_cell(r.output),
fmt_cell(r.reasoning),
fmt_cell(r.cw5 + r.cw1),
fmt_cell(r.cread),
ui::bold_white(&ui::fmt_compact(
r.input + r.output + r.cw5 + r.cw1 + r.cread,
)),
ui::green(&ui::fmt_cost(r.cost)),
]);
}
add_totals(&mut t, rows, false);
render_table_with_submodels(&t, rows, per_bucket_models);
}
fn render_table_with_submodels(
t: &ui::Table,
rows: &[Row],
per_bucket_models: &std::collections::HashMap<String, Vec<BucketModel>>,
) {
let cols = t.headers.len();
let mut widths = vec![0usize; cols];
for (i, h) in t.headers.iter().enumerate() {
widths[i] = widths[i].max(ui::visible_len(h));
}
for row in &t.rows {
for (i, cell) in row.iter().enumerate() {
widths[i] = widths[i].max(ui::visible_len(cell));
}
}
if let Some(tot) = &t.totals {
for (i, cell) in tot.iter().enumerate() {
if i < widths.len() {
widths[i] = widths[i].max(ui::visible_len(cell));
}
}
}
let bucket_col_w = widths[0];
let render = |cells: &[String], bold_header: bool| -> String {
let mut out = String::new();
for (i, cell) in cells.iter().enumerate() {
let styled = if bold_header {
ui::bold_white(cell)
} else {
cell.clone()
};
let padded = match t.aligns.get(i).unwrap_or(&ui::Align::Left) {
ui::Align::Left => ui::pad_right(&styled, widths[i]),
ui::Align::Right => ui::pad_left(&styled, widths[i]),
};
out.push_str(&padded);
if i + 1 < cells.len() {
out.push_str(" ");
}
}
out
};
println!(" {}", render(&t.headers, true));
let rule: Vec<String> = widths.iter().map(|w| ui::dim(&"─".repeat(*w))).collect();
println!(" {}", render(&rule, false));
for (row, row_data) in t.rows.iter().zip(rows.iter()) {
println!(" {}", render(row, false));
if let Some(models) = per_bucket_models.get(&row_data.bucket) {
if let Some(sub) = format_model_breakdown(models, row_data.cost) {
let indent = " ".repeat(bucket_col_w + 2);
println!(" {}{}", indent, ui::dim(&format!("â”” {sub}")));
}
}
}
if let Some(tot) = &t.totals {
println!(" {}", render(&rule, false));
let styled: Vec<String> = tot.iter().map(|c| ui::bold_green(c)).collect();
println!(" {}", render(&styled, false));
}
}
fn format_model_breakdown(models: &[BucketModel], _bucket_cost: f64) -> Option<String> {
let relevant: Vec<&BucketModel> = models
.iter()
.filter(|m| m.cost > 0.0 || m.tokens > 0)
.collect();
if relevant.is_empty() {
return None;
}
const MAX_SHOWN: usize = 6;
let mut shown: Vec<String> = Vec::new();
let mut hidden = 0usize;
for m in &relevant {
if shown.len() >= MAX_SHOWN {
hidden += 1;
continue;
}
shown.push(format!(
"{} {}",
ui::short_model(&m.name),
fmt_inline_cost(m.cost),
));
}
let mut out = shown.join(" · ");
if hidden > 0 {
out.push_str(&format!(" (+{hidden} more)"));
}
Some(out)
}
fn fmt_inline_cost(v: f64) -> String {
if v <= 0.0 {
"—".to_string()
} else if v >= 10.0 {
format!("${}", v.round() as u64)
} else {
format!("${v:.2}")
}
}
fn add_totals(t: &mut ui::Table, rows: &[Row], with_dim_col: bool) {
let total_cost: f64 = rows.iter().map(|r| r.cost).sum();
let total_tok: u64 = rows
.iter()
.map(|r| r.input + r.output + r.cw5 + r.cw1 + r.cread)
.sum();
let total_input: u64 = rows.iter().map(|r| r.input).sum();
let total_output: u64 = rows.iter().map(|r| r.output).sum();
let total_reason: u64 = rows.iter().map(|r| r.reasoning).sum();
let total_cw: u64 = rows.iter().map(|r| r.cw5 + r.cw1).sum();
let total_cr: u64 = rows.iter().map(|r| r.cread).sum();
let label_row = format!(
"{} row{}",
rows.len(),
if rows.len() == 1 { "" } else { "s" }
);
let mut cells = vec![String::from("TOTAL")];
if with_dim_col {
cells.push(label_row);
} else {
let last = cells.last_mut().unwrap();
*last = format!("TOTAL ({label_row})");
}
cells.extend([
ui::fmt_compact(total_input),
ui::fmt_compact(total_output),
ui::fmt_compact(total_reason),
ui::fmt_compact(total_cw),
ui::fmt_compact(total_cr),
ui::fmt_compact(total_tok),
ui::fmt_cost(total_cost),
]);
t.with_totals(cells);
}
fn fmt_cell(n: u64) -> String {
if n == 0 {
ui::dim("·")
} else {
ui::fmt_int(n)
}
}
fn print_json(args: &ReportArgs, rows: &[Row]) -> Result<()> {
let dim_key = match args.by {
Some(By::Model) => "model",
Some(By::Project) => "project",
Some(By::Source) => "source",
None => "models",
};
let json: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"bucket": r.bucket,
dim_key: r.dim,
"input_tokens": r.input,
"output_tokens": r.output,
"reasoning_tokens": r.reasoning,
"cache_write_tokens": r.cw5 + r.cw1,
"cache_read_tokens": r.cread,
"total_tokens": r.input + r.output + r.cw5 + r.cw1 + r.cread,
"cost_usd": r.cost,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&json)?);
Ok(())
}
pub fn stats(global: bool) -> Result<()> {
let db = Db::open()?;
let (scope_sql, scope_binds): (String, Vec<rusqlite::types::Value>) = if global {
(String::new(), Vec::new())
} else {
let cwd = std::env::current_dir()?.to_string_lossy().to_string();
(
"project_path = ?1 OR cwd = ?1".to_string(),
vec![rusqlite::types::Value::Text(cwd)],
)
};
let real_where = combine_where(&scope_sql, "model != '<synthetic>'");
let sql = format!(
"SELECT
COUNT(*),
COUNT(DISTINCT session_id),
COUNT(DISTINCT project_path),
SUM(input_tokens + output_tokens + cache_creation_5m
+ cache_creation_1h + cache_read_tokens),
SUM(cost_usd)
FROM usage_events {real_where}"
);
let (events, sessions, projects, total_tokens, total_cost): (
i64,
i64,
i64,
Option<i64>,
Option<f64>,
) = db
.conn
.query_row(&sql, rusqlite::params_from_iter(scope_binds.iter()), |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
})?;
let total_tokens = total_tokens.unwrap_or(0).max(0) as u64;
let total_cost = total_cost.unwrap_or(0.0);
let err_where = combine_where(&scope_sql, "model = '<synthetic>'");
let err_count: i64 = db
.conn
.query_row(
&format!("SELECT COUNT(*) FROM usage_events {err_where}"),
rusqlite::params_from_iter(scope_binds.iter()),
|r| r.get(0),
)
.unwrap_or(0);
struct SrcRow {
name: String,
events: i64,
cost: f64,
}
let mut src_rows: Vec<SrcRow> = Vec::new();
let by_src_sql = format!(
"SELECT source, COUNT(*), COALESCE(SUM(cost_usd), 0.0)
FROM usage_events {real_where}
GROUP BY source ORDER BY 3 DESC"
);
{
let mut stmt = db.conn.prepare(&by_src_sql)?;
let mut it = stmt.query(rusqlite::params_from_iter(scope_binds.iter()))?;
while let Some(r) = it.next()? {
src_rows.push(SrcRow {
name: r.get(0)?,
events: r.get(1)?,
cost: r.get::<_, f64>(2).unwrap_or(0.0),
});
}
}
struct ModelRow {
name: String,
cost: f64,
tokens: u64,
}
let mut model_rows: Vec<ModelRow> = Vec::new();
let sql_models = format!(
"SELECT model,
COALESCE(SUM(cost_usd), 0.0) AS c,
COALESCE(SUM(input_tokens + output_tokens + cache_creation_5m
+ cache_creation_1h + cache_read_tokens), 0)
FROM usage_events {real_where}
GROUP BY model ORDER BY c DESC LIMIT 8"
);
{
let mut stmt = db.conn.prepare(&sql_models)?;
let mut it = stmt.query(rusqlite::params_from_iter(scope_binds.iter()))?;
while let Some(r) = it.next()? {
model_rows.push(ModelRow {
name: r.get(0)?,
cost: r.get::<_, f64>(1).unwrap_or(0.0),
tokens: r.get::<_, i64>(2).unwrap_or(0).max(0) as u64,
});
}
}
let inner = 76usize;
let scope_label = if global {
"tokr · global usage".to_string()
} else {
let cwd = std::env::current_dir()
.map(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))
.ok()
.flatten()
.unwrap_or_else(|| "this project".to_string());
format!("tokr · {cwd}")
};
println!("{}", ui::box_top(&scope_label, inner));
println!("{}", ui::box_blank(inner));
let cost_s = ui::bold_green(&ui::fmt_cost(total_cost));
let tok_s = ui::bold_white(&ui::fmt_compact(total_tokens));
let ev_s = ui::bold_white(&ui::fmt_int(events as u64));
let sess_s = ui::bold_white(&ui::fmt_int(sessions as u64));
let proj_s = ui::bold_white(&ui::fmt_int(projects as u64));
let err_s = if err_count > 0 {
ui::bold_red(&ui::fmt_int(err_count as u64))
} else {
ui::dim("0")
};
let col_w = 22;
let line_labels_1 = format!(
" {} {} {}",
ui::pad_right(&ui::dim("Cost"), col_w),
ui::pad_right(&ui::dim("Tokens"), col_w),
ui::pad_right(&ui::dim("Events"), col_w),
);
let line_values_1 = format!(
" {} {} {}",
ui::pad_right(&cost_s, col_w),
ui::pad_right(&tok_s, col_w),
ui::pad_right(&ev_s, col_w),
);
let line_labels_2 = format!(
" {} {} {}",
ui::pad_right(&ui::dim("Sessions"), col_w),
ui::pad_right(&ui::dim("Projects"), col_w),
ui::pad_right(&ui::dim("API errors"), col_w),
);
let line_values_2 = format!(
" {} {} {}",
ui::pad_right(&sess_s, col_w),
ui::pad_right(&proj_s, col_w),
ui::pad_right(&err_s, col_w),
);
println!("{}", ui::box_row(&line_labels_1, inner));
println!("{}", ui::box_row(&line_values_1, inner));
println!("{}", ui::box_blank(inner));
println!("{}", ui::box_row(&line_labels_2, inner));
println!("{}", ui::box_row(&line_values_2, inner));
println!("{}", ui::box_blank(inner));
let total_src_cost: f64 = src_rows.iter().map(|r| r.cost).sum::<f64>().max(0.0001);
println!("{}", ui::box_mid("Source breakdown", inner));
println!("{}", ui::box_blank(inner));
for sr in &src_rows {
let pct = sr.cost / total_src_cost;
let bar = ui::bar(pct, 20);
let line = format!(
"{} {} {} {} {}",
ui::pad_right(&ui::cyan(&sr.name), 14),
ui::pad_left(&ui::fmt_int(sr.events as u64), 8),
ui::dim("events"),
ui::pad_left(&ui::green(&ui::fmt_cost(sr.cost)), 11),
format!(
"{} {}",
bar,
ui::pad_left(&format!("{:>5.1}%", pct * 100.0), 6)
),
);
println!("{}", ui::box_row(&line, inner));
}
println!("{}", ui::box_blank(inner));
if !model_rows.is_empty() {
println!("{}", ui::box_mid("Top models by cost", inner));
println!("{}", ui::box_blank(inner));
let total_m_cost: f64 = model_rows.iter().map(|r| r.cost).sum::<f64>().max(0.0001);
for m in &model_rows {
let pct = m.cost / total_m_cost;
let bar = ui::bar(pct, 16);
let line = format!(
"{} {} {} {} {}",
ui::pad_right(&ui::magenta(&ui::truncate(&m.name, 26)), 26),
ui::pad_left(&ui::dim(&ui::fmt_compact(m.tokens)), 8),
ui::pad_left(&ui::green(&ui::fmt_cost(m.cost)), 11),
bar,
ui::pad_left(&format!("{:>5.1}%", pct * 100.0), 6),
);
println!("{}", ui::box_row(&line, inner));
}
println!("{}", ui::box_blank(inner));
}
println!("{}", ui::box_bottom(inner));
Ok(())
}
fn combine_where(scope: &str, extra: &str) -> String {
match (scope.is_empty(), extra.is_empty()) {
(true, true) => String::new(),
(true, false) => format!("WHERE {extra}"),
(false, true) => format!("WHERE {scope}"),
(false, false) => format!("WHERE ({scope}) AND ({extra})"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tz_modifier_accepts_well_formed_offsets() {
assert_eq!(tz_modifier("+00:00"), "+00:00");
assert_eq!(tz_modifier("-05:30"), "-05:30");
assert_eq!(tz_modifier("+13:45"), "+13:45");
}
#[test]
fn tz_modifier_accepts_named_zones() {
assert_eq!(tz_modifier(""), "");
assert_eq!(tz_modifier("utc"), "");
assert_eq!(tz_modifier("UTC"), "");
assert_eq!(tz_modifier("local"), "localtime");
assert_eq!(tz_modifier("localtime"), "localtime");
}
#[test]
fn tz_modifier_rejects_sql_injection_attempts() {
assert_eq!(tz_modifier("+0:00' || '1"), "localtime");
assert_eq!(tz_modifier("+00:0'--"), "localtime");
assert_eq!(tz_modifier("-0a:00"), "localtime");
assert_eq!(tz_modifier("+00:00 "), "localtime");
assert_eq!(tz_modifier("+00:"), "localtime");
assert_eq!(tz_modifier("xx"), "localtime");
assert_eq!(tz_modifier("'; DROP TABLE usage_events; --"), "localtime");
}
}