Skip to main content

cc_token_usage/output/
text.rs

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
11// ─── Helpers ────────────────────────────────────────────────────────────────
12
13fn 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
46// ─── 1. Overview ────────────────────────────────────────────────────────────
47
48pub 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    // Subscription value
113    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    // Model breakdown
124    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    // Cost by category
159    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    // Efficiency metrics
193    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    // Tool usage top 10
217    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    // Top 5 projects
235    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    // Usage insights
277    if !result.session_summaries.is_empty() {
278        let summaries = &result.session_summaries;
279
280        // Daily average cost
281        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        // Compaction stats
294        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        // Max context
306        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        // Average session duration
312        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        // Top 3 most expensive sessions
323        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    // Data quality summary
347    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    // Surface orphan sessions (parent jsonl deleted, subagents still on disk).
363    // Their cost / turns / tokens are already included in the totals above —
364    // this line just makes the count visible.
365    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    // Pricing fallback warnings — surfaced last so they're not buried.
380    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
428// ─── 2. Projects ────────────────────────────────────────────────────────────
429
430pub 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
481// ─── 3. Session ─────────────────────────────────────────────────────────────
482
483pub 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    // ── Metadata section ──
525    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    // ── Performance section ──
558    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    // Per-agent breakdown
645    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        // Main agent line
657        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    // ── Context Collapse section ──
699    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                // Determine per-summary risk from snapshot staged spans if available
719                // We don't have per-commit risk, so just show the summary text
720                // Mark if max_risk > 0.5 for the last entry (heuristic)
721                let display = truncate_str(summary, 60);
722                writeln!(out, "    {}. \"{}\"", i + 1, display).unwrap();
723            }
724        }
725    }
726
727    // ── Phase 2: session-level capability inventory ──
728    // Each row only renders when its data is non-empty (mirrors how metadata
729    // rows are gated). Old sessions (pre-2.1.104/2.1.138) emit nothing here.
730
731    // Subagent chips: group by `agent_type` so a session with 7 builder calls
732    // renders one chip `builder x 7 ($X.YY)` rather than seven per-agent_id
733    // chips. `subagent_types` is empty exactly when `subagents` is.
734    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    // ── Workflow runs section (Claude Code 2.1.159+) ──
784    // One block per `agent()` orchestration run discovered for this session.
785    // Shows the declared snapshot metadata alongside the measured (parsed)
786    // token/cost/agent totals so any drift is visible at a glance.
787    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    // ── Code Attribution section ──
823    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
851// ─── 4. Trend ───────────────────────────────────────────────────────────────
852
853pub 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    // Find max cost for sparkline scaling
859    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        // Sparkline bar
866        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        // Primary model for this period
874        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        // Cost per turn
882        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    // Structure checks
929    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    // Per-session results
949    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    // Show failed sessions in detail
981    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    // Summary
1007    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
1026// ─── 5. Wrapped ────────────────────────────────────────────────────────────
1027
1028pub fn render_wrapped(result: &WrappedResult) -> String {
1029    let mut out = String::new();
1030    let w = 50; // inner width
1031
1032    // Top border
1033    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    // Activity
1048    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    // Volume
1064    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    // Archetype
1085    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    // Peak patterns
1095    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    // Efficiency
1106    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    // Top Tools
1131    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    // Top Projects
1149    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    // Most Expensive Session
1164    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    // Longest Session
1179    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    // Model distribution
1196    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    // Metadata footer
1214    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    // Bottom border
1246    writeln!(out, "\u{255a}{}\u{255d}", "\u{2550}".repeat(w)).unwrap();
1247
1248    out
1249}
1250
1251// ─── 6. Heatmap ────────────────────────────────────────────────────────────
1252
1253pub 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    // Map turns to glyph
1268    let glyph = |turns: usize| -> char {
1269        if turns == 0 {
1270            '\u{00B7}' // middle dot for zero
1271        } else if turns < p25 {
1272            '\u{2591}' // light shade
1273        } else if turns < p50 {
1274            '\u{2592}' // medium shade
1275        } else if turns < p75 {
1276            '\u{2593}' // dark shade
1277        } else {
1278            '\u{2588}' // full block
1279        }
1280    };
1281
1282    // Build the calendar grid.
1283    // Columns = weeks, rows = weekdays (Mon=0 .. Sun=6).
1284    // Find the Monday on or before start_date to align the grid.
1285    let start_weekday = result.start_date.weekday().num_days_from_monday(); // 0=Mon
1286    let grid_start = result.start_date - chrono::Duration::days(start_weekday as i64);
1287
1288    // End at the Sunday on or after end_date
1289    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    // Build a lookup from date -> turns
1296    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    // Render month labels on top.
1303    // Each week column is 1 char wide. Month labels ("Jan", etc.) are 3 chars.
1304    // A label is placed at the week column containing the 1st of that month.
1305    // Labels that would overlap a previous label are skipped.
1306    let label_width = 5; // "Mon  " prefix width
1307
1308    // Collect (week_index, month_abbr) for each month that has its 1st within the grid
1309    let mut month_marks: Vec<(usize, &str)> = Vec::new();
1310    {
1311        // Walk from the first month that starts on or after grid_start
1312        let mut d = if grid_start.day() == 1 {
1313            grid_start
1314        } else {
1315            // Advance to the 1st of the next month
1316            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            // Advance to the 1st of the next month
1328            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    // Build the header string by placing labels at correct column positions
1337    let mut month_header = " ".repeat(label_width);
1338    let mut cursor = 0usize; // tracks how many week-columns we have filled
1339    for (col, name) in &month_marks {
1340        if *col >= cursor {
1341            // Pad with spaces from cursor to this column
1342            for _ in cursor..*col {
1343                month_header.push(' ');
1344            }
1345            month_header.push_str(name);
1346            cursor = col + name.len(); // name takes 3 column slots
1347        }
1348        // else: skip this label (overlaps with previous)
1349    }
1350    writeln!(out, "{}", month_header.trim_end()).unwrap();
1351
1352    // Render each weekday row (show Mon, Wed, Fri, Sun labels; blank for others)
1353    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    // Legend
1372    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    // Stats
1381    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// ─── Tests ──────────────────────────────────────────────────────────────────
1435
1436#[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}