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)| {
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    // Subscription value
83    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    // Model breakdown
89    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    // Cost by category
111    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    // Tool usage top 10
122    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    // Top 5 projects
132    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    // Usage insights
154    if !result.session_summaries.is_empty() {
155        let summaries = &result.session_summaries;
156
157        // Daily average cost
158        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        // Compaction stats
166        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        // Max context
174        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        // Average session duration
180        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        // Top 3 most expensive sessions
190        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    // Data quality summary
207    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
231// ─── 2. Projects ────────────────────────────────────────────────────────────
232
233pub 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
268// ─── 3. Session ─────────────────────────────────────────────────────────────
269
270pub 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    // Per-agent breakdown
287    if !result.agent_summary.agents.is_empty() {
288        writeln!(out).unwrap();
289        writeln!(out, "  Agent Breakdown").unwrap();
290        writeln!(out, "  {:<14} {:<40} {:>6} {:>10} {:>9}",
291            "Type", "Description", "Turns", "Output", "Cost").unwrap();
292        writeln!(out, "  {}", "-".repeat(83)).unwrap();
293
294        // Main agent line
295        let main_turns = result.turn_details.iter().filter(|t| !t.is_agent).count();
296        let main_output: u64 = result.turn_details.iter()
297            .filter(|t| !t.is_agent).map(|t| t.output_tokens).sum();
298        let main_cost = result.total_cost - result.agent_summary.agent_cost;
299        writeln!(out, "  {:<14} {:<40} {:>6} {:>10} {:>9}",
300            "main", "(this conversation)",
301            main_turns, format_number(main_output), format_cost(main_cost)).unwrap();
302
303        for agent in &result.agent_summary.agents {
304            let desc = if agent.description.len() > 40 {
305                format!("{}...", &agent.description[..agent.description.floor_char_boundary(37)])
306            } else {
307                agent.description.clone()
308            };
309            writeln!(out, "  {:<14} {:<40} {:>6} {:>10} {:>9}",
310                agent.agent_type,
311                desc,
312                agent.turns,
313                format_number(agent.output_tokens),
314                format_cost(agent.cost),
315            ).unwrap();
316        }
317    }
318
319    out
320}
321
322// ─── 4. Trend ───────────────────────────────────────────────────────────────
323
324pub fn render_trend(result: &TrendResult) -> String {
325    let mut out = String::new();
326    let mut total_cost = 0.0f64;
327    let mut total_turns = 0usize;
328
329    // Find max cost for sparkline scaling
330    let max_cost = result.entries.iter().map(|e| e.cost).fold(0.0f64, f64::max);
331
332    writeln!(out, "Usage by {}", result.group_label).unwrap();
333    writeln!(out).unwrap();
334
335    for entry in &result.entries {
336        // Sparkline bar
337        let bar_len = if max_cost > 0.0 { (entry.cost / max_cost * 16.0).round() as usize } else { 0 };
338        let bar = "▇".repeat(bar_len);
339
340        // Primary model for this period
341        let top_model = entry.models.iter()
342            .max_by_key(|(_, tokens)| *tokens)
343            .map(|(m, _)| short_model(m))
344            .unwrap_or_default();
345
346        // Cost per turn
347        let cpt = if entry.turn_count > 0 { entry.cost / entry.turn_count as f64 } else { 0.0 };
348
349        writeln!(out, "  {:<10}  {:>4} sess  {:>6} turns  {:>9}  ${:.3}/t  {:<12} {}",
350            entry.label, entry.session_count, entry.turn_count,
351            format_cost(entry.cost), cpt,
352            truncate_str(&top_model, 12),
353            bar,
354        ).unwrap();
355        total_cost += entry.cost;
356        total_turns += entry.turn_count;
357    }
358
359    writeln!(out).unwrap();
360    let avg_cpt = if total_turns > 0 { total_cost / total_turns as f64 } else { 0.0 };
361    writeln!(out, "  Total: {}  ({} turns, avg ${:.3}/turn)", format_cost(total_cost), format_number(total_turns as u64), avg_cpt).unwrap();
362    out
363}
364
365pub fn render_validation(report: &ValidationReport, failures_only: bool) -> String {
366    let mut out = String::new();
367
368    writeln!(out, "Token Validation Report").unwrap();
369    writeln!(out, "{}", "━".repeat(60)).unwrap();
370    writeln!(out).unwrap();
371
372    // Structure checks
373    writeln!(out, "Structure Checks:").unwrap();
374    for check in &report.structure_checks {
375        if failures_only && check.passed { continue; }
376        let status = if check.passed { "OK" } else { "FAIL" };
377        if check.passed {
378            writeln!(out, "  [{:>4}] {}: {}", status, check.name, check.actual).unwrap();
379        } else {
380            writeln!(out, "  [{:>4}] {}: expected={}, actual={}", status, check.name, check.expected, check.actual).unwrap();
381        }
382    }
383    writeln!(out).unwrap();
384
385    // Per-session results
386    let mut fail_sessions = Vec::new();
387    for sv in &report.session_results {
388        let all_checks: Vec<_> = sv.token_checks.iter().chain(sv.agent_checks.iter()).collect();
389        let has_failures = all_checks.iter().any(|c| !c.passed);
390
391        if failures_only && !has_failures { continue; }
392
393        if has_failures {
394            fail_sessions.push(sv);
395        }
396    }
397
398    if !failures_only {
399        writeln!(out, "Session Validation: {} sessions checked", report.session_results.len()).unwrap();
400        let sessions_ok = report.summary.sessions_passed;
401        let sessions_fail = report.summary.sessions_validated - sessions_ok;
402        writeln!(out, "  {} PASS, {} FAIL", sessions_ok, sessions_fail).unwrap();
403        writeln!(out).unwrap();
404    }
405
406    // Show failed sessions in detail
407    if !fail_sessions.is_empty() {
408        writeln!(out, "Failed Sessions:").unwrap();
409        writeln!(out).unwrap();
410    }
411    for sv in &fail_sessions {
412        writeln!(out, "  Session {}  {}", &sv.session_id[..8.min(sv.session_id.len())], sv.project).unwrap();
413        for check in sv.token_checks.iter().chain(sv.agent_checks.iter()) {
414            if !check.passed {
415                writeln!(out, "    [FAIL] {}: expected={}, actual={}", check.name, check.expected, check.actual).unwrap();
416            }
417        }
418        writeln!(out).unwrap();
419    }
420
421    // Summary
422    writeln!(out, "{}", "━".repeat(60)).unwrap();
423    let result_text = if report.summary.failed == 0 { "PASS" } else { "FAIL" };
424    writeln!(out, "Result: {} ({}/{} checks passed, {} sessions validated)",
425        result_text,
426        report.summary.passed,
427        report.summary.total_checks,
428        report.summary.sessions_validated,
429    ).unwrap();
430
431    out
432}
433
434// ─── Tests ──────────────────────────────────────────────────────────────────
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    #[test]
441    fn test_format_number() {
442        assert_eq!(format_number(0), "0");
443        assert_eq!(format_number(999), "999");
444        assert_eq!(format_number(1_000), "1,000");
445        assert_eq!(format_number(1_234_567), "1,234,567");
446    }
447
448    #[test]
449    fn test_format_cost() {
450        assert_eq!(format_cost(0.0), "$0.00");
451        assert_eq!(format_cost(1.5), "$1.50");
452        assert_eq!(format_cost(1234.56), "$1,234.56");
453    }
454}