relayburn-cli 2.8.3

The `burn` CLI — published to crates.io. Crate name is relayburn-cli because `burn` is taken on crates.io; the binary keeps the `burn` invocation.
Documentation
//! TS-CLI-equivalent formatting helpers for human-rendered output.
//!
//! Wave 2 PRs need byte-for-byte parity with `packages/cli/src/format.ts`
//! (the `--json` mode handles its own shape via `serde_json`). The TS
//! helpers are tiny pure functions over numbers and `string[][]`; this
//! module mirrors them in Rust.
//!
//! - [`format_usd`] — money rendering with three rate-tier precisions.
//! - [`format_uint`] — `toLocaleString('en-US')` thousands-separator output.
//! - [`format_tokens`] — collapse 1k+ values to `<n>.<d>k`.
//! - [`render_table`] — pad-end columns separated by `"  "` (two spaces),
//!   `trim_end` each row, `\n`-joined. NOT the comfy-table preset; this
//!   is the spartan layout the TS CLI snapshots assume.

/// `formatUsd` from `packages/cli/src/format.ts`. Three rate tiers:
/// - `0`           → `$0.00`
/// - `< 0.01`      → `$<n.toFixed(4)>`
/// - `< 1`         → `$<n.toFixed(3)>`
/// - `>= 1`        → `$<n.toFixed(2)>`
pub fn format_usd(n: f64) -> String {
    if n == 0.0 {
        return "$0.00".to_string();
    }
    if n.abs() < 0.01 {
        return format!("${:.4}", n);
    }
    if n.abs() < 1.0 {
        return format!("${:.3}", n);
    }
    format!("${:.2}", n)
}

/// `formatInt` from `packages/cli/src/format.ts`. JS
/// `Number.toLocaleString('en-US')` uses thousands separators (`,`)
/// for non-negative integers. Token counts and row counts are all
/// unsigned, so we expose just the `u64` form.
pub fn format_uint(n: u64) -> String {
    let raw = n.to_string();
    let bytes = raw.as_bytes();
    let len = bytes.len();
    if len <= 3 {
        return raw;
    }
    let mut out = String::with_capacity(len + (len - 1) / 3);
    let first = len % 3;
    if first > 0 {
        out.push_str(&raw[..first]);
        if len > first {
            out.push(',');
        }
    }
    let mut i = first;
    while i + 3 <= len {
        out.push_str(&raw[i..i + 3]);
        if i + 3 < len {
            out.push(',');
        }
        i += 3;
    }
    out
}

/// Collapse token counts to a compact `<n>.<d>k` form once they reach
/// 1,000; sub-1k values render as plain integers. Mirrors the TS
/// `formatTokens` helper used by `burn overhead` output.
pub fn format_tokens(tokens: u64) -> String {
    if tokens >= 1000 {
        let v = tokens as f64 / 1000.0;
        return format!("{v:.1}k");
    }
    tokens.to_string()
}

/// `table()` from `packages/cli/src/format.ts`. Each column gets padded to
/// the max width seen in that column; columns are joined with two spaces;
/// trailing whitespace is trimmed per row. The first row is the header.
///
/// The cell length is measured as the number of `char`s (Unicode code
/// points), not bytes — same as JS `String.prototype.length` (UTF-16 code
/// units), which for the CLI's actual output is equivalent. Multi-byte
/// glyphs like `—` (U+2014) count as 1.
pub fn render_table(rows: &[Vec<String>]) -> String {
    if rows.is_empty() {
        return String::new();
    }
    let mut widths: Vec<usize> = Vec::new();
    for row in rows {
        for (i, cell) in row.iter().enumerate() {
            let w = cell.chars().count();
            if i >= widths.len() {
                widths.push(w);
            } else if widths[i] < w {
                widths[i] = w;
            }
        }
    }
    let mut out = String::new();
    for (ri, row) in rows.iter().enumerate() {
        let mut line = String::new();
        for (i, cell) in row.iter().enumerate() {
            if i > 0 {
                line.push_str("  ");
            }
            line.push_str(cell);
            // Pad with ASCII spaces to the column width. Skip padding the
            // last cell — TS uses padEnd then trimEnd, which leaves the
            // last column unpadded in the output.
            let target = widths[i];
            let cur = cell.chars().count();
            if cur < target {
                for _ in 0..(target - cur) {
                    line.push(' ');
                }
            }
        }
        let trimmed = line.trim_end();
        out.push_str(trimmed);
        if ri + 1 < rows.len() {
            out.push('\n');
        }
    }
    out
}

/// Walk a `serde_json::Value` and rewrite any whole-number `f64` field
/// to its integer-shaped equivalent, matching JS's `JSON.stringify`
/// number formatting (which doesn't distinguish int from float). The
/// SDK uses `f64` for cost / token-share fields; left to its default
/// formatter, `serde_json` would emit `0.0` for them where the TS CLI
/// emits `0`. Recursive over arrays and objects.
pub fn coerce_whole_f64_to_int(v: &mut serde_json::Value) {
    use serde_json::{Number, Value};
    match v {
        Value::Number(n) => {
            if !n.is_f64() {
                return;
            }
            let Some(f) = n.as_f64() else {
                return;
            };
            if !(f.is_finite() && f.fract() == 0.0) {
                return;
            }
            if f >= 0.0 && f <= u64::MAX as f64 {
                let int = f as u64;
                // Round-trip check: skip cases where f64 → u64 → f64 lost
                // precision (e.g. very large doubles); leaving them as f64
                // is safer than producing a different integer.
                if int as f64 == f {
                    *n = Number::from(int);
                }
            } else if f < 0.0 && f >= i64::MIN as f64 {
                let int = f as i64;
                if int as f64 == f {
                    *n = Number::from(int);
                }
            }
        }
        Value::Array(arr) => {
            for item in arr {
                coerce_whole_f64_to_int(item);
            }
        }
        Value::Object(obj) => {
            for (_, val) in obj.iter_mut() {
                coerce_whole_f64_to_int(val);
            }
        }
        _ => {}
    }
}

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

    #[test]
    fn format_usd_zero() {
        assert_eq!(format_usd(0.0), "$0.00");
    }

    #[test]
    fn format_usd_under_one_cent() {
        assert_eq!(format_usd(0.0065), "$0.0065");
    }

    #[test]
    fn format_usd_under_one_dollar() {
        assert_eq!(format_usd(0.034), "$0.034");
        assert_eq!(format_usd(0.0335), "$0.034");
    }

    #[test]
    fn format_usd_above_one_dollar() {
        assert_eq!(format_usd(1.234), "$1.23");
    }

    #[test]
    fn format_uint_thousands() {
        assert_eq!(format_uint(0), "0");
        assert_eq!(format_uint(999), "999");
        assert_eq!(format_uint(1_000), "1,000");
        assert_eq!(format_uint(5_100), "5,100");
        assert_eq!(format_uint(19_500), "19,500");
        assert_eq!(format_uint(1_000_000), "1,000,000");
    }

    #[test]
    fn format_tokens_collapses_kilos() {
        assert_eq!(format_tokens(999), "999");
        assert_eq!(format_tokens(1_000), "1.0k");
        assert_eq!(format_tokens(2_500), "2.5k");
    }

    #[test]
    fn render_table_pads_columns_and_trims_trailing_space() {
        let rendered = render_table(&[
            vec!["model".into(), "turns".into()],
            vec!["claude-sonnet-4-6".into(), "12".into()],
            vec!["claude-haiku".into(), "3".into()],
        ]);
        let expected = "model              turns\nclaude-sonnet-4-6  12\nclaude-haiku       3";
        assert_eq!(rendered, expected);
    }

    #[test]
    fn render_table_handles_em_dash_as_single_char_width() {
        let rendered = render_table(&[vec!["k".into(), "v".into()], vec!["x".into(), "".into()]]);
        // `—` (U+2014) is one codepoint; column width should be 1.
        assert_eq!(rendered, "k  v\nx  —");
    }

    #[test]
    fn render_table_returns_empty_for_empty_rows() {
        let rendered = render_table(&[]);
        assert_eq!(rendered, "");
    }
}