Skip to main content

lean_ctx/core/stats/format/
util.rs

1//! Shared number/string formatting helpers for the stats views.
2
3use crate::core::theme::{self, Theme};
4
5use super::super::model::{CommandStats, CostModel, DayStats};
6
7pub(super) fn active_theme() -> Theme {
8    let cfg = crate::core::config::Config::load();
9    theme::load_theme(&cfg.theme)
10}
11
12pub(super) fn format_usd(amount: f64) -> String {
13    if amount >= 0.01 {
14        format!("${amount:.2}")
15    } else {
16        format!("${amount:.3}")
17    }
18}
19
20pub(super) fn usd_estimate(tokens: u64) -> String {
21    let model = CostModel::default();
22    let cost = tokens as f64 * model.input_price_per_m / 1_000_000.0;
23    format_usd(cost)
24}
25
26pub(super) fn format_pct_1dp(val: f64) -> String {
27    if val == 0.0 {
28        "0.0%".to_string()
29    } else if val > 0.0 && val < 0.1 {
30        "<0.1%".to_string()
31    } else {
32        format!("{val:.1}%")
33    }
34}
35
36pub(super) fn format_big(n: u64) -> String {
37    if n >= 1_000_000_000_000 {
38        format!("{:.2}T", n as f64 / 1_000_000_000_000.0)
39    } else if n >= 1_000_000_000 {
40        // Heavy users cross 1B; show B so the receipt keeps growing past "1000.0M".
41        format!("{:.2}B", n as f64 / 1_000_000_000.0)
42    } else if n >= 1_000_000 {
43        format!("{:.1}M", n as f64 / 1_000_000.0)
44    } else if n >= 1_000 {
45        format!("{:.1}K", n as f64 / 1_000.0)
46    } else {
47        format!("{n}")
48    }
49}
50
51pub(super) fn format_num(n: u64) -> String {
52    if n >= 1_000_000_000_000 {
53        format!("{:.2}T", n as f64 / 1_000_000_000_000.0)
54    } else if n >= 1_000_000_000 {
55        format!("{:.2}B", n as f64 / 1_000_000_000.0)
56    } else if n >= 1_000_000 {
57        format!("{:.1}M", n as f64 / 1_000_000.0)
58    } else if n >= 1_000 {
59        format!("{},{:03}", n / 1_000, n % 1_000)
60    } else {
61        format!("{n}")
62    }
63}
64
65pub(super) fn truncate_cmd(cmd: &str, max: usize) -> String {
66    if cmd.len() <= max {
67        return cmd.to_string();
68    }
69    // Cut on a char boundary: byte-indexed slicing panics mid-codepoint for
70    // multibyte command names (GitHub #386).
71    let mut end = max.saturating_sub(1);
72    while end > 0 && !cmd.is_char_boundary(end) {
73        end -= 1;
74    }
75    format!("{}…", &cmd[..end])
76}
77
78pub(super) fn cmd_total_saved(s: &CommandStats, _cm: &CostModel) -> u64 {
79    s.input_tokens.saturating_sub(s.output_tokens)
80}
81
82pub(super) fn day_total_saved(d: &DayStats, _cm: &CostModel) -> u64 {
83    d.input_tokens.saturating_sub(d.output_tokens)
84}
85
86/// Compression rate only exists when lean-ctx observed a non-zero baseline.
87/// Returning `None` prevents an unmetered/write-only day from masquerading as 0%.
88pub(super) fn measured_compression_rate(input: u64, output: u64) -> Option<f64> {
89    (input > 0).then(|| input.saturating_sub(output) as f64 / input as f64 * 100.0)
90}
91
92pub fn normalize_command(command: &str) -> String {
93    let parts: Vec<&str> = command.split_whitespace().collect();
94    if parts.is_empty() {
95        return command.to_string();
96    }
97
98    let base = std::path::Path::new(parts[0])
99        .file_name()
100        .and_then(|n| n.to_str())
101        .unwrap_or(parts[0]);
102
103    match base {
104        "git" => {
105            if parts.len() > 1 {
106                format!("git {}", parts[1])
107            } else {
108                "git".to_string()
109            }
110        }
111        "cargo" => {
112            if parts.len() > 1 {
113                format!("cargo {}", parts[1])
114            } else {
115                "cargo".to_string()
116            }
117        }
118        "npm" | "yarn" | "pnpm" => {
119            if parts.len() > 1 {
120                format!("{} {}", base, parts[1])
121            } else {
122                base.to_string()
123            }
124        }
125        "docker" => {
126            if parts.len() > 1 {
127                format!("docker {}", parts[1])
128            } else {
129                "docker".to_string()
130            }
131        }
132        _ => base.to_string(),
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::{format_big, format_num, format_pct_1dp, measured_compression_rate};
139
140    #[test]
141    fn format_big_scales_through_billions() {
142        assert_eq!(format_big(900), "900");
143        assert_eq!(format_big(2_500), "2.5K");
144        assert_eq!(format_big(4_200_000), "4.2M");
145        // The gain receipt must read in B once savings cross 1B, not "1310.0M".
146        assert_eq!(format_big(1_310_000_000), "1.31B");
147        assert_eq!(format_big(3_000_000_000_000), "3.00T");
148    }
149
150    #[test]
151    fn format_num_scales_through_billions() {
152        assert_eq!(format_num(950), "950");
153        assert_eq!(format_num(12_345), "12,345");
154        assert_eq!(format_num(7_800_000), "7.8M");
155        assert_eq!(format_num(1_310_000_000), "1.31B");
156        assert_eq!(format_num(2_500_000_000_000), "2.50T");
157    }
158
159    #[test]
160    fn format_pct_1dp_normal() {
161        assert_eq!(format_pct_1dp(50.0), "50.0%");
162        assert_eq!(format_pct_1dp(100.0), "100.0%");
163        assert_eq!(format_pct_1dp(33.333), "33.3%");
164    }
165
166    #[test]
167    fn format_pct_1dp_small_values() {
168        assert_eq!(format_pct_1dp(0.0), "0.0%");
169        assert_eq!(format_pct_1dp(0.05), "<0.1%");
170        assert_eq!(format_pct_1dp(0.09), "<0.1%");
171        assert_eq!(format_pct_1dp(0.1), "0.1%");
172        assert_eq!(format_pct_1dp(0.5), "0.5%");
173    }
174
175    #[test]
176    fn compression_rate_distinguishes_unmetered_from_zero_savings() {
177        assert_eq!(measured_compression_rate(0, 0), None);
178        assert_eq!(measured_compression_rate(100, 100), Some(0.0));
179        assert_eq!(measured_compression_rate(100, 25), Some(75.0));
180    }
181}