1use crate::core::session::SessionState;
2use crate::core::stats;
3
4pub struct WrappedReport {
5 pub period: String,
6 pub tokens_saved: u64,
7 pub tokens_input: u64,
8 pub cost_avoided_usd: f64,
9 pub total_commands: u64,
10 pub sessions_count: usize,
11 pub top_commands: Vec<(String, u64, f64)>,
12 pub cache_hit_rate: f64,
13 pub files_touched: u64,
14}
15
16impl WrappedReport {
17 pub fn generate(period: &str) -> Self {
18 let store = stats::load();
19 let sessions = SessionState::list_sessions();
20
21 let (tokens_saved, tokens_input, total_commands) = match period {
22 "week" => aggregate_recent_stats(&store, 7),
23 "month" => aggregate_recent_stats(&store, 30),
24 _ => (
25 store
26 .total_input_tokens
27 .saturating_sub(store.total_output_tokens),
28 store.total_input_tokens,
29 store.total_commands,
30 ),
31 };
32
33 let env_model = std::env::var("LEAN_CTX_MODEL")
34 .or_else(|_| std::env::var("LCTX_MODEL"))
35 .ok();
36 let pricing = crate::core::gain::model_pricing::ModelPricing::load();
37 let quote = pricing.quote(env_model.as_deref());
38 let cost_avoided_usd = quote.cost.estimate_usd(tokens_saved, 0, 0, 0);
39
40 let sessions_count = match period {
41 "week" => count_recent_sessions(&sessions, 7),
42 "month" => count_recent_sessions(&sessions, 30),
43 _ => sessions.len(),
44 };
45
46 let mut top_commands: Vec<(String, u64, f64)> = store
47 .commands
48 .iter()
49 .map(|(cmd, stats)| {
50 let saved = stats.input_tokens.saturating_sub(stats.output_tokens);
51 let pct = if stats.input_tokens > 0 {
52 saved as f64 / stats.input_tokens as f64 * 100.0
53 } else {
54 0.0
55 };
56 (cmd.clone(), saved, pct)
57 })
58 .collect();
59 top_commands.sort_by_key(|x| std::cmp::Reverse(x.1));
60 top_commands.truncate(5);
61
62 let cache_hit_rate = if tokens_input > 0 {
63 tokens_saved as f64 / tokens_input as f64 * 100.0
64 } else {
65 0.0
66 };
67
68 let files_touched: u64 = sessions.iter().map(|s| s.tool_calls as u64).sum();
69
70 WrappedReport {
71 period: period.to_string(),
72 tokens_saved,
73 tokens_input,
74 cost_avoided_usd,
75 total_commands,
76 sessions_count,
77 top_commands,
78 cache_hit_rate,
79 files_touched,
80 }
81 }
82
83 pub fn format_ascii(&self) -> String {
84 let period_label = match self.period.as_str() {
85 "week" => format!("Week of {}", chrono::Utc::now().format("%B %d, %Y")),
86 "month" => format!("Month of {}", chrono::Utc::now().format("%B %Y")),
87 _ => "All Time".to_string(),
88 };
89
90 let saved_str = format_tokens(self.tokens_saved);
91 let cost_str = format!("${:.2}", self.cost_avoided_usd);
92
93 let top_str = if self.top_commands.is_empty() {
94 "No data yet".to_string()
95 } else {
96 self.top_commands
97 .iter()
98 .take(3)
99 .map(|(cmd, _, pct)| format!("{cmd} {pct:.0}%"))
100 .collect::<Vec<_>>()
101 .join(" | ")
102 };
103
104 let width = 48;
105 let border = "\u{2500}".repeat(width);
106
107 format!(
108 "\n LeanCTX Wrapped \u{2014} {period_label}\n \
109 {border}\n \
110 {saved_str} tokens saved {cost_str} avoided\n \
111 {sessions} sessions {cmds} commands\n \
112 Top: {top_str}\n \
113 Compression rate: {cache:.1}%\n \
114 {border}\n \
115 \"Your AI saw only what mattered.\"\n \
116 leanctx.com\n",
117 sessions = self.sessions_count,
118 cmds = self.total_commands,
119 cache = self.cache_hit_rate,
120 )
121 }
122
123 pub fn format_compact(&self) -> String {
124 let saved_str = format_tokens(self.tokens_saved);
125 let cost_str = format!("${:.2}", self.cost_avoided_usd);
126 let top_str = self
127 .top_commands
128 .iter()
129 .take(3)
130 .map(|(cmd, _, pct)| format!("{cmd} {pct:.0}%"))
131 .collect::<Vec<_>>()
132 .join(" | ");
133
134 format!(
135 "WRAPPED [{}]: {} tok saved, {} avoided, {} sessions, {} cmds | Top: {} | Compression: {:.1}%",
136 self.period, saved_str, cost_str, self.sessions_count,
137 self.total_commands, top_str, self.cache_hit_rate,
138 )
139 }
140}
141
142fn aggregate_recent_stats(store: &stats::StatsStore, days: usize) -> (u64, u64, u64) {
143 let recent_days: Vec<&stats::DayStats> = store.daily.iter().rev().take(days).collect();
144
145 let input: u64 = recent_days.iter().map(|d| d.input_tokens).sum();
146 let output: u64 = recent_days.iter().map(|d| d.output_tokens).sum();
147 let commands: u64 = recent_days.iter().map(|d| d.commands).sum();
148 let saved = input.saturating_sub(output);
149
150 (saved, input, commands)
151}
152
153fn count_recent_sessions(sessions: &[crate::core::session::SessionSummary], days: i64) -> usize {
154 let cutoff = chrono::Utc::now() - chrono::Duration::days(days);
155 sessions.iter().filter(|s| s.updated_at > cutoff).count()
156}
157
158fn format_tokens(tokens: u64) -> String {
159 if tokens >= 1_000_000 {
160 format!("{:.1}M", tokens as f64 / 1_000_000.0)
161 } else if tokens >= 1_000 {
162 format!("{:.1}K", tokens as f64 / 1_000.0)
163 } else {
164 format!("{tokens}")
165 }
166}