1use std::fmt::Write as _;
2
3use crate::analysis::validate::ValidationReport;
4use crate::analysis::{OverviewResult, ProjectResult, SessionResult, TrendResult};
5use crate::pricing::calculator::PricingCalculator;
6
7fn 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
42pub 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)| {
50 let ls = s.with_timezone(&chrono::Local);
51 let le = e.with_timezone(&chrono::Local);
52 format!("{} ~ {}", ls.format("%Y-%m-%d"), le.format("%Y-%m-%d"))
53 })
54 .unwrap_or_default();
55
56 writeln!(out, "Claude Code Token Report").unwrap();
57 writeln!(out, "{}", range).unwrap();
58 writeln!(out).unwrap();
59
60 writeln!(out, " {} conversations, {} rounds of back-and-forth",
61 format_number(result.total_sessions as u64),
62 format_number(result.total_turns as u64)).unwrap();
63 if result.total_agent_turns > 0 {
64 writeln!(out, " ({} agent turns, {:.0}% of total)",
65 format_number(result.total_agent_turns as u64),
66 result.total_agent_turns as f64 / result.total_turns.max(1) as f64 * 100.0).unwrap();
67 }
68 writeln!(out).unwrap();
69
70 writeln!(out, " Claude read {} tokens",
71 format_number(result.total_context_tokens)).unwrap();
72 writeln!(out, " Claude wrote {} tokens",
73 format_number(result.total_output_tokens)).unwrap();
74 writeln!(out).unwrap();
75
76 writeln!(out, " Cache saved you {} ({:.0}% of reads were free)",
77 format_cost(result.cache_savings.total_saved),
78 result.cache_savings.savings_pct).unwrap();
79 writeln!(out, " All that would cost {} at API rates",
80 format_cost(result.total_cost)).unwrap();
81
82 if let Some(ref sub) = result.subscription_value {
84 writeln!(out, " Subscription: {}/mo -> {:.1}x value multiplier",
85 format_cost(sub.monthly_price), sub.value_multiplier).unwrap();
86 }
87
88 writeln!(out).unwrap();
90 writeln!(out, " Model Wrote Rounds Cost").unwrap();
91 writeln!(out, " ---------------------------------------------------------").unwrap();
92
93 let mut models: Vec<(&String, &crate::analysis::AggregatedTokens)> = result.tokens_by_model.iter().collect();
94 models.sort_by(|a, b| {
95 let ca = result.cost_by_model.get(a.0).unwrap_or(&0.0);
96 let cb = result.cost_by_model.get(b.0).unwrap_or(&0.0);
97 cb.partial_cmp(ca).unwrap_or(std::cmp::Ordering::Equal)
98 });
99
100 for (model, tokens) in &models {
101 let cost = result.cost_by_model.get(*model).unwrap_or(&0.0);
102 let short = short_model(model);
103 writeln!(out, " {:<25} {:>10} {:>9} {:>9}",
104 short,
105 format_number(tokens.output_tokens),
106 format_number(tokens.turns as u64),
107 format_cost(*cost)).unwrap();
108 }
109
110 writeln!(out).unwrap();
112 let cat = &result.cost_by_category;
113 let total = result.total_cost.max(0.001);
114 writeln!(out, " Cost Breakdown").unwrap();
115 writeln!(out, " Output: {:>9} ({:.0}%)", format_cost(cat.output_cost), cat.output_cost / total * 100.0).unwrap();
116 writeln!(out, " Cache Write: {:>9} ({:.0}%)", format_cost(cat.cache_write_5m_cost + cat.cache_write_1h_cost),
117 (cat.cache_write_5m_cost + cat.cache_write_1h_cost) / total * 100.0).unwrap();
118 writeln!(out, " Input: {:>9} ({:.0}%)", format_cost(cat.input_cost), cat.input_cost / total * 100.0).unwrap();
119 writeln!(out, " Cache Read: {:>9} ({:.0}%)", format_cost(cat.cache_read_cost), cat.cache_read_cost / total * 100.0).unwrap();
120
121 if !result.tool_counts.is_empty() {
123 writeln!(out).unwrap();
124 writeln!(out, " Top Tools").unwrap();
125 for (name, count) in result.tool_counts.iter().take(10) {
126 let bar_len = (*count as f64 / result.tool_counts[0].1.max(1) as f64 * 20.0).round() as usize;
127 writeln!(out, " {:<18} {:>6} {}", name, format_number(*count as u64), "█".repeat(bar_len)).unwrap();
128 }
129 }
130
131 if !result.session_summaries.is_empty() {
133 writeln!(out).unwrap();
134 writeln!(out, " Top Projects Sessions Turns Cost").unwrap();
135 writeln!(out, " -------------------------------------------------------------------").unwrap();
136
137 let mut project_map: std::collections::HashMap<&str, (usize, usize, f64)> = std::collections::HashMap::new();
138 for s in &result.session_summaries {
139 let e = project_map.entry(&s.project_display_name).or_default();
140 e.0 += 1;
141 e.1 += s.turn_count;
142 e.2 += s.cost;
143 }
144 let mut projects: Vec<_> = project_map.into_iter().collect();
145 projects.sort_by(|a, b| b.1.2.partial_cmp(&a.1.2).unwrap_or(std::cmp::Ordering::Equal));
146
147 for (name, (sessions, turns, cost)) in projects.iter().take(5) {
148 writeln!(out, " {:<40} {:>5} {:>7} {:>9}",
149 name, sessions, turns, format_cost(*cost)).unwrap();
150 }
151 }
152
153 if !result.session_summaries.is_empty() {
155 let summaries = &result.session_summaries;
156
157 if let Some((start, end)) = result.quality.time_range {
159 let days = (end - start).num_days().max(1) as f64;
160 writeln!(out).unwrap();
161 writeln!(out, " Daily avg: {} / day ({} days)",
162 format_cost(result.total_cost / days), days as u64).unwrap();
163 }
164
165 let total_compactions: usize = summaries.iter().map(|s| s.compaction_count).sum();
167 let sessions_with_compaction = summaries.iter().filter(|s| s.compaction_count > 0).count();
168 if total_compactions > 0 {
169 writeln!(out, " Compactions: {} total across {} sessions",
170 total_compactions, sessions_with_compaction).unwrap();
171 }
172
173 let max_ctx = summaries.iter().map(|s| s.max_context).max().unwrap_or(0);
175 if max_ctx > 0 {
176 writeln!(out, " Peak context: {} tokens", format_number(max_ctx)).unwrap();
177 }
178
179 let durations: Vec<f64> = summaries.iter()
181 .map(|s| s.duration_minutes)
182 .filter(|d| *d > 0.0)
183 .collect();
184 if !durations.is_empty() {
185 let avg_dur = durations.iter().sum::<f64>() / durations.len() as f64;
186 writeln!(out, " Avg session: {}", format_duration(avg_dur)).unwrap();
187 }
188
189 let mut by_cost: Vec<&crate::analysis::SessionSummary> = summaries.iter().collect();
191 by_cost.sort_by(|a, b| b.cost.partial_cmp(&a.cost).unwrap_or(std::cmp::Ordering::Equal));
192 writeln!(out).unwrap();
193 writeln!(out, " Most Expensive Sessions").unwrap();
194 for s in by_cost.iter().take(3) {
195 let dur = format_duration(s.duration_minutes);
196 writeln!(out, " {} {} {:>5} turns {} {}",
197 &s.session_id[..s.session_id.len().min(8)],
198 truncate_str(&s.project_display_name, 25),
199 s.turn_count,
200 dur,
201 format_cost(s.cost),
202 ).unwrap();
203 }
204 }
205
206 writeln!(out).unwrap();
208 writeln!(out, " Data: {} session files, {} agent files",
209 result.quality.total_session_files, result.quality.total_agent_files).unwrap();
210 if result.quality.orphan_agents > 0 {
211 writeln!(out, " ({} orphan agents without parent session)", result.quality.orphan_agents).unwrap();
212 }
213
214 writeln!(out).unwrap();
215
216 out
217}
218
219fn short_model(name: &str) -> String {
220 let s = name.strip_prefix("claude-").unwrap_or(name);
221 if s.len() > 9 {
222 let last_dash = s.rfind('-').unwrap_or(s.len());
223 let suffix = &s[last_dash + 1..];
224 if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) {
225 return s[..last_dash].to_string();
226 }
227 }
228 s.to_string()
229}
230
231pub fn render_projects(result: &ProjectResult) -> String {
234 let mut out = String::new();
235 let mut total_cost = 0.0f64;
236
237 writeln!(out, "Projects by Cost").unwrap();
238 writeln!(out).unwrap();
239 writeln!(out, " # Project Sessions Turns Agent $/Sess Model Cost").unwrap();
240 writeln!(out, " ─────────────────────────────────────────────────────────────────────────────────────────").unwrap();
241
242 for (i, proj) in result.projects.iter().enumerate() {
243 let avg_cost = if proj.session_count > 0 { proj.cost / proj.session_count as f64 } else { 0.0 };
244 let model_short = short_model(&proj.primary_model);
245 writeln!(out, " {:>2}. {:<30} {:>5} {:>6} {:>5} {:>6} {:<12} {:>9}",
246 i + 1,
247 truncate_str(&proj.display_name, 30),
248 proj.session_count,
249 proj.total_turns,
250 proj.agent_turns,
251 format_cost(avg_cost),
252 truncate_str(&model_short, 12),
253 format_cost(proj.cost),
254 ).unwrap();
255 total_cost += proj.cost;
256 }
257
258 writeln!(out).unwrap();
259 writeln!(out, " Total: {} projects, {}", result.projects.len(), format_cost(total_cost)).unwrap();
260 out
261}
262
263fn truncate_str(s: &str, max: usize) -> String {
264 if s.len() <= max { s.to_string() }
265 else { format!("{}...", &s[..s.floor_char_boundary(max.saturating_sub(3))]) }
266}
267
268pub fn render_session(result: &SessionResult) -> String {
271 let mut out = String::new();
272
273 let main_turns = result.turn_details.iter().filter(|t| !t.is_agent).count();
274
275 writeln!(out, "Session {} {}", &result.session_id[..result.session_id.len().min(8)], result.project).unwrap();
276 writeln!(out).unwrap();
277 writeln!(out, " Turns: {:>6} (+ {} agent) Duration: {}",
278 main_turns, result.agent_summary.total_agent_turns, format_duration(result.duration_minutes)).unwrap();
279 writeln!(out, " Model: {:<20} MaxCtx: {}",
280 result.model, format_number(result.max_context)).unwrap();
281 writeln!(out, " CacheHit: {:>5.1}% Compacts: {}",
282 result.total_tokens.cache_read_tokens as f64 / result.total_tokens.context_tokens().max(1) as f64 * 100.0,
283 result.compaction_count).unwrap();
284 writeln!(out, " Cost: {}", format_cost(result.total_cost)).unwrap();
285
286 out
287}
288
289pub fn render_trend(result: &TrendResult) -> String {
292 let mut out = String::new();
293 let mut total_cost = 0.0f64;
294 let mut total_turns = 0usize;
295
296 let max_cost = result.entries.iter().map(|e| e.cost).fold(0.0f64, f64::max);
298
299 writeln!(out, "Usage by {}", result.group_label).unwrap();
300 writeln!(out).unwrap();
301
302 for entry in &result.entries {
303 let bar_len = if max_cost > 0.0 { (entry.cost / max_cost * 16.0).round() as usize } else { 0 };
305 let bar = "▇".repeat(bar_len);
306
307 let top_model = entry.models.iter()
309 .max_by_key(|(_, tokens)| *tokens)
310 .map(|(m, _)| short_model(m))
311 .unwrap_or_default();
312
313 let cpt = if entry.turn_count > 0 { entry.cost / entry.turn_count as f64 } else { 0.0 };
315
316 writeln!(out, " {:<10} {:>4} sess {:>6} turns {:>9} ${:.3}/t {:<12} {}",
317 entry.label, entry.session_count, entry.turn_count,
318 format_cost(entry.cost), cpt,
319 truncate_str(&top_model, 12),
320 bar,
321 ).unwrap();
322 total_cost += entry.cost;
323 total_turns += entry.turn_count;
324 }
325
326 writeln!(out).unwrap();
327 let avg_cpt = if total_turns > 0 { total_cost / total_turns as f64 } else { 0.0 };
328 writeln!(out, " Total: {} ({} turns, avg ${:.3}/turn)", format_cost(total_cost), format_number(total_turns as u64), avg_cpt).unwrap();
329 out
330}
331
332pub fn render_validation(report: &ValidationReport, failures_only: bool) -> String {
333 let mut out = String::new();
334
335 writeln!(out, "Token Validation Report").unwrap();
336 writeln!(out, "{}", "━".repeat(60)).unwrap();
337 writeln!(out).unwrap();
338
339 writeln!(out, "Structure Checks:").unwrap();
341 for check in &report.structure_checks {
342 if failures_only && check.passed { continue; }
343 let status = if check.passed { "OK" } else { "FAIL" };
344 if check.passed {
345 writeln!(out, " [{:>4}] {}: {}", status, check.name, check.actual).unwrap();
346 } else {
347 writeln!(out, " [{:>4}] {}: expected={}, actual={}", status, check.name, check.expected, check.actual).unwrap();
348 }
349 }
350 writeln!(out).unwrap();
351
352 let mut fail_sessions = Vec::new();
354 for sv in &report.session_results {
355 let all_checks: Vec<_> = sv.token_checks.iter().chain(sv.agent_checks.iter()).collect();
356 let has_failures = all_checks.iter().any(|c| !c.passed);
357
358 if failures_only && !has_failures { continue; }
359
360 if has_failures {
361 fail_sessions.push(sv);
362 }
363 }
364
365 if !failures_only {
366 writeln!(out, "Session Validation: {} sessions checked", report.session_results.len()).unwrap();
367 let sessions_ok = report.summary.sessions_passed;
368 let sessions_fail = report.summary.sessions_validated - sessions_ok;
369 writeln!(out, " {} PASS, {} FAIL", sessions_ok, sessions_fail).unwrap();
370 writeln!(out).unwrap();
371 }
372
373 if !fail_sessions.is_empty() {
375 writeln!(out, "Failed Sessions:").unwrap();
376 writeln!(out).unwrap();
377 }
378 for sv in &fail_sessions {
379 writeln!(out, " Session {} {}", &sv.session_id[..8.min(sv.session_id.len())], sv.project).unwrap();
380 for check in sv.token_checks.iter().chain(sv.agent_checks.iter()) {
381 if !check.passed {
382 writeln!(out, " [FAIL] {}: expected={}, actual={}", check.name, check.expected, check.actual).unwrap();
383 }
384 }
385 writeln!(out).unwrap();
386 }
387
388 writeln!(out, "{}", "━".repeat(60)).unwrap();
390 let result_text = if report.summary.failed == 0 { "PASS" } else { "FAIL" };
391 writeln!(out, "Result: {} ({}/{} checks passed, {} sessions validated)",
392 result_text,
393 report.summary.passed,
394 report.summary.total_checks,
395 report.summary.sessions_validated,
396 ).unwrap();
397
398 out
399}
400
401#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn test_format_number() {
409 assert_eq!(format_number(0), "0");
410 assert_eq!(format_number(999), "999");
411 assert_eq!(format_number(1_000), "1,000");
412 assert_eq!(format_number(1_234_567), "1,234,567");
413 }
414
415 #[test]
416 fn test_format_cost() {
417 assert_eq!(format_cost(0.0), "$0.00");
418 assert_eq!(format_cost(1.5), "$1.50");
419 assert_eq!(format_cost(1234.56), "$1,234.56");
420 }
421}