Skip to main content

cc_token_usage/output/
text.rs

1use std::fmt::Write as _;
2
3use crate::analysis::validate::ValidationReport;
4use crate::analysis::{OverviewResult, ProjectResult, SessionResult, TrendResult};
5use crate::pricing::calculator::PricingCalculator;
6
7// ─── Helpers ────────────────────────────────────────────────────────────────
8
9fn format_number(n: u64) -> String {
10    let s = n.to_string();
11    let mut result = String::with_capacity(s.len() + s.len() / 3);
12    for (i, ch) in s.chars().rev().enumerate() {
13        if i > 0 && i % 3 == 0 {
14            result.push(',');
15        }
16        result.push(ch);
17    }
18    result.chars().rev().collect()
19}
20
21fn format_cost(c: f64) -> String {
22    let abs = c.abs();
23    let total_cents = (abs * 100.0).round() as u64;
24    let whole = total_cents / 100;
25    let cents = total_cents % 100;
26    let sign = if c < 0.0 { "-" } else { "" };
27    format!("{}${}.{:02}", sign, format_number(whole), cents)
28}
29
30fn format_duration(minutes: f64) -> String {
31    if minutes < 1.0 {
32        format!("{:.0}s", minutes * 60.0)
33    } else if minutes < 60.0 {
34        format!("{:.0}m", minutes)
35    } else {
36        let h = (minutes / 60.0).floor();
37        let m = (minutes % 60.0).round();
38        format!("{:.0}h{:.0}m", h, m)
39    }
40}
41
42// ─── 1. Overview ────────────────────────────────────────────────────────────
43
44pub fn render_overview(result: &OverviewResult, calc: &PricingCalculator) -> String {
45    let mut out = String::new();
46    let _ = calc;
47
48    let range = result.quality.time_range
49        .map(|(s, e)| format!("{} ~ {}", s.format("%Y-%m-%d"), e.format("%Y-%m-%d")))
50        .unwrap_or_default();
51
52    writeln!(out, "Claude Code Token Report").unwrap();
53    writeln!(out, "{}", range).unwrap();
54    writeln!(out).unwrap();
55
56    writeln!(out, "  {} conversations, {} rounds of back-and-forth",
57        format_number(result.total_sessions as u64),
58        format_number(result.total_turns as u64)).unwrap();
59    writeln!(out).unwrap();
60
61    writeln!(out, "  Claude read  {} tokens",
62        format_number(result.total_context_tokens)).unwrap();
63    writeln!(out, "  Claude wrote {} tokens",
64        format_number(result.total_output_tokens)).unwrap();
65    writeln!(out).unwrap();
66
67    writeln!(out, "  Cache saved you {} ({:.0}% of reads were free)",
68        format_cost(result.cache_savings.total_saved),
69        result.cache_savings.savings_pct).unwrap();
70    writeln!(out, "  All that would cost {} at API rates",
71        format_cost(result.total_cost)).unwrap();
72
73    // Model breakdown
74    writeln!(out).unwrap();
75    writeln!(out, "  Model                      Wrote        Rounds     Cost").unwrap();
76    writeln!(out, "  ---------------------------------------------------------").unwrap();
77
78    let mut models: Vec<(&String, &crate::analysis::AggregatedTokens)> = result.tokens_by_model.iter().collect();
79    models.sort_by(|a, b| {
80        let ca = result.cost_by_model.get(a.0).unwrap_or(&0.0);
81        let cb = result.cost_by_model.get(b.0).unwrap_or(&0.0);
82        cb.partial_cmp(ca).unwrap_or(std::cmp::Ordering::Equal)
83    });
84
85    for (model, tokens) in &models {
86        let cost = result.cost_by_model.get(*model).unwrap_or(&0.0);
87        let short = short_model(model);
88        writeln!(out, "  {:<25} {:>10} {:>9} {:>9}",
89            short,
90            format_number(tokens.output_tokens),
91            format_number(tokens.turns as u64),
92            format_cost(*cost)).unwrap();
93    }
94
95    // Top 5 projects
96    if !result.session_summaries.is_empty() {
97        writeln!(out).unwrap();
98        writeln!(out, "  Top Projects                              Sessions   Turns    Cost").unwrap();
99        writeln!(out, "  -------------------------------------------------------------------").unwrap();
100
101        // Group by project
102        let mut project_map: std::collections::HashMap<&str, (usize, usize, f64)> = std::collections::HashMap::new();
103        for s in &result.session_summaries {
104            let e = project_map.entry(&s.project_display_name).or_default();
105            e.0 += 1;
106            e.1 += s.turn_count;
107            e.2 += s.cost;
108        }
109        let mut projects: Vec<_> = project_map.into_iter().collect();
110        projects.sort_by(|a, b| b.1.2.partial_cmp(&a.1.2).unwrap_or(std::cmp::Ordering::Equal));
111
112        for (name, (sessions, turns, cost)) in projects.iter().take(5) {
113            writeln!(out, "  {:<40} {:>5} {:>7} {:>9}",
114                name, sessions, turns, format_cost(*cost)).unwrap();
115        }
116    }
117
118    // Monthly trend
119    writeln!(out).unwrap();
120
121    out
122}
123
124fn short_model(name: &str) -> String {
125    let s = name.strip_prefix("claude-").unwrap_or(name);
126    if s.len() > 9 {
127        let last_dash = s.rfind('-').unwrap_or(s.len());
128        let suffix = &s[last_dash + 1..];
129        if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) {
130            return s[..last_dash].to_string();
131        }
132    }
133    s.to_string()
134}
135
136// ─── 2. Projects ────────────────────────────────────────────────────────────
137
138pub fn render_projects(result: &ProjectResult) -> String {
139    let mut out = String::new();
140    let mut total_cost = 0.0f64;
141
142    writeln!(out, "Projects by Cost").unwrap();
143    writeln!(out).unwrap();
144
145    for (i, proj) in result.projects.iter().enumerate() {
146        writeln!(out, "  {:>2}. {:<35} {:>5} sess  {:>6} turns  {}",
147            i + 1, proj.display_name,
148            proj.session_count, proj.total_turns,
149            format_cost(proj.cost)).unwrap();
150        total_cost += proj.cost;
151    }
152
153    writeln!(out).unwrap();
154    writeln!(out, "  Total: {} projects, {}", result.projects.len(), format_cost(total_cost)).unwrap();
155    out
156}
157
158// ─── 3. Session ─────────────────────────────────────────────────────────────
159
160pub fn render_session(result: &SessionResult) -> String {
161    let mut out = String::new();
162
163    let main_turns = result.turn_details.iter().filter(|t| !t.is_agent).count();
164
165    writeln!(out, "Session {}  {}", &result.session_id[..result.session_id.len().min(8)], result.project).unwrap();
166    writeln!(out).unwrap();
167    writeln!(out, "  Turns:     {:>6} (+ {} agent)   Duration: {}",
168        main_turns, result.agent_summary.total_agent_turns, format_duration(result.duration_minutes)).unwrap();
169    writeln!(out, "  Model:     {:<20}  MaxCtx:   {}",
170        result.model, format_number(result.max_context)).unwrap();
171    writeln!(out, "  CacheHit:  {:>5.1}%                Compacts: {}",
172        result.total_tokens.cache_read_tokens as f64 / result.total_tokens.context_tokens().max(1) as f64 * 100.0,
173        result.compaction_count).unwrap();
174    writeln!(out, "  Cost:      {}", format_cost(result.total_cost)).unwrap();
175
176    out
177}
178
179// ─── 4. Trend ───────────────────────────────────────────────────────────────
180
181pub fn render_trend(result: &TrendResult) -> String {
182    let mut out = String::new();
183    let mut total_cost = 0.0f64;
184
185    writeln!(out, "Usage by {}", result.group_label).unwrap();
186    writeln!(out).unwrap();
187
188    for entry in &result.entries {
189        writeln!(out, "  {:<10}  {:>4} sess  {:>6} turns  {:>10} output  {}",
190            entry.label, entry.session_count, entry.turn_count,
191            format_number(entry.tokens.output_tokens),
192            format_cost(entry.cost)).unwrap();
193        total_cost += entry.cost;
194    }
195
196    writeln!(out).unwrap();
197    writeln!(out, "  Total: {}", format_cost(total_cost)).unwrap();
198    out
199}
200
201pub fn render_validation(report: &ValidationReport, failures_only: bool) -> String {
202    let mut out = String::new();
203
204    writeln!(out, "Token Validation Report").unwrap();
205    writeln!(out, "{}", "━".repeat(60)).unwrap();
206    writeln!(out).unwrap();
207
208    // Structure checks
209    writeln!(out, "Structure Checks:").unwrap();
210    for check in &report.structure_checks {
211        if failures_only && check.passed { continue; }
212        let status = if check.passed { "OK" } else { "FAIL" };
213        if check.passed {
214            writeln!(out, "  [{:>4}] {}: {}", status, check.name, check.actual).unwrap();
215        } else {
216            writeln!(out, "  [{:>4}] {}: expected={}, actual={}", status, check.name, check.expected, check.actual).unwrap();
217        }
218    }
219    writeln!(out).unwrap();
220
221    // Per-session results
222    let mut fail_sessions = Vec::new();
223    for sv in &report.session_results {
224        let all_checks: Vec<_> = sv.token_checks.iter().chain(sv.agent_checks.iter()).collect();
225        let has_failures = all_checks.iter().any(|c| !c.passed);
226
227        if failures_only && !has_failures { continue; }
228
229        if has_failures {
230            fail_sessions.push(sv);
231        }
232    }
233
234    if !failures_only {
235        writeln!(out, "Session Validation: {} sessions checked", report.session_results.len()).unwrap();
236        let sessions_ok = report.summary.sessions_passed;
237        let sessions_fail = report.summary.sessions_validated - sessions_ok;
238        writeln!(out, "  {} PASS, {} FAIL", sessions_ok, sessions_fail).unwrap();
239        writeln!(out).unwrap();
240    }
241
242    // Show failed sessions in detail
243    if !fail_sessions.is_empty() {
244        writeln!(out, "Failed Sessions:").unwrap();
245        writeln!(out).unwrap();
246    }
247    for sv in &fail_sessions {
248        writeln!(out, "  Session {}  {}", &sv.session_id[..8.min(sv.session_id.len())], sv.project).unwrap();
249        for check in sv.token_checks.iter().chain(sv.agent_checks.iter()) {
250            if !check.passed {
251                writeln!(out, "    [FAIL] {}: expected={}, actual={}", check.name, check.expected, check.actual).unwrap();
252            }
253        }
254        writeln!(out).unwrap();
255    }
256
257    // Summary
258    writeln!(out, "{}", "━".repeat(60)).unwrap();
259    let result_text = if report.summary.failed == 0 { "PASS" } else { "FAIL" };
260    writeln!(out, "Result: {} ({}/{} checks passed, {} sessions validated)",
261        result_text,
262        report.summary.passed,
263        report.summary.total_checks,
264        report.summary.sessions_validated,
265    ).unwrap();
266
267    out
268}
269
270// ─── Tests ──────────────────────────────────────────────────────────────────
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_format_number() {
278        assert_eq!(format_number(0), "0");
279        assert_eq!(format_number(999), "999");
280        assert_eq!(format_number(1_000), "1,000");
281        assert_eq!(format_number(1_234_567), "1,234,567");
282    }
283
284    #[test]
285    fn test_format_cost() {
286        assert_eq!(format_cost(0.0), "$0.00");
287        assert_eq!(format_cost(1.5), "$1.50");
288        assert_eq!(format_cost(1234.56), "$1,234.56");
289    }
290}