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
363 writeln!(out).unwrap();
364
365 out
366}
367
368fn short_model(name: &str) -> String {
369 let s = name.strip_prefix("claude-").unwrap_or(name);
370 if s.len() > 9 {
371 let last_dash = s.rfind('-').unwrap_or(s.len());
372 let suffix = &s[last_dash + 1..];
373 if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) {
374 return s[..last_dash].to_string();
375 }
376 }
377 s.to_string()
378}
379
380pub fn render_projects(result: &ProjectResult) -> String {
383 let mut out = String::new();
384 let mut total_cost = 0.0f64;
385
386 writeln!(out, "Projects by Cost").unwrap();
387 writeln!(out).unwrap();
388 writeln!(out, " # Project Sessions Turns Agent $/Sess Model Cost").unwrap();
389 writeln!(out, " ─────────────────────────────────────────────────────────────────────────────────────────").unwrap();
390
391 for (i, proj) in result.projects.iter().enumerate() {
392 let avg_cost = if proj.session_count > 0 {
393 proj.cost / proj.session_count as f64
394 } else {
395 0.0
396 };
397 let model_short = short_model(&proj.primary_model);
398 writeln!(
399 out,
400 " {:>2}. {:<30} {:>5} {:>6} {:>5} {:>6} {:<12} {:>9}",
401 i + 1,
402 truncate_str(&proj.display_name, 30),
403 proj.session_count,
404 proj.total_turns,
405 proj.agent_turns,
406 format_cost(avg_cost),
407 truncate_str(&model_short, 12),
408 format_cost(proj.cost),
409 )
410 .unwrap();
411 total_cost += proj.cost;
412 }
413
414 writeln!(out).unwrap();
415 writeln!(
416 out,
417 " Total: {} projects, {}",
418 result.projects.len(),
419 format_cost(total_cost)
420 )
421 .unwrap();
422 out
423}
424
425fn truncate_str(s: &str, max: usize) -> String {
426 if s.len() <= max {
427 s.to_string()
428 } else {
429 format!("{}...", &s[..s.floor_char_boundary(max.saturating_sub(3))])
430 }
431}
432
433pub fn render_session(result: &SessionResult) -> String {
436 let mut out = String::new();
437
438 let main_turns = result.turn_details.iter().filter(|t| !t.is_agent).count();
439
440 writeln!(
441 out,
442 "Session {} {}",
443 &result.session_id[..result.session_id.len().min(8)],
444 result.project
445 )
446 .unwrap();
447 writeln!(out).unwrap();
448 writeln!(
449 out,
450 " Turns: {:>6} (+ {} agent) Duration: {}",
451 main_turns,
452 result.agent_summary.total_agent_turns,
453 format_duration(result.duration_minutes)
454 )
455 .unwrap();
456 writeln!(
457 out,
458 " Model: {:<20} MaxCtx: {}",
459 result.model,
460 format_number(result.max_context)
461 )
462 .unwrap();
463 writeln!(
464 out,
465 " CacheHit: {:>5.1}% Compacts: {}",
466 result.total_tokens.cache_read_tokens as f64
467 / result.total_tokens.context_tokens().max(1) as f64
468 * 100.0,
469 result.compaction_count
470 )
471 .unwrap();
472 writeln!(out, " Cost: {}", format_cost(result.total_cost)).unwrap();
473
474 let has_metadata = result.title.is_some()
476 || !result.tags.is_empty()
477 || result.mode.is_some()
478 || !result.git_branches.is_empty()
479 || !result.pr_links.is_empty();
480
481 if has_metadata {
482 writeln!(out).unwrap();
483 writeln!(out, " ── Metadata ──────────────────────────────────").unwrap();
484 if let Some(ref title) = result.title {
485 writeln!(out, " Title: {}", truncate_str(title, 60)).unwrap();
486 }
487 if !result.tags.is_empty() {
488 writeln!(out, " Tags: {}", result.tags.join(", ")).unwrap();
489 }
490 if let Some(ref mode) = result.mode {
491 writeln!(out, " Mode: {}", mode).unwrap();
492 }
493 if !result.git_branches.is_empty() {
494 let mut branches: Vec<_> = result.git_branches.iter().collect();
495 branches.sort_by(|a, b| b.1.cmp(a.1));
496 let parts: Vec<String> = branches
497 .iter()
498 .map(|(name, count)| format!("{} ({} turns)", name, count))
499 .collect();
500 writeln!(out, " Branch: {}", parts.join(", ")).unwrap();
501 }
502 for pr in &result.pr_links {
503 writeln!(out, " PR: {}#{}", pr.repository, pr.number).unwrap();
504 }
505 }
506
507 let has_performance = result.user_prompt_count > 0
509 || result.truncated_count > 0
510 || result.speculation_accepts > 0
511 || !result.service_tiers.is_empty()
512 || !result.speeds.is_empty()
513 || !result.inference_geos.is_empty()
514 || result.api_error_count > 0
515 || result.tool_error_count > 0;
516
517 if has_performance {
518 writeln!(out).unwrap();
519 writeln!(out, " ── Performance ───────────────────────────────").unwrap();
520 if result.user_prompt_count > 0 {
521 let total_turns = result.turn_details.len();
522 writeln!(
523 out,
524 " Autonomy: 1:{:.1} ({} turns / {} user prompts)",
525 result.autonomy_ratio, total_turns, result.user_prompt_count
526 )
527 .unwrap();
528 }
529 if result.truncated_count > 0 {
530 writeln!(
531 out,
532 " Truncated: {} turns hit max_tokens",
533 result.truncated_count
534 )
535 .unwrap();
536 }
537 if result.api_error_count > 0 || result.tool_error_count > 0 {
538 let mut parts = Vec::new();
539 if result.api_error_count > 0 {
540 parts.push(format!("{} API errors", result.api_error_count));
541 }
542 if result.tool_error_count > 0 {
543 parts.push(format!("{} tool errors", result.tool_error_count));
544 }
545 writeln!(out, " Errors: {}", parts.join(", ")).unwrap();
546 }
547 if result.speculation_accepts > 0 {
548 let saved_secs = result.speculation_time_saved_ms / 1000.0;
549 writeln!(
550 out,
551 " Speculation: saved {:.1}s across {} accepts",
552 saved_secs, result.speculation_accepts
553 )
554 .unwrap();
555 }
556 if !result.service_tiers.is_empty() {
557 let total: usize = result.service_tiers.values().sum();
558 let mut tiers: Vec<_> = result.service_tiers.iter().collect();
559 tiers.sort_by(|a, b| b.1.cmp(a.1));
560 let parts: Vec<String> = tiers
561 .iter()
562 .map(|(name, count)| {
563 format!("{} ({:.0}%)", name, **count as f64 / total as f64 * 100.0)
564 })
565 .collect();
566 writeln!(out, " Service: {}", parts.join(", ")).unwrap();
567 }
568 if !result.speeds.is_empty() {
569 let total: usize = result.speeds.values().sum();
570 let mut spds: Vec<_> = result.speeds.iter().collect();
571 spds.sort_by(|a, b| b.1.cmp(a.1));
572 let parts: Vec<String> = spds
573 .iter()
574 .map(|(name, count)| {
575 format!("{} ({:.0}%)", name, **count as f64 / total as f64 * 100.0)
576 })
577 .collect();
578 writeln!(out, " Speed: {}", parts.join(", ")).unwrap();
579 }
580 if !result.inference_geos.is_empty() {
581 let total: usize = result.inference_geos.values().sum();
582 let mut geos: Vec<_> = result.inference_geos.iter().collect();
583 geos.sort_by(|a, b| b.1.cmp(a.1));
584 let parts: Vec<String> = geos
585 .iter()
586 .map(|(name, count)| {
587 format!("{} ({:.0}%)", name, **count as f64 / total as f64 * 100.0)
588 })
589 .collect();
590 writeln!(out, " Geo: {}", parts.join(", ")).unwrap();
591 }
592 }
593
594 if !result.agent_summary.agents.is_empty() {
596 writeln!(out).unwrap();
597 writeln!(out, " Agent Breakdown").unwrap();
598 writeln!(
599 out,
600 " {:<14} {:<40} {:>6} {:>10} {:>9}",
601 "Type", "Description", "Turns", "Output", "Cost"
602 )
603 .unwrap();
604 writeln!(out, " {}", "-".repeat(83)).unwrap();
605
606 let main_turns = result.turn_details.iter().filter(|t| !t.is_agent).count();
608 let main_output: u64 = result
609 .turn_details
610 .iter()
611 .filter(|t| !t.is_agent)
612 .map(|t| t.output_tokens)
613 .sum();
614 let main_cost = result.total_cost - result.agent_summary.agent_cost;
615 writeln!(
616 out,
617 " {:<14} {:<40} {:>6} {:>10} {:>9}",
618 "main",
619 "(this conversation)",
620 main_turns,
621 format_number(main_output),
622 format_cost(main_cost)
623 )
624 .unwrap();
625
626 for agent in &result.agent_summary.agents {
627 let desc = if agent.description.len() > 40 {
628 format!(
629 "{}...",
630 &agent.description[..agent.description.floor_char_boundary(37)]
631 )
632 } else {
633 agent.description.clone()
634 };
635 writeln!(
636 out,
637 " {:<14} {:<40} {:>6} {:>10} {:>9}",
638 agent.agent_type,
639 desc,
640 agent.turns,
641 format_number(agent.output_tokens),
642 format_cost(agent.cost),
643 )
644 .unwrap();
645 }
646 }
647
648 if result.collapse_count > 0 {
650 writeln!(out).unwrap();
651 writeln!(out, " ── Context Collapse ──────────────────────────").unwrap();
652
653 let risk_warning = if result.collapse_max_risk > 0.5 {
654 " \u{26a0}"
655 } else {
656 ""
657 };
658 writeln!(
659 out,
660 " Collapses: {} (avg risk: {:.2}, max: {:.2}{})",
661 result.collapse_count, result.collapse_avg_risk, result.collapse_max_risk, risk_warning
662 )
663 .unwrap();
664
665 if !result.collapse_summaries.is_empty() {
666 writeln!(out, " Summaries:").unwrap();
667 for (i, summary) in result.collapse_summaries.iter().enumerate() {
668 let display = truncate_str(summary, 60);
672 writeln!(out, " {}. \"{}\"", i + 1, display).unwrap();
673 }
674 }
675 }
676
677 if let Some(ref attr) = result.attribution {
679 writeln!(out).unwrap();
680 writeln!(out, " ── Code Attribution ──────────────────────────").unwrap();
681 writeln!(out, " Files touched: {}", attr.file_count).unwrap();
682 writeln!(
683 out,
684 " Claude wrote: {} chars",
685 format_number(attr.total_claude_contribution)
686 )
687 .unwrap();
688 if let Some(prompts) = attr.prompt_count {
689 let escape_str = attr
690 .escape_count
691 .filter(|&e| e > 0)
692 .map(|e| format!(" ({} escaped)", e))
693 .unwrap_or_default();
694 writeln!(out, " Prompts: {}{}", prompts, escape_str).unwrap();
695 }
696 if let Some(perms) = attr.permission_prompt_count {
697 if perms > 0 {
698 writeln!(out, " Permissions: {} prompts shown", perms).unwrap();
699 }
700 }
701 }
702
703 out
704}
705
706pub fn render_trend(result: &TrendResult) -> String {
709 let mut out = String::new();
710 let mut total_cost = 0.0f64;
711 let mut total_turns = 0usize;
712
713 let max_cost = result.entries.iter().map(|e| e.cost).fold(0.0f64, f64::max);
715
716 writeln!(out, "Usage by {}", result.group_label).unwrap();
717 writeln!(out).unwrap();
718
719 for entry in &result.entries {
720 let bar_len = if max_cost > 0.0 {
722 (entry.cost / max_cost * 16.0).round() as usize
723 } else {
724 0
725 };
726 let bar = "▇".repeat(bar_len);
727
728 let top_model = entry
730 .models
731 .iter()
732 .max_by_key(|(_, tokens)| *tokens)
733 .map(|(m, _)| short_model(m))
734 .unwrap_or_default();
735
736 let cpt = if entry.turn_count > 0 {
738 entry.cost / entry.turn_count as f64
739 } else {
740 0.0
741 };
742
743 writeln!(
744 out,
745 " {:<10} {:>4} sess {:>6} turns {:>9} ${:.3}/t {:<12} {}",
746 entry.label,
747 entry.session_count,
748 entry.turn_count,
749 format_cost(entry.cost),
750 cpt,
751 truncate_str(&top_model, 12),
752 bar,
753 )
754 .unwrap();
755 total_cost += entry.cost;
756 total_turns += entry.turn_count;
757 }
758
759 writeln!(out).unwrap();
760 let avg_cpt = if total_turns > 0 {
761 total_cost / total_turns as f64
762 } else {
763 0.0
764 };
765 writeln!(
766 out,
767 " Total: {} ({} turns, avg ${:.3}/turn)",
768 format_cost(total_cost),
769 format_number(total_turns as u64),
770 avg_cpt
771 )
772 .unwrap();
773 out
774}
775
776pub fn render_validation(report: &ValidationReport, failures_only: bool) -> String {
777 let mut out = String::new();
778
779 writeln!(out, "Token Validation Report").unwrap();
780 writeln!(out, "{}", "━".repeat(60)).unwrap();
781 writeln!(out).unwrap();
782
783 writeln!(out, "Structure Checks:").unwrap();
785 for check in &report.structure_checks {
786 if failures_only && check.passed {
787 continue;
788 }
789 let status = if check.passed { "OK" } else { "FAIL" };
790 if check.passed {
791 writeln!(out, " [{:>4}] {}: {}", status, check.name, check.actual).unwrap();
792 } else {
793 writeln!(
794 out,
795 " [{:>4}] {}: expected={}, actual={}",
796 status, check.name, check.expected, check.actual
797 )
798 .unwrap();
799 }
800 }
801 writeln!(out).unwrap();
802
803 let mut fail_sessions = Vec::new();
805 for sv in &report.session_results {
806 let all_checks: Vec<_> = sv
807 .token_checks
808 .iter()
809 .chain(sv.agent_checks.iter())
810 .collect();
811 let has_failures = all_checks.iter().any(|c| !c.passed);
812
813 if failures_only && !has_failures {
814 continue;
815 }
816
817 if has_failures {
818 fail_sessions.push(sv);
819 }
820 }
821
822 if !failures_only {
823 writeln!(
824 out,
825 "Session Validation: {} sessions checked",
826 report.session_results.len()
827 )
828 .unwrap();
829 let sessions_ok = report.summary.sessions_passed;
830 let sessions_fail = report.summary.sessions_validated - sessions_ok;
831 writeln!(out, " {} PASS, {} FAIL", sessions_ok, sessions_fail).unwrap();
832 writeln!(out).unwrap();
833 }
834
835 if !fail_sessions.is_empty() {
837 writeln!(out, "Failed Sessions:").unwrap();
838 writeln!(out).unwrap();
839 }
840 for sv in &fail_sessions {
841 writeln!(
842 out,
843 " Session {} {}",
844 &sv.session_id[..8.min(sv.session_id.len())],
845 sv.project
846 )
847 .unwrap();
848 for check in sv.token_checks.iter().chain(sv.agent_checks.iter()) {
849 if !check.passed {
850 writeln!(
851 out,
852 " [FAIL] {}: expected={}, actual={}",
853 check.name, check.expected, check.actual
854 )
855 .unwrap();
856 }
857 }
858 writeln!(out).unwrap();
859 }
860
861 writeln!(out, "{}", "━".repeat(60)).unwrap();
863 let result_text = if report.summary.failed == 0 {
864 "PASS"
865 } else {
866 "FAIL"
867 };
868 writeln!(
869 out,
870 "Result: {} ({}/{} checks passed, {} sessions validated)",
871 result_text,
872 report.summary.passed,
873 report.summary.total_checks,
874 report.summary.sessions_validated,
875 )
876 .unwrap();
877
878 out
879}
880
881pub fn render_wrapped(result: &WrappedResult) -> String {
884 let mut out = String::new();
885 let w = 50; writeln!(out, "\u{2554}{}\u{2557}", "\u{2550}".repeat(w)).unwrap();
889 let title = format!("Your {} Claude Code Wrapped", result.year);
890 let pad = (w.saturating_sub(title.len())) / 2;
891 writeln!(
892 out,
893 "\u{2551}{}{}{}\u{2551}",
894 " ".repeat(pad),
895 title,
896 " ".repeat(w.saturating_sub(pad + title.len()))
897 )
898 .unwrap();
899 writeln!(out, "\u{2560}{}\u{2563}", "\u{2550}".repeat(w)).unwrap();
900 writeln!(out).unwrap();
901
902 let active_pct = if result.total_days > 0 {
904 result.active_days as f64 / result.total_days as f64 * 100.0
905 } else {
906 0.0
907 };
908 writeln!(
909 out,
910 " Active Days: {} / {} ({:.0}%)",
911 result.active_days, result.total_days, active_pct
912 )
913 .unwrap();
914 writeln!(out, " Longest Streak: {} days", result.longest_streak).unwrap();
915 writeln!(out, " Ghost Days: {}", result.ghost_days).unwrap();
916 writeln!(out).unwrap();
917
918 writeln!(
920 out,
921 " {} sessions, {} turns",
922 format_number(result.total_sessions as u64),
923 format_number(result.total_turns as u64)
924 )
925 .unwrap();
926 if result.total_agent_turns > 0 {
927 let agent_pct = result.total_agent_turns as f64 / result.total_turns.max(1) as f64 * 100.0;
928 writeln!(
929 out,
930 " {} agent turns ({:.0}% autonomous)",
931 format_number(result.total_agent_turns as u64),
932 agent_pct
933 )
934 .unwrap();
935 }
936 writeln!(out, " {} API equivalent", format_cost(result.total_cost)).unwrap();
937 writeln!(out).unwrap();
938
939 writeln!(
941 out,
942 " Developer Archetype: \"{}\"",
943 result.archetype.label()
944 )
945 .unwrap();
946 writeln!(out, " {}", result.archetype.description()).unwrap();
947 writeln!(out).unwrap();
948
949 writeln!(
951 out,
952 " Peak Hour: {:02}:00-{:02}:00",
953 result.peak_hour,
954 (result.peak_hour + 1) % 24
955 )
956 .unwrap();
957 writeln!(out, " Peak Day: {}", result.peak_weekday).unwrap();
958 writeln!(out).unwrap();
959
960 if result.autonomy_ratio > 0.0 {
962 writeln!(
963 out,
964 " Autonomy: 1:{:.1} (turns per user prompt)",
965 result.autonomy_ratio
966 )
967 .unwrap();
968 }
969 if result.avg_session_duration_min > 0.0 {
970 writeln!(
971 out,
972 " Avg Session: {}",
973 format_duration(result.avg_session_duration_min)
974 )
975 .unwrap();
976 }
977 writeln!(
978 out,
979 " Avg Cost: {}/session",
980 format_cost(result.avg_cost_per_session)
981 )
982 .unwrap();
983 writeln!(out).unwrap();
984
985 if !result.top_tools.is_empty() {
987 writeln!(out, " Top Tools").unwrap();
988 let max_count = result.top_tools.first().map(|(_, c)| *c).unwrap_or(1);
989 for (name, count) in &result.top_tools {
990 let bar_len = (*count as f64 / max_count.max(1) as f64 * 20.0).round() as usize;
991 writeln!(
992 out,
993 " {:<18} {:>6} {}",
994 name,
995 format_number(*count as u64),
996 "\u{2588}".repeat(bar_len)
997 )
998 .unwrap();
999 }
1000 writeln!(out).unwrap();
1001 }
1002
1003 if !result.top_projects.is_empty() {
1005 writeln!(out, " Top Projects").unwrap();
1006 for (name, cost) in &result.top_projects {
1007 writeln!(
1008 out,
1009 " {:<30} {}",
1010 truncate_str(name, 30),
1011 format_cost(*cost)
1012 )
1013 .unwrap();
1014 }
1015 writeln!(out).unwrap();
1016 }
1017
1018 if let Some((ref id, cost, ref project)) = result.most_expensive_session {
1020 writeln!(out, " Most Expensive Session").unwrap();
1021 let short_id = if id.len() > 8 { &id[..8] } else { id };
1022 writeln!(
1023 out,
1024 " {} {} {}",
1025 short_id,
1026 truncate_str(project, 25),
1027 format_cost(cost)
1028 )
1029 .unwrap();
1030 writeln!(out).unwrap();
1031 }
1032
1033 if let Some((ref id, dur_min, ref project)) = result.longest_session {
1035 if dur_min > 0.0 {
1036 writeln!(out, " Longest Session").unwrap();
1037 let short_id = if id.len() > 8 { &id[..8] } else { id };
1038 writeln!(
1039 out,
1040 " {} {} {}",
1041 short_id,
1042 truncate_str(project, 25),
1043 format_duration(dur_min)
1044 )
1045 .unwrap();
1046 writeln!(out).unwrap();
1047 }
1048 }
1049
1050 if !result.model_distribution.is_empty() {
1052 writeln!(out, " Models").unwrap();
1053 for (model, turns) in &result.model_distribution {
1054 let short = short_model(model);
1055 let pct = *turns as f64 / result.total_turns.max(1) as f64 * 100.0;
1056 writeln!(
1057 out,
1058 " {:<25} {:>6} turns ({:.0}%)",
1059 short,
1060 format_number(*turns as u64),
1061 pct
1062 )
1063 .unwrap();
1064 }
1065 writeln!(out).unwrap();
1066 }
1067
1068 let mut meta_lines: Vec<String> = Vec::new();
1070 if result.total_speculation_time_saved_ms > 0.0 {
1071 let saved_sec = result.total_speculation_time_saved_ms / 1000.0;
1072 if saved_sec >= 60.0 {
1073 meta_lines.push(format!(
1074 " Speculation saved you {:.1} minutes",
1075 saved_sec / 60.0
1076 ));
1077 } else {
1078 meta_lines.push(format!(" Speculation saved you {:.1} seconds", saved_sec));
1079 }
1080 }
1081 if result.total_pr_count > 0 {
1082 meta_lines.push(format!(
1083 " {} PRs shipped via Claude Code",
1084 result.total_pr_count
1085 ));
1086 }
1087 if result.total_collapse_count > 0 {
1088 meta_lines.push(format!(
1089 " {} context collapses",
1090 result.total_collapse_count
1091 ));
1092 }
1093 if !meta_lines.is_empty() {
1094 for line in &meta_lines {
1095 writeln!(out, "{}", line).unwrap();
1096 }
1097 writeln!(out).unwrap();
1098 }
1099
1100 writeln!(out, "\u{255a}{}\u{255d}", "\u{2550}".repeat(w)).unwrap();
1102
1103 out
1104}
1105
1106pub fn render_heatmap(result: &HeatmapResult) -> String {
1109 let mut out = String::new();
1110 let (p25, p50, p75) = result.thresholds;
1111
1112 writeln!(out, "Activity Heatmap").unwrap();
1113 writeln!(
1114 out,
1115 "{} ~ {}",
1116 result.start_date.format("%Y-%m-%d"),
1117 result.end_date.format("%Y-%m-%d")
1118 )
1119 .unwrap();
1120 writeln!(out).unwrap();
1121
1122 let glyph = |turns: usize| -> char {
1124 if turns == 0 {
1125 '\u{00B7}' } else if turns < p25 {
1127 '\u{2591}' } else if turns < p50 {
1129 '\u{2592}' } else if turns < p75 {
1131 '\u{2593}' } else {
1133 '\u{2588}' }
1135 };
1136
1137 let start_weekday = result.start_date.weekday().num_days_from_monday(); let grid_start = result.start_date - chrono::Duration::days(start_weekday as i64);
1142
1143 let end_weekday = result.end_date.weekday().num_days_from_monday();
1145 let grid_end = result.end_date + chrono::Duration::days((6 - end_weekday) as i64);
1146
1147 let total_days = (grid_end - grid_start).num_days() as usize + 1;
1148 let num_weeks = total_days.div_ceil(7);
1149
1150 let mut turns_by_date: std::collections::HashMap<NaiveDate, usize> =
1152 std::collections::HashMap::new();
1153 for d in &result.daily {
1154 turns_by_date.insert(d.date, d.turns);
1155 }
1156
1157 let label_width = 5; let mut month_marks: Vec<(usize, &str)> = Vec::new();
1165 {
1166 let mut d = if grid_start.day() == 1 {
1168 grid_start
1169 } else {
1170 let (y, m) = if grid_start.month() == 12 {
1172 (grid_start.year() + 1, 1)
1173 } else {
1174 (grid_start.year(), grid_start.month() + 1)
1175 };
1176 NaiveDate::from_ymd_opt(y, m, 1).unwrap_or(grid_start)
1177 };
1178
1179 while d <= grid_end {
1180 let week_idx = ((d - grid_start).num_days() / 7) as usize;
1181 month_marks.push((week_idx, month_abbr(d.month())));
1182 d = if d.month() == 12 {
1184 NaiveDate::from_ymd_opt(d.year() + 1, 1, 1).unwrap()
1185 } else {
1186 NaiveDate::from_ymd_opt(d.year(), d.month() + 1, 1).unwrap()
1187 };
1188 }
1189 }
1190
1191 let mut month_header = " ".repeat(label_width);
1193 let mut cursor = 0usize; for (col, name) in &month_marks {
1195 if *col >= cursor {
1196 for _ in cursor..*col {
1198 month_header.push(' ');
1199 }
1200 month_header.push_str(name);
1201 cursor = col + name.len(); }
1203 }
1205 writeln!(out, "{}", month_header.trim_end()).unwrap();
1206
1207 let weekday_labels = ["Mon", " ", "Wed", " ", "Fri", " ", "Sun"];
1209
1210 for row in 0..7u32 {
1211 let label = weekday_labels[row as usize];
1212 write!(out, "{:<5}", label).unwrap();
1213
1214 for week_idx in 0..num_weeks {
1215 let day = grid_start + chrono::Duration::days((week_idx * 7 + row as usize) as i64);
1216 if day < result.start_date || day > result.end_date {
1217 write!(out, " ").unwrap();
1218 } else {
1219 let turns = turns_by_date.get(&day).copied().unwrap_or(0);
1220 write!(out, "{}", glyph(turns)).unwrap();
1221 }
1222 }
1223 writeln!(out).unwrap();
1224 }
1225
1226 writeln!(out).unwrap();
1228 writeln!(
1229 out,
1230 " \u{00B7}=0 \u{2591}<P25({}) \u{2592}<P50({}) \u{2593}<P75({}) \u{2588}\u{2265}P75",
1231 p25, p50, p75
1232 )
1233 .unwrap();
1234
1235 writeln!(out).unwrap();
1237 writeln!(
1238 out,
1239 " Active days: {}/{}",
1240 result.stats.active_days, result.stats.total_days
1241 )
1242 .unwrap();
1243 writeln!(
1244 out,
1245 " Current streak: {} days",
1246 result.stats.current_streak
1247 )
1248 .unwrap();
1249 writeln!(
1250 out,
1251 " Longest streak: {} days",
1252 result.stats.longest_streak
1253 )
1254 .unwrap();
1255
1256 if let Some((date, turns)) = result.stats.busiest_day {
1257 writeln!(
1258 out,
1259 " Busiest day: {} ({} turns)",
1260 date.format("%Y-%m-%d"),
1261 turns
1262 )
1263 .unwrap();
1264 }
1265
1266 writeln!(out).unwrap();
1267
1268 out
1269}
1270
1271fn month_abbr(m: u32) -> &'static str {
1272 match m {
1273 1 => "Jan",
1274 2 => "Feb",
1275 3 => "Mar",
1276 4 => "Apr",
1277 5 => "May",
1278 6 => "Jun",
1279 7 => "Jul",
1280 8 => "Aug",
1281 9 => "Sep",
1282 10 => "Oct",
1283 11 => "Nov",
1284 12 => "Dec",
1285 _ => "???",
1286 }
1287}
1288
1289#[cfg(test)]
1292mod tests {
1293 use super::*;
1294
1295 #[test]
1296 fn test_format_number() {
1297 assert_eq!(format_number(0), "0");
1298 assert_eq!(format_number(999), "999");
1299 assert_eq!(format_number(1_000), "1,000");
1300 assert_eq!(format_number(1_234_567), "1,234,567");
1301 }
1302
1303 #[test]
1304 fn test_format_cost() {
1305 assert_eq!(format_cost(0.0), "$0.00");
1306 assert_eq!(format_cost(1.5), "$1.50");
1307 assert_eq!(format_cost(1234.56), "$1,234.56");
1308 }
1309}