1use std::fmt::Write as _;
2
3use crate::analysis::{OverviewResult, ProjectResult, SessionResult, TrendResult};
4use crate::pricing::calculator::PricingCalculator;
5
6fn 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
41pub 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 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 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 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 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
135pub 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
157pub 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
178pub 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#[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}