use std::collections::BTreeSet;
use anyhow::{anyhow, Result};
use relayburn_sdk::{
build_compare_table, has_minimum_fidelity, load_pricing, summarize_fidelity,
AnalyzeCompareOptions as CompareOptions, CompareCell, CompareTable, EnrichedTurn,
FidelityClass, FidelitySummary, Ledger, LedgerOpenOptions, Query, UsageGranularity,
DEFAULT_MIN_SAMPLE,
};
use serde_json::{json, Value};
use crate::cli::{CompareArgs, GlobalArgs};
use crate::render::error::report_error;
use crate::render::format::{format_uint, format_usd};
use crate::render::json::render_json;
use crate::render::progress::TaskProgress;
const FIDELITY_CHOICES: &[&str] = &[
"full",
"usage-only",
"aggregate-only",
"cost-only",
"partial",
];
const FIDELITY_ORDER: &[&str] = &[
"cost-only",
"aggregate-only",
"partial",
"usage-only",
"full",
];
const NEEDS_MODELS_MSG: &str =
"compare: needs at least 2 models. Run `burn summary --by-provider` (or `burn summary --by-tool`) to see which models have data.";
const NOTE_LIMIT: usize = 8;
const DASH: &str = "—";
pub fn run(globals: &GlobalArgs, args: CompareArgs) -> i32 {
match run_inner(globals, args) {
Ok(code) => code,
Err(e) => report_error(&e, globals),
}
}
fn run_inner(globals: &GlobalArgs, args: CompareArgs) -> Result<i32> {
let raw = match args.models.as_deref() {
Some(s) => s,
None => {
return Err(anyhow!("{NEEDS_MODELS_MSG}"));
}
};
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut models: Vec<String> = Vec::new();
for part in raw.split(',') {
let m = part.trim();
if m.is_empty() {
continue;
}
if seen.insert(m.to_string()) {
models.push(m.to_string());
}
}
if models.len() < 2 {
return Err(anyhow!("{NEEDS_MODELS_MSG}"));
}
let mut min_fidelity: FidelityClass = FidelityClass::UsageOnly;
if let Some(raw) = args.fidelity.as_deref() {
if !FIDELITY_CHOICES.contains(&raw) {
return Err(anyhow!(
"invalid --fidelity: {raw} (expected one of {})",
FIDELITY_CHOICES.join(", ")
));
}
min_fidelity = parse_fidelity(raw)?;
}
if args.include_partial {
if let Some(raw) = args.fidelity.as_deref() {
if raw != "partial" {
return Err(anyhow!(
"--include-partial conflicts with --fidelity {raw}"
));
}
}
min_fidelity = FidelityClass::Partial;
}
if globals.json && args.csv {
return Err(anyhow!("--json and --csv are mutually exclusive; pick one."));
}
if args.provider.is_some() {
return Err(anyhow!(
"burn compare: --provider filter is not yet wired through the Rust SDK (#246 follow-up)"
));
}
let min_sample = args.min_sample.unwrap_or(DEFAULT_MIN_SAMPLE);
if min_sample < 1 {
return Err(anyhow!("invalid --min-sample: {min_sample}"));
}
let _ = args.no_archive;
let mut q = Query::default();
if let Some(s) = normalize_since(args.since.as_deref())? {
q.since = Some(s);
}
if let Some(p) = args.project.as_deref() {
q.project = Some(p.to_string());
}
if let Some(s) = args.session.as_deref() {
q.session_id = Some(s.to_string());
}
if args.workflow.is_some() || args.agent.is_some() {
return Err(anyhow!(
"burn compare: --workflow / --agent filters are not yet wired through the Rust ledger query (#246 follow-up)"
));
}
let progress = TaskProgress::new(globals, "compare");
let ledger_opts = match globals.ledger_path.as_deref() {
Some(p) => LedgerOpenOptions::with_home(p),
None => LedgerOpenOptions::default(),
};
progress.set_task("opening ledger");
let handle = Ledger::open(ledger_opts)?;
progress.set_task("loading turns");
let queried_turns: Vec<EnrichedTurn> = handle.raw().query_turns(&q)?;
let filtered_by_provider: Vec<EnrichedTurn> = queried_turns;
let fidelity_summary = summarize_fidelity(
&filtered_by_provider
.iter()
.map(|et| et.turn.clone())
.collect::<Vec<_>>(),
);
let filtered_turns: Vec<EnrichedTurn> = if matches!(min_fidelity, FidelityClass::Partial) {
filtered_by_provider
} else {
filtered_by_provider
.into_iter()
.filter(|et| has_minimum_fidelity(et.turn.fidelity.as_ref(), min_fidelity))
.collect()
};
let analyzed_turns = filtered_turns.len();
let pricing = load_pricing(None);
let opts = CompareOptions {
pricing: &pricing,
models: Some(models.clone()),
min_sample: Some(min_sample),
};
progress.set_task("building comparison");
let table = build_compare_table(&filtered_turns, &opts);
progress.finish_and_clear();
if globals.json {
let v = build_json(&table, analyzed_turns, min_fidelity, &fidelity_summary);
render_json(&v)?;
return Ok(0);
}
if args.csv {
let csv = render_csv(&table);
print!("{csv}");
return Ok(0);
}
let tty = render_tty(
&table,
analyzed_turns,
min_fidelity,
&fidelity_summary,
);
print!("{tty}");
Ok(0)
}
fn parse_fidelity(s: &str) -> Result<FidelityClass> {
match s {
"full" => Ok(FidelityClass::Full),
"usage-only" => Ok(FidelityClass::UsageOnly),
"aggregate-only" => Ok(FidelityClass::AggregateOnly),
"cost-only" => Ok(FidelityClass::CostOnly),
"partial" => Ok(FidelityClass::Partial),
other => Err(anyhow!("invalid fidelity class: {other}")),
}
}
fn normalize_since(since: Option<&str>) -> Result<Option<String>> {
let Some(raw) = since else {
return Ok(None);
};
if raw.is_empty() {
return Ok(None);
}
if let Some((n, unit)) = parse_relative(raw) {
let secs_back = match unit {
'h' => n * 3_600,
'd' => n * 86_400,
'w' => n * 7 * 86_400,
'm' => n * 30 * 86_400,
_ => unreachable!(),
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let when = now.saturating_sub(secs_back);
return Ok(Some(format_iso_z_ms(when, 0)));
}
if let Some(canonical) = normalize_iso_to_utc_z(raw) {
return Ok(Some(canonical));
}
Err(anyhow!(
"invalid since: {raw} (expected ISO timestamp or relative range like 7d)"
))
}
fn parse_relative(s: &str) -> Option<(u64, char)> {
let bytes = s.as_bytes();
if bytes.len() < 2 {
return None;
}
let unit = bytes[bytes.len() - 1] as char;
if !matches!(unit, 'h' | 'd' | 'w' | 'm') {
return None;
}
let num = &s[..s.len() - 1];
if num.is_empty() || !num.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let n: u64 = num.parse().ok()?;
Some((n, unit))
}
fn normalize_iso_to_utc_z(s: &str) -> Option<String> {
let bytes = s.as_bytes();
if bytes.len() < 10 {
return None;
}
if !(bytes[0..4].iter().all(|c| c.is_ascii_digit())
&& bytes[4] == b'-'
&& bytes[5..7].iter().all(|c| c.is_ascii_digit())
&& bytes[7] == b'-'
&& bytes[8..10].iter().all(|c| c.is_ascii_digit()))
{
return None;
}
let year: i64 = std::str::from_utf8(&bytes[0..4]).ok()?.parse().ok()?;
let month: u32 = std::str::from_utf8(&bytes[5..7]).ok()?.parse().ok()?;
let day: u32 = std::str::from_utf8(&bytes[8..10]).ok()?.parse().ok()?;
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return None;
}
let mut hour: u32 = 0;
let mut minute: u32 = 0;
let mut second: u32 = 0;
let mut millis: u32 = 0;
let mut offset_minutes: i32 = 0;
if bytes.len() > 10 {
if !(bytes[10] == b'T' || bytes[10] == b't' || bytes[10] == b' ') {
return None;
}
if bytes.len() < 19 {
return None;
}
if !(bytes[11..13].iter().all(|c| c.is_ascii_digit())
&& bytes[13] == b':'
&& bytes[14..16].iter().all(|c| c.is_ascii_digit())
&& bytes[16] == b':'
&& bytes[17..19].iter().all(|c| c.is_ascii_digit()))
{
return None;
}
hour = std::str::from_utf8(&bytes[11..13]).ok()?.parse().ok()?;
minute = std::str::from_utf8(&bytes[14..16]).ok()?.parse().ok()?;
second = std::str::from_utf8(&bytes[17..19]).ok()?.parse().ok()?;
if hour > 23 || minute > 59 || second > 60 {
return None;
}
let mut idx = 19;
if idx < bytes.len() && (bytes[idx] == b'.' || bytes[idx] == b',') {
idx += 1;
let frac_start = idx;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
if idx == frac_start {
return None;
}
let mut frac_str = String::from(std::str::from_utf8(&bytes[frac_start..idx]).ok()?);
if frac_str.len() > 3 {
frac_str.truncate(3);
}
while frac_str.len() < 3 {
frac_str.push('0');
}
millis = frac_str.parse().ok()?;
}
if idx < bytes.len() {
match bytes[idx] {
b'Z' | b'z' => {
if idx + 1 != bytes.len() {
return None;
}
}
b'+' | b'-' => {
let sign: i32 = if bytes[idx] == b'-' { -1 } else { 1 };
idx += 1;
if bytes.len() < idx + 5 {
return None;
}
if !(bytes[idx..idx + 2].iter().all(|c| c.is_ascii_digit())
&& bytes[idx + 2] == b':'
&& bytes[idx + 3..idx + 5].iter().all(|c| c.is_ascii_digit()))
{
return None;
}
let oh: i32 = std::str::from_utf8(&bytes[idx..idx + 2])
.ok()?
.parse()
.ok()?;
let om: i32 = std::str::from_utf8(&bytes[idx + 3..idx + 5])
.ok()?
.parse()
.ok()?;
if oh > 23 || om > 59 {
return None;
}
offset_minutes = sign * (oh * 60 + om);
if idx + 5 != bytes.len() {
return None;
}
}
_ => return None,
}
}
}
let days = ymd_to_days(year, month, day)?;
let local_secs: i64 = days * 86_400 + (hour as i64) * 3_600 + (minute as i64) * 60 + (second as i64);
let utc_secs: i64 = local_secs - (offset_minutes as i64) * 60;
Some(format_iso_z_ms_signed(utc_secs, millis))
}
fn format_iso_z_ms(secs: u64, millis: u32) -> String {
format_iso_z_ms_signed(secs as i64, millis)
}
fn format_iso_z_ms_signed(secs: i64, millis: u32) -> String {
let total_days = secs.div_euclid(86_400);
let secs_in_day = secs.rem_euclid(86_400) as u32;
let hour = secs_in_day / 3_600;
let minute = (secs_in_day / 60) % 60;
let second = secs_in_day % 60;
let (year, month, day) = days_to_ymd(total_days);
format!(
"{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z"
)
}
fn ymd_to_days(year: i64, month: u32, day: u32) -> Option<i64> {
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return None;
}
let m = month as i64;
let d = day as i64;
let y = if m <= 2 { year - 1 } else { year };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as u64; let mp = if m > 2 { m - 3 } else { m + 9 } as u64; let doy = (153 * mp + 2) / 5 + (d as u64) - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; Some(era * 146_097 + (doe as i64) - 719_468)
}
fn days_to_ymd(days_from_epoch: i64) -> (i64, u32, u32) {
let z = days_from_epoch + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if m <= 2 { y + 1 } else { y };
(year, m as u32, d as u32)
}
fn format_pct(rate: f64) -> String {
let pct = (rate * 100.0).round() as i64;
format!("{pct}%")
}
fn to_fixed(n: f64, digits: usize) -> String {
let s = format!("{n:.*}", digits);
trim_trailing_zeros(&s)
}
fn trim_trailing_zeros(s: &str) -> String {
if !s.contains('.') {
return s.to_string();
}
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
if trimmed.is_empty() || trimmed == "-" {
"0".to_string()
} else {
trimmed.to_string()
}
}
fn round_json(n: f64, digits: usize) -> Value {
let s = format!("{n:.*}", digits);
let parsed: f64 = s.parse().unwrap_or(0.0);
f64_to_json(parsed)
}
fn f64_to_json(n: f64) -> Value {
if n.is_nan() || n.is_infinite() {
return Value::Null;
}
if n == 0.0 {
return Value::from(0u64);
}
if n.fract() == 0.0 && n.abs() < (i64::MAX as f64) {
return Value::from(n as i64);
}
Value::from(n)
}
fn opt_f64_to_json(n: Option<f64>) -> Value {
match n {
Some(v) => f64_to_json(v),
None => Value::Null,
}
}
fn round_opt(n: Option<f64>, digits: usize) -> Value {
match n {
Some(v) => round_json(v, digits),
None => Value::Null,
}
}
#[derive(Default)]
struct ExcludedBreakdown {
total: u64,
aggregate_only: u64,
cost_only: u64,
partial: u64,
usage_only: u64,
}
fn compute_excluded(summary: &FidelitySummary, minimum: FidelityClass) -> ExcludedBreakdown {
let mut out = ExcludedBreakdown::default();
if matches!(minimum, FidelityClass::Partial) {
return out;
}
let need = FIDELITY_ORDER
.iter()
.position(|c| *c == minimum.wire_str())
.unwrap_or(0);
for (i, key) in FIDELITY_ORDER.iter().enumerate() {
if i >= need {
continue;
}
let cls = parse_fidelity(key).unwrap();
let n = summary.by_class.get(&cls).copied().unwrap_or(0);
if n == 0 {
continue;
}
out.total += n;
match *key {
"aggregate-only" => out.aggregate_only += n,
"cost-only" => out.cost_only += n,
"partial" => out.partial += n,
"usage-only" => out.usage_only += n,
_ => {}
}
}
out
}
fn build_json(
table: &CompareTable,
analyzed_turns: usize,
minimum: FidelityClass,
summary: &FidelitySummary,
) -> Value {
let excluded = compute_excluded(summary, minimum);
let mut cells: Vec<Value> = Vec::with_capacity(table.models.len() * table.categories.len());
for m in &table.models {
for cat in &table.categories {
let c = table
.cells
.get(m)
.and_then(|by_cat| by_cat.get(cat))
.cloned()
.unwrap_or_else(empty_cell);
cells.push(json!({
"model": m,
"category": cat,
"turns": c.turns,
"editTurns": c.edit_turns,
"oneShotTurns": c.one_shot_turns,
"pricedTurns": c.priced_turns,
"totalCost": round_json(c.total_cost, 6),
"costPerTurn": round_opt(c.cost_per_turn, 6),
"oneShotRate": round_opt(c.one_shot_rate, 4),
"cacheHitRate": round_opt(c.cache_hit_rate, 4),
"medianRetries": opt_f64_to_json(c.median_retries),
"noData": c.no_data,
"insufficientSample": c.insufficient_sample,
}));
}
}
let mut totals = serde_json::Map::new();
for m in &table.models {
let totals_for = table.totals.get(m).cloned().unwrap_or_default();
totals.insert(
m.clone(),
json!({
"turns": totals_for.turns,
"totalCost": f64_to_json(totals_for.total_cost),
}),
);
}
json!({
"analyzedTurns": analyzed_turns,
"minSample": table.min_sample,
"models": &table.models,
"categories": &table.categories,
"totals": Value::Object(totals),
"cells": cells,
"fidelity": {
"minimum": minimum.wire_str(),
"excluded": {
"total": excluded.total,
"aggregateOnly": excluded.aggregate_only,
"costOnly": excluded.cost_only,
"partial": excluded.partial,
"usageOnly": excluded.usage_only,
},
"summary": fidelity_summary_to_value(summary),
}
})
}
fn fidelity_summary_to_value(s: &FidelitySummary) -> Value {
let mut by_class = serde_json::Map::new();
for key in &["full", "usage-only", "aggregate-only", "cost-only", "partial"] {
let cls = parse_fidelity(key).unwrap();
let n = s.by_class.get(&cls).copied().unwrap_or(0);
by_class.insert((*key).to_string(), Value::from(n));
}
let mut by_granularity = serde_json::Map::new();
for key in &["per-turn", "per-message", "per-session-aggregate", "cost-only"] {
let g = match *key {
"per-turn" => UsageGranularity::PerTurn,
"per-message" => UsageGranularity::PerMessage,
"per-session-aggregate" => UsageGranularity::PerSessionAggregate,
"cost-only" => UsageGranularity::CostOnly,
_ => unreachable!(),
};
let n = s.by_granularity.get(&g).copied().unwrap_or(0);
by_granularity.insert((*key).to_string(), Value::from(n));
}
let coverage_keys = &[
"hasInputTokens",
"hasOutputTokens",
"hasReasoningTokens",
"hasCacheReadTokens",
"hasCacheCreateTokens",
"hasToolCalls",
"hasToolResultEvents",
"hasSessionRelationships",
"hasRawContent",
];
let mut missing = serde_json::Map::new();
for k in coverage_keys {
let n = s.missing_coverage.get(*k).copied().unwrap_or(0);
missing.insert((*k).to_string(), Value::from(n));
}
let mut out = serde_json::Map::new();
out.insert("total".to_string(), Value::from(s.total));
out.insert("byClass".to_string(), Value::Object(by_class));
out.insert("byGranularity".to_string(), Value::Object(by_granularity));
out.insert("missingCoverage".to_string(), Value::Object(missing));
out.insert("unknown".to_string(), Value::from(s.unknown));
Value::Object(out)
}
fn empty_cell() -> CompareCell {
CompareCell {
turns: 0,
edit_turns: 0,
one_shot_turns: 0,
priced_turns: 0,
total_cost: 0.0,
cost_per_turn: None,
one_shot_rate: None,
cache_hit_rate: None,
median_retries: None,
no_data: true,
insufficient_sample: false,
}
}
fn render_csv(table: &CompareTable) -> String {
let header = [
"model",
"category",
"turns",
"editTurns",
"oneShotTurns",
"pricedTurns",
"totalCost",
"costPerTurn",
"oneShotRate",
"cacheHitRate",
"medianRetries",
"noData",
"insufficientSample",
];
let mut rows: Vec<String> = Vec::new();
rows.push(header.join(","));
for m in &table.models {
for cat in &table.categories {
let c = table
.cells
.get(m)
.and_then(|by_cat| by_cat.get(cat))
.cloned()
.unwrap_or_else(empty_cell);
let row = vec![
csv_cell(m),
csv_cell(cat),
c.turns.to_string(),
c.edit_turns.to_string(),
c.one_shot_turns.to_string(),
c.priced_turns.to_string(),
num_csv(c.total_cost, 6),
c.cost_per_turn
.map(|v| num_csv(v, 6))
.unwrap_or_default(),
c.one_shot_rate
.map(|v| num_csv(v, 4))
.unwrap_or_default(),
c.cache_hit_rate
.map(|v| num_csv(v, 4))
.unwrap_or_default(),
c.median_retries
.map(|v| {
if v.fract() == 0.0 {
(v as i64).to_string()
} else {
v.to_string()
}
})
.unwrap_or_default(),
if c.no_data { "true" } else { "false" }.to_string(),
if c.insufficient_sample {
"true"
} else {
"false"
}
.to_string(),
];
rows.push(row.join(","));
}
}
format!("{}\n", rows.join("\n"))
}
fn csv_cell(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
fn num_csv(n: f64, digits: usize) -> String {
to_fixed(n, digits)
}
fn cell_fields(c: &CompareCell) -> [String; 3] {
if c.no_data {
return [DASH.to_string(), DASH.to_string(), DASH.to_string()];
}
let turns = format_uint(c.turns);
let cost = c
.cost_per_turn
.map(format_usd)
.unwrap_or_else(|| DASH.to_string());
let one_shot = c
.one_shot_rate
.map(format_pct)
.unwrap_or_else(|| DASH.to_string());
[turns, cost, one_shot]
}
fn render_tty(
table: &CompareTable,
analyzed_turns: usize,
minimum: FidelityClass,
summary: &FidelitySummary,
) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push(String::new());
lines.push(format!("turns analyzed: {}", format_uint(analyzed_turns as u64)));
let excluded = compute_excluded(summary, minimum);
if excluded.total > 0 {
lines.push(format_excluded_note(&excluded, minimum));
}
lines.push(String::new());
if table.models.is_empty() || table.categories.is_empty() {
lines.push(
"no data to compare (need turns spanning ≥1 model and ≥1 activity).".to_string(),
);
lines.push(String::new());
return lines.join("\n");
}
let sub_header = build_sub_header(&table.models);
let owned_empty = empty_cell();
let cell_for = |m: &str, cat: &str| -> CompareCell {
table
.cells
.get(m)
.and_then(|by| by.get(cat))
.cloned()
.unwrap_or_else(empty_cell)
};
let _ = &owned_empty;
let mut data_rows: Vec<Vec<String>> = Vec::new();
for cat in &table.categories {
let mut row: Vec<String> = vec![cat.clone()];
for m in &table.models {
let cell = cell_for(m, cat);
let [a, b, c] = cell_fields(&cell);
row.push(a);
row.push(b);
row.push(c);
}
data_rows.push(row);
}
let mut widths = vec![0usize; sub_header.len()];
for row in std::iter::once(&sub_header).chain(data_rows.iter()) {
for (i, cell) in row.iter().enumerate() {
widths[i] = widths[i].max(display_width(cell));
}
}
const SEP: &str = " ";
for mi in 0..table.models.len() {
let start = 1 + mi * 3;
let group_width =
widths[start] + SEP.len() + widths[start + 1] + SEP.len() + widths[start + 2];
let name = display_model_name(&table.models[mi]);
let name_w = display_width(name);
if name_w > group_width {
widths[start + 2] += name_w - group_width;
}
}
let mut group_line: Vec<String> = vec![pad_end("", widths[0])];
for mi in 0..table.models.len() {
let start = 1 + mi * 3;
let group_width =
widths[start] + SEP.len() + widths[start + 1] + SEP.len() + widths[start + 2];
let name = display_model_name(&table.models[mi]);
group_line.push(pad_end(name, group_width));
}
lines.push(rstrip(&group_line.join(SEP)));
lines.push(render_row(&sub_header, &widths, SEP));
for row in &data_rows {
lines.push(render_row(row, &widths, SEP));
}
let mut notes: Vec<String> = Vec::new();
for cat in &table.categories {
let any_has_data = table
.models
.iter()
.any(|m| !cell_for(m, cat).no_data);
if !any_has_data {
continue;
}
for m in &table.models {
let cell = cell_for(m, cat);
if cell.no_data {
notes.push(format!(
"no {} data in '{cat}' — no comparison available.",
display_model_name(m)
));
} else if cell.insufficient_sample {
notes.push(format!(
"low {} sample in '{cat}' ({} turns < {}) — treat as indicative.",
display_model_name(m),
cell.turns,
table.min_sample
));
}
}
}
if !notes.is_empty() {
lines.push(String::new());
let shown = notes.iter().take(NOTE_LIMIT);
for n in shown {
lines.push(format!(" {n}"));
}
if notes.len() > NOTE_LIMIT {
lines.push(format!(
" … and {} more coverage gaps.",
notes.len() - NOTE_LIMIT
));
}
}
lines.push(String::new());
for m in &table.models {
let tot = table.totals.get(m).cloned().unwrap_or_default();
let total_cost = if tot.turns > 0 {
format_usd(tot.total_cost)
} else {
DASH.to_string()
};
lines.push(format!(
"{}: {} turns, {} total",
display_model_name(m),
format_uint(tot.turns),
total_cost
));
}
lines.push(String::new());
lines.join("\n")
}
fn build_sub_header(models: &[String]) -> Vec<String> {
let mut row: Vec<String> = vec!["Activity".to_string()];
for _ in models {
row.push("Turns".to_string());
row.push("Cost/turn".to_string());
row.push("1-shot".to_string());
}
row
}
fn render_row(row: &[String], widths: &[usize], sep: &str) -> String {
let mut parts: Vec<String> = Vec::with_capacity(row.len());
for (i, cell) in row.iter().enumerate() {
parts.push(pad_end(cell, widths[i]));
}
rstrip(&parts.join(sep))
}
fn pad_end(s: &str, width: usize) -> String {
let w = display_width(s);
if w >= width {
return s.to_string();
}
let pad = " ".repeat(width - w);
format!("{s}{pad}")
}
fn rstrip(s: &str) -> String {
s.trim_end_matches(' ').to_string()
}
fn display_width(s: &str) -> usize {
s.chars().count()
}
fn display_model_name(m: &str) -> &str {
match m.find('/') {
Some(i) => &m[i + 1..],
None => m,
}
}
fn format_excluded_note(excluded: &ExcludedBreakdown, minimum: FidelityClass) -> String {
let mut parts: Vec<String> = Vec::new();
if excluded.aggregate_only > 0 {
parts.push(format!("{} aggregate-only", excluded.aggregate_only));
}
if excluded.cost_only > 0 {
parts.push(format!("{} cost-only", excluded.cost_only));
}
if excluded.partial > 0 {
parts.push(format!("{} partial", excluded.partial));
}
if excluded.usage_only > 0 {
parts.push(format!("{} usage-only", excluded.usage_only));
}
let breakdown = if parts.is_empty() {
String::new()
} else {
format!(" ({})", parts.join(", "))
};
let noun = if excluded.total == 1 { "turn" } else { "turns" };
format!(
"excluded {} {noun} below {} fidelity{breakdown}",
format_uint(excluded.total),
minimum.wire_str()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_pct_rounds_to_int() {
assert_eq!(format_pct(0.0), "0%");
assert_eq!(format_pct(0.5), "50%");
assert_eq!(format_pct(1.0), "100%");
assert_eq!(format_pct(2.0 / 3.0), "67%");
}
#[test]
fn round_json_matches_js_to_fixed() {
let v = round_json(1.0, 4);
assert_eq!(v.to_string(), "1");
let v = round_json(0.5, 4);
assert_eq!(v.to_string(), "0.5");
let v = round_json(0.0112499999, 6);
assert_eq!(v.to_string(), "0.01125");
}
#[test]
fn parse_fidelity_known_classes() {
assert!(matches!(parse_fidelity("full").unwrap(), FidelityClass::Full));
assert!(matches!(
parse_fidelity("usage-only").unwrap(),
FidelityClass::UsageOnly
));
assert!(parse_fidelity("nope").is_err());
}
#[test]
fn display_model_name_strips_provider_prefix() {
assert_eq!(display_model_name("anthropic/claude-sonnet-4-6"), "claude-sonnet-4-6");
assert_eq!(display_model_name("claude-haiku-4-5"), "claude-haiku-4-5");
}
#[test]
fn normalize_iso_widens_no_fraction_to_three_zeros() {
assert_eq!(
normalize_iso_to_utc_z("2026-05-06T00:00:00Z"),
Some("2026-05-06T00:00:00.000Z".to_string()),
);
}
#[test]
fn normalize_iso_preserves_millisecond_precision() {
assert_eq!(
normalize_iso_to_utc_z("2026-05-06T00:00:00.500Z"),
Some("2026-05-06T00:00:00.500Z".to_string()),
);
assert_eq!(
normalize_iso_to_utc_z("2026-05-06T00:00:00.500999Z"),
Some("2026-05-06T00:00:00.500Z".to_string()),
);
assert_eq!(
normalize_iso_to_utc_z("2026-05-06T00:00:00.5Z"),
Some("2026-05-06T00:00:00.500Z".to_string()),
);
}
#[test]
fn normalize_iso_converts_negative_offset_to_utc() {
assert_eq!(
normalize_iso_to_utc_z("2026-05-06T00:00:00-07:00"),
Some("2026-05-06T07:00:00.000Z".to_string()),
);
}
#[test]
fn normalize_iso_converts_positive_offset_to_utc() {
assert_eq!(
normalize_iso_to_utc_z("2026-05-06T00:00:00+09:00"),
Some("2026-05-05T15:00:00.000Z".to_string()),
);
}
#[test]
fn normalize_iso_handles_lowercase_z() {
assert_eq!(
normalize_iso_to_utc_z("2026-05-06t00:00:00.500z"),
Some("2026-05-06T00:00:00.500Z".to_string()),
);
}
#[test]
fn normalize_iso_accepts_date_only() {
assert_eq!(
normalize_iso_to_utc_z("2026-05-06"),
Some("2026-05-06T00:00:00.000Z".to_string()),
);
}
#[test]
fn normalize_iso_rejects_garbage() {
assert_eq!(normalize_iso_to_utc_z("not a date"), None);
assert_eq!(normalize_iso_to_utc_z("2026/05/06"), None);
assert_eq!(normalize_iso_to_utc_z("2026-13-01T00:00:00Z"), None); assert_eq!(normalize_iso_to_utc_z("2026-05-06T25:00:00Z"), None); assert_eq!(normalize_iso_to_utc_z("2026-05-06T00:00:00+9"), None); }
#[test]
fn normalize_since_relative_emits_milliseconds() {
let out = normalize_since(Some("7d")).unwrap().unwrap();
assert!(out.ends_with(".000Z"), "expected .000Z suffix in {out}");
assert_eq!(out.len(), 24, "expected 24-char canonical shape: {out}");
}
#[test]
fn normalize_since_iso_pass_normalizes_offset() {
let out = normalize_since(Some("2026-05-06T00:00:00-07:00"))
.unwrap()
.unwrap();
assert_eq!(out, "2026-05-06T07:00:00.000Z");
}
#[test]
fn normalize_since_relative_format_is_lex_compatible_with_ledger_rows() {
let cutoff = "2026-05-06T12:00:00.000Z";
let row_a = "2026-05-06T12:00:00.500Z";
let row_b = "2026-05-06T12:00:00.001Z";
assert!(cutoff <= row_a);
assert!(cutoff <= row_b);
}
#[test]
fn ymd_round_trip() {
for (y, m, d) in &[(1970, 1, 1), (2026, 5, 6), (2000, 2, 29), (1999, 12, 31)] {
let days = ymd_to_days(*y, *m, *d).unwrap();
let (ry, rm, rd) = days_to_ymd(days);
assert_eq!((*y, *m, *d), (ry, rm, rd));
}
}
}