1use std::fmt::Write as _;
2
3use chrono::{Datelike, NaiveDate};
4
5use crate::analysis::heatmap::HeatmapResult;
6use crate::analysis::validate::ValidationReport;
7use crate::analysis::wrapped::WrappedResult;
8use crate::analysis::{OverviewResult, ProjectResult, SessionResult, TrendResult};
9use crate::pricing::calculator::PricingCalculator;
10
11fn format_number(n: u64) -> String {
14 let s = n.to_string();
15 let mut result = String::with_capacity(s.len() + s.len() / 3);
16 for (i, ch) in s.chars().rev().enumerate() {
17 if i > 0 && i % 3 == 0 {
18 result.push(',');
19 }
20 result.push(ch);
21 }
22 result.chars().rev().collect()
23}
24
25fn format_cost(c: f64) -> String {
26 let abs = c.abs();
27 let total_cents = (abs * 100.0).round() as u64;
28 let whole = total_cents / 100;
29 let cents = total_cents % 100;
30 let sign = if c < 0.0 { "-" } else { "" };
31 format!("{}${}.{:02}", sign, format_number(whole), cents)
32}
33
34fn format_duration(minutes: f64) -> String {
35 if minutes < 1.0 {
36 format!("{:.0}s", minutes * 60.0)
37 } else if minutes < 60.0 {
38 format!("{:.0}m", minutes)
39 } else {
40 let h = (minutes / 60.0).floor();
41 let m = (minutes % 60.0).round();
42 format!("{:.0}h{:.0}m", h, m)
43 }
44}
45
46pub fn render_overview(result: &OverviewResult, calc: &PricingCalculator) -> String {
49 let mut out = String::new();
50 let _ = calc;
51
52 let range = result
53 .quality
54 .time_range
55 .map(|(s, e)| {
56 let ls = s.with_timezone(&chrono::Local);
57 let le = e.with_timezone(&chrono::Local);
58 format!("{} ~ {}", ls.format("%Y-%m-%d"), le.format("%Y-%m-%d"))
59 })
60 .unwrap_or_default();
61
62 writeln!(out, "Claude Code Token Report").unwrap();
63 writeln!(out, "{}", range).unwrap();
64 writeln!(out).unwrap();
65
66 writeln!(
67 out,
68 " {} conversations, {} rounds of back-and-forth",
69 format_number(result.total_sessions as u64),
70 format_number(result.total_turns as u64)
71 )
72 .unwrap();
73 if result.total_agent_turns > 0 {
74 writeln!(
75 out,
76 " ({} agent turns, {:.0}% of total)",
77 format_number(result.total_agent_turns as u64),
78 result.total_agent_turns as f64 / result.total_turns.max(1) as f64 * 100.0
79 )
80 .unwrap();
81 }
82 writeln!(out).unwrap();
83
84 writeln!(
85 out,
86 " Claude read {} tokens",
87 format_number(result.total_context_tokens)
88 )
89 .unwrap();
90 writeln!(
91 out,
92 " Claude wrote {} tokens",
93 format_number(result.total_output_tokens)
94 )
95 .unwrap();
96 writeln!(out).unwrap();
97
98 writeln!(
99 out,
100 " Cache saved you {} ({:.0}% of reads were free)",
101 format_cost(result.cache_savings.total_saved),
102 result.cache_savings.savings_pct
103 )
104 .unwrap();
105 writeln!(
106 out,
107 " All that would cost {} at API rates",
108 format_cost(result.total_cost)
109 )
110 .unwrap();
111
112 if let Some(ref sub) = result.subscription_value {
114 writeln!(
115 out,
116 " Subscription: {}/mo -> {:.1}x value multiplier",
117 format_cost(sub.monthly_price),
118 sub.value_multiplier
119 )
120 .unwrap();
121 }
122
123 writeln!(out).unwrap();
125 writeln!(
126 out,
127 " Model Wrote Rounds Cost"
128 )
129 .unwrap();
130 writeln!(
131 out,
132 " ---------------------------------------------------------"
133 )
134 .unwrap();
135
136 let mut models: Vec<(&String, &crate::analysis::AggregatedTokens)> =
137 result.tokens_by_model.iter().collect();
138 models.sort_by(|a, b| {
139 let ca = result.cost_by_model.get(a.0).unwrap_or(&0.0);
140 let cb = result.cost_by_model.get(b.0).unwrap_or(&0.0);
141 cb.partial_cmp(ca).unwrap_or(std::cmp::Ordering::Equal)
142 });
143
144 for (model, tokens) in &models {
145 let cost = result.cost_by_model.get(*model).unwrap_or(&0.0);
146 let short = short_model(model);
147 writeln!(
148 out,
149 " {:<25} {:>10} {:>9} {:>9}",
150 short,
151 format_number(tokens.output_tokens),
152 format_number(tokens.turns as u64),
153 format_cost(*cost)
154 )
155 .unwrap();
156 }
157
158 writeln!(out).unwrap();
160 let cat = &result.cost_by_category;
161 let total = result.total_cost.max(0.001);
162 writeln!(out, " Cost Breakdown").unwrap();
163 writeln!(
164 out,
165 " Output: {:>9} ({:.0}%)",
166 format_cost(cat.output_cost),
167 cat.output_cost / total * 100.0
168 )
169 .unwrap();
170 writeln!(
171 out,
172 " Cache Write: {:>9} ({:.0}%)",
173 format_cost(cat.cache_write_5m_cost + cat.cache_write_1h_cost),
174 (cat.cache_write_5m_cost + cat.cache_write_1h_cost) / total * 100.0
175 )
176 .unwrap();
177 writeln!(
178 out,
179 " Input: {:>9} ({:.0}%)",
180 format_cost(cat.input_cost),
181 cat.input_cost / total * 100.0
182 )
183 .unwrap();
184 writeln!(
185 out,
186 " Cache Read: {:>9} ({:.0}%)",
187 format_cost(cat.cache_read_cost),
188 cat.cache_read_cost / total * 100.0
189 )
190 .unwrap();
191
192 writeln!(out).unwrap();
194 writeln!(out, " Efficiency").unwrap();
195 writeln!(
196 out,
197 " Output ratio: {:.2}% ({} output / {} input)",
198 result.output_ratio,
199 format_number(result.total_output_tokens),
200 format_number(result.total_context_tokens)
201 )
202 .unwrap();
203 writeln!(
204 out,
205 " Cost per turn: ${:.3}/turn",
206 result.cost_per_turn
207 )
208 .unwrap();
209 writeln!(
210 out,
211 " Output per turn: {} tokens/turn avg",
212 format_number(result.tokens_per_output_turn)
213 )
214 .unwrap();
215
216 if !result.tool_counts.is_empty() {
218 writeln!(out).unwrap();
219 writeln!(out, " Top Tools").unwrap();
220 for (name, count) in result.tool_counts.iter().take(10) {
221 let bar_len =
222 (*count as f64 / result.tool_counts[0].1.max(1) as f64 * 20.0).round() as usize;
223 writeln!(
224 out,
225 " {:<18} {:>6} {}",
226 name,
227 format_number(*count as u64),
228 "█".repeat(bar_len)
229 )
230 .unwrap();
231 }
232 }
233
234 if !result.session_summaries.is_empty() {
236 writeln!(out).unwrap();
237 writeln!(
238 out,
239 " Top Projects Sessions Turns Cost"
240 )
241 .unwrap();
242 writeln!(
243 out,
244 " -------------------------------------------------------------------"
245 )
246 .unwrap();
247
248 let mut project_map: std::collections::HashMap<&str, (usize, usize, f64)> =
249 std::collections::HashMap::new();
250 for s in &result.session_summaries {
251 let e = project_map.entry(&s.project_display_name).or_default();
252 e.0 += 1;
253 e.1 += s.turn_count;
254 e.2 += s.cost;
255 }
256 let mut projects: Vec<_> = project_map.into_iter().collect();
257 projects.sort_by(|a, b| {
258 b.1 .2
259 .partial_cmp(&a.1 .2)
260 .unwrap_or(std::cmp::Ordering::Equal)
261 });
262
263 for (name, (sessions, turns, cost)) in projects.iter().take(5) {
264 writeln!(
265 out,
266 " {:<40} {:>5} {:>7} {:>9}",
267 name,
268 sessions,
269 turns,
270 format_cost(*cost)
271 )
272 .unwrap();
273 }
274 }
275
276 if !result.session_summaries.is_empty() {
278 let summaries = &result.session_summaries;
279
280 if let Some((start, end)) = result.quality.time_range {
282 let days = (end - start).num_days().max(1) as f64;
283 writeln!(out).unwrap();
284 writeln!(
285 out,
286 " Daily avg: {} / day ({} days)",
287 format_cost(result.total_cost / days),
288 days as u64
289 )
290 .unwrap();
291 }
292
293 let total_compactions: usize = summaries.iter().map(|s| s.compaction_count).sum();
295 let sessions_with_compaction = summaries.iter().filter(|s| s.compaction_count > 0).count();
296 if total_compactions > 0 {
297 writeln!(
298 out,
299 " Compactions: {} total across {} sessions",
300 total_compactions, sessions_with_compaction
301 )
302 .unwrap();
303 }
304
305 let max_ctx = summaries.iter().map(|s| s.max_context).max().unwrap_or(0);
307 if max_ctx > 0 {
308 writeln!(out, " Peak context: {} tokens", format_number(max_ctx)).unwrap();
309 }
310
311 let durations: Vec<f64> = summaries
313 .iter()
314 .map(|s| s.duration_minutes)
315 .filter(|d| *d > 0.0)
316 .collect();
317 if !durations.is_empty() {
318 let avg_dur = durations.iter().sum::<f64>() / durations.len() as f64;
319 writeln!(out, " Avg session: {}", format_duration(avg_dur)).unwrap();
320 }
321
322 let mut by_cost: Vec<&crate::analysis::SessionSummary> = summaries.iter().collect();
324 by_cost.sort_by(|a, b| {
325 b.cost
326 .partial_cmp(&a.cost)
327 .unwrap_or(std::cmp::Ordering::Equal)
328 });
329 writeln!(out).unwrap();
330 writeln!(out, " Most Expensive Sessions").unwrap();
331 for s in by_cost.iter().take(3) {
332 let dur = format_duration(s.duration_minutes);
333 writeln!(
334 out,
335 " {} {} {:>5} turns {} {}",
336 &s.session_id[..s.session_id.len().min(8)],
337 truncate_str(&s.project_display_name, 25),
338 s.turn_count,
339 dur,
340 format_cost(s.cost),
341 )
342 .unwrap();
343 }
344 }
345
346 writeln!(out).unwrap();
348 writeln!(
349 out,
350 " Data: {} session files, {} agent files",
351 result.quality.total_session_files, result.quality.total_agent_files
352 )
353 .unwrap();
354 if result.quality.orphan_agents > 0 {
355 writeln!(
356 out,
357 " ({} orphan agents without parent session)",
358 result.quality.orphan_agents
359 )
360 .unwrap();
361 }
362 let orphan_session_count = result
366 .session_summaries
367 .iter()
368 .filter(|s| s.is_orphan)
369 .count();
370 if orphan_session_count > 0 {
371 writeln!(
372 out,
373 " Orphaned subagents detected: {} (still counted in totals)",
374 orphan_session_count
375 )
376 .unwrap();
377 }
378
379 if !result.pricing_warnings.is_empty() {
381 writeln!(out).unwrap();
382 writeln!(
383 out,
384 "! Pricing fallback ({} unknown model{})",
385 result.pricing_warnings.len(),
386 if result.pricing_warnings.len() == 1 {
387 ""
388 } else {
389 "s"
390 }
391 )
392 .unwrap();
393 for w in &result.pricing_warnings {
394 writeln!(
395 out,
396 " \u{00b7} {}: {} turns, {} \u{2014} used {} pricing",
397 w.unknown_model,
398 format_number(w.turn_count),
399 format_cost(w.fallback_cost),
400 w.fallback_to
401 )
402 .unwrap();
403 }
404 writeln!(
405 out,
406 " These costs are estimates. Update the pricing table when actual rates are known."
407 )
408 .unwrap();
409 }
410
411 writeln!(out).unwrap();
412
413 out
414}
415
416fn short_model(name: &str) -> String {
417 let s = name.strip_prefix("claude-").unwrap_or(name);
418 if s.len() > 9 {
419 let last_dash = s.rfind('-').unwrap_or(s.len());
420 let suffix = &s[last_dash + 1..];
421 if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) {
422 return s[..last_dash].to_string();
423 }
424 }
425 s.to_string()
426}
427
428pub fn render_projects(result: &ProjectResult) -> String {
431 let mut out = String::new();
432 let mut total_cost = 0.0f64;
433
434 writeln!(out, "Projects by Cost").unwrap();
435 writeln!(out).unwrap();
436 writeln!(out, " # Project Sessions Turns Agent $/Sess Model Cost").unwrap();
437 writeln!(out, " ─────────────────────────────────────────────────────────────────────────────────────────").unwrap();
438
439 for (i, proj) in result.projects.iter().enumerate() {
440 let avg_cost = if proj.session_count > 0 {
441 proj.cost / proj.session_count as f64
442 } else {
443 0.0
444 };
445 let model_short = short_model(&proj.primary_model);
446 writeln!(
447 out,
448 " {:>2}. {:<30} {:>5} {:>6} {:>5} {:>6} {:<12} {:>9}",
449 i + 1,
450 truncate_str(&proj.display_name, 30),
451 proj.session_count,
452 proj.total_turns,
453 proj.agent_turns,
454 format_cost(avg_cost),
455 truncate_str(&model_short, 12),
456 format_cost(proj.cost),
457 )
458 .unwrap();
459 total_cost += proj.cost;
460 }
461
462 writeln!(out).unwrap();
463 writeln!(
464 out,
465 " Total: {} projects, {}",
466 result.projects.len(),
467 format_cost(total_cost)
468 )
469 .unwrap();
470 out
471}
472
473fn truncate_str(s: &str, max: usize) -> String {
474 if s.len() <= max {
475 s.to_string()
476 } else {
477 format!("{}...", &s[..s.floor_char_boundary(max.saturating_sub(3))])
478 }
479}
480
481pub fn render_session(result: &SessionResult) -> String {
484 let mut out = String::new();
485
486 let main_turns = result.turn_details.iter().filter(|t| !t.is_agent).count();
487
488 let orphan_tag = if result.is_orphan { " [orphan]" } else { "" };
489 writeln!(
490 out,
491 "Session {} {}{}",
492 &result.session_id[..result.session_id.len().min(8)],
493 result.project,
494 orphan_tag
495 )
496 .unwrap();
497 writeln!(out).unwrap();
498 writeln!(
499 out,
500 " Turns: {:>6} (+ {} agent) Duration: {}",
501 main_turns,
502 result.agent_summary.total_agent_turns,
503 format_duration(result.duration_minutes)
504 )
505 .unwrap();
506 writeln!(
507 out,
508 " Model: {:<20} MaxCtx: {}",
509 result.model,
510 format_number(result.max_context)
511 )
512 .unwrap();
513 writeln!(
514 out,
515 " CacheHit: {:>5.1}% Compacts: {}",
516 result.total_tokens.cache_read_tokens as f64
517 / result.total_tokens.context_tokens().max(1) as f64
518 * 100.0,
519 result.compaction_count
520 )
521 .unwrap();
522 writeln!(out, " Cost: {}", format_cost(result.total_cost)).unwrap();
523
524 let has_metadata = result.title.is_some()
526 || !result.tags.is_empty()
527 || result.mode.is_some()
528 || !result.git_branches.is_empty()
529 || !result.pr_links.is_empty();
530
531 if has_metadata {
532 writeln!(out).unwrap();
533 writeln!(out, " ── Metadata ──────────────────────────────────").unwrap();
534 if let Some(ref title) = result.title {
535 writeln!(out, " Title: {}", truncate_str(title, 60)).unwrap();
536 }
537 if !result.tags.is_empty() {
538 writeln!(out, " Tags: {}", result.tags.join(", ")).unwrap();
539 }
540 if let Some(ref mode) = result.mode {
541 writeln!(out, " Mode: {}", mode).unwrap();
542 }
543 if !result.git_branches.is_empty() {
544 let mut branches: Vec<_> = result.git_branches.iter().collect();
545 branches.sort_by(|a, b| b.1.cmp(a.1));
546 let parts: Vec<String> = branches
547 .iter()
548 .map(|(name, count)| format!("{} ({} turns)", name, count))
549 .collect();
550 writeln!(out, " Branch: {}", parts.join(", ")).unwrap();
551 }
552 for pr in &result.pr_links {
553 writeln!(out, " PR: {}#{}", pr.repository, pr.number).unwrap();
554 }
555 }
556
557 let has_performance = result.user_prompt_count > 0
559 || result.truncated_count > 0
560 || result.speculation_accepts > 0
561 || !result.service_tiers.is_empty()
562 || !result.speeds.is_empty()
563 || !result.inference_geos.is_empty()
564 || result.api_error_count > 0
565 || result.tool_error_count > 0;
566
567 if has_performance {
568 writeln!(out).unwrap();
569 writeln!(out, " ── Performance ───────────────────────────────").unwrap();
570 if result.user_prompt_count > 0 {
571 let total_turns = result.turn_details.len();
572 writeln!(
573 out,
574 " Autonomy: 1:{:.1} ({} turns / {} user prompts)",
575 result.autonomy_ratio, total_turns, result.user_prompt_count
576 )
577 .unwrap();
578 }
579 if result.truncated_count > 0 {
580 writeln!(
581 out,
582 " Truncated: {} turns hit max_tokens",
583 result.truncated_count
584 )
585 .unwrap();
586 }
587 if result.api_error_count > 0 || result.tool_error_count > 0 {
588 let mut parts = Vec::new();
589 if result.api_error_count > 0 {
590 parts.push(format!("{} API errors", result.api_error_count));
591 }
592 if result.tool_error_count > 0 {
593 parts.push(format!("{} tool errors", result.tool_error_count));
594 }
595 writeln!(out, " Errors: {}", parts.join(", ")).unwrap();
596 }
597 if result.speculation_accepts > 0 {
598 let saved_secs = result.speculation_time_saved_ms / 1000.0;
599 writeln!(
600 out,
601 " Speculation: saved {:.1}s across {} accepts",
602 saved_secs, result.speculation_accepts
603 )
604 .unwrap();
605 }
606 if !result.service_tiers.is_empty() {
607 let total: usize = result.service_tiers.values().sum();
608 let mut tiers: Vec<_> = result.service_tiers.iter().collect();
609 tiers.sort_by(|a, b| b.1.cmp(a.1));
610 let parts: Vec<String> = tiers
611 .iter()
612 .map(|(name, count)| {
613 format!("{} ({:.0}%)", name, **count as f64 / total as f64 * 100.0)
614 })
615 .collect();
616 writeln!(out, " Service: {}", parts.join(", ")).unwrap();
617 }
618 if !result.speeds.is_empty() {
619 let total: usize = result.speeds.values().sum();
620 let mut spds: Vec<_> = result.speeds.iter().collect();
621 spds.sort_by(|a, b| b.1.cmp(a.1));
622 let parts: Vec<String> = spds
623 .iter()
624 .map(|(name, count)| {
625 format!("{} ({:.0}%)", name, **count as f64 / total as f64 * 100.0)
626 })
627 .collect();
628 writeln!(out, " Speed: {}", parts.join(", ")).unwrap();
629 }
630 if !result.inference_geos.is_empty() {
631 let total: usize = result.inference_geos.values().sum();
632 let mut geos: Vec<_> = result.inference_geos.iter().collect();
633 geos.sort_by(|a, b| b.1.cmp(a.1));
634 let parts: Vec<String> = geos
635 .iter()
636 .map(|(name, count)| {
637 format!("{} ({:.0}%)", name, **count as f64 / total as f64 * 100.0)
638 })
639 .collect();
640 writeln!(out, " Geo: {}", parts.join(", ")).unwrap();
641 }
642 }
643
644 if !result.agent_summary.agents.is_empty() {
646 writeln!(out).unwrap();
647 writeln!(out, " Agent Breakdown").unwrap();
648 writeln!(
649 out,
650 " {:<14} {:<40} {:>6} {:>10} {:>9}",
651 "Type", "Description", "Turns", "Output", "Cost"
652 )
653 .unwrap();
654 writeln!(out, " {}", "-".repeat(83)).unwrap();
655
656 let main_turns = result.turn_details.iter().filter(|t| !t.is_agent).count();
658 let main_output: u64 = result
659 .turn_details
660 .iter()
661 .filter(|t| !t.is_agent)
662 .map(|t| t.output_tokens)
663 .sum();
664 let main_cost = result.total_cost - result.agent_summary.agent_cost;
665 writeln!(
666 out,
667 " {:<14} {:<40} {:>6} {:>10} {:>9}",
668 "main",
669 "(this conversation)",
670 main_turns,
671 format_number(main_output),
672 format_cost(main_cost)
673 )
674 .unwrap();
675
676 for agent in &result.agent_summary.agents {
677 let desc = if agent.description.len() > 40 {
678 format!(
679 "{}...",
680 &agent.description[..agent.description.floor_char_boundary(37)]
681 )
682 } else {
683 agent.description.clone()
684 };
685 writeln!(
686 out,
687 " {:<14} {:<40} {:>6} {:>10} {:>9}",
688 agent.agent_type,
689 desc,
690 agent.turns,
691 format_number(agent.output_tokens),
692 format_cost(agent.cost),
693 )
694 .unwrap();
695 }
696 }
697
698 if result.collapse_count > 0 {
700 writeln!(out).unwrap();
701 writeln!(out, " ── Context Collapse ──────────────────────────").unwrap();
702
703 let risk_warning = if result.collapse_max_risk > 0.5 {
704 " \u{26a0}"
705 } else {
706 ""
707 };
708 writeln!(
709 out,
710 " Collapses: {} (avg risk: {:.2}, max: {:.2}{})",
711 result.collapse_count, result.collapse_avg_risk, result.collapse_max_risk, risk_warning
712 )
713 .unwrap();
714
715 if !result.collapse_summaries.is_empty() {
716 writeln!(out, " Summaries:").unwrap();
717 for (i, summary) in result.collapse_summaries.iter().enumerate() {
718 let display = truncate_str(summary, 60);
722 writeln!(out, " {}. \"{}\"", i + 1, display).unwrap();
723 }
724 }
725 }
726
727 if !result.subagent_types.is_empty() {
735 let parts: Vec<String> = result
736 .subagent_types
737 .iter()
738 .map(|agg| {
739 format!(
740 "{} x {} ({})",
741 agg.agent_type,
742 agg.count,
743 format_cost(agg.total_cost)
744 )
745 })
746 .collect();
747 writeln!(out).unwrap();
748 writeln!(out, " Subagents: {}", parts.join(" | ")).unwrap();
749 }
750
751 if !result.plugins.is_empty() {
752 let parts: Vec<String> = result
753 .plugins
754 .iter()
755 .map(|p| format!("{} ({} turns, {})", p.plugin, p.turns, format_cost(p.cost)))
756 .collect();
757 writeln!(out, " Plugins: {}", parts.join(" | ")).unwrap();
758 }
759
760 if !result.skills.is_empty() {
761 let parts: Vec<String> = result
762 .skills
763 .iter()
764 .map(|s| format!("{} ({} turns, {})", s.skill, s.turns, format_cost(s.cost)))
765 .collect();
766 writeln!(out, " Skills: {}", parts.join(" | ")).unwrap();
767 }
768
769 if !result.hooks.is_empty() {
770 let parts: Vec<String> = result
771 .hooks
772 .iter()
773 .map(|h| {
774 format!(
775 "{} ({} invocations, {} ms total)",
776 h.command, h.invocations, h.total_duration_ms
777 )
778 })
779 .collect();
780 writeln!(out, " Hooks: {}", parts.join(" | ")).unwrap();
781 }
782
783 if !result.workflows.is_empty() {
788 writeln!(out).unwrap();
789 writeln!(out, " ── Workflows ─────────────────────────────────").unwrap();
790 for wf in &result.workflows {
791 let name = wf.workflow_name.as_deref().unwrap_or(&wf.run_id);
792 let status = wf.status.as_deref().unwrap_or("?");
793 writeln!(out, " {} [{}]", name, status).unwrap();
794 writeln!(
795 out,
796 " agents: {} | turns: {} | output: {} tok | cost: {}",
797 wf.parsed_agent_count,
798 wf.parsed_turns,
799 format_number(wf.parsed_output_tokens),
800 format_cost(wf.parsed_cost)
801 )
802 .unwrap();
803 if let Some(snap_tokens) = wf.snapshot_total_tokens {
804 writeln!(
805 out,
806 " snapshot: {} tok reported{}",
807 format_number(snap_tokens),
808 wf.snapshot_duration_ms
809 .map(|d| format!(", {} ms", format_number(d)))
810 .unwrap_or_default()
811 )
812 .unwrap();
813 }
814 for phase in &wf.phases {
815 if let Some(title) = phase.title.as_deref().filter(|t| !t.is_empty()) {
816 writeln!(out, " • {}", title).unwrap();
817 }
818 }
819 }
820 }
821
822 if let Some(ref attr) = result.attribution {
824 writeln!(out).unwrap();
825 writeln!(out, " ── Code Attribution ──────────────────────────").unwrap();
826 writeln!(out, " Files touched: {}", attr.file_count).unwrap();
827 writeln!(
828 out,
829 " Claude wrote: {} chars",
830 format_number(attr.total_claude_contribution)
831 )
832 .unwrap();
833 if let Some(prompts) = attr.prompt_count {
834 let escape_str = attr
835 .escape_count
836 .filter(|&e| e > 0)
837 .map(|e| format!(" ({} escaped)", e))
838 .unwrap_or_default();
839 writeln!(out, " Prompts: {}{}", prompts, escape_str).unwrap();
840 }
841 if let Some(perms) = attr.permission_prompt_count {
842 if perms > 0 {
843 writeln!(out, " Permissions: {} prompts shown", perms).unwrap();
844 }
845 }
846 }
847
848 out
849}
850
851pub fn render_trend(result: &TrendResult) -> String {
854 let mut out = String::new();
855 let mut total_cost = 0.0f64;
856 let mut total_turns = 0usize;
857
858 let max_cost = result.entries.iter().map(|e| e.cost).fold(0.0f64, f64::max);
860
861 writeln!(out, "Usage by {}", result.group_label).unwrap();
862 writeln!(out).unwrap();
863
864 for entry in &result.entries {
865 let bar_len = if max_cost > 0.0 {
867 (entry.cost / max_cost * 16.0).round() as usize
868 } else {
869 0
870 };
871 let bar = "▇".repeat(bar_len);
872
873 let top_model = entry
875 .models
876 .iter()
877 .max_by_key(|(_, tokens)| *tokens)
878 .map(|(m, _)| short_model(m))
879 .unwrap_or_default();
880
881 let cpt = if entry.turn_count > 0 {
883 entry.cost / entry.turn_count as f64
884 } else {
885 0.0
886 };
887
888 writeln!(
889 out,
890 " {:<10} {:>4} sess {:>6} turns {:>9} ${:.3}/t {:<12} {}",
891 entry.label,
892 entry.session_count,
893 entry.turn_count,
894 format_cost(entry.cost),
895 cpt,
896 truncate_str(&top_model, 12),
897 bar,
898 )
899 .unwrap();
900 total_cost += entry.cost;
901 total_turns += entry.turn_count;
902 }
903
904 writeln!(out).unwrap();
905 let avg_cpt = if total_turns > 0 {
906 total_cost / total_turns as f64
907 } else {
908 0.0
909 };
910 writeln!(
911 out,
912 " Total: {} ({} turns, avg ${:.3}/turn)",
913 format_cost(total_cost),
914 format_number(total_turns as u64),
915 avg_cpt
916 )
917 .unwrap();
918 out
919}
920
921pub fn render_validation(report: &ValidationReport, failures_only: bool) -> String {
922 let mut out = String::new();
923
924 writeln!(out, "Token Validation Report").unwrap();
925 writeln!(out, "{}", "━".repeat(60)).unwrap();
926 writeln!(out).unwrap();
927
928 writeln!(out, "Structure Checks:").unwrap();
930 for check in &report.structure_checks {
931 if failures_only && check.passed {
932 continue;
933 }
934 let status = if check.passed { "OK" } else { "FAIL" };
935 if check.passed {
936 writeln!(out, " [{:>4}] {}: {}", status, check.name, check.actual).unwrap();
937 } else {
938 writeln!(
939 out,
940 " [{:>4}] {}: expected={}, actual={}",
941 status, check.name, check.expected, check.actual
942 )
943 .unwrap();
944 }
945 }
946 writeln!(out).unwrap();
947
948 let mut fail_sessions = Vec::new();
950 for sv in &report.session_results {
951 let all_checks: Vec<_> = sv
952 .token_checks
953 .iter()
954 .chain(sv.agent_checks.iter())
955 .collect();
956 let has_failures = all_checks.iter().any(|c| !c.passed);
957
958 if failures_only && !has_failures {
959 continue;
960 }
961
962 if has_failures {
963 fail_sessions.push(sv);
964 }
965 }
966
967 if !failures_only {
968 writeln!(
969 out,
970 "Session Validation: {} sessions checked",
971 report.session_results.len()
972 )
973 .unwrap();
974 let sessions_ok = report.summary.sessions_passed;
975 let sessions_fail = report.summary.sessions_validated - sessions_ok;
976 writeln!(out, " {} PASS, {} FAIL", sessions_ok, sessions_fail).unwrap();
977 writeln!(out).unwrap();
978 }
979
980 if !fail_sessions.is_empty() {
982 writeln!(out, "Failed Sessions:").unwrap();
983 writeln!(out).unwrap();
984 }
985 for sv in &fail_sessions {
986 writeln!(
987 out,
988 " Session {} {}",
989 &sv.session_id[..8.min(sv.session_id.len())],
990 sv.project
991 )
992 .unwrap();
993 for check in sv.token_checks.iter().chain(sv.agent_checks.iter()) {
994 if !check.passed {
995 writeln!(
996 out,
997 " [FAIL] {}: expected={}, actual={}",
998 check.name, check.expected, check.actual
999 )
1000 .unwrap();
1001 }
1002 }
1003 writeln!(out).unwrap();
1004 }
1005
1006 writeln!(out, "{}", "━".repeat(60)).unwrap();
1008 let result_text = if report.summary.failed == 0 {
1009 "PASS"
1010 } else {
1011 "FAIL"
1012 };
1013 writeln!(
1014 out,
1015 "Result: {} ({}/{} checks passed, {} sessions validated)",
1016 result_text,
1017 report.summary.passed,
1018 report.summary.total_checks,
1019 report.summary.sessions_validated,
1020 )
1021 .unwrap();
1022
1023 out
1024}
1025
1026pub fn render_wrapped(result: &WrappedResult) -> String {
1029 let mut out = String::new();
1030 let w = 50; writeln!(out, "\u{2554}{}\u{2557}", "\u{2550}".repeat(w)).unwrap();
1034 let title = format!("Your {} Claude Code Wrapped", result.year);
1035 let pad = (w.saturating_sub(title.len())) / 2;
1036 writeln!(
1037 out,
1038 "\u{2551}{}{}{}\u{2551}",
1039 " ".repeat(pad),
1040 title,
1041 " ".repeat(w.saturating_sub(pad + title.len()))
1042 )
1043 .unwrap();
1044 writeln!(out, "\u{2560}{}\u{2563}", "\u{2550}".repeat(w)).unwrap();
1045 writeln!(out).unwrap();
1046
1047 let active_pct = if result.total_days > 0 {
1049 result.active_days as f64 / result.total_days as f64 * 100.0
1050 } else {
1051 0.0
1052 };
1053 writeln!(
1054 out,
1055 " Active Days: {} / {} ({:.0}%)",
1056 result.active_days, result.total_days, active_pct
1057 )
1058 .unwrap();
1059 writeln!(out, " Longest Streak: {} days", result.longest_streak).unwrap();
1060 writeln!(out, " Ghost Days: {}", result.ghost_days).unwrap();
1061 writeln!(out).unwrap();
1062
1063 writeln!(
1065 out,
1066 " {} sessions, {} turns",
1067 format_number(result.total_sessions as u64),
1068 format_number(result.total_turns as u64)
1069 )
1070 .unwrap();
1071 if result.total_agent_turns > 0 {
1072 let agent_pct = result.total_agent_turns as f64 / result.total_turns.max(1) as f64 * 100.0;
1073 writeln!(
1074 out,
1075 " {} agent turns ({:.0}% autonomous)",
1076 format_number(result.total_agent_turns as u64),
1077 agent_pct
1078 )
1079 .unwrap();
1080 }
1081 writeln!(out, " {} API equivalent", format_cost(result.total_cost)).unwrap();
1082 writeln!(out).unwrap();
1083
1084 writeln!(
1086 out,
1087 " Developer Archetype: \"{}\"",
1088 result.archetype.label()
1089 )
1090 .unwrap();
1091 writeln!(out, " {}", result.archetype.description()).unwrap();
1092 writeln!(out).unwrap();
1093
1094 writeln!(
1096 out,
1097 " Peak Hour: {:02}:00-{:02}:00",
1098 result.peak_hour,
1099 (result.peak_hour + 1) % 24
1100 )
1101 .unwrap();
1102 writeln!(out, " Peak Day: {}", result.peak_weekday).unwrap();
1103 writeln!(out).unwrap();
1104
1105 if result.autonomy_ratio > 0.0 {
1107 writeln!(
1108 out,
1109 " Autonomy: 1:{:.1} (turns per user prompt)",
1110 result.autonomy_ratio
1111 )
1112 .unwrap();
1113 }
1114 if result.avg_session_duration_min > 0.0 {
1115 writeln!(
1116 out,
1117 " Avg Session: {}",
1118 format_duration(result.avg_session_duration_min)
1119 )
1120 .unwrap();
1121 }
1122 writeln!(
1123 out,
1124 " Avg Cost: {}/session",
1125 format_cost(result.avg_cost_per_session)
1126 )
1127 .unwrap();
1128 writeln!(out).unwrap();
1129
1130 if !result.top_tools.is_empty() {
1132 writeln!(out, " Top Tools").unwrap();
1133 let max_count = result.top_tools.first().map(|(_, c)| *c).unwrap_or(1);
1134 for (name, count) in &result.top_tools {
1135 let bar_len = (*count as f64 / max_count.max(1) as f64 * 20.0).round() as usize;
1136 writeln!(
1137 out,
1138 " {:<18} {:>6} {}",
1139 name,
1140 format_number(*count as u64),
1141 "\u{2588}".repeat(bar_len)
1142 )
1143 .unwrap();
1144 }
1145 writeln!(out).unwrap();
1146 }
1147
1148 if !result.top_projects.is_empty() {
1150 writeln!(out, " Top Projects").unwrap();
1151 for (name, cost) in &result.top_projects {
1152 writeln!(
1153 out,
1154 " {:<30} {}",
1155 truncate_str(name, 30),
1156 format_cost(*cost)
1157 )
1158 .unwrap();
1159 }
1160 writeln!(out).unwrap();
1161 }
1162
1163 if let Some((ref id, cost, ref project)) = result.most_expensive_session {
1165 writeln!(out, " Most Expensive Session").unwrap();
1166 let short_id = if id.len() > 8 { &id[..8] } else { id };
1167 writeln!(
1168 out,
1169 " {} {} {}",
1170 short_id,
1171 truncate_str(project, 25),
1172 format_cost(cost)
1173 )
1174 .unwrap();
1175 writeln!(out).unwrap();
1176 }
1177
1178 if let Some((ref id, dur_min, ref project)) = result.longest_session {
1180 if dur_min > 0.0 {
1181 writeln!(out, " Longest Session").unwrap();
1182 let short_id = if id.len() > 8 { &id[..8] } else { id };
1183 writeln!(
1184 out,
1185 " {} {} {}",
1186 short_id,
1187 truncate_str(project, 25),
1188 format_duration(dur_min)
1189 )
1190 .unwrap();
1191 writeln!(out).unwrap();
1192 }
1193 }
1194
1195 if !result.model_distribution.is_empty() {
1197 writeln!(out, " Models").unwrap();
1198 for (model, turns) in &result.model_distribution {
1199 let short = short_model(model);
1200 let pct = *turns as f64 / result.total_turns.max(1) as f64 * 100.0;
1201 writeln!(
1202 out,
1203 " {:<25} {:>6} turns ({:.0}%)",
1204 short,
1205 format_number(*turns as u64),
1206 pct
1207 )
1208 .unwrap();
1209 }
1210 writeln!(out).unwrap();
1211 }
1212
1213 let mut meta_lines: Vec<String> = Vec::new();
1215 if result.total_speculation_time_saved_ms > 0.0 {
1216 let saved_sec = result.total_speculation_time_saved_ms / 1000.0;
1217 if saved_sec >= 60.0 {
1218 meta_lines.push(format!(
1219 " Speculation saved you {:.1} minutes",
1220 saved_sec / 60.0
1221 ));
1222 } else {
1223 meta_lines.push(format!(" Speculation saved you {:.1} seconds", saved_sec));
1224 }
1225 }
1226 if result.total_pr_count > 0 {
1227 meta_lines.push(format!(
1228 " {} PRs shipped via Claude Code",
1229 result.total_pr_count
1230 ));
1231 }
1232 if result.total_collapse_count > 0 {
1233 meta_lines.push(format!(
1234 " {} context collapses",
1235 result.total_collapse_count
1236 ));
1237 }
1238 if !meta_lines.is_empty() {
1239 for line in &meta_lines {
1240 writeln!(out, "{}", line).unwrap();
1241 }
1242 writeln!(out).unwrap();
1243 }
1244
1245 writeln!(out, "\u{255a}{}\u{255d}", "\u{2550}".repeat(w)).unwrap();
1247
1248 out
1249}
1250
1251pub fn render_heatmap(result: &HeatmapResult) -> String {
1254 let mut out = String::new();
1255 let (p25, p50, p75) = result.thresholds;
1256
1257 writeln!(out, "Activity Heatmap").unwrap();
1258 writeln!(
1259 out,
1260 "{} ~ {}",
1261 result.start_date.format("%Y-%m-%d"),
1262 result.end_date.format("%Y-%m-%d")
1263 )
1264 .unwrap();
1265 writeln!(out).unwrap();
1266
1267 let glyph = |turns: usize| -> char {
1269 if turns == 0 {
1270 '\u{00B7}' } else if turns < p25 {
1272 '\u{2591}' } else if turns < p50 {
1274 '\u{2592}' } else if turns < p75 {
1276 '\u{2593}' } else {
1278 '\u{2588}' }
1280 };
1281
1282 let start_weekday = result.start_date.weekday().num_days_from_monday(); let grid_start = result.start_date - chrono::Duration::days(start_weekday as i64);
1287
1288 let end_weekday = result.end_date.weekday().num_days_from_monday();
1290 let grid_end = result.end_date + chrono::Duration::days((6 - end_weekday) as i64);
1291
1292 let total_days = (grid_end - grid_start).num_days() as usize + 1;
1293 let num_weeks = total_days.div_ceil(7);
1294
1295 let mut turns_by_date: std::collections::HashMap<NaiveDate, usize> =
1297 std::collections::HashMap::new();
1298 for d in &result.daily {
1299 turns_by_date.insert(d.date, d.turns);
1300 }
1301
1302 let label_width = 5; let mut month_marks: Vec<(usize, &str)> = Vec::new();
1310 {
1311 let mut d = if grid_start.day() == 1 {
1313 grid_start
1314 } else {
1315 let (y, m) = if grid_start.month() == 12 {
1317 (grid_start.year() + 1, 1)
1318 } else {
1319 (grid_start.year(), grid_start.month() + 1)
1320 };
1321 NaiveDate::from_ymd_opt(y, m, 1).unwrap_or(grid_start)
1322 };
1323
1324 while d <= grid_end {
1325 let week_idx = ((d - grid_start).num_days() / 7) as usize;
1326 month_marks.push((week_idx, month_abbr(d.month())));
1327 d = if d.month() == 12 {
1329 NaiveDate::from_ymd_opt(d.year() + 1, 1, 1).unwrap()
1330 } else {
1331 NaiveDate::from_ymd_opt(d.year(), d.month() + 1, 1).unwrap()
1332 };
1333 }
1334 }
1335
1336 let mut month_header = " ".repeat(label_width);
1338 let mut cursor = 0usize; for (col, name) in &month_marks {
1340 if *col >= cursor {
1341 for _ in cursor..*col {
1343 month_header.push(' ');
1344 }
1345 month_header.push_str(name);
1346 cursor = col + name.len(); }
1348 }
1350 writeln!(out, "{}", month_header.trim_end()).unwrap();
1351
1352 let weekday_labels = ["Mon", " ", "Wed", " ", "Fri", " ", "Sun"];
1354
1355 for row in 0..7u32 {
1356 let label = weekday_labels[row as usize];
1357 write!(out, "{:<5}", label).unwrap();
1358
1359 for week_idx in 0..num_weeks {
1360 let day = grid_start + chrono::Duration::days((week_idx * 7 + row as usize) as i64);
1361 if day < result.start_date || day > result.end_date {
1362 write!(out, " ").unwrap();
1363 } else {
1364 let turns = turns_by_date.get(&day).copied().unwrap_or(0);
1365 write!(out, "{}", glyph(turns)).unwrap();
1366 }
1367 }
1368 writeln!(out).unwrap();
1369 }
1370
1371 writeln!(out).unwrap();
1373 writeln!(
1374 out,
1375 " \u{00B7}=0 \u{2591}<P25({}) \u{2592}<P50({}) \u{2593}<P75({}) \u{2588}\u{2265}P75",
1376 p25, p50, p75
1377 )
1378 .unwrap();
1379
1380 writeln!(out).unwrap();
1382 writeln!(
1383 out,
1384 " Active days: {}/{}",
1385 result.stats.active_days, result.stats.total_days
1386 )
1387 .unwrap();
1388 writeln!(
1389 out,
1390 " Current streak: {} days",
1391 result.stats.current_streak
1392 )
1393 .unwrap();
1394 writeln!(
1395 out,
1396 " Longest streak: {} days",
1397 result.stats.longest_streak
1398 )
1399 .unwrap();
1400
1401 if let Some((date, turns)) = result.stats.busiest_day {
1402 writeln!(
1403 out,
1404 " Busiest day: {} ({} turns)",
1405 date.format("%Y-%m-%d"),
1406 turns
1407 )
1408 .unwrap();
1409 }
1410
1411 writeln!(out).unwrap();
1412
1413 out
1414}
1415
1416fn month_abbr(m: u32) -> &'static str {
1417 match m {
1418 1 => "Jan",
1419 2 => "Feb",
1420 3 => "Mar",
1421 4 => "Apr",
1422 5 => "May",
1423 6 => "Jun",
1424 7 => "Jul",
1425 8 => "Aug",
1426 9 => "Sep",
1427 10 => "Oct",
1428 11 => "Nov",
1429 12 => "Dec",
1430 _ => "???",
1431 }
1432}
1433
1434#[cfg(test)]
1437mod tests {
1438 use super::*;
1439
1440 #[test]
1441 fn test_format_number() {
1442 assert_eq!(format_number(0), "0");
1443 assert_eq!(format_number(999), "999");
1444 assert_eq!(format_number(1_000), "1,000");
1445 assert_eq!(format_number(1_234_567), "1,234,567");
1446 }
1447
1448 #[test]
1449 fn test_format_cost() {
1450 assert_eq!(format_cost(0.0), "$0.00");
1451 assert_eq!(format_cost(1.5), "$1.50");
1452 assert_eq!(format_cost(1234.56), "$1,234.56");
1453 }
1454}