Skip to main content

cc_token_usage/output/
text.rs

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