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 compression_rate_pct: f64,
13 pub files_touched: u64,
14 pub daily_savings: Vec<u64>,
15}
16
17impl WrappedReport {
18 pub fn generate(period: &str) -> Self {
19 let store = stats::load();
20 let sessions = SessionState::list_sessions();
21
22 let (tokens_saved, tokens_input, total_commands) = match period {
23 "week" => aggregate_recent_stats(&store, 7),
24 "month" => aggregate_recent_stats(&store, 30),
25 _ => (
26 store
27 .total_input_tokens
28 .saturating_sub(store.total_output_tokens),
29 store.total_input_tokens,
30 store.total_commands,
31 ),
32 };
33
34 let env_model = std::env::var("LEAN_CTX_MODEL")
35 .or_else(|_| std::env::var("LCTX_MODEL"))
36 .ok();
37 let pricing = crate::core::gain::model_pricing::ModelPricing::load();
38 let quote = pricing.quote(env_model.as_deref());
39 let cost_avoided_usd = quote.cost.estimate_usd(tokens_saved, 0, 0, 0);
40
41 let sessions_count = match period {
42 "week" => count_recent_sessions(&sessions, 7),
43 "month" => count_recent_sessions(&sessions, 30),
44 _ => sessions.len(),
45 };
46
47 let mut top_commands: Vec<(String, u64, f64)> = store
48 .commands
49 .iter()
50 .map(|(cmd, stats)| {
51 let saved = stats.input_tokens.saturating_sub(stats.output_tokens);
52 let pct = if stats.input_tokens > 0 {
53 saved as f64 / stats.input_tokens as f64 * 100.0
54 } else {
55 0.0
56 };
57 (cmd.clone(), saved, pct)
58 })
59 .collect();
60 top_commands.sort_by_key(|x| std::cmp::Reverse(x.1));
61 top_commands.truncate(5);
62
63 let compression_rate_pct = if tokens_input > 0 {
64 tokens_saved as f64 / tokens_input as f64 * 100.0
65 } else {
66 0.0
67 };
68
69 let files_touched: u64 = sessions.iter().map(|s| s.tool_calls as u64).sum();
70
71 let day_saved = |d: &stats::DayStats| d.input_tokens.saturating_sub(d.output_tokens);
72 let take_recent = |n: usize| -> Vec<u64> {
73 store
74 .daily
75 .iter()
76 .rev()
77 .take(n)
78 .collect::<Vec<_>>()
79 .into_iter()
80 .rev()
81 .map(day_saved)
82 .collect()
83 };
84 let daily_savings = match period {
85 "week" => take_recent(7),
86 "month" => take_recent(30),
87 _ => store.daily.iter().map(day_saved).collect(),
88 };
89
90 WrappedReport {
91 period: period.to_string(),
92 tokens_saved,
93 tokens_input,
94 cost_avoided_usd,
95 total_commands,
96 sessions_count,
97 top_commands,
98 compression_rate_pct,
99 files_touched,
100 daily_savings,
101 }
102 }
103
104 #[allow(clippy::many_single_char_names)] pub fn format_ascii(&self) -> String {
109 use crate::core::theme;
110
111 let cfg = crate::core::config::Config::load();
112 let t = theme::load_theme(&cfg.theme);
113 let rst = theme::rst();
114 let bold = theme::bold();
115 let dim = theme::dim();
116
117 let period_label = match self.period.as_str() {
118 "week" => format!("Week of {}", chrono::Utc::now().format("%b %d, %Y")),
119 "month" => format!("Month of {}", chrono::Utc::now().format("%B %Y")),
120 _ => "All Time".to_string(),
121 };
122
123 let w = 52;
124 let side = t.box_side();
125 let box_line = |content: &str| -> String {
126 let padded = theme::pad_right(content, w);
127 format!(" {side}{padded}{side}")
128 };
129
130 let mut out: Vec<String> = Vec::new();
131 out.push(String::new());
132 out.push(format!(" {}", t.box_top(w)));
133 out.push(box_line(""));
134 out.push(box_line(&format!(
135 " {icon} {brand} {accent}Wrapped{rst} {dim}· {period_label}{rst}",
136 icon = t.header_icon(),
137 brand = t.brand_title(),
138 accent = t.accent.fg(),
139 )));
140 out.push(box_line(""));
141 out.push(format!(" {}", t.box_mid(w)));
142 out.push(box_line(""));
143
144 let kw = 16;
146 let sc = t.success.fg();
147 let c2 = t.secondary.fg();
148 let c3 = t.warning.fg();
149 let c4 = t.accent.fg();
150
151 let v1 = theme::pad_right(
152 &format!("{sc}{bold}{}{rst}", format_tokens(self.tokens_saved)),
153 kw,
154 );
155 let v2 = theme::pad_right(&format!("{c4}{bold}${:.2}{rst}", self.cost_avoided_usd), kw);
156 let v3 = theme::pad_right(&format!("{c3}{bold}{}{rst}", self.total_commands), kw);
157 out.push(box_line(&format!(" {v1}{v2}{v3}")));
158 let l1 = theme::pad_right(&format!("{dim}tokens saved{rst}"), kw);
159 let l2 = theme::pad_right(&format!("{dim}cost avoided{rst}"), kw);
160 let l3 = theme::pad_right(&format!("{dim}commands{rst}"), kw);
161 out.push(box_line(&format!(" {l1}{l2}{l3}")));
162 out.push(box_line(""));
163
164 let v4 = theme::pad_right(&format!("{c2}{bold}{}{rst}", self.sessions_count), kw);
166 let v5 = theme::pad_right(
167 &format!(
168 "{pc}{bold}{:.1}%{rst}",
169 self.compression_rate_pct,
170 pc = t.pct_color(self.compression_rate_pct),
171 ),
172 kw,
173 );
174 out.push(box_line(&format!(" {v4}{v5}")));
175 let l4 = theme::pad_right(&format!("{dim}sessions{rst}"), kw);
176 let l5 = theme::pad_right(&format!("{dim}compression{rst}"), kw);
177 out.push(box_line(&format!(" {l4}{l5}")));
178 out.push(box_line(""));
179
180 if self.daily_savings.iter().filter(|v| **v > 0).count() >= 2 {
182 let spark = t.gradient_sparkline(&self.daily_savings);
183 out.push(box_line(&format!(" {dim}trend{rst} {spark}")));
184 out.push(box_line(""));
185 }
186
187 if !self.top_commands.is_empty() {
189 let prefix_visible = 8; let budget = w.saturating_sub(prefix_visible);
191 let mut top_str = self
192 .top_commands
193 .iter()
194 .take(3)
195 .map(|(cmd, _, pct)| format!("{cmd} {pct:.0}%"))
196 .collect::<Vec<_>>()
197 .join(" · ");
198 if top_str.chars().count() > budget {
199 let truncated: String = top_str.chars().take(budget.saturating_sub(1)).collect();
200 top_str = format!("{truncated}…");
201 }
202 out.push(format!(" {}", t.box_mid(w)));
203 out.push(box_line(&format!(
204 " {m}top{rst} {top_str}",
205 m = t.muted.fg()
206 )));
207 }
208
209 out.push(format!(" {}", t.box_bottom(w)));
210 out.push(format!(
211 " {dim}\"Your AI saw only what mattered.\"{rst} {accent}leanctx.com{rst}",
212 accent = t.accent.fg(),
213 ));
214 out.push(String::new());
215
216 out.join("\n")
217 }
218
219 pub fn format_compact(&self) -> String {
220 let saved_str = format_tokens(self.tokens_saved);
221 let cost_str = format!("${:.2}", self.cost_avoided_usd);
222 let top_str = self
223 .top_commands
224 .iter()
225 .take(3)
226 .map(|(cmd, _, pct)| format!("{cmd} {pct:.0}%"))
227 .collect::<Vec<_>>()
228 .join(" | ");
229
230 format!(
231 "WRAPPED [{}]: {} tok saved, {} avoided, {} sessions, {} cmds | Top: {} | Compression: {:.1}%",
232 self.period, saved_str, cost_str, self.sessions_count,
233 self.total_commands, top_str, self.compression_rate_pct,
234 )
235 }
236}
237
238fn aggregate_recent_stats(store: &stats::StatsStore, days: usize) -> (u64, u64, u64) {
239 let recent_days: Vec<&stats::DayStats> = store.daily.iter().rev().take(days).collect();
240
241 let input: u64 = recent_days.iter().map(|d| d.input_tokens).sum();
242 let output: u64 = recent_days.iter().map(|d| d.output_tokens).sum();
243 let commands: u64 = recent_days.iter().map(|d| d.commands).sum();
244 let saved = input.saturating_sub(output);
245
246 (saved, input, commands)
247}
248
249fn count_recent_sessions(sessions: &[crate::core::session::SessionSummary], days: i64) -> usize {
250 let cutoff = chrono::Utc::now() - chrono::Duration::days(days);
251 sessions.iter().filter(|s| s.updated_at > cutoff).count()
252}
253
254fn format_tokens(tokens: u64) -> String {
255 if tokens >= 1_000_000 {
256 format!("{:.1}M", tokens as f64 / 1_000_000.0)
257 } else if tokens >= 1_000 {
258 format!("{:.1}K", tokens as f64 / 1_000.0)
259 } else {
260 format!("{tokens}")
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 fn sample() -> WrappedReport {
269 WrappedReport {
270 period: "all".into(),
271 tokens_saved: 348_300_000,
272 tokens_input: 580_000_000,
273 cost_avoided_usd: 870.81,
274 total_commands: 17_055,
275 sessions_count: 67,
276 top_commands: vec![
277 ("ctx_search".into(), 100, 60.0),
278 ("cli_grep".into(), 80, 85.0),
279 ("cli_shell".into(), 50, 37.0),
280 ],
281 compression_rate_pct: 60.2,
282 files_touched: 1_234,
283 daily_savings: vec![10, 50, 30, 30, 80, 80, 20, 5, 5, 40, 60, 40, 5, 50, 15],
284 }
285 }
286
287 fn is_box_line(l: &str) -> bool {
288 let trimmed = l.trim_start();
289 ["│", "╭", "├", "╰"].iter().any(|c| trimmed.starts_with(c))
290 }
291
292 #[test]
293 fn wrapped_ascii_box_lines_have_uniform_width() {
294 let out = sample().format_ascii();
296 let widths: Vec<usize> = out
297 .lines()
298 .filter(|l| is_box_line(l))
299 .map(|l| l.chars().count())
300 .collect();
301 assert!(widths.len() >= 4, "expected several box lines:\n{out}");
302 let first = widths[0];
303 for w in &widths {
304 assert_eq!(*w, first, "box line widths must be uniform:\n{out}");
305 }
306 }
307
308 #[test]
309 fn wrapped_ascii_includes_brand_and_metrics() {
310 let out = sample().format_ascii();
311 assert!(out.contains("leanctx.com"), "missing brand footer:\n{out}");
312 assert!(out.contains("Wrapped"));
313 assert!(out.contains("tokens saved"));
314 assert!(out.contains("compression"));
315 }
316
317 #[test]
318 fn wrapped_ascii_truncates_overlong_top_line() {
319 let out = sample().format_ascii();
320 let max = out
322 .lines()
323 .filter(|l| is_box_line(l))
324 .map(|l| l.chars().count())
325 .max()
326 .unwrap_or(0);
327 let min = out
328 .lines()
329 .filter(|l| is_box_line(l))
330 .map(|l| l.chars().count())
331 .min()
332 .unwrap_or(0);
333 assert_eq!(max, min, "top line overflowed the box:\n{out}");
334 }
335
336 #[test]
337 fn wrapped_compact_is_single_line_summary() {
338 let out = sample().format_compact();
339 assert!(out.starts_with("WRAPPED"), "compact summary changed: {out}");
340 assert!(out.contains("Compression:"));
341 }
342}