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 pub bounce_tokens: u64,
18 pub model_key: String,
20 pub pricing_estimated: bool,
23 pub percentile: Option<u8>,
26}
27
28impl WrappedReport {
29 pub fn generate(period: &str) -> Self {
30 let store = stats::load();
31 let sessions = SessionState::list_sessions();
32
33 let (gross_tokens_saved, tokens_input, total_commands) = match period {
34 "week" => aggregate_recent_stats(&store, 7),
35 "month" => aggregate_recent_stats(&store, 30),
36 _ => (
37 store
38 .total_input_tokens
39 .saturating_sub(store.total_output_tokens),
40 store.total_input_tokens,
41 store.total_commands,
42 ),
43 };
44
45 let period_days = match period {
48 "week" => Some(7),
49 "month" => Some(30),
50 _ => None,
51 };
52 let bounce_tokens = crate::core::savings_ledger::bounce_tokens(period_days);
53 let tokens_saved = gross_tokens_saved.saturating_sub(bounce_tokens);
54
55 let env_model = std::env::var("LEAN_CTX_MODEL")
56 .or_else(|_| std::env::var("LCTX_MODEL"))
57 .ok();
58 let pricing = crate::core::gain::model_pricing::ModelPricing::load();
59 let quote = pricing.quote(env_model.as_deref());
60 let cost_avoided_usd = quote.cost.estimate_usd(tokens_saved, 0, 0, 0);
64 let pricing_estimated = matches!(
65 quote.match_kind,
66 crate::core::gain::model_pricing::PricingMatchKind::Fallback
67 );
68 let model_key = quote.model_key.clone();
69
70 let sessions_count = match period {
71 "week" => count_recent_sessions(&sessions, 7),
72 "month" => count_recent_sessions(&sessions, 30),
73 _ => sessions.len(),
74 };
75
76 let mut top_commands: Vec<(String, u64, f64)> = store
77 .commands
78 .iter()
79 .map(|(cmd, stats)| {
80 let saved = stats.input_tokens.saturating_sub(stats.output_tokens);
81 let pct = if stats.input_tokens > 0 {
82 saved as f64 / stats.input_tokens as f64 * 100.0
83 } else {
84 0.0
85 };
86 (cmd.clone(), saved, pct)
87 })
88 .collect();
89 top_commands.sort_by_key(|x| std::cmp::Reverse(x.1));
90 top_commands.truncate(5);
91
92 let compression_rate_pct = if tokens_input > 0 {
93 tokens_saved as f64 / tokens_input as f64 * 100.0
94 } else {
95 0.0
96 };
97
98 let files_touched: u64 = sessions.iter().map(|s| s.tool_calls as u64).sum();
99
100 let day_saved = |d: &stats::DayStats| d.input_tokens.saturating_sub(d.output_tokens);
101 let take_recent = |n: usize| -> Vec<u64> {
102 store
103 .daily
104 .iter()
105 .rev()
106 .take(n)
107 .collect::<Vec<_>>()
108 .into_iter()
109 .rev()
110 .map(day_saved)
111 .collect()
112 };
113 let daily_savings = match period {
114 "week" => take_recent(7),
115 "month" => take_recent(30),
116 _ => store.daily.iter().map(day_saved).collect(),
117 };
118
119 WrappedReport {
120 period: period.to_string(),
121 tokens_saved,
122 tokens_input,
123 cost_avoided_usd,
124 total_commands,
125 sessions_count,
126 top_commands,
127 compression_rate_pct,
128 files_touched,
129 daily_savings,
130 bounce_tokens,
131 model_key,
132 pricing_estimated,
133 percentile: estimate_percentile(tokens_saved),
134 }
135 }
136
137 pub fn methodology_line(&self) -> String {
141 let price = if self.pricing_estimated {
142 format!(
143 "{} blended fallback price (set LEAN_CTX_MODEL for exact)",
144 self.model_key
145 )
146 } else {
147 format!("{} input price", self.model_key)
148 };
149 let basis = if self.bounce_tokens > 0 {
150 format!(
151 "measured original - compressed - {} bounce tokens",
152 format_tokens(self.bounce_tokens)
153 )
154 } else {
155 "measured original - compressed tokens".to_string()
156 };
157 format!("Savings = {basis}; USD is an upper bound at {price}")
158 }
159
160 #[allow(clippy::many_single_char_names)] pub fn format_ascii(&self) -> String {
165 use crate::core::theme;
166
167 let cfg = crate::core::config::Config::load();
168 let t = theme::load_theme(&cfg.theme);
169 let rst = theme::rst();
170 let bold = theme::bold();
171 let dim = theme::dim();
172
173 let period_label = match self.period.as_str() {
174 "week" => format!("Week of {}", chrono::Utc::now().format("%b %d, %Y")),
175 "month" => format!("Month of {}", chrono::Utc::now().format("%B %Y")),
176 _ => "All Time".to_string(),
177 };
178
179 let w = 52;
180 let side = t.box_side();
181 let box_line = |content: &str| -> String {
182 let padded = theme::pad_right(content, w);
183 format!(" {side}{padded}{side}")
184 };
185
186 let mut out: Vec<String> = Vec::new();
187 out.push(String::new());
188 out.push(format!(" {}", t.box_top(w)));
189 out.push(box_line(""));
190 out.push(box_line(&format!(
191 " {icon} {brand} {accent}Wrapped{rst} {dim}· {period_label}{rst}",
192 icon = t.header_icon(),
193 brand = t.brand_title(),
194 accent = t.accent.fg(),
195 )));
196 out.push(box_line(""));
197 out.push(format!(" {}", t.box_mid(w)));
198 out.push(box_line(""));
199
200 let kw = 16;
202 let sc = t.success.fg();
203 let c2 = t.secondary.fg();
204 let c3 = t.warning.fg();
205 let c4 = t.accent.fg();
206
207 let v1 = theme::pad_right(
208 &format!("{sc}{bold}{}{rst}", format_tokens(self.tokens_saved)),
209 kw,
210 );
211 let v2 = theme::pad_right(&format!("{c4}{bold}${:.2}{rst}", self.cost_avoided_usd), kw);
212 let v3 = theme::pad_right(&format!("{c3}{bold}{}{rst}", self.total_commands), kw);
213 out.push(box_line(&format!(" {v1}{v2}{v3}")));
214 let l1 = theme::pad_right(&format!("{dim}tokens saved{rst}"), kw);
215 let l2 = theme::pad_right(&format!("{dim}cost avoided{rst}"), kw);
216 let l3 = theme::pad_right(&format!("{dim}commands{rst}"), kw);
217 out.push(box_line(&format!(" {l1}{l2}{l3}")));
218 out.push(box_line(""));
219
220 let v4 = theme::pad_right(&format!("{c2}{bold}{}{rst}", self.sessions_count), kw);
223 let v5 = theme::pad_right(
224 &format!(
225 "{pc}{bold}{:.1}%{rst}",
226 self.compression_rate_pct,
227 pc = t.pct_color(self.compression_rate_pct),
228 ),
229 kw,
230 );
231 let energy = crate::core::energy::format_for_tokens(self.tokens_saved);
232 let v6 = theme::pad_right(&format!("{c4}{bold}{energy}{rst}"), kw);
233 out.push(box_line(&format!(" {v4}{v5}{v6}")));
234 let l4 = theme::pad_right(&format!("{dim}sessions{rst}"), kw);
235 let l5 = theme::pad_right(&format!("{dim}compression{rst}"), kw);
236 let l6 = theme::pad_right(&format!("{dim}energy saved{rst}"), kw);
237 out.push(box_line(&format!(" {l4}{l5}{l6}")));
238 out.push(box_line(""));
239
240 if self.daily_savings.iter().filter(|v| **v > 0).count() >= 2 {
242 let spark = t.gradient_sparkline(&self.daily_savings);
243 out.push(box_line(&format!(" {dim}trend{rst} {spark}")));
244 out.push(box_line(""));
245 }
246
247 if !self.top_commands.is_empty() {
249 let prefix_visible = 8; let budget = w.saturating_sub(prefix_visible);
251 let mut top_str = self
252 .top_commands
253 .iter()
254 .take(3)
255 .map(|(cmd, _, pct)| format!("{cmd} {pct:.0}%"))
256 .collect::<Vec<_>>()
257 .join(" · ");
258 if top_str.chars().count() > budget {
259 let truncated: String = top_str.chars().take(budget.saturating_sub(1)).collect();
260 top_str = format!("{truncated}…");
261 }
262 out.push(format!(" {}", t.box_mid(w)));
263 out.push(box_line(&format!(
264 " {m}top{rst} {top_str}",
265 m = t.muted.fg()
266 )));
267 }
268
269 out.push(format!(" {}", t.box_bottom(w)));
270 out.push(format!(
271 " {dim}\"Your AI saw only what mattered.\"{rst} {accent}leanctx.com{rst}",
272 accent = t.accent.fg(),
273 ));
274 let est_marker = if self.pricing_estimated {
275 " (est.)"
276 } else {
277 ""
278 };
279 out.push(format!(
280 " {dim}model {model}{est_marker} · USD = upper bound{rst}",
281 model = self.model_key,
282 ));
283 out.push(String::new());
284
285 out.join("\n")
286 }
287
288 pub fn format_compact(&self) -> String {
289 let saved_str = format_tokens(self.tokens_saved);
290 let cost_str = format!("${:.2}", self.cost_avoided_usd);
291 let top_str = self
292 .top_commands
293 .iter()
294 .take(3)
295 .map(|(cmd, _, pct)| format!("{cmd} {pct:.0}%"))
296 .collect::<Vec<_>>()
297 .join(" | ");
298
299 let est_marker = if self.pricing_estimated {
300 " (est.)"
301 } else {
302 ""
303 };
304 format!(
305 "WRAPPED [{}]: {} tok saved, {} avoided{}, {} sessions, {} cmds | Top: {} | Compression: {:.1}% | Energy: {} | model={}",
306 self.period, saved_str, cost_str, est_marker, self.sessions_count,
307 self.total_commands, top_str, self.compression_rate_pct,
308 crate::core::energy::format_for_tokens(self.tokens_saved), self.model_key,
309 )
310 }
311}
312
313fn aggregate_recent_stats(store: &stats::StatsStore, days: usize) -> (u64, u64, u64) {
314 let recent_days: Vec<&stats::DayStats> = store.daily.iter().rev().take(days).collect();
315
316 let input: u64 = recent_days.iter().map(|d| d.input_tokens).sum();
317 let output: u64 = recent_days.iter().map(|d| d.output_tokens).sum();
318 let commands: u64 = recent_days.iter().map(|d| d.commands).sum();
319 let saved = input.saturating_sub(output);
320
321 (saved, input, commands)
322}
323
324fn count_recent_sessions(sessions: &[crate::core::session::SessionSummary], days: i64) -> usize {
325 let cutoff = chrono::Utc::now() - chrono::Duration::days(days);
326 sessions.iter().filter(|s| s.updated_at > cutoff).count()
327}
328
329pub(crate) fn format_tokens(tokens: u64) -> String {
330 if tokens >= 1_000_000 {
331 format!("{:.1}M", tokens as f64 / 1_000_000.0)
332 } else if tokens >= 1_000 {
333 format!("{:.1}K", tokens as f64 / 1_000.0)
334 } else {
335 format!("{tokens}")
336 }
337}
338
339fn estimate_percentile(tokens_saved: u64) -> Option<u8> {
343 if tokens_saved < 1_000 {
344 return None;
345 }
346 let pct = if tokens_saved >= 100_000_000 {
349 99
350 } else if tokens_saved >= 50_000_000 {
351 97
352 } else if tokens_saved >= 10_000_000 {
353 95
354 } else if tokens_saved >= 5_000_000 {
355 90
356 } else if tokens_saved >= 1_000_000 {
357 80
358 } else if tokens_saved >= 500_000 {
359 70
360 } else if tokens_saved >= 100_000 {
361 55
362 } else if tokens_saved >= 50_000 {
363 40
364 } else if tokens_saved >= 10_000 {
365 25
366 } else {
367 10
368 };
369 Some(pct)
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 fn sample() -> WrappedReport {
377 WrappedReport {
378 period: "all".into(),
379 tokens_saved: 348_300_000,
380 tokens_input: 580_000_000,
381 cost_avoided_usd: 870.81,
382 total_commands: 17_055,
383 sessions_count: 67,
384 top_commands: vec![
385 ("ctx_search".into(), 100, 60.0),
386 ("cli_grep".into(), 80, 85.0),
387 ("cli_shell".into(), 50, 37.0),
388 ],
389 compression_rate_pct: 60.2,
390 files_touched: 1_234,
391 daily_savings: vec![10, 50, 30, 30, 80, 80, 20, 5, 5, 40, 60, 40, 5, 50, 15],
392 bounce_tokens: 0,
393 model_key: "claude-3.5-sonnet".into(),
394 pricing_estimated: false,
395 percentile: Some(95),
396 }
397 }
398
399 fn is_box_line(l: &str) -> bool {
400 let trimmed = l.trim_start();
401 ["│", "╭", "├", "╰"].iter().any(|c| trimmed.starts_with(c))
402 }
403
404 #[test]
405 fn wrapped_ascii_box_lines_have_uniform_width() {
406 let out = sample().format_ascii();
408 let widths: Vec<usize> = out
409 .lines()
410 .filter(|l| is_box_line(l))
411 .map(|l| l.chars().count())
412 .collect();
413 assert!(widths.len() >= 4, "expected several box lines:\n{out}");
414 let first = widths[0];
415 for w in &widths {
416 assert_eq!(*w, first, "box line widths must be uniform:\n{out}");
417 }
418 }
419
420 #[test]
421 fn wrapped_ascii_includes_brand_and_metrics() {
422 let out = sample().format_ascii();
423 assert!(out.contains("leanctx.com"), "missing brand footer:\n{out}");
424 assert!(out.contains("Wrapped"));
425 assert!(out.contains("tokens saved"));
426 assert!(out.contains("compression"));
427 }
428
429 #[test]
430 fn wrapped_ascii_truncates_overlong_top_line() {
431 let out = sample().format_ascii();
432 let max = out
434 .lines()
435 .filter(|l| is_box_line(l))
436 .map(|l| l.chars().count())
437 .max()
438 .unwrap_or(0);
439 let min = out
440 .lines()
441 .filter(|l| is_box_line(l))
442 .map(|l| l.chars().count())
443 .min()
444 .unwrap_or(0);
445 assert_eq!(max, min, "top line overflowed the box:\n{out}");
446 }
447
448 #[test]
449 fn wrapped_compact_is_single_line_summary() {
450 let out = sample().format_compact();
451 assert!(out.starts_with("WRAPPED"), "compact summary changed: {out}");
452 assert!(out.contains("Compression:"));
453 assert!(
454 out.contains("model="),
455 "compact must name the pricing model: {out}"
456 );
457 }
458
459 #[test]
460 fn methodology_is_conservative_and_explainable() {
461 let m = sample().methodology_line();
462 assert!(
463 m.contains("upper bound"),
464 "must state it is an upper bound: {m}"
465 );
466 assert!(m.contains("claude-3.5-sonnet"), "must name the model: {m}");
467 }
468
469 #[test]
470 fn ascii_footer_surfaces_model_and_upper_bound() {
471 let out = sample().format_ascii();
472 assert!(
473 out.contains("model claude-3.5-sonnet"),
474 "footer must name model:\n{out}"
475 );
476 assert!(
477 out.contains("USD = upper bound"),
478 "footer must flag upper bound:\n{out}"
479 );
480 }
481
482 #[test]
483 fn estimated_pricing_is_flagged() {
484 let mut r = sample();
485 r.pricing_estimated = true;
486 assert!(
487 r.format_ascii().contains("(est.)"),
488 "estimated price must show (est.)"
489 );
490 assert!(r.format_compact().contains("(est.)"));
491 assert!(r.methodology_line().contains("fallback"));
492 }
493}